Files
PaperClipAI/server/src/__tests__/nested-clone-migration.test.ts
T
Devin FoleyandPaperclip 70de7d49e9 Migrate standalone nested clones onto the shared project clone (#17407)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Each task runs in a git worktree. Extra project repositories sit in
the task under `.paperclip-repositories/`.
> - #17382 made new nested checkouts git worktrees of one shared clone
per project repository. Existing standalone nested clones stayed as full
clones.
> - Long-lived tasks keep those clones for a long time. Many tasks hold
the same large repository, so most of the disk is duplicate `.git` data.
> - #17369 added a locked and verified move of isolated workspaces onto
the shared clone, with a reaper sweep behind a setting.
> - This pull request adds the same kind of locked and verified
conversion for standalone nested clones in open tasks, and runs it from
the same reaper path behind a new setting.
> - The benefit is that the duplicate clones go away without losing any
state or local history, and the disk stops filling.

## Linked Issues or Issue Description

Refs #17382 #17369. Bug report described in-PR (bug report template).

**What happened?**
On one instance, 44 open task worktrees in one project each hold a
standalone nested clone of the same large repository. Together they use
about 25 GB. About 16 GB of that is duplicate `.git` data. Another
project has 33 smaller clones. The clones are removed only when their
tasks expire, and many tasks stay open for a long time. The disk keeps
filling.

**Expected behavior**
Existing standalone nested clones in open tasks become worktrees of the
project's shared clone, with the same state and local history, and the
standalone clones are deleted.

**Steps to reproduce**
1. Prepare a task with a nested project repository before #17382, or
without a shared clone, so that `.paperclip-repositories/<name>` is a
standalone clone.
2. Create the project's shared clone, as #17382 does for new tasks.
3. Observe that the old checkout stays a full clone until the task
expires.

**Paperclip version or commit:** `master` at 8549ecaf9a.
**Deployment mode:** any.
**Agent adapter(s) involved:** not adapter-specific (core).

## What Changed

- New `server/src/services/nested-clone-migration.ts`. It converts one
standalone nested clone in an open execution workspace into a worktree
of the shared clone:
- It holds the source lock and workspace lifecycle lock (as git cache
expiration does), the per-shared-clone lock from #17382, and the
expiration lock files (`index.lock`, `HEAD.lock`, current branch ref
lock) in the clone.
- It requires the #17369 no-live-consumer eligibility (no live runs,
leased finalizations, unreleased leases, running services, or reopen
flag). It checks again before the swap.
- It identifies the repository by managed checkout name, project
workspace rows and the origin URL, and it requires a verified shared
clone.
- It brings local history into the shared clone under the checkout's own
`paperclip/<id>/` prefix. The current branch becomes
`paperclip/<id>/<branch>`, tracking `origin/<branch>`. Unpushed local
branches become `paperclip/<id>/<branch>`. Unpushed tags become
`paperclip/<id>/tags/<tag>`. A restore point carries the commits, the
`HEAD` reflog and `ORIG_HEAD`.
- It stages the worktree at `<name>.clone-migrating`, applies the
restore point, copies the `HEAD` reflog and `ORIG_HEAD`, and moves
regenerable dirs, untracked `.env` files and
`.claude/settings.local.json` by rename. Old and new must then match
exactly (snapshot, history, branch, created refs, moved items).
- Swap: it renames the clone to `<name>.clone-standalone`, runs `git
worktree move` and `git worktree repair`, updates a saved restoration
recipe that lists the checkout, and then renames the old clone to trash
and deletes it.
- A `nestedCloneMigrationPending.<name>` metadata record makes recovery
possible. A crash before the swap rolls back (moves items back, removes
the staged worktree and the created branches). A crash after the swap
rolls forward.
- Skip reasons (nothing changes): `live_or_unresolved_consumer`,
`unknown_managed_checkout`, `local_source_checkout`, `foreign_origin`,
`no_shared_base`, `shared_base_repo_mismatch`,
`unsupported_base_git_config`, `nested_clone_has_stash` (stashes are
skipped, because `refs/stash` would be shared by every worktree),
`nested_clone_has_worktrees`, `nested_clone_has_hooks`,
`unsupported_local_git_config`, `unsupported_local_git_state`,
`unsupported_info_exclude`, `unsupported_nested_clone_refs`,
`unsupported_index_flags`, `unique_branch_reflog_commits`,
`nested_clone_ref_conflict`, `snapshot_mismatch`, `history_mismatch`,
restore point limits, and `nested_migration_recovery_conflict`.
- Reaper: `sweepTerminalWorkspaces` runs the conversion for open
workspaces when `PAPERCLIP_NESTED_CLONE_MIGRATION_ENABLED=true` (default
off). `PAPERCLIP_NESTED_CLONE_MIGRATION_DRY_RUN=true` only reports. Each
attempt logs `event: "execution_workspace.nested_clone_migration"` with
outcome, reason, branch, candidate bytes and reclaimed bytes. There is
no new timer. A retry deadline is registered on the existing reaper
deadlines only for a pending record or a transient skip (live consumer,
busy lock, changed row). A workspace is attempted again at most hourly.
A workspace without standalone clones costs one directory listing.
- `isolated-repository-rehome.ts`: I exported the shared pieces
(eligibility, locks, safe base config check, transfer plan, history
boundary, snapshot and history keys, item moves, rollback and
verification) instead of copying them. Re-home behavior does not change.
- `nested-repository-worktrees.ts`: I exported the stale registration
cleanup and added the `moveNestedRepositoryWorktree` (move and repair)
and `trackNestedWorktreeUpstream` helpers.
- Docs: `doc/recoverable-disk-cleanup.md` (new section about the
conversion) and `doc/DEVELOPING.md` (settings).

## Verification

- `cd server && pnpm exec vitest run
src/__tests__/nested-clone-migration.test.ts`: 19 passed. It uses
embedded Postgres and real git repositories, and covers:
- Conversion with an unpushed commit, a second branch with unpushed
commits, a lightweight tag and an annotated tag, staged and unstaged
edits, a deleted file, an untracked file with mode 0600, an ignored
file, `node_modules`, `.env` and the settings file. The result is a
worktree of the shared clone (`--git-common-dir`), with the same HEAD,
status, staged content and reflog, the owned branch tracking
`origin/main`, and the history under the task's prefix. No other branch
in the shared clone changes. The old clone is gone and reclaimed bytes
are reported.
- Skips with nothing changed: stash, live run, foreign origin, unknown
checkout, no shared clone, local excludes, and a change between capture
and verification (hook).
- Two tasks convert concurrently against one shared clone. The test
holds the shared-clone lock and shows that neither conversion advances.
Then they run one at a time (peak 1), and each keeps its own branches.
- Crash recovery at `afterAside`, `afterMove` and `afterFinalize` (roll
forward), and before the swap (roll back, then convert). A dry run
reports the pending record and changes nothing.
- Expiration and restore of a converted checkout work afterwards.
Unpushed branches in a converted checkout block expiration
(`unpushed_managed_checkout_branches`).
- Reaper: disabled by default; dry run changes nothing and registers no
deadline; a live run registers a retry deadline that survives a second
sweep; enabled conversion leaves no deadline.
- Commits that only the clone's `origin` remote-tracking refs hold are
kept as `paperclip/<id>/remotes/origin/<branch>`.
- A pending conversion is finished after the workspace is archived, and
forgotten (keeping unpushed branches) when the worktree is gone.
- A filter planted in the shared clone right before the worktree add is
refused (`unsupported_base_git_config`) and never runs.
- Only checkouts named after configured project repositories are
considered; at most 8 per sweep, pending first, rotating (unit test).
- Related suites (run before review fixes; the nested, re-home and
execution workspace service suites were rerun after them): `pnpm exec
vitest run` on execution-workspaces-routes,
execution-workspaces-service, git-reproducible-managed-checkouts,
git-restore-point (all 5 files), git-workspace-contents,
git-workspace-expiration, git-workspace-expiration-eligibility,
heartbeat-git-restore-point-resume, heartbeat-project-repositories,
isolated-repository-rehome, nested-clone-migration: 15 files, 393 tests
passed.
- `cd server && pnpm run typecheck` (`tsc --noEmit`): passes.
- Mutation checks. Each mutation makes the targeted test fail, and I
reverted each one:
  - Remove both eligibility checks: the live run test fails.
- Remove the snapshot comparison: the capture/verify change test fails.
  - Disable roll forward after the swap: the recovery tests fail.
- Drop the `paperclip/<id>/` prefix from transferred branches: the
conversion and concurrency tests fail.
  - Remove the shared-clone lock: the concurrency test fails.
- Drop remote-tracking refs from the plan, the deadline restore during
backoff, archived recovery, or the config check before the worktree add:
each targeted test fails.
- Not run: the full server suite and a run on a live instance.

## Risks

- The setting is off by default. Enable a dry run first and read the
`execution_workspace.nested_clone_migration` logs.
- A clone that a run writes to between verification and the swap rename
can lose those writes. This window is the same as in #17369. Locks, the
eligibility check and the git lock files make it small.
- A crash in the short time between the rename aside and `git worktree
move` can collide with a run that recreates `<name>`. In that case the
conversion stops with `nested_migration_recovery_conflict` and keeps
both copies. Expiration keeps such a workspace because of the `.clone-`
names.
- Stashes, annotated tag messages (the tagged commit is kept as a
branch), custom hooks, custom local config and per-branch reflog-only
history are not converted. Clones with them are skipped with a named
reason. Only unpushed branches and tags get a name in the shared clone.
A pushed local branch name is not kept, but its commits stay on the
remote.
- The conversion fetches the shared clone first. A failed fetch is
ignored, and then the restore point can be larger.
- Pushing from a converted checkout needs an explicit destination, as
for all #17382 nested worktrees.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), 1M context, through Claude Code
with tool use (shell, file editing, test execution).

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [ ] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-10-23 19:52:07 -07:00

489 lines
33 KiB
TypeScript

import { promises as fs } from "node:fs";
import os from "node:os";
import path from "node:path";
import { execFileSync } from "node:child_process";
import { randomUUID } from "node:crypto";
import { pathToFileURL } from "node:url";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { eq, sql } from "drizzle-orm";
import { agents, companies, createDb, executionWorkspaceRestorePoints, executionWorkspaces, heartbeatRuns, issues, projects, projectWorkspaces } from "@paperclipai/db";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
import { materializeManagedProjectWorkspace } from "../services/managed-repository-checkout.js";
import { materializeIsolatedRemoteWorkspaceSource } from "../services/isolated-remote-workspace-source.js";
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 { 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";
const gitEnv = { ...process.env, GIT_AUTHOR_NAME: "Fixture", GIT_AUTHOR_EMAIL: "fixture@example.test",
GIT_COMMITTER_NAME: "Fixture", GIT_COMMITTER_EMAIL: "fixture@example.test", GIT_TERMINAL_PROMPT: "0" };
const git = (cwd: string, ...args: string[]) => execFileSync("git", args, { cwd, env: gitEnv, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
const exists = (target: string) => fs.lstat(target).then(() => true, () => false);
const DAY_MS = 24 * 60 * 60 * 1000;
const noAuth = async () => null;
describe("nested clone migration batches", () => {
it("puts pending recoveries first, rotates through the rest and stops retrying an exhausted rotation until the list changes", () => {
const listed = Array.from({ length: 10 }, (_, index) => `repo-${index.toString(16).padStart(12, "0")}`);
const first = nextNestedCloneMigrationBatch(listed, [listed[9]!], undefined, 4);
expect(first).toMatchObject({ names: [listed[9], listed[0], listed[1], listed[2]], overflow: true, retryOverflow: true });
const second = nextNestedCloneMigrationBatch(listed, [], first.rotation, 4);
expect(second).toMatchObject({ names: [listed[3], listed[4], listed[5], listed[6]], retryOverflow: true });
const third = nextNestedCloneMigrationBatch(listed, [], second.rotation, 4);
expect(third).toMatchObject({ names: [listed[7], listed[8], listed[0], listed[1]], retryOverflow: false });
expect([...third.rotation.visited]).toEqual([]);
const fourth = nextNestedCloneMigrationBatch(listed, [], third.rotation, 4);
expect(fourth).toMatchObject({ names: listed.slice(0, 4), retryOverflow: false });
const grown = [...listed, "repo-aaaaaaaaaaaa"];
expect(nextNestedCloneMigrationBatch(grown, [], fourth.rotation, 4)).toMatchObject({ retryOverflow: true });
expect(nextNestedCloneMigrationBatch(listed.slice(0, 3), [], undefined, 4)).toMatchObject({ names: listed.slice(0, 3), overflow: false, retryOverflow: false });
});
});
const support = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = support.supported ? describe : describe.skip;
describeEmbeddedPostgres("nested repository clone migration", () => {
let db: ReturnType<typeof createDb>;
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
let root: string;
const previousHome = process.env.PAPERCLIP_HOME;
const previousInstance = process.env.PAPERCLIP_INSTANCE_ID;
beforeAll(async () => {
database = await startEmbeddedPostgresTestDatabase("nested-clone-migration-");
db = createDb(database.connectionString);
root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-nested-migration-"));
process.env.PAPERCLIP_HOME = path.join(root, "home");
process.env.PAPERCLIP_INSTANCE_ID = "default";
vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY", "51".repeat(32));
});
afterEach(async () => {
await db.delete(executionWorkspaceRestorePoints);
await db.execute(sql`TRUNCATE TABLE companies CASCADE`);
});
afterAll(async () => {
vi.unstubAllEnvs();
if (previousHome === undefined) delete process.env.PAPERCLIP_HOME; else process.env.PAPERCLIP_HOME = previousHome;
if (previousInstance === undefined) delete process.env.PAPERCLIP_INSTANCE_ID; else process.env.PAPERCLIP_INSTANCE_ID = previousInstance;
await db.$client.end({ timeout: 1 }); await database.cleanup(); await fs.rm(root, { recursive: true, force: true });
});
async function repository(location: string, name: string, files: Record<string, string>) {
const author = path.join(location, `${name}-author`), remote = path.join(location, `${name}.git`);
await fs.mkdir(author, { recursive: true });
git(author, "init", "--initial-branch=main");
for (const [file, contents] of Object.entries(files)) await fs.writeFile(path.join(author, file), contents);
git(author, "add", "."); git(author, "commit", "-m", `${name} A`);
git(location, "clone", "--bare", author, remote);
return { author, remote, url: pathToFileURL(remote).href, commit: git(author, "rev-parse", "HEAD") };
}
async function fixture(options: { issueStatus?: "in_progress" | "done"; sharedBase?: boolean } = {}) {
const companyId = randomUUID(), projectId = randomUUID();
const location = path.join(root, companyId);
const main = await repository(location, "remote", { "marker.txt": "commit A\n", ".gitignore": "node_modules/\n.claude/settings.local.json\n" });
const nested = await repository(location, "nested", { "nested.txt": "nested A\n", "remove-me.txt": "tracked\n",
".gitignore": "node_modules/\n.env\n.claude/settings.local.json\n*.log\n" });
await db.insert(companies).values({ id: companyId, name: "Nested migration fixture", issuePrefix: `NM${companyId.slice(0, 6)}` });
await db.insert(projects).values({ id: projectId, companyId, name: "Fixture project" });
const [source] = await db.insert(projectWorkspaces).values({ companyId, projectId, name: "Nested repository", repoUrl: nested.url, repoRef: "main" }).returning();
const candidates = nestedRepositoryBaseCandidates({ companyId, projectId, repoUrl: nested.url });
if (options.sharedBase !== false) await materializeManagedProjectWorkspace(candidates[0]!, { repoUrl: nested.url });
const done = options.issueStatus === "done";
const task = async (label: string) => {
const issueId = randomUUID(), workspaceId = randomUUID();
await db.insert(issues).values({ id: issueId, companyId, projectId, title: `Task ${label}`, status: done ? "done" : "in_progress",
...(done ? { completedAt: new Date(Date.now() - 2 * DAY_MS) } : {}) });
const baseCwd = await materializeIsolatedRemoteWorkspaceSource({ companyId, issueId, repoUrl: main.url, resolveGitAuth: noAuth });
const base = { baseCwd, source: "task_session" as const, projectId: null, workspaceId: null, repoUrl: main.url, repoRef: "origin/main" };
const realized = await realizeExecutionWorkspace({ base, issue: { id: issueId, identifier: `NM-${label}`, title: "Fixture" },
agent: { id: null, name: "Fixture", companyId }, skipHostProvision: true,
config: { workspaceStrategy: { type: "git_worktree", branchTemplate: `task-${label}`, worktreeParentDir: path.join(baseCwd, ".paperclip", "worktrees"), baseRef: "origin/main" } } });
await db.insert(executionWorkspaces).values({ id: workspaceId, companyId, projectId, sourceIssueId: issueId, mode: "isolated_workspace",
strategyType: "git_worktree", name: `Task ${label}`, status: "active", providerType: "local_fs", cwd: realized.cwd, providerRef: realized.cwd,
repoUrl: main.url, baseRef: "origin/main", branchName: realized.branchName,
metadata: { createdByRuntime: realized.branchCreatedByRuntime, gitBranchOwnershipVersion: 1, baseRefSha: realized.baseRefSha,
isolatedRemoteSource: { schema: "paperclip.isolated-remote-source.v1", companyId, issueId, repoUrl: main.url } } });
await db.update(issues).set({ executionWorkspaceId: workspaceId }).where(eq(issues.id, issueId));
const [prepared] = await prepareProjectRepositoryWorkspaces({ cwd: realized.cwd, anchorRepoUrl: main.url, workspaces: [source!], resolveGitAuth: noAuth });
const checkout = await fs.realpath(prepared!.cwd);
const row = () => db.select().from(executionWorkspaces).where(eq(executionWorkspaces.id, workspaceId)).then(rows => rows[0]!);
const migrate = async (migrationOptions: NestedCloneMigrationOptions = {}) =>
migrateNestedRepositoryClone(db, await row(), path.basename(checkout), { resolveGitAuth: noAuth, ...migrationOptions });
const expire = async () => expireReconstructableGitWorkspace(db, await row(), { dryRun: false, expireBase: false, now: new Date(),
restorePoint: { storage: getStorageService(), notBefore: new Date(Date.now() - DAY_MS), keyDurable: true } });
const reopen = () => executionWorkspaceService(db).reopenClosedIsolatedExecutionWorkspaceForIssue({ workspaceId,
issue: { id: issueId, companyId, projectId: null }, actor: { actorType: "user", agentId: null },
authorizationActor: { type: "board", source: "local_implicit", isInstanceAdmin: true } });
return { issueId, workspaceId, cwd: realized.cwd, checkout, name: path.basename(checkout), prefix: await nestedWorktreeBranchPrefix(checkout),
row, migrate, expire, reopen };
};
const first = await task("one");
return { companyId, projectId, main, nested, candidates, base: candidates[0]!, task, ...first };
}
async function nestedWork(checkout: string) {
await fs.writeFile(path.join(checkout, "nested.txt"), "nested B (unpushed)\n");
git(checkout, "commit", "-qam", "Nested B unpushed");
const unpushed = git(checkout, "rev-parse", "HEAD");
git(checkout, "checkout", "-q", "-b", "side");
await fs.writeFile(path.join(checkout, "side.txt"), "side branch work\n");
git(checkout, "add", "side.txt"); git(checkout, "commit", "-qm", "Side work");
const side = git(checkout, "rev-parse", "HEAD");
git(checkout, "tag", "local-tag");
git(checkout, "checkout", "-q", "main");
git(checkout, "tag", "-a", "-m", "annotated local release", "local-release");
await fs.writeFile(path.join(checkout, "nested.txt"), "staged edit\n");
git(checkout, "add", "nested.txt");
await fs.writeFile(path.join(checkout, "nested.txt"), "unstaged edit\n");
await fs.rm(path.join(checkout, "remove-me.txt"));
await fs.writeFile(path.join(checkout, "draft.md"), "untracked work\n");
await fs.chmod(path.join(checkout, "draft.md"), 0o600);
await fs.writeFile(path.join(checkout, "build.log"), "ignored output\n");
await fs.mkdir(path.join(checkout, "node_modules", "pkg"), { recursive: true });
await fs.writeFile(path.join(checkout, "node_modules", "pkg", "index.js"), "module.exports = 1;\n");
await fs.writeFile(path.join(checkout, ".env"), "LOCAL_SETTING=1\n");
await fs.mkdir(path.join(checkout, ".claude"));
await fs.writeFile(path.join(checkout, ".claude", "settings.local.json"), "{\"local\":true}\n");
return { unpushed, side };
}
const gitView = (cwd: string) => ({ head: git(cwd, "rev-parse", "HEAD"), status: git(cwd, "status", "--porcelain=v1", "--untracked-files=all"),
reflog: git(cwd, "reflog", "show", "--format=%H", "HEAD"), staged: git(cwd, "show", ":nested.txt") });
const ownedRefs = (base: string, prefix: string) => git(base, "for-each-ref", "--format=%(refname) %(objectname)", `refs/heads/${prefix}`);
const worktreePaths = (base: string) => git(base, "worktree", "list", "--porcelain").split("\n").filter(line => line.startsWith("worktree ")).map(line => line.slice(9));
const staging = (checkout: string) => [".clone-migrating", ".clone-standalone", ".clone-trash"].map(suffix => `${checkout}${suffix}`);
async function expectUntouched(f: Awaited<ReturnType<typeof fixture>>, before: Awaited<ReturnType<typeof currentRestorePointSnapshot>>, checkout = f.checkout, prefix = f.prefix) {
expect((await fs.lstat(path.join(checkout, ".git"))).isDirectory()).toBe(true);
expect(await currentRestorePointSnapshot(checkout)).toEqual(before);
for (const target of staging(checkout)) expect(await exists(target)).toBe(false);
expect(ownedRefs(f.base, prefix)).toBe("");
expect(worktreePaths(f.base)).toHaveLength(1);
expect((await f.row()).metadata?.nestedCloneMigrationPending).toBeUndefined();
}
it("converts a standalone nested clone into a worktree of the shared clone with its exact state and history", async () => {
const f = await fixture();
const { unpushed, side } = await nestedWork(f.checkout);
const before = await currentRestorePointSnapshot(f.checkout);
const view = gitView(f.checkout);
const realBase = await fs.realpath(f.base);
expect(await listNestedCloneMigrationNames(db, await f.row())).toEqual([f.name]);
const result = await f.migrate();
expect(result).toMatchObject({ outcome: "migrated", reason: null, branchName: `${f.prefix}main` });
expect(result.reclaimedBytes).toBeGreaterThan(0);
expect(result.candidateBytes).toBeGreaterThan(0);
expect((await fs.lstat(path.join(f.checkout, ".git"))).isFile()).toBe(true);
expect(git(f.checkout, "rev-parse", "--path-format=absolute", "--git-common-dir")).toBe(path.join(realBase, ".git"));
expect(await readLinkedNestedCheckout(f.checkout)).toMatchObject({ base: realBase, checkout: f.checkout });
expect(worktreePaths(f.base)).toContain(f.checkout);
expect(await currentRestorePointSnapshot(f.checkout)).toEqual(before);
expect(gitView(f.checkout)).toEqual(view);
expect(view.head).toBe(unpushed);
expect(git(f.checkout, "symbolic-ref", "--short", "HEAD")).toBe(`${f.prefix}main`);
expect(git(f.checkout, "rev-parse", "--abbrev-ref", "@{upstream}")).toBe("origin/main");
expect(await fs.readFile(path.join(f.checkout, "nested.txt"), "utf8")).toBe("unstaged edit\n");
expect(await exists(path.join(f.checkout, "remove-me.txt"))).toBe(false);
expect((await fs.stat(path.join(f.checkout, "draft.md"))).mode & 0o777).toBe(0o600);
expect(await fs.readFile(path.join(f.checkout, "build.log"), "utf8")).toBe("ignored output\n");
expect(await fs.readFile(path.join(f.checkout, "node_modules", "pkg", "index.js"), "utf8")).toBe("module.exports = 1;\n");
expect(await fs.readFile(path.join(f.checkout, ".env"), "utf8")).toBe("LOCAL_SETTING=1\n");
expect(await fs.readFile(path.join(f.checkout, ".claude", "settings.local.json"), "utf8")).toBe("{\"local\":true}\n");
expect(git(f.base, "rev-parse", `refs/heads/${f.prefix}main`)).toBe(unpushed);
expect(git(f.base, "rev-parse", `refs/heads/${f.prefix}side`)).toBe(side);
expect(git(f.base, "rev-parse", `refs/heads/${f.prefix}tags/local-tag`)).toBe(side);
expect(git(f.base, "rev-parse", `refs/heads/${f.prefix}tags/local-release`)).toBe(unpushed);
expect(git(f.base, "for-each-ref", "--format=%(refname)", "refs/heads").split("\n").filter(ref => !ref.startsWith(`refs/heads/${f.prefix}`))).toEqual(["refs/heads/main"]);
expect(git(f.base, "status", "--porcelain")).toBe("");
for (const target of staging(f.checkout)) expect(await exists(target)).toBe(false);
expect((await f.row()).metadata?.nestedCloneMigrationPending).toBeUndefined();
expect(await f.migrate()).toMatchObject({ outcome: "skipped", reason: "already_worktree_backed" });
expect(await listNestedCloneMigrationNames(db, await f.row())).toEqual([]);
});
it("keeps commits that only the clone's remote-tracking refs still hold", async () => {
const f = await fixture();
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(() => git(f.base, "cat-file", "-e", `${feature}^{commit}`)).toThrow();
expect(await f.migrate()).toMatchObject({ outcome: "migrated" });
expect(git(f.base, "rev-parse", `refs/heads/${f.prefix}remotes/origin/squashed-away`)).toBe(feature);
expect(git(f.checkout, "cat-file", "-t", feature)).toBe("commit");
});
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);
const before = await currentRestorePointSnapshot(f.checkout);
expect(await f.migrate({ hooks: { afterMove: async () => { throw new Error("simulated crash"); } } })).toMatchObject({ outcome: "skipped" });
await db.update(executionWorkspaces).set({ status: "archived", closedAt: new Date() }).where(eq(executionWorkspaces.id, f.workspaceId));
const service = executionWorkspaceService(db, { nestedCloneMigrationEnabled: true });
await service.sweepTerminalWorkspaces();
expect((await f.row()).metadata?.nestedCloneMigrationPending).toBeUndefined();
expect(await exists(`${f.checkout}.clone-standalone`)).toBe(false);
expect(git(f.checkout, "rev-parse", "HEAD")).toBe(unpushed);
expect(await currentRestorePointSnapshot(f.checkout)).toEqual(before);
expect(await service.nextTerminalWorkspaceSweepAt()).toBeNull();
const second = await f.task("two");
await nestedWork(second.checkout);
expect(await second.migrate({ hooks: { afterAside: async () => { throw new Error("simulated crash"); } } })).toMatchObject({ outcome: "skipped" });
await db.update(executionWorkspaces).set({ status: "archived", closedAt: new Date() }).where(eq(executionWorkspaces.id, second.workspaceId));
await fs.rm(second.cwd, { recursive: true, force: true });
expect(await second.migrate()).toMatchObject({ outcome: "rolled_back", reason: "workspace_removed" });
expect((await second.row()).metadata?.nestedCloneMigrationPending).toBeUndefined();
expect(git(f.base, "rev-parse", `refs/heads/${second.prefix}main`)).toBeTruthy();
});
it("skips a nested clone with a stash without changing anything", async () => {
const f = await fixture();
await fs.writeFile(path.join(f.checkout, "nested.txt"), "stashed edit\n");
git(f.checkout, "stash", "-q");
const before = await currentRestorePointSnapshot(f.checkout);
expect(await f.migrate()).toMatchObject({ outcome: "skipped", reason: "nested_clone_has_stash" });
await expectUntouched(f, before);
expect(git(f.checkout, "stash", "list")).not.toBe("");
});
it("skips a nested clone whose local excludes the shared clone would not apply", async () => {
const f = await fixture();
await fs.appendFile(path.join(f.checkout, ".git", "info", "exclude"), "\nscratch/\n");
await fs.mkdir(path.join(f.checkout, "scratch"));
await fs.writeFile(path.join(f.checkout, "scratch", "notes.txt"), "locally excluded\n");
const before = await currentRestorePointSnapshot(f.checkout);
expect(await f.migrate()).toMatchObject({ outcome: "skipped", reason: "unsupported_info_exclude" });
await expectUntouched(f, before);
});
it("never checks out through filters planted in the shared clone during the conversion", async () => {
const f = await fixture();
await fs.writeFile(path.join(f.checkout, ".gitattributes"), "*.dat filter=evil\n");
await fs.writeFile(path.join(f.checkout, "blob.dat"), "payload\n");
git(f.checkout, "add", ".gitattributes", "blob.dat"); git(f.checkout, "commit", "-qm", "Attributes");
await fs.writeFile(path.join(f.checkout, "draft.md"), "untracked work\n");
const marker = path.join(path.dirname(f.nested.remote), "smudge-ran");
const before = await currentRestorePointSnapshot(f.checkout);
const result = await f.migrate({ hooks: { beforeWorktreeAdd: async () => {
git(f.base, "config", "filter.evil.smudge", `sh -c 'touch ${marker}; cat'`);
git(f.base, "config", "filter.evil.required", "true");
} } });
expect(result).toMatchObject({ outcome: "skipped", reason: "unsupported_base_git_config" });
expect(await exists(marker)).toBe(false);
git(f.base, "config", "--remove-section", "filter.evil");
await expectUntouched(f, before);
});
it("skips while a run is live", async () => {
const f = await fixture();
await nestedWork(f.checkout);
const agentId = randomUUID();
await db.insert(agents).values({ id: agentId, companyId: f.companyId, name: "Fixture", role: "engineer", adapterType: "process" });
await db.insert(heartbeatRuns).values({ id: randomUUID(), companyId: f.companyId, agentId, issueId: f.issueId, status: "running" });
const before = await currentRestorePointSnapshot(f.checkout);
expect(await f.migrate()).toMatchObject({ outcome: "skipped", reason: "live_or_unresolved_consumer" });
await expectUntouched(f, before);
});
it("skips a nested clone whose origin differs from the project repository", async () => {
const f = await fixture();
await nestedWork(f.checkout);
git(f.checkout, "remote", "set-url", "origin", f.main.url);
const before = await currentRestorePointSnapshot(f.checkout);
expect(await f.migrate()).toMatchObject({ outcome: "skipped", reason: "foreign_origin" });
await expectUntouched(f, before);
});
it("skips a checkout that is not a configured project repository", async () => {
const f = await fixture();
const stray = path.join(path.dirname(f.checkout), "stray-0123456789ab");
git(path.dirname(stray), "clone", "-q", f.nested.url, stray);
const before = await currentRestorePointSnapshot(stray);
for (let index = 0; index < 20; index += 1) await fs.mkdir(path.join(path.dirname(f.checkout), `agent-${index.toString(16).padStart(12, "0")}`, ".git"), { recursive: true });
expect(await listNestedCloneMigrationNames(db, await f.row())).toEqual([f.name]);
expect(await migrateNestedRepositoryClone(db, await f.row(), "stray-0123456789ab", { resolveGitAuth: noAuth }))
.toMatchObject({ outcome: "skipped", reason: "unknown_managed_checkout" });
await expectUntouched(f, before, stray, await nestedWorktreeBranchPrefix(stray));
});
it("skips when the project has no shared clone", async () => {
const f = await fixture({ sharedBase: false });
expect(await f.migrate()).toMatchObject({ outcome: "skipped", reason: "no_shared_base" });
expect((await fs.lstat(path.join(f.checkout, ".git"))).isDirectory()).toBe(true);
});
it("rolls back and keeps the clone when it changes between capture and verification", async () => {
const f = await fixture();
await nestedWork(f.checkout);
const before = await currentRestorePointSnapshot(f.checkout);
const result = await f.migrate({ hooks: { beforeVerify: async ({ from, to }) => {
expect(await exists(path.join(to, "node_modules", "pkg", "index.js"))).toBe(true);
await fs.writeFile(path.join(from, "late.txt"), "written by a concurrent run\n");
} } });
expect(result).toMatchObject({ outcome: "skipped", reason: "snapshot_mismatch" });
await fs.rm(path.join(f.checkout, "late.txt"));
await expectUntouched(f, before);
expect(await fs.readFile(path.join(f.checkout, "node_modules", "pkg", "index.js"), "utf8")).toBe("module.exports = 1;\n");
expect(await fs.readFile(path.join(f.checkout, ".env"), "utf8")).toBe("LOCAL_SETTING=1\n");
});
it("serializes two tasks converting against the same shared clone and keeps their branches apart", async () => {
const f = await fixture();
const second = await f.task("two");
const one = await nestedWork(f.checkout);
await fs.writeFile(path.join(second.checkout, "nested.txt"), "second task work\n");
git(second.checkout, "commit", "-qam", "Second task unpushed");
const twoHead = git(second.checkout, "rev-parse", "HEAD");
let active = 0, peak = 0, reached = 0;
const hooks = { beforeVerify: async () => {
reached += 1; active += 1; peak = Math.max(peak, active);
await new Promise(resolve => setTimeout(resolve, 200));
active -= 1;
} };
let release!: () => void;
const held = withNestedRepositoryBaseLock(f.base, () => new Promise<void>(resolve => { release = resolve; }));
const running = Promise.all([f.migrate({ hooks }), second.migrate({ hooks })]);
await new Promise(resolve => setTimeout(resolve, 3_000));
expect(reached).toBe(0);
expect((await fs.lstat(path.join(f.checkout, ".git"))).isDirectory()).toBe(true);
expect((await fs.lstat(path.join(second.checkout, ".git"))).isDirectory()).toBe(true);
expect(ownedRefs(f.base, f.prefix)).toBe("");
release();
await held;
const [a, b] = await running;
expect(a).toMatchObject({ outcome: "migrated" });
expect(b).toMatchObject({ outcome: "migrated" });
expect(peak).toBe(1);
expect(f.prefix).not.toBe(second.prefix);
expect(git(f.checkout, "rev-parse", "HEAD")).toBe(one.unpushed);
expect(git(second.checkout, "rev-parse", "HEAD")).toBe(twoHead);
expect(ownedRefs(f.base, second.prefix)).toBe(`refs/heads/${second.prefix}main ${twoHead}`);
expect(ownedRefs(f.base, f.prefix).split("\n")).toHaveLength(4);
expect(worktreePaths(f.base)).toEqual(expect.arrayContaining([f.checkout, second.checkout]));
});
it.each(["afterAside", "afterMove", "afterFinalize"] as const)("recovers a conversion interrupted at %s on the next pass", async (step) => {
const f = await fixture();
const { unpushed } = await nestedWork(f.checkout);
const before = await currentRestorePointSnapshot(f.checkout);
const crashed = await f.migrate({ hooks: { [step]: async () => { throw new Error("simulated crash"); } } });
expect(crashed).toMatchObject({ outcome: "skipped" });
expect(await exists(`${f.checkout}.clone-standalone`)).toBe(true);
expect((await f.row()).metadata?.nestedCloneMigrationPending).toMatchObject({ [f.name]: { phase: step === "afterFinalize" ? "cleanup" : "swap" } });
expect(await listNestedCloneMigrationNames(db, await f.row())).toEqual([f.name]);
expect(await f.migrate({ dryRun: true })).toMatchObject({ outcome: "dry_run", reason: "interrupted_migration_pending" });
expect(await exists(`${f.checkout}.clone-standalone`)).toBe(true);
const recovered = await f.migrate();
expect(recovered).toMatchObject({ outcome: "recovered", reason: null, branchName: `${f.prefix}main` });
expect(recovered.reclaimedBytes).toBeGreaterThan(0);
expect((await fs.lstat(path.join(f.checkout, ".git"))).isFile()).toBe(true);
expect(await currentRestorePointSnapshot(f.checkout)).toEqual(before);
expect(git(f.checkout, "rev-parse", "HEAD")).toBe(unpushed);
for (const target of staging(f.checkout)) expect(await exists(target)).toBe(false);
expect((await f.row()).metadata?.nestedCloneMigrationPending).toBeUndefined();
expect(worktreePaths(f.base)).toEqual([await fs.realpath(f.base), f.checkout]);
});
it("undoes a conversion interrupted before the swap and then completes it", async () => {
const f = await fixture();
const { unpushed } = await nestedWork(f.checkout);
const before = await currentRestorePointSnapshot(f.checkout);
const realBase = await fs.realpath(f.base);
const staged = `${f.checkout}.clone-migrating`;
const branch = `${f.prefix}main`;
git(realBase, "fetch", "-q", f.checkout, `refs/heads/main:refs/heads/${branch}`);
git(realBase, "worktree", "add", "-q", staged, branch);
await fs.rename(path.join(f.checkout, "node_modules"), path.join(staged, "node_modules"));
const pending = { base: realBase, head: unpushed, branch, refs: [{ ref: `refs/heads/${branch}`, commit: unpushed, upstream: "main" }],
moves: ["node_modules"], phase: "transfer" };
const current = await f.row();
await db.update(executionWorkspaces).set({ metadata: { ...current.metadata, nestedCloneMigrationPending: { [f.name]: pending } } })
.where(eq(executionWorkspaces.id, f.workspaceId));
expect(await f.migrate({ dryRun: true })).toMatchObject({ outcome: "dry_run", reason: "interrupted_migration_pending" });
expect(await exists(path.join(staged, "node_modules"))).toBe(true);
const result = await f.migrate();
expect(result).toMatchObject({ outcome: "migrated", reason: null });
expect(await currentRestorePointSnapshot(f.checkout)).toEqual(before);
expect(await fs.readFile(path.join(f.checkout, "node_modules", "pkg", "index.js"), "utf8")).toBe("module.exports = 1;\n");
for (const target of staging(f.checkout)) expect(await exists(target)).toBe(false);
expect(worktreePaths(f.base)).toEqual([realBase, f.checkout]);
expect((await f.row()).metadata?.nestedCloneMigrationPending).toBeUndefined();
});
it("expires and restores a converted checkout and protects its unpushed work", async () => {
const f = await fixture({ issueStatus: "done" });
await fs.writeFile(path.join(f.checkout, "nested.txt"), "nested B (unpushed)\n");
git(f.checkout, "commit", "-qam", "Nested B unpushed");
const unpushed = git(f.checkout, "rev-parse", "HEAD");
await fs.writeFile(path.join(f.checkout, "draft.md"), "nested untracked work\n");
expect(await f.migrate()).toMatchObject({ outcome: "migrated" });
const realBase = await fs.realpath(f.base);
expect(await f.expire()).toMatchObject({ expired: true, restorePoint: true, skipReason: null });
expect(await exists(f.cwd)).toBe(false);
const recipe = (await f.row()).metadata?.gitCacheRestoration as Record<string, any>;
expect(recipe.contents.checkouts[0]).toMatchObject({ name: f.name, commit: unpushed, branchName: `${f.prefix}main`, worktree: { base: realBase } });
expect(await f.reopen()).toMatchObject({ ok: true, reopened: true });
expect(git(f.checkout, "rev-parse", "--path-format=absolute", "--git-common-dir")).toBe(path.join(realBase, ".git"));
expect(git(f.checkout, "symbolic-ref", "--short", "HEAD")).toBe(`${f.prefix}main`);
expect(git(f.checkout, "rev-parse", "HEAD")).toBe(unpushed);
expect(await fs.readFile(path.join(f.checkout, "draft.md"), "utf8")).toBe("nested untracked work\n");
const second = await f.task("two");
await nestedWork(second.checkout);
await fs.rm(path.join(second.checkout, "node_modules"), { recursive: true });
expect(await second.migrate()).toMatchObject({ outcome: "migrated" });
expect(await second.expire()).toMatchObject({ expired: false, skipReason: "unpushed_managed_checkout_branches" });
expect(await exists(second.checkout)).toBe(true);
});
it("is disabled by default, changes nothing in dry run, and runs from the reaper when enabled", async () => {
const f = await fixture();
const { unpushed } = await nestedWork(f.checkout);
const before = await currentRestorePointSnapshot(f.checkout);
const disabled = executionWorkspaceService(db);
await disabled.sweepTerminalWorkspaces();
await expectUntouched(f, before);
expect(await disabled.nextTerminalWorkspaceSweepAt()).toBeNull();
const dryRun = executionWorkspaceService(db, { nestedCloneMigrationEnabled: true, nestedCloneMigrationDryRun: true });
await dryRun.sweepTerminalWorkspaces();
await expectUntouched(f, before);
expect(await dryRun.nextTerminalWorkspaceSweepAt()).toBeNull();
const agentId = randomUUID();
await db.insert(agents).values({ id: agentId, companyId: f.companyId, name: "Fixture", role: "engineer", adapterType: "process" });
const [run] = await db.insert(heartbeatRuns).values({ companyId: f.companyId, agentId, issueId: f.issueId, status: "running" }).returning();
const busy = executionWorkspaceService(db, { nestedCloneMigrationEnabled: true });
await busy.sweepTerminalWorkspaces();
await expectUntouched(f, before);
const retryAt = await busy.nextTerminalWorkspaceSweepAt();
expect(retryAt).toBeGreaterThan(Date.now());
await busy.sweepTerminalWorkspaces();
expect(await busy.nextTerminalWorkspaceSweepAt()).toBe(retryAt);
await db.update(heartbeatRuns).set({ status: "succeeded", finishedAt: new Date() }).where(eq(heartbeatRuns.id, run!.id));
const enabled = executionWorkspaceService(db, { nestedCloneMigrationEnabled: true });
await enabled.sweepTerminalWorkspaces();
expect((await fs.lstat(path.join(f.checkout, ".git"))).isFile()).toBe(true);
expect(git(f.checkout, "rev-parse", "HEAD")).toBe(unpushed);
expect(await currentRestorePointSnapshot(f.checkout)).toEqual(before);
expect(await enabled.nextTerminalWorkspaceSweepAt()).toBeNull();
});
});