diff --git a/src/git.test.ts b/src/git.test.ts index d07717d..73ea498 100644 --- a/src/git.test.ts +++ b/src/git.test.ts @@ -11,6 +11,7 @@ import { extractBranchNameFromMergeMessage, getCommitContext, getCommitContextsBetweenShas, + countCommitsInRange, getCurrentGitInfo, getCommitParents, getRemoteUrl, @@ -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 }); + } + }); +}); diff --git a/src/git.ts b/src/git.ts index 2733469..72512b5 100644 --- a/src/git.ts +++ b/src/git.ts @@ -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}`, { diff --git a/src/index.ts b/src/index.ts index 0b7c1f5..fb84434 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,7 @@ import { readFileSync } from "node:fs"; import { LinearClient, LinearClientOptions } from "@linear/sdk"; import { assertGitAvailable, + countCommitsInRange, ensureCommitAvailable, getCommitContextsBetweenShas, getCurrentGitInfo, @@ -11,6 +12,7 @@ import { } from "./git"; import { assertBaseRefIsAncestor, + evaluateScanRangeSize, getBroadScanWarning, ScanBase, selectAutomaticScanBase, @@ -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", diff --git a/src/scan-base.test.ts b/src/scan-base.test.ts index b62c513..fc61740 100644 --- a/src/scan-base.test.ts +++ b/src/scan-base.test.ts @@ -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, @@ -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 }); + }); +}); diff --git a/src/scan-base.ts b/src/scan-base.ts index e8fe70a..576d4f2 100644 --- a/src/scan-base.ts +++ b/src/scan-base.ts @@ -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,