diff --git a/server/src/__tests__/nested-clone-migration.test.ts b/server/src/__tests__/nested-clone-migration.test.ts index e77222c4b9e..b417a4ce897 100644 --- a/server/src/__tests__/nested-clone-migration.test.ts +++ b/server/src/__tests__/nested-clone-migration.test.ts @@ -14,7 +14,7 @@ import { realizeExecutionWorkspace } from "../services/workspace-runtime.js"; import { executionWorkspaceService } from "../services/execution-workspaces.js"; import { prepareProjectRepositoryWorkspaces } from "../services/heartbeat/workspaces.js"; import { expireReconstructableGitWorkspace } from "../services/git-workspace-expiration.js"; -import { currentRestorePointSnapshot } from "../services/git-restore-point.js"; +import { currentRestorePointSnapshot, restorePointHistoryDigest } from "../services/git-restore-point.js"; import { nestedRepositoryBaseCandidates, nestedWorktreeBranchPrefix, readLinkedNestedCheckout, withNestedRepositoryBaseLock } from "../services/nested-repository-worktrees.js"; import { listNestedCloneMigrationNames, migrateNestedRepositoryClone, nextNestedCloneMigrationBatch, type NestedCloneMigrationOptions } from "../services/nested-clone-migration.js"; import { getStorageService } from "../storage/index.js"; @@ -225,6 +225,41 @@ describeEmbeddedPostgres("nested repository clone migration", () => { expect(git(f.checkout, "cat-file", "-t", feature)).toBe("commit"); }); + it("expires a converted checkout whose only extra history is remote-tracking and restores that history", async () => { + const f = await fixture({ issueStatus: "done" }); + git(f.nested.author, "checkout", "-q", "-b", "squashed-away"); + await fs.writeFile(path.join(f.nested.author, "feature.txt"), "feature\n"); + git(f.nested.author, "add", "."); git(f.nested.author, "commit", "-qm", "Feature"); + const feature = git(f.nested.author, "rev-parse", "HEAD"); + git(f.nested.author, "push", "-q", f.nested.remote, "squashed-away"); + git(f.checkout, "fetch", "-q", "origin"); + git(f.nested.remote, "branch", "-D", "squashed-away"); + git(f.nested.remote, "reflog", "expire", "--expire=now", "--all"); + git(f.nested.remote, "gc", "-q", "--prune=now"); + expect(await f.migrate()).toMatchObject({ outcome: "migrated" }); + + expect(await f.expire()).toMatchObject({ expired: true, restorePoint: true, skipReason: null }); + for (const line of ownedRefs(f.base, f.prefix).split("\n").filter(Boolean)) git(f.base, "update-ref", "-d", line.split(" ")[0]!); + git(f.base, "reflog", "expire", "--expire=now", "--all"); + git(f.base, "gc", "-q", "--prune=now"); + expect(() => git(f.base, "cat-file", "-e", `${feature}^{commit}`)).toThrow(); + expect(await f.reopen()).toMatchObject({ ok: true, reopened: true }); + expect(git(f.checkout, "cat-file", "-t", feature)).toBe("commit"); + }); + + it("fingerprints a converted checkout's own history rather than the shared clone's remote refs", async () => { + const f = await fixture(); + expect(await f.migrate()).toMatchObject({ outcome: "migrated" }); + const head = git(f.checkout, "rev-parse", "HEAD"); + const before = await restorePointHistoryDigest(f.checkout); + git(f.base, "update-ref", "refs/remotes/origin/another-task", head); + expect(await restorePointHistoryDigest(f.checkout)).toBe(before); + git(f.base, "update-ref", `refs/heads/${f.prefix}remotes/origin/extra`, head); + expect(await restorePointHistoryDigest(f.checkout)).not.toBe(before); + for (let index = 0; index < 64; index++) git(f.base, "update-ref", `refs/heads/${f.prefix}remotes/origin/bulk-${index}`, head); + await expect(restorePointHistoryDigest(f.checkout)).rejects.toThrow("managed_checkout_local_tip_budget"); + }); + it("finishes an interrupted conversion after the workspace is archived and forgets one whose workspace is gone", async () => { const f = await fixture(); const { unpushed } = await nestedWork(f.checkout); diff --git a/server/src/services/git-restore-point.ts b/server/src/services/git-restore-point.ts index 698b1395ade..3af9997b693 100644 --- a/server/src/services/git-restore-point.ts +++ b/server/src/services/git-restore-point.ts @@ -595,9 +595,21 @@ 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(() => ""); - 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)); + const linked = (await fs.lstat(path.join(cwd, ".git")).catch(() => null))?.isFile() ?? false; + const nested = linked ? await import("./nested-repository-worktrees.js") : null; + const scope = nested ? `refs/heads/${await nested.nestedWorktreeBranchPrefix(cwd)}` : "refs/remotes"; + const remotes = await gitText(cwd, ["for-each-ref", ...nested ? [`--count=${nested.OWNED_NESTED_REF_LIMIT + 1}`] : [], + "--format=%(objectname) %(refname)", scope], { signal }).catch(() => ""); + const remoteReflogs: string[] = []; + if (nested) { + if (remotes.split("\n").filter(Boolean).length > nested.OWNED_NESTED_REF_LIMIT) throw new Error("managed_checkout_local_tip_budget"); + for (const ref of remotes.split("\n").map((line) => line.split(" ")[1]).filter(Boolean)) { + remoteReflogs.push(...(await gitText(cwd, ["reflog", "show", "--format=%H", ref!], { signal }).catch(() => "")).split("\n").filter((line) => SHA.test(line)).map((sha) => `${sha} ${ref}`)); + } + } else { + remoteReflogs.push(...(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-restoration.ts b/server/src/services/git-workspace-restoration.ts index f71c1851e7c..5aacb475a80 100644 --- a/server/src/services/git-workspace-restoration.ts +++ b/server/src/services/git-workspace-restoration.ts @@ -18,7 +18,7 @@ import { ensureGitRestorePointCommit, validateGitRestorePointRef, type GitRestor import { getStorageService } from "../storage/index.js"; import type { StorageService } from "../storage/types.js"; import { PROJECT_REPOSITORIES_DIR } from "@paperclipai/adapter-utils/git-workspace-sync"; -import { forgetExpiredNestedRepositoryWorktree, nestedRepositoryBaseCandidates, readLinkedNestedCheckout, recordedNestedRepositoryBase, type LinkedNestedCheckout } from "./nested-repository-worktrees.js"; +import { OWNED_NESTED_REF_LIMIT, forgetExpiredNestedRepositoryWorktree, nestedRepositoryBaseCandidates, readLinkedNestedCheckout, recordedNestedRepositoryBase, type LinkedNestedCheckout } from "./nested-repository-worktrees.js"; const execFile = promisify(execFileCallback); const shaPattern = /^[a-f0-9]{40}$/; @@ -249,7 +249,6 @@ async function assertCommitMessageSaved(cwd: string, gitDir: string, signal?: Ab if (!saved.includes(message.trim())) throw new Error("uncommitted_git_message"); } -const OWNED_NESTED_REF_LIMIT = 64; async function ownedNestedRefs(linked: LinkedNestedCheckout, signal?: AbortSignal) { const refs = (await gitCacheCommand(linked.checkout, ["for-each-ref", `--count=${OWNED_NESTED_REF_LIMIT + 1}`, @@ -380,9 +379,13 @@ async function provenNestedWorktreeHistory(linked: LinkedNestedCheckout, repoUrl 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 owned = await ownedNestedRefs(linked, signal); + const allOwned = await ownedNestedRefs(linked, signal); + const remotePrefix = `refs/heads/${linked.branchPrefix}remotes/`; + const owned = allOwned.filter(([, ref]) => !ref.startsWith(remotePrefix)); + const remoteTracked = [...new Set([...allOwned.filter(([, ref]) => ref.startsWith(remotePrefix)).map(([sha]) => sha), + ...await ownedNestedReflogs(linked, allOwned.filter(([, ref]) => ref.startsWith(remotePrefix)), signal)])]; const recorded = new Set([...owned.map(([sha]) => sha), ...await ownedNestedReflogs(linked, owned, signal)]); - if (recorded.size + covered.size > MANAGED_CHECKOUT_LOCAL_TIP_LIMIT) throw new Error("managed_checkout_local_tip_budget"); + if (recorded.size + covered.size + remoteTracked.length > MANAGED_CHECKOUT_LOCAL_TIP_LIMIT) throw new Error("managed_checkout_local_tip_budget"); const current = (await gitCacheCommand(checkout, ["symbolic-ref", "--quiet", "HEAD"], signal).catch(() => "")).trim(); const otherTips = owned.filter(([, ref]) => ref !== current).map(([sha]) => sha); if (otherTips.length && await hasCommitsMissingUpstream(checkout, otherTips, repoUrl, resolveGitAuth, signal, [head])) { @@ -393,7 +396,9 @@ async function provenNestedWorktreeHistory(linked: LinkedNestedCheckout, repoUrl throw new Error("unpushed_managed_checkout_branches"); } const headOnRemote = !await hasCommitsMissingUpstream(checkout, [head], repoUrl, resolveGitAuth, signal); - return { headOnRemote, preserve: [] }; + 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, diff --git a/server/src/services/nested-repository-worktrees.ts b/server/src/services/nested-repository-worktrees.ts index 8a2969ec2f3..ad907159cc3 100644 --- a/server/src/services/nested-repository-worktrees.ts +++ b/server/src/services/nested-repository-worktrees.ts @@ -15,6 +15,7 @@ import { sanitizeRuntimeServiceBaseEnv } from "./runtime-service-env.js"; const execFile = promisify(execFileCallback); const NESTED_WORKTREE_GIT_TIMEOUT_MS = 10 * 60 * 1000; const NESTED_WORKTREE_BRANCH = /^paperclip\/[a-f0-9]{12}\/./; +export const OWNED_NESTED_REF_LIMIT = 64; export const DETACHED_REPOSITORIES_DIR = path.join(".paperclip-runtime", "detached-repositories"); export type LinkedNestedCheckout = { checkout: string; base: string; gitDir: string; admin: string; branchPrefix: string };