diff --git a/doc/recoverable-disk-cleanup.md b/doc/recoverable-disk-cleanup.md index ac17c3dd6e5..3345ccafc6b 100644 --- a/doc/recoverable-disk-cleanup.md +++ b/doc/recoverable-disk-cleanup.md @@ -141,6 +141,12 @@ remote lacks, when any reflog holds commits that are neither on the remote nor p the checked-out history the restore point bundles, when it has objects reachable from nothing, or when its `COMMIT_EDITMSG` holds a draft that matches no commit in its history. A `COMMIT_EDITMSG` that matches a commit is accepted for every nested checkout. +A nested checkout's remote-tracking refs (`refs/remotes/origin/*`) and their reflogs can +point at commits the remote has since dropped, for example a branch deleted after a squash +merge. Strict expiration keeps such a checkout. In restore point mode those commits are +saved in the checkout's restore point instead, as history-only objects (the remote-tracking +refs themselves are not recreated), and any change to the remote-tracking refs after capture +rolls expiration back. Each restore point also records a digest of its `HEAD` reflog and `ORIG_HEAD`; under the expiration locks that history must be unchanged too, so a commit followed by a reset after capture rolls expiration back. Reopening diff --git a/server/src/__tests__/git-restore-point.test.ts b/server/src/__tests__/git-restore-point.test.ts index ba6a860ef9b..cebe5d17593 100644 --- a/server/src/__tests__/git-restore-point.test.ts +++ b/server/src/__tests__/git-restore-point.test.ts @@ -199,6 +199,23 @@ describe("git restore points", () => { .rejects.toMatchObject({ reason: "restore_point_history_too_large" }); }); + it("refuses more preserved commits than the limit before checking each one", async () => { + const extra = Array.from({ length: 51 }, (_, index) => index.toString(16).padStart(40, "0")); + await expect(captureRestorePoint({ cwd: work, upstreamCommits: upstream(), preserveCommits: extra })) + .rejects.toMatchObject({ reason: "restore_point_history_too_large" }); + }); + + it("saves preserved commits that exist and ignores ones that do not", async () => { + await write(work, "orphan.txt", "only reachable from a preserved commit\n"); + git(work, "add", "orphan.txt"); git(work, "commit", "-q", "-m", "orphan"); + const orphan = git(work, "rev-parse", "HEAD"); + git(work, "reset", "-q", "--hard", "HEAD~1"); + git(work, "reflog", "expire", "--expire=now", "--all"); + const { manifest } = (await captureRestorePoint({ cwd: work, upstreamCommits: upstream(), + preserveCommits: [orphan, "f".repeat(40)] }))!; + expect(manifest.preserved).toEqual([orphan]); + }); + it("keeps capture objects out of the repository", async () => { await write(work, "notes/todo.md", "untracked work\n"); const before = git(work, "count-objects", "-v"); diff --git a/server/src/__tests__/git-workspace-expiration.test.ts b/server/src/__tests__/git-workspace-expiration.test.ts index 0b527a257c5..4a5b6a7d307 100644 --- a/server/src/__tests__/git-workspace-expiration.test.ts +++ b/server/src/__tests__/git-workspace-expiration.test.ts @@ -321,6 +321,74 @@ describeEmbeddedPostgres("reconstructable Git expiration", () => { vi.unstubAllEnvs(); }); + async function staleRemoteTrackingRef(f: Awaited>) { + git(f.nestedSource, "checkout", "-q", "-b", "squashed-away"); + await fs.writeFile(path.join(f.nestedSource, "feature.txt"), "merged by squash, then deleted\n"); + git(f.nestedSource, "add", "."); git(f.nestedSource, "commit", "-m", "Feature"); + const feature = git(f.nestedSource, "rev-parse", "HEAD"); + git(f.nestedSource, "push", "-q", f.nestedRemote, "squashed-away"); + git(f.nestedSource, "checkout", "-q", "main"); + git(f.nested.cwd, "fetch", "-q", "origin", "+refs/heads/squashed-away:refs/remotes/origin/squashed-away"); + git(f.nestedRemote, "branch", "-D", "squashed-away"); + git(f.nestedRemote, "reflog", "expire", "--expire=now", "--all"); + git(f.nestedRemote, "gc", "-q", "--prune=now"); + expect(() => git(f.nestedRemote, "cat-file", "-e", `${feature}^{commit}`)).toThrow(); + return feature; + } + + it("saves a clean nested checkout's stale remote-tracking commits in its restore point", async () => { + vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY", "31".repeat(32)); + const f = await nestedFixture(false); + const feature = await staleRemoteTrackingRef(f); + expect(await f.expire({ expireBase: false })).toMatchObject({ expired: false, skipReason: "unpushed_managed_checkout" }); + expect(await f.expire({ expireBase: false, restorePoint: restorePoint() })).toMatchObject({ expired: true, restorePoint: true, skipReason: null }); + const recipe = (await f.row()).metadata?.gitCacheRestoration as Record; + expect(recipe.contents.checkouts[0].restorePoint).toMatchObject({ head: f.nestedA, headOnRemote: true, worktree: null }); + expect(await f.reopen()).toMatchObject({ ok: true, reopened: true }); + expect(git(f.nested.cwd, "rev-parse", "HEAD")).toBe(f.nestedA); + expect(git(f.nested.cwd, "cat-file", "-t", feature)).toBe("commit"); + vi.unstubAllEnvs(); + }); + + it("rolls back when a nested checkout's remote-tracking refs change after capture", async () => { + vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY", "31".repeat(32)); + const f = await nestedFixture(false); + await staleRemoteTrackingRef(f); + const result = await f.expire({ expireBase: false, restorePoint: restorePoint(), beforeDelete: async () => { + const later = execFileSync("git", ["commit-tree", git(f.nested.cwd, "rev-parse", "HEAD^{tree}"), "-p", "HEAD", "-m", "Fetched after capture"], + { cwd: f.nested.cwd, env: gitEnv, encoding: "utf8" }).trim(); + git(f.nested.cwd, "update-ref", "refs/remotes/origin/fetched-later", later); + } }); + expect(result).toMatchObject({ expired: false, skipReason: "restore_point_contents_changed" }); + expect(await exists(f.cwd)).toBe(true); + vi.unstubAllEnvs(); + }); + + it("rolls back when only a nested checkout's remote-tracking reflog changes after capture", async () => { + vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY", "31".repeat(32)); + const f = await nestedFixture(false); + await staleRemoteTrackingRef(f); + const result = await f.expire({ expireBase: false, restorePoint: restorePoint(), beforeDelete: async () => { + const tip = git(f.nested.cwd, "rev-parse", "refs/remotes/origin/squashed-away"); + git(f.nested.cwd, "update-ref", "-m", "moved", "--create-reflog", "refs/remotes/origin/squashed-away", f.nestedA); + git(f.nested.cwd, "update-ref", "-m", "moved back", "refs/remotes/origin/squashed-away", tip); + } }); + expect(result).toMatchObject({ expired: false, skipReason: "restore_point_contents_changed" }); + vi.unstubAllEnvs(); + }); + + it("counts distinct reflog commits, not repeated entries, against the tip budget", async () => { + vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY", "31".repeat(32)); + const f = await nestedFixture(false); + await unpushedNestedWork(f.nested.cwd); + const head = git(f.nested.cwd, "rev-parse", "HEAD"); + const log = path.join(f.nested.cwd, ".git", "logs", "refs", "heads", "main"); + const entry = `${head} ${head} Fixture 1700000000 +0000\treset: moving to HEAD\n`; + await fs.appendFile(log, entry.repeat(6_000)); + expect(await f.expire({ expireBase: false, restorePoint: restorePoint() })).toMatchObject({ expired: true, restorePoint: true, skipReason: null }); + vi.unstubAllEnvs(); + }); + it("keeps a workspace whose nested checkout has unpushed work on another branch", async () => { vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY", "31".repeat(32)); const f = await nestedFixture(false); diff --git a/server/src/services/git-restore-point-storage.ts b/server/src/services/git-restore-point-storage.ts index aea44db5827..1b7fb4bc9b9 100644 --- a/server/src/services/git-restore-point-storage.ts +++ b/server/src/services/git-restore-point-storage.ts @@ -85,11 +85,14 @@ export async function storeGitRestorePoint(input: { checkout?: string; upstreamCommits: readonly string[]; headOnRemote: boolean; + resolvePreserveCommits?: () => Promise; limits?: Partial; signal?: AbortSignal; }): Promise<{ ref: GitRestorePointRef; ledgerId: string; manifest: RestorePointManifest } | null> { const history = await restorePointHistoryDigest(input.cwd, input.signal); - const captured = await captureRestorePoint({ cwd: input.cwd, upstreamCommits: input.upstreamCommits, limits: input.limits, signal: input.signal }); + const preserveCommits = await input.resolvePreserveCommits?.(); + const captured = await captureRestorePoint({ cwd: input.cwd, upstreamCommits: input.upstreamCommits, preserveCommits, + limits: input.limits, signal: input.signal }); if (!captured) return null; if (await restorePointHistoryDigest(input.cwd, input.signal) !== history) throw new RestorePointRestoreFailure("restore_point_history_changed"); const id = randomUUID(); diff --git a/server/src/services/git-restore-point.ts b/server/src/services/git-restore-point.ts index 74cbbfcbc12..5c949c5208f 100644 --- a/server/src/services/git-restore-point.ts +++ b/server/src/services/git-restore-point.ts @@ -331,8 +331,16 @@ async function existingCommits(cwd: string, commits: readonly string[], signal?: return present; } -async function unpreservedHistory(cwd: string, head: string, boundary: readonly string[], signal?: AbortSignal) { - const candidates = new Set(); +async function presentCommits(cwd: string, commits: readonly string[], signal?: AbortSignal) { + const unique = [...new Set(commits.filter((commit) => SHA.test(commit)))]; + if (unique.length === 0) return []; + const output = await gitText(cwd, ["cat-file", "--batch-check=%(objectname) %(objecttype)"], { signal, input: unique.join("\n") + "\n" }); + return output.split("\n").map((line) => line.split(" ")).filter(([sha, type]) => SHA.test(sha ?? "") && type === "commit").map(([sha]) => sha!); +} + +async function unpreservedHistory(cwd: string, head: string, boundary: readonly string[], signal?: AbortSignal, extra: readonly string[] = []) { + if (new Set(extra).size > MAX_PRESERVED_COMMITS) throw new RestorePointSkip("restore_point_history_too_large"); + const candidates = new Set(await presentCommits(cwd, extra, signal)); const reflog = await gitText(cwd, ["reflog", "show", "--format=%H", "HEAD"], { signal }).catch(() => ""); for (const line of reflog.split("\n")) if (SHA.test(line)) candidates.add(line); const original = await gitText(cwd, ["rev-parse", "--verify", "-q", "ORIG_HEAD^{commit}"], { signal }).catch(() => ""); @@ -385,6 +393,7 @@ export function decodeRestorePointPayload(payload: Buffer): { manifest: RestoreP export async function captureRestorePoint(input: { cwd: string; upstreamCommits: readonly string[]; + preserveCommits?: readonly string[]; limits?: Partial; signal?: AbortSignal; }): Promise<{ payload: Buffer; manifest: RestorePointManifest } | null> { @@ -396,7 +405,7 @@ export async function captureRestorePoint(input: { const { store, env } = await snapshotStore(cwd, temporary); const snapshot = await snapshotCommits(cwd, env, temporary, modes, input.signal); const boundary = await existingCommits(cwd, input.upstreamCommits, input.signal); - const preserved = await unpreservedHistory(cwd, snapshot.head, boundary, input.signal); + const preserved = await unpreservedHistory(cwd, snapshot.head, boundary, input.signal, input.preserveCommits); const headOnUpstream = await (async () => { for (const tip of boundary) { if (await git(cwd, ["merge-base", "--is-ancestor", snapshot.head, tip], { signal: input.signal }).then(() => true, () => false)) return true; @@ -559,5 +568,9 @@ export async function restorePointHistoryDigest(input: string, signal?: AbortSig const cwd = await fs.realpath(input); const reflog = await gitText(cwd, ["reflog", "show", "--format=%H", "HEAD"], { signal }).catch(() => ""); const original = await gitText(cwd, ["rev-parse", "--verify", "-q", "ORIG_HEAD^{commit}"], { signal }).catch(() => ""); - return createHash("sha256").update(JSON.stringify([reflog.split("\n").filter((line) => SHA.test(line)), SHA.test(original) ? original : null])).digest("hex"); + const remotes = await gitText(cwd, ["for-each-ref", "--format=%(objectname) %(refname)", "refs/remotes"], { signal }).catch(() => ""); + const remoteReflogs = (await gitText(cwd, ["reflog", "show", "--all", "--format=%H %gD"], { signal }).catch(() => "")) + .split("\n").filter((line) => / refs\/remotes\//.test(line)); + return createHash("sha256").update(JSON.stringify([reflog.split("\n").filter((line) => SHA.test(line)), SHA.test(original) ? original : null, + remotes.split("\n").filter(Boolean), remoteReflogs])).digest("hex"); } diff --git a/server/src/services/git-workspace-expiration.ts b/server/src/services/git-workspace-expiration.ts index 76cfcef5264..0d9684f5e90 100644 --- a/server/src/services/git-workspace-expiration.ts +++ b/server/src/services/git-workspace-expiration.ts @@ -13,7 +13,7 @@ import { abandonGitRestorePoint, abandonGitRestorePointsByKey, activateGitRestor import { withIsolatedRepositorySourceLock, verifiedRecordedIsolatedRemoteSource } from "./isolated-remote-workspace-source.js"; import { managedProjectCheckoutName } from "./managed-project-checkout-name.js"; import type { GitRemoteAuthProvider } from "./git-credentials.js"; -import { assertReproducibleGitFiles, assertReproducibleBaseGitStorage, assertReproducibleManagedCheckout, inspectGitCacheRecipe, inspectGitCacheRecipeForRestorePoint, readGitCacheRestoration, GIT_CACHE_RESTORATION_SCHEMA_V2, +import { assertReproducibleGitFiles, assertReproducibleBaseGitStorage, assertReproducibleManagedCheckout, provenManagedCheckoutHistory, inspectGitCacheRecipe, inspectGitCacheRecipeForRestorePoint, readGitCacheRestoration, GIT_CACHE_RESTORATION_SCHEMA_V2, verifyRemoteGitCommit, gitCacheExpirationLockLocations, recoverGitCacheExpirationLocks, GitCacheRestorationFailure, type ManagedCheckoutProver, gitCacheCommand, readGitCacheExpiration, finishGitCacheTrash, type GitCacheExpiration, type GitCacheRestoration, GIT_CACHE_RESTORATION_KEY, GIT_CACHE_EXPIRATION_REASON } from "./git-workspace-restoration.js"; async function allocatedBytes(target: string, shouldContinue: () => boolean): Promise { @@ -294,7 +294,9 @@ export async function expireReconstructableGitWorkspace(db: Db, snapshot: Worksp } else if (!headOnRemote) throw new Error("upstream_commit_unverified"); for (const [name, need] of [...checkoutRestorePoints.entries()].sort(([a], [b]) => a.localeCompare(b))) { const storedCheckout = await storeGitRestorePoint({ db, storage: options.restorePoint!.storage, workspace: owner, - cwd: need.path, checkout: name, generation, headOnRemote: need.headOnRemote, limits: options.restorePoint!.limits, + cwd: need.path, checkout: name, generation, headOnRemote: need.headOnRemote, + resolvePreserveCommits: async () => (await provenManagedCheckoutHistory(need.path, need.repoUrl, auth, options.signal)).preserve, + limits: options.restorePoint!.limits, signal: options.signal, upstreamCommits: await restorePointBoundary(need.path, need.repoUrl, need.headOnRemote, auth, options.signal) }); if (!storedCheckout) { if (!need.headOnRemote) throw new Error("unpushed_managed_checkout"); diff --git a/server/src/services/git-workspace-restoration.ts b/server/src/services/git-workspace-restoration.ts index 82dec297fec..45eab45fd0a 100644 --- a/server/src/services/git-workspace-restoration.ts +++ b/server/src/services/git-workspace-restoration.ts @@ -250,7 +250,7 @@ async function assertCommitMessageSaved(cwd: string, gitDir: string, signal?: Ab export async function assertReproducibleManagedCheckout(checkout: string, input: { repoUrl: string; resolveGitAuth: GitRemoteAuthProvider; signal?: AbortSignal; restorePoint?: boolean; -}): Promise<{ headOnRemote: boolean }> { +}): Promise<{ headOnRemote: boolean; preserve: string[] }> { const { signal } = input; const gitDir = await fs.lstat(path.join(checkout, ".git")).catch(() => null); if (!gitDir?.isDirectory()) throw new Error("unproven_managed_checkout"); @@ -266,30 +266,10 @@ export async function assertReproducibleManagedCheckout(checkout: string, input: const refs = (await gitCacheCommand(checkout, ["for-each-ref", "--format=%(refname)"], signal)).trim().split("\n").filter(Boolean); if (refs.some(ref => !/^refs\/(heads|tags|remotes\/origin)\//.test(ref))) throw new Error("unsupported_managed_checkout_refs"); if (input.restorePoint) { - const head = (await gitCacheCommand(checkout, ["rev-parse", "--verify", "HEAD^{commit}"], signal)).trim(); - const shas = (output: string) => output.split("\n").map(line => line.trim()).filter(sha => shaPattern.test(sha)); - const covered = new Set([head, - ...shas(await gitCacheCommand(checkout, ["reflog", "show", "--format=%H", "HEAD"], signal).catch(() => "")), - ...shas(await gitCacheCommand(checkout, ["rev-parse", "--verify", "-q", "ORIG_HEAD^{commit}"], signal).catch(() => ""))]); - const recorded = new Set([ - ...shas(await gitCacheCommand(checkout, ["for-each-ref", "--format=%(objectname)", "refs/heads", "refs/tags"], signal)), - ...shas(await gitCacheCommand(checkout, ["reflog", "show", "--all", "--format=%H"], signal).catch(() => "")), - ]); - const current = (await gitCacheCommand(checkout, ["symbolic-ref", "--quiet", "HEAD"], signal).catch(() => "")).trim(); - const otherTips = (await gitCacheCommand(checkout, ["for-each-ref", "--format=%(objectname) %(refname)", "refs/heads", "refs/tags"], signal)) - .split("\n").map(line => line.trim().split(" ")).filter(([sha, ref]) => shaPattern.test(sha ?? "") && ref !== current).map(([sha]) => sha!); - if (otherTips.length && await hasCommitsMissingUpstream(checkout, otherTips, input.repoUrl, input.resolveGitAuth, signal, [head])) { - throw new Error("unpushed_managed_checkout_branches"); - } - const uncovered = [...recorded].filter(sha => !covered.has(sha)); - if (uncovered.length + covered.size > MANAGED_CHECKOUT_LOCAL_TIP_LIMIT) throw new Error("managed_checkout_local_tip_budget"); - if (uncovered.length && await hasCommitsMissingUpstream(checkout, uncovered, input.repoUrl, input.resolveGitAuth, signal, [...covered])) { - throw new Error("unpushed_managed_checkout_branches"); - } - const headOnRemote = !await hasCommitsMissingUpstream(checkout, [head], input.repoUrl, input.resolveGitAuth, signal); + const { headOnRemote, preserve } = await provenManagedCheckoutHistory(checkout, input.repoUrl, input.resolveGitAuth, signal); const unreachable = await gitCacheCommand(checkout, ["fsck", "--connectivity-only", "--unreachable", "--no-progress"], signal); if (unreachable.trim()) throw new Error("unreachable_managed_checkout_objects"); - return { headOnRemote }; + return { headOnRemote, preserve }; } const localTips = new Set([ ...(await gitCacheCommand(checkout, ["rev-parse", "--verify", "HEAD^{commit}"], signal)).split("\n"), @@ -302,13 +282,61 @@ export async function assertReproducibleManagedCheckout(checkout: string, input: } const unreachable = await gitCacheCommand(checkout, ["fsck", "--connectivity-only", "--unreachable", "--no-progress"], signal); if (unreachable.trim()) throw new Error("unreachable_managed_checkout_objects"); - return { headOnRemote: true }; + return { headOnRemote: true, preserve: [] }; } const MANAGED_CHECKOUT_LOCAL_TIP_LIMIT = 5_000; +export async function provenManagedCheckoutHistory(checkout: string, repoUrl: string, resolveGitAuth: GitRemoteAuthProvider, + signal?: AbortSignal): Promise<{ headOnRemote: boolean; preserve: string[] }> { + const head = (await gitCacheCommand(checkout, ["rev-parse", "--verify", "HEAD^{commit}"], signal)).trim(); + const shas = (output: string) => output.split("\n").map(line => line.trim()).filter(sha => shaPattern.test(sha)); + const covered = new Set([head, + ...shas(await gitCacheCommand(checkout, ["reflog", "show", "--format=%H", "HEAD"], signal).catch(() => "")), + ...shas(await gitCacheCommand(checkout, ["rev-parse", "--verify", "-q", "ORIG_HEAD^{commit}"], signal).catch(() => ""))]); + const reflogs = [...new Set((await gitCacheCommand(checkout, ["reflog", "show", "--all", "--format=%H %gD"], signal).catch(() => "")) + .split("\n").map(line => line.trim().split(" ")).filter(([sha]) => shaPattern.test(sha ?? "")) + .map(([sha, selector]) => `${sha} ${(selector ?? "").startsWith("refs/remotes/") ? "remote" : "local"}`))].map(entry => entry.split(" ")); + if (reflogs.length > MANAGED_CHECKOUT_LOCAL_TIP_LIMIT) throw new Error("managed_checkout_local_tip_budget"); + const remote = (kind: string | undefined) => kind === "remote"; + const recorded = new Set([ + ...shas(await gitCacheCommand(checkout, ["for-each-ref", "--format=%(objectname)", "refs/heads", "refs/tags"], signal)), + ...reflogs.filter(([, selector]) => !remote(selector)).map(([sha]) => sha!), + ]); + const remoteTracked = [...new Set([ + ...shas(await gitCacheCommand(checkout, ["for-each-ref", "--format=%(objectname)", "refs/remotes"], signal)), + ...reflogs.filter(([, selector]) => remote(selector)).map(([sha]) => sha!), + ])]; + const current = (await gitCacheCommand(checkout, ["symbolic-ref", "--quiet", "HEAD"], signal).catch(() => "")).trim(); + const otherTips = (await gitCacheCommand(checkout, ["for-each-ref", "--format=%(objectname) %(refname)", "refs/heads", "refs/tags"], signal)) + .split("\n").map(line => line.trim().split(" ")).filter(([sha, ref]) => shaPattern.test(sha ?? "") && ref !== current).map(([sha]) => sha!); + if (otherTips.length && await hasCommitsMissingUpstream(checkout, otherTips, repoUrl, resolveGitAuth, signal, [head])) { + throw new Error("unpushed_managed_checkout_branches"); + } + const uncovered = [...recorded].filter(sha => !covered.has(sha)); + if (uncovered.length + covered.size + remoteTracked.length > MANAGED_CHECKOUT_LOCAL_TIP_LIMIT) throw new Error("managed_checkout_local_tip_budget"); + if (uncovered.length && await hasCommitsMissingUpstream(checkout, uncovered, repoUrl, resolveGitAuth, signal, [...covered])) { + throw new Error("unpushed_managed_checkout_branches"); + } + const headOnRemote = !await hasCommitsMissingUpstream(checkout, [head], repoUrl, resolveGitAuth, signal); + const preserve = remoteTracked.length + ? await tipsMissingUpstream(checkout, remoteTracked, repoUrl, resolveGitAuth, signal, [...covered, ...recorded]) : []; + return { headOnRemote, preserve }; +} + +async function tipsMissingUpstream(checkout: string, tips: string[], repoUrl: string, + resolveGitAuth: GitRemoteAuthProvider, signal?: AbortSignal, covered: string[] = []) { + const missing = new Set(await commitsMissingUpstream(checkout, tips, repoUrl, resolveGitAuth, signal, covered)); + return tips.filter(tip => missing.has(tip)).sort(); +} + async function hasCommitsMissingUpstream(checkout: string, localTips: string[], repoUrl: string, resolveGitAuth: GitRemoteAuthProvider, signal?: AbortSignal, covered: string[] = []) { + return (await commitsMissingUpstream(checkout, localTips, repoUrl, resolveGitAuth, signal, covered, 1)).length > 0; +} + +async function commitsMissingUpstream(checkout: string, localTips: string[], repoUrl: string, + resolveGitAuth: GitRemoteAuthProvider, signal?: AbortSignal, covered: string[] = [], limit?: number) { const store = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-git-checkout-proof-")); try { const options = { timeout: 120_000, signal, maxBuffer: 16 * 1024 * 1024, cwd: os.tmpdir(), @@ -324,8 +352,9 @@ async function hasCommitsMissingUpstream(checkout: string, localTips: string[], if (signal?.aborted) throw error; throw new Error("managed_checkout_upstream_unavailable"); } - const missing = await execFile("git", ["-C", store, "rev-list", "--max-count=1", ...localTips, "--not", "--glob=refs/upstream/*", ...covered], options); - return missing.stdout.trim() !== ""; + const missing = await execFile("git", ["-C", store, "rev-list", ...(limit ? [`--max-count=${limit}`] : []), ...localTips, + "--not", "--glob=refs/upstream/*", ...covered], options); + return missing.stdout.split("\n").map(line => line.trim()).filter(Boolean); } finally { await fs.rm(store, { recursive: true, force: true }); }