Skip to content

Commit 4c8723e

Browse files
authored
Cap automatic scan ranges at 10k commits with a rev-list precheck (#123)
1 parent 9537c01 commit 4c8723e

5 files changed

Lines changed: 129 additions & 0 deletions

File tree

src/git.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
extractBranchNameFromMergeMessage,
1212
getCommitContext,
1313
getCommitContextsBetweenShas,
14+
countCommitsInRange,
1415
getCurrentGitInfo,
1516
getCommitParents,
1617
getRemoteUrl,
@@ -1224,3 +1225,38 @@ describe("assertGitAvailable", () => {
12241225
}
12251226
});
12261227
});
1228+
1229+
describe("countCommitsInRange", () => {
1230+
it("counts commits exclusive of the from SHA", () => {
1231+
const repo = initTempRepo({
1232+
prefix: "linear-release-count-",
1233+
dirs: ["src"],
1234+
seedFile: { path: "src/file.txt", content: "one" },
1235+
});
1236+
try {
1237+
writeFileSync(join(repo.cwd, "src", "file.txt"), "two");
1238+
runGit('commit -am "second"', repo.cwd);
1239+
writeFileSync(join(repo.cwd, "src", "file.txt"), "three");
1240+
runGit('commit -am "third"', repo.cwd);
1241+
const head = runGit("rev-parse HEAD", repo.cwd);
1242+
1243+
expect(countCommitsInRange(repo.base, head, repo.cwd)).toBe(2);
1244+
expect(countCommitsInRange(head, head, repo.cwd)).toBe(0);
1245+
} finally {
1246+
rmSync(repo.cwd, { recursive: true, force: true });
1247+
}
1248+
});
1249+
1250+
it("returns null when the range cannot be resolved", () => {
1251+
const repo = initTempRepo({
1252+
prefix: "linear-release-count-invalid-",
1253+
dirs: ["src"],
1254+
seedFile: { path: "src/file.txt", content: "one" },
1255+
});
1256+
try {
1257+
expect(countCommitsInRange("0".repeat(40), repo.base, repo.cwd)).toBeNull();
1258+
} finally {
1259+
rmSync(repo.cwd, { recursive: true, force: true });
1260+
}
1261+
});
1262+
});

src/git.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,24 @@ export function getCommitParents(sha: string, cwd: string = process.cwd()): stri
160160
}
161161
}
162162

163+
/**
164+
* Counts commits in `fromSha..toSha` (exclusive of `fromSha`). Returns null
165+
* when the count cannot be determined; callers treat that as "no guard".
166+
*/
167+
export function countCommitsInRange(fromSha: string, toSha: string, cwd: string = process.cwd()): number | null {
168+
try {
169+
const out = execFileSync("git", ["rev-list", "--count", `${fromSha}..${toSha}`], {
170+
cwd,
171+
stdio: ["ignore", "pipe", "ignore"],
172+
encoding: "utf8",
173+
}).trim();
174+
const count = Number.parseInt(out, 10);
175+
return Number.isNaN(count) ? null : count;
176+
} catch {
177+
return null;
178+
}
179+
}
180+
163181
export function commitExists(sha: string, cwd: string = process.cwd()): boolean {
164182
try {
165183
execSync(`git cat-file -e ${sha}^{commit}`, {

src/index.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { readFileSync } from "node:fs";
22
import { LinearClient, LinearClientOptions } from "@linear/sdk";
33
import {
44
assertGitAvailable,
5+
countCommitsInRange,
56
ensureCommitAvailable,
67
getCommitContextsBetweenShas,
78
getCurrentGitInfo,
@@ -11,6 +12,7 @@ import {
1112
} from "./git";
1213
import {
1314
assertBaseRefIsAncestor,
15+
evaluateScanRangeSize,
1416
getBroadScanWarning,
1517
ScanBase,
1618
selectAutomaticScanBase,
@@ -313,6 +315,21 @@ async function syncCommand(): Promise<{
313315
}
314316
}
315317

318+
if (latestSha !== currentCommit.commit) {
319+
const rangeCommitCount = countCommitsInRange(latestSha, currentCommit.commit);
320+
if (rangeCommitCount !== null) {
321+
verbose(`Range ${latestSha.slice(0, 7)}..${currentCommit.commit.slice(0, 7)} spans ${rangeCommitCount} commits`);
322+
}
323+
const rangeSize = evaluateScanRangeSize(rangeCommitCount, scanBase);
324+
if (rangeSize.warning) {
325+
warn(rangeSize.warning);
326+
}
327+
if (rangeSize.degradeToCurrentCommit) {
328+
inspectingOnlyCurrentCommit = true;
329+
latestSha = currentCommit.commit;
330+
}
331+
}
332+
316333
const commits = await getCommitContextsBetweenShas(latestSha, currentCommit.commit, {
317334
includePaths: effectiveIncludePaths,
318335
inspectSingleCommit: scanBase.kind !== "base-ref",

src/scan-base.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ import * as log from "./log";
88
import {
99
assertBaseRefIsAncestor,
1010
BROAD_SCAN_COMMIT_THRESHOLD,
11+
evaluateScanRangeSize,
1112
getBroadScanWarning,
13+
SCAN_COMMIT_HARD_LIMIT,
1214
type ScanBase,
1315
selectAutomaticScanBase,
1416
shouldCreateReleaseForScan,
@@ -401,3 +403,28 @@ describe("scan base selection", () => {
401403
}
402404
});
403405
});
406+
407+
describe("evaluateScanRangeSize", () => {
408+
const releaseBase: ScanBase = { kind: "release", sha: "a".repeat(40) };
409+
const baseRefBase: ScanBase = { kind: "base-ref", sha: "b".repeat(40), ref: "v1.0.0" };
410+
411+
it("degrades an automatic range above the hard limit to current-commit-only", () => {
412+
const result = evaluateScanRangeSize(SCAN_COMMIT_HARD_LIMIT + 1, releaseBase);
413+
expect(result.degradeToCurrentCommit).toBe(true);
414+
expect(result.warning).toContain(`${SCAN_COMMIT_HARD_LIMIT}-commit safety limit`);
415+
});
416+
417+
it("does not degrade at exactly the hard limit", () => {
418+
expect(evaluateScanRangeSize(SCAN_COMMIT_HARD_LIMIT, releaseBase)).toEqual({ degradeToCurrentCommit: false });
419+
});
420+
421+
it("warns but proceeds for an explicitly requested --base-ref range above the limit", () => {
422+
const result = evaluateScanRangeSize(SCAN_COMMIT_HARD_LIMIT + 1, baseRefBase);
423+
expect(result.degradeToCurrentCommit).toBe(false);
424+
expect(result.warning).toContain("explicitly requested");
425+
});
426+
427+
it("never blocks the scan when the count could not be determined", () => {
428+
expect(evaluateScanRangeSize(null, releaseBase)).toEqual({ degradeToCurrentCommit: false });
429+
});
430+
});

src/scan-base.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,37 @@ export type ScanBase =
1010

1111
export const BROAD_SCAN_COMMIT_THRESHOLD = 100;
1212

13+
export const SCAN_COMMIT_HARD_LIMIT = 10_000;
14+
15+
/**
16+
* Judges a scan range's commit count before the (potentially heavy) log scan
17+
* runs. Automatic ranges above the hard limit degrade to current-commit-only —
18+
* an anchor that far back is stale or from another repository, and syncing it
19+
* would link months of shipped work to one release. Explicit --base-ref ranges
20+
* are trusted but warned. A null count (couldn't determine) never blocks.
21+
*/
22+
export function evaluateScanRangeSize(
23+
commitCount: number | null,
24+
scanBase: ScanBase,
25+
): { degradeToCurrentCommit: boolean; warning?: string } {
26+
if (commitCount === null || commitCount <= SCAN_COMMIT_HARD_LIMIT) {
27+
return { degradeToCurrentCommit: false };
28+
}
29+
30+
if (scanBase.kind === "base-ref") {
31+
return {
32+
degradeToCurrentCommit: false,
33+
warning: `Scanning ${commitCount} commits from --base-ref ${scanBase.ref} (${scanBase.sha.slice(0, 7)}), above the ${SCAN_COMMIT_HARD_LIMIT}-commit safety limit. Proceeding because this range was explicitly requested.`,
34+
};
35+
}
36+
37+
const range = scanBase.kind === "release" ? `release anchor ${scanBase.sha.slice(0, 7)}` : "the first-sync fallback";
38+
return {
39+
degradeToCurrentCommit: true,
40+
warning: `Scan range from ${range} spans ${commitCount} commits, above the ${SCAN_COMMIT_HARD_LIMIT}-commit safety limit — the anchor is likely stale or from another repository. Syncing only the current commit. Pass --base-ref to scan an explicit range.`,
41+
};
42+
}
43+
1344
export function selectAutomaticScanBase(
1445
candidates: Release[],
1546
currentSha: string,

0 commit comments

Comments
 (0)