Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions src/git.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
extractBranchNameFromMergeMessage,
getCommitContext,
getCommitContextsBetweenShas,
countCommitsInRange,
getCurrentGitInfo,
getCommitParents,
getRemoteUrl,
Expand Down Expand Up @@ -1224,3 +1225,38 @@ describe("assertGitAvailable", () => {
}
});
});

describe("countCommitsInRange", () => {
it("counts commits exclusive of the from SHA", () => {
const repo = initTempRepo({
prefix: "linear-release-count-",
dirs: ["src"],
seedFile: { path: "src/file.txt", content: "one" },
});
try {
writeFileSync(join(repo.cwd, "src", "file.txt"), "two");
runGit('commit -am "second"', repo.cwd);
writeFileSync(join(repo.cwd, "src", "file.txt"), "three");
runGit('commit -am "third"', repo.cwd);
const head = runGit("rev-parse HEAD", repo.cwd);

expect(countCommitsInRange(repo.base, head, repo.cwd)).toBe(2);
expect(countCommitsInRange(head, head, repo.cwd)).toBe(0);
} finally {
rmSync(repo.cwd, { recursive: true, force: true });
}
});

it("returns null when the range cannot be resolved", () => {
const repo = initTempRepo({
prefix: "linear-release-count-invalid-",
dirs: ["src"],
seedFile: { path: "src/file.txt", content: "one" },
});
try {
expect(countCommitsInRange("0".repeat(40), repo.base, repo.cwd)).toBeNull();
} finally {
rmSync(repo.cwd, { recursive: true, force: true });
}
});
});
18 changes: 18 additions & 0 deletions src/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,24 @@ export function getCommitParents(sha: string, cwd: string = process.cwd()): stri
}
}

/**
* Counts commits in `fromSha..toSha` (exclusive of `fromSha`). Returns null
* when the count cannot be determined; callers treat that as "no guard".
*/
export function countCommitsInRange(fromSha: string, toSha: string, cwd: string = process.cwd()): number | null {
try {
const out = execFileSync("git", ["rev-list", "--count", `${fromSha}..${toSha}`], {
cwd,
stdio: ["ignore", "pipe", "ignore"],
encoding: "utf8",
}).trim();
const count = Number.parseInt(out, 10);
return Number.isNaN(count) ? null : count;
} catch {
return null;
}
}

export function commitExists(sha: string, cwd: string = process.cwd()): boolean {
try {
execSync(`git cat-file -e ${sha}^{commit}`, {
Expand Down
17 changes: 17 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { readFileSync } from "node:fs";
import { LinearClient, LinearClientOptions } from "@linear/sdk";
import {
assertGitAvailable,
countCommitsInRange,
ensureCommitAvailable,
getCommitContextsBetweenShas,
getCurrentGitInfo,
Expand All @@ -11,6 +12,7 @@ import {
} from "./git";
import {
assertBaseRefIsAncestor,
evaluateScanRangeSize,
getBroadScanWarning,
ScanBase,
selectAutomaticScanBase,
Expand Down Expand Up @@ -313,6 +315,21 @@ async function syncCommand(): Promise<{
}
}

if (latestSha !== currentCommit.commit) {
const rangeCommitCount = countCommitsInRange(latestSha, currentCommit.commit);
if (rangeCommitCount !== null) {
verbose(`Range ${latestSha.slice(0, 7)}..${currentCommit.commit.slice(0, 7)} spans ${rangeCommitCount} commits`);
}
const rangeSize = evaluateScanRangeSize(rangeCommitCount, scanBase);
if (rangeSize.warning) {
warn(rangeSize.warning);
}
if (rangeSize.degradeToCurrentCommit) {
inspectingOnlyCurrentCommit = true;
latestSha = currentCommit.commit;
}
}

const commits = await getCommitContextsBetweenShas(latestSha, currentCommit.commit, {
includePaths: effectiveIncludePaths,
inspectSingleCommit: scanBase.kind !== "base-ref",
Expand Down
27 changes: 27 additions & 0 deletions src/scan-base.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ import * as log from "./log";
import {
assertBaseRefIsAncestor,
BROAD_SCAN_COMMIT_THRESHOLD,
evaluateScanRangeSize,
getBroadScanWarning,
SCAN_COMMIT_HARD_LIMIT,
type ScanBase,
selectAutomaticScanBase,
shouldCreateReleaseForScan,
Expand Down Expand Up @@ -401,3 +403,28 @@ describe("scan base selection", () => {
}
});
});

describe("evaluateScanRangeSize", () => {
const releaseBase: ScanBase = { kind: "release", sha: "a".repeat(40) };
const baseRefBase: ScanBase = { kind: "base-ref", sha: "b".repeat(40), ref: "v1.0.0" };

it("degrades an automatic range above the hard limit to current-commit-only", () => {
const result = evaluateScanRangeSize(SCAN_COMMIT_HARD_LIMIT + 1, releaseBase);
expect(result.degradeToCurrentCommit).toBe(true);
expect(result.warning).toContain(`${SCAN_COMMIT_HARD_LIMIT}-commit safety limit`);
});

it("does not degrade at exactly the hard limit", () => {
expect(evaluateScanRangeSize(SCAN_COMMIT_HARD_LIMIT, releaseBase)).toEqual({ degradeToCurrentCommit: false });
});

it("warns but proceeds for an explicitly requested --base-ref range above the limit", () => {
const result = evaluateScanRangeSize(SCAN_COMMIT_HARD_LIMIT + 1, baseRefBase);
expect(result.degradeToCurrentCommit).toBe(false);
expect(result.warning).toContain("explicitly requested");
});

it("never blocks the scan when the count could not be determined", () => {
expect(evaluateScanRangeSize(null, releaseBase)).toEqual({ degradeToCurrentCommit: false });
});
});
31 changes: 31 additions & 0 deletions src/scan-base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,37 @@ export type ScanBase =

export const BROAD_SCAN_COMMIT_THRESHOLD = 100;

export const SCAN_COMMIT_HARD_LIMIT = 10_000;

/**
* Judges a scan range's commit count before the (potentially heavy) log scan
* runs. Automatic ranges above the hard limit degrade to current-commit-only —
* an anchor that far back is stale or from another repository, and syncing it
* would link months of shipped work to one release. Explicit --base-ref ranges
* are trusted but warned. A null count (couldn't determine) never blocks.
*/
export function evaluateScanRangeSize(
commitCount: number | null,
scanBase: ScanBase,
): { degradeToCurrentCommit: boolean; warning?: string } {
if (commitCount === null || commitCount <= SCAN_COMMIT_HARD_LIMIT) {
return { degradeToCurrentCommit: false };
}

if (scanBase.kind === "base-ref") {
return {
degradeToCurrentCommit: false,
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.`,
};
}

const range = scanBase.kind === "release" ? `release anchor ${scanBase.sha.slice(0, 7)}` : "the first-sync fallback";
return {
degradeToCurrentCommit: true,
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.`,
};
}

export function selectAutomaticScanBase(
candidates: Release[],
currentSha: string,
Expand Down
Loading