mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-30 21:49:18 +01:00
## 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>
346 lines
18 KiB
TypeScript
346 lines
18 KiB
TypeScript
import fs from "node:fs/promises";
|
|
import { constants } from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { createHash } from "node:crypto";
|
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
import { execFile as execFileCallback } from "node:child_process";
|
|
import { promisify } from "node:util";
|
|
import { PROJECT_REPOSITORIES_DIR } from "@paperclipai/adapter-utils/git-workspace-sync";
|
|
import { resolveManagedProjectWorkspaceDir } from "../home-paths.js";
|
|
import { deriveRepoNameFromRepoUrl } from "./managed-project-checkout-name.js";
|
|
import { scrubGitCredentialText, type GitRemoteAuthProvider } from "./git-credentials.js";
|
|
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 DETACHED_REPOSITORIES_DIR = path.join(".paperclip-runtime", "detached-repositories");
|
|
|
|
export type LinkedNestedCheckout = { checkout: string; base: string; gitDir: string; admin: string; branchPrefix: string };
|
|
type GitInvocation = { configArgs?: string[]; env?: Record<string, string>; timeout?: number };
|
|
|
|
async function git(cwd: string, args: string[], options: GitInvocation = {}) {
|
|
const result = await execFile("git", [...options.configArgs ?? [], "-c", "core.fsmonitor=false", "-c", `core.hooksPath=${os.devNull}`, "-C", cwd, ...args], {
|
|
timeout: options.timeout ?? 60_000, maxBuffer: 16 * 1024 * 1024,
|
|
env: { ...sanitizeRuntimeServiceBaseEnv(process.env), GIT_TERMINAL_PROMPT: "0", ...options.env },
|
|
});
|
|
return result.stdout;
|
|
}
|
|
|
|
async function missing(target: string) {
|
|
return fs.lstat(target).then(() => false, (error: NodeJS.ErrnoException) => {
|
|
if (error.code === "ENOENT") return true;
|
|
throw error;
|
|
});
|
|
}
|
|
|
|
async function canonicalPath(target: string) {
|
|
return path.join(await fs.realpath(path.dirname(target)), path.basename(target));
|
|
}
|
|
|
|
const WORKTREE_POINTER_LIMIT = 4096;
|
|
|
|
export async function readWorktreePointer(admin: string) {
|
|
const target = path.join(admin, "gitdir");
|
|
const entry = await fs.lstat(target);
|
|
if (!entry.isFile() || entry.nlink !== 1 || entry.size > WORKTREE_POINTER_LIMIT) throw new Error("unproven_managed_checkout");
|
|
const handle = await fs.open(target, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
try {
|
|
const stat = await handle.stat();
|
|
if (!stat.isFile() || stat.nlink !== 1 || stat.size > WORKTREE_POINTER_LIMIT) throw new Error("unproven_managed_checkout");
|
|
const buffer = Buffer.alloc(WORKTREE_POINTER_LIMIT + 1);
|
|
let length = 0;
|
|
while (length < buffer.length) {
|
|
const { bytesRead } = await handle.read(buffer, length, buffer.length - length, length);
|
|
if (bytesRead === 0) break;
|
|
length += bytesRead;
|
|
}
|
|
if (length > WORKTREE_POINTER_LIMIT) throw new Error("unproven_managed_checkout");
|
|
return buffer.subarray(0, length).toString("utf8").trim();
|
|
} finally { await handle.close(); }
|
|
}
|
|
|
|
export function nestedRepositoryBaseCandidates(input: { companyId: string; projectId: string; repoUrl: string }) {
|
|
const root = resolveManagedProjectWorkspaceDir({ companyId: input.companyId, projectId: input.projectId,
|
|
repoName: deriveRepoNameFromRepoUrl(input.repoUrl) });
|
|
return [root, `${root}-${createHash("sha256").update(input.repoUrl).digest("hex").slice(0, 12)}`];
|
|
}
|
|
|
|
async function looseCanonicalPath(target: string): Promise<string> {
|
|
const real = await fs.realpath(target).catch(() => null);
|
|
if (real) return real;
|
|
const parent = path.dirname(target);
|
|
return parent === target ? target : path.join(await looseCanonicalPath(parent), path.basename(target));
|
|
}
|
|
|
|
export async function recordedNestedRepositoryBase(recorded: string, candidates: string[]) {
|
|
for (const candidate of candidates) if (await looseCanonicalPath(candidate) === recorded) return candidate;
|
|
return null;
|
|
}
|
|
|
|
export async function nestedWorktreeBranchPrefix(checkout: string) {
|
|
const id = createHash("sha256").update(await canonicalPath(checkout)).digest("hex").slice(0, 12);
|
|
return `paperclip/${id}/`;
|
|
}
|
|
|
|
export function isNestedWorktreeBranchName(name: string) {
|
|
return NESTED_WORKTREE_BRANCH.test(name);
|
|
}
|
|
|
|
export async function isVerifiedNestedRepositoryBase(base: string, candidates: string[], repoUrl: string) {
|
|
const real = await fs.realpath(base).catch(() => null);
|
|
if (!real) return false;
|
|
const allowed = await Promise.all(candidates.map(candidate => fs.realpath(candidate).catch(() => null)));
|
|
if (!allowed.includes(real)) return false;
|
|
const storage = await fs.lstat(path.join(real, ".git")).catch(() => null);
|
|
if (!storage?.isDirectory() || storage.isSymbolicLink()) return false;
|
|
const common = (await git(real, ["rev-parse", "--path-format=absolute", "--git-common-dir"]).catch(() => "")).trim();
|
|
if (common !== path.join(real, ".git")) return false;
|
|
return (await git(real, ["config", "--local", "--null", "--get-all", "remote.origin.url"]).catch(() => "")) === `${repoUrl}\0`;
|
|
}
|
|
|
|
export async function readLinkedNestedCheckout(checkout: string, baseCandidates?: string[]): Promise<LinkedNestedCheckout | null> {
|
|
const marker = await fs.lstat(path.join(checkout, ".git")).catch((error: NodeJS.ErrnoException) => {
|
|
if (error.code === "ENOENT") return null;
|
|
throw error;
|
|
});
|
|
if (!marker || marker.isDirectory()) return null;
|
|
if (!marker.isFile()) throw new Error("unproven_managed_checkout");
|
|
const real = await canonicalPath(checkout);
|
|
if (await fs.realpath(checkout) !== real) throw new Error("unproven_managed_checkout");
|
|
const gitDir = await fs.realpath((await git(real, ["rev-parse", "--absolute-git-dir"])).trim());
|
|
const common = await fs.realpath((await git(real, ["rev-parse", "--path-format=absolute", "--git-common-dir"])).trim());
|
|
if (path.basename(common) !== ".git" || path.dirname(gitDir) !== path.join(common, "worktrees")) throw new Error("unproven_managed_checkout");
|
|
const pointer = await readWorktreePointer(gitDir);
|
|
if (path.basename(pointer) !== ".git" || path.join(await fs.realpath(path.dirname(pointer)).catch(() => ""), ".git") !== path.join(real, ".git")) {
|
|
throw new Error("unproven_managed_checkout");
|
|
}
|
|
if ((await git(real, ["rev-parse", "--show-toplevel"])).trim() !== real) throw new Error("unproven_managed_checkout");
|
|
const base = path.dirname(common);
|
|
const storage = await fs.lstat(common);
|
|
if (!storage.isDirectory() || storage.isSymbolicLink()) throw new Error("unproven_managed_checkout");
|
|
if (baseCandidates) {
|
|
const allowed = await Promise.all(baseCandidates.map(candidate => fs.realpath(candidate).catch(() => null)));
|
|
if (!allowed.includes(base)) throw new Error("unproven_managed_checkout");
|
|
}
|
|
return { checkout: real, base, gitDir, admin: path.basename(gitDir), branchPrefix: await nestedWorktreeBranchPrefix(real) };
|
|
}
|
|
|
|
const heldBaseLocks = new AsyncLocalStorage<ReadonlySet<string>>();
|
|
const baseLocks = new Map<string, Promise<void>>();
|
|
|
|
export async function withNestedRepositoryBaseLock<T>(base: string, action: () => Promise<T>): Promise<T> {
|
|
const key = await looseCanonicalPath(path.resolve(base));
|
|
const held = heldBaseLocks.getStore() ?? new Set<string>();
|
|
if (held.has(key)) return action();
|
|
const previous = baseLocks.get(key) ?? Promise.resolve();
|
|
let release!: () => void;
|
|
const current = previous.then(() => new Promise<void>(resolve => { release = resolve; }));
|
|
baseLocks.set(key, current);
|
|
await previous;
|
|
try {
|
|
return await heldBaseLocks.run(new Set([...held, key]), action);
|
|
} finally {
|
|
release();
|
|
if (baseLocks.get(key) === current) baseLocks.delete(key);
|
|
}
|
|
}
|
|
|
|
export async function removeStaleNestedRegistrations(base: string, checkout: string) {
|
|
const expected = path.join(checkout, ".git");
|
|
const root = path.join(base, ".git", "worktrees");
|
|
for (const admin of await fs.readdir(root).catch(() => [] as string[])) {
|
|
const directory = path.join(root, admin);
|
|
const entry = await fs.lstat(directory).catch(() => null);
|
|
if (!entry?.isDirectory() || entry.isSymbolicLink()) continue;
|
|
const pointer = await readWorktreePointer(directory).catch(() => "");
|
|
if (pointer !== expected || !await missing(checkout)) continue;
|
|
await fs.rm(directory, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
async function checkedOutBranches(base: string) {
|
|
return new Set((await git(base, ["worktree", "list", "--porcelain"])).split("\n")
|
|
.filter(line => line.startsWith("branch ")).map(line => line.slice("branch ".length)));
|
|
}
|
|
|
|
async function resolveStartPoint(base: string, repoRef: string | null) {
|
|
const commit = async (ref: string) => (await git(base, ["rev-parse", "--verify", "-q", `${ref}^{commit}`]).catch(() => "")).trim();
|
|
const ref = repoRef?.trim().replace(/^refs\/heads\//, "") || null;
|
|
if (ref) {
|
|
if (await commit(`refs/remotes/origin/${ref}`)) return { branch: ref, start: `refs/remotes/origin/${ref}` };
|
|
const detached = await commit(ref);
|
|
if (!detached) throw new Error(`Repository ref "${ref}" was not found`);
|
|
return { branch: null, start: detached };
|
|
}
|
|
const remoteHead = (await git(base, ["symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"]).catch(() => "")).trim();
|
|
const fallback = (await git(base, ["symbolic-ref", "--quiet", "--short", "HEAD"]).catch(() => "")).trim();
|
|
const branch = remoteHead.startsWith("refs/remotes/origin/") ? remoteHead.slice("refs/remotes/origin/".length) : fallback;
|
|
if (branch && await commit(`refs/remotes/origin/${branch}`)) return { branch, start: `refs/remotes/origin/${branch}` };
|
|
return { branch: null, start: await commit("HEAD") };
|
|
}
|
|
|
|
class NestedWorktreeAuthorizationFailure extends Error {
|
|
constructor(readonly cause: unknown) { super("nested_worktree_authorization_failed"); }
|
|
}
|
|
|
|
export async function addNestedRepositoryWorktree(input: {
|
|
base: string;
|
|
cwd: string;
|
|
repoUrl: string;
|
|
repoRef: string | null;
|
|
resolveGitAuth?: GitRemoteAuthProvider | null;
|
|
beforeAdd?: () => Promise<void>;
|
|
}): Promise<{ branchName: string | null }> {
|
|
try {
|
|
return await withNestedRepositoryBaseLock(input.base, async () => {
|
|
const auth = input.resolveGitAuth ? await input.resolveGitAuth(input.repoUrl) : null;
|
|
await git(input.base, ["fetch", "--quiet", "--prune", "--no-write-fetch-head", "origin"], { configArgs: auth?.configArgs,
|
|
env: auth?.env, timeout: NESTED_WORKTREE_GIT_TIMEOUT_MS });
|
|
if (!input.repoRef?.trim()) {
|
|
await git(input.base, ["remote", "set-head", "origin", "--auto"], { configArgs: auth?.configArgs, env: auth?.env }).catch(() => undefined);
|
|
}
|
|
const { branch, start } = await resolveStartPoint(input.base, input.repoRef);
|
|
if (!start) throw new Error("Repository has no commit to check out");
|
|
const cwd = await canonicalPath(input.cwd);
|
|
await removeStaleNestedRegistrations(input.base, cwd);
|
|
await input.beforeAdd?.().catch((error: unknown) => { throw new NestedWorktreeAuthorizationFailure(error); });
|
|
if (!branch) {
|
|
await git(input.base, ["worktree", "add", "--quiet", "--detach", cwd, start], { timeout: NESTED_WORKTREE_GIT_TIMEOUT_MS });
|
|
return { branchName: null };
|
|
}
|
|
const prefix = await nestedWorktreeBranchPrefix(cwd);
|
|
const held = await checkedOutBranches(input.base);
|
|
let owned = `${prefix}${branch}`;
|
|
for (let attempt = 2; held.has(`refs/heads/${owned}`); attempt += 1) owned = `${prefix}r${attempt}/${branch}`;
|
|
await git(input.base, ["check-ref-format", "--branch", owned]);
|
|
const existing = (await git(input.base, ["rev-parse", "--verify", "-q", `refs/heads/${owned}`]).catch(() => "")).trim();
|
|
await git(input.base, existing ? ["worktree", "add", "--quiet", cwd, owned]
|
|
: ["worktree", "add", "--quiet", "--track", "-b", owned, cwd, start],
|
|
{ timeout: NESTED_WORKTREE_GIT_TIMEOUT_MS });
|
|
return { branchName: owned };
|
|
});
|
|
} catch (error) {
|
|
if (error instanceof NestedWorktreeAuthorizationFailure) throw error.cause;
|
|
const reason = error instanceof Error ? error.message : String(error);
|
|
throw new Error(scrubGitCredentialText(`Failed to prepare managed worktree for "${input.repoUrl}" at "${input.cwd}": ${reason}`));
|
|
}
|
|
}
|
|
|
|
export async function restoreNestedRepositoryWorktree(input: {
|
|
base: string;
|
|
cwd: string;
|
|
commit: string;
|
|
branchName: string | null;
|
|
repoRef: string | null;
|
|
preserved?: readonly string[];
|
|
afterAdd: (cwd: string) => Promise<void>;
|
|
}) {
|
|
await withNestedRepositoryBaseLock(input.base, async () => {
|
|
const cwd = await canonicalPath(input.cwd);
|
|
const prefix = await nestedWorktreeBranchPrefix(cwd);
|
|
if (input.branchName && !input.branchName.startsWith(prefix)) throw new Error("unowned_nested_worktree_branch");
|
|
await removeStaleNestedRegistrations(input.base, cwd);
|
|
const existing = input.branchName
|
|
? (await git(input.base, ["rev-parse", "--verify", "-q", `refs/heads/${input.branchName}`]).catch(() => "")).trim() : "";
|
|
if (existing && existing !== input.commit) throw new Error("nested_worktree_branch_moved");
|
|
await git(input.base, ["worktree", "add", "--quiet", "--detach", cwd, input.commit], { timeout: NESTED_WORKTREE_GIT_TIMEOUT_MS });
|
|
try {
|
|
for (const commit of input.preserved ?? []) {
|
|
await git(cwd, ["update-ref", "--no-deref", "-m", "paperclip: restore preserved history", "HEAD", commit]);
|
|
}
|
|
if (input.preserved?.length) await git(cwd, ["update-ref", "--no-deref", "-m", "paperclip: restore saved commit", "HEAD", input.commit]);
|
|
if (input.branchName) {
|
|
await git(cwd, existing ? ["checkout", "--quiet", input.branchName] : ["checkout", "--quiet", "-b", input.branchName]);
|
|
const rest = input.branchName.slice(prefix.length);
|
|
const { branch: upstream } = await resolveStartPoint(input.base, input.repoRef).catch(() => ({ branch: null }));
|
|
if (upstream && (rest === upstream || rest.replace(/^r\d+\//, "") === upstream)) {
|
|
await git(cwd, ["branch", "--quiet", `--set-upstream-to=origin/${upstream}`, input.branchName]);
|
|
}
|
|
}
|
|
await input.afterAdd(cwd);
|
|
} catch (error) {
|
|
await git(input.base, ["worktree", "remove", "--force", cwd]).catch(() => undefined);
|
|
await removeStaleNestedRegistrations(input.base, cwd).catch(() => undefined);
|
|
if (input.branchName && !existing) {
|
|
await git(input.base, ["update-ref", "-d", `refs/heads/${input.branchName}`, input.commit]).catch(() => undefined);
|
|
}
|
|
throw error;
|
|
}
|
|
});
|
|
}
|
|
|
|
export async function forgetExpiredNestedRepositoryWorktree(input: {
|
|
base: string; checkout: string; admin: string; branchName: string | null; commit: string;
|
|
}) {
|
|
await withNestedRepositoryBaseLock(input.base, async () => {
|
|
const admin = path.join(input.base, ".git", "worktrees", input.admin);
|
|
const entry = await fs.lstat(admin).catch(() => null);
|
|
if (entry && (!entry.isDirectory() || entry.isSymbolicLink())) throw new Error("unproven_worktree_admin");
|
|
if (entry && await missing(input.checkout)
|
|
&& await readWorktreePointer(admin).catch(() => "") === path.join(input.checkout, ".git")) {
|
|
await fs.rm(admin, { recursive: true, force: true });
|
|
}
|
|
if (input.branchName && await missing(input.checkout)) {
|
|
await git(input.base, ["update-ref", "-d", `refs/heads/${input.branchName}`, input.commit]).catch(() => undefined);
|
|
}
|
|
});
|
|
}
|
|
|
|
export async function collectNestedRepositoryWorktrees(taskCwd: string): Promise<LinkedNestedCheckout[]> {
|
|
const found: LinkedNestedCheckout[] = [];
|
|
for (const parent of [PROJECT_REPOSITORIES_DIR, DETACHED_REPOSITORIES_DIR]) {
|
|
const root = path.join(taskCwd, parent);
|
|
const rootEntry = await fs.lstat(root).catch(() => null);
|
|
if (!rootEntry?.isDirectory() || rootEntry.isSymbolicLink()) continue;
|
|
for (const name of await fs.readdir(root)) {
|
|
const linked = await readLinkedNestedCheckout(path.join(root, name)).catch(() => null);
|
|
if (!linked) continue;
|
|
const head = (await git(linked.checkout, ["symbolic-ref", "--quiet", "--short", "HEAD"]).catch(() => "")).trim();
|
|
found.push(isNestedWorktreeBranchName(head) ? { ...linked, branchPrefix: head.split("/").slice(0, 2).join("/") + "/" } : linked);
|
|
}
|
|
}
|
|
return found;
|
|
}
|
|
|
|
export async function releaseNestedRepositoryWorktrees(worktrees: LinkedNestedCheckout[]): Promise<string[]> {
|
|
const warnings: string[] = [];
|
|
for (const worktree of worktrees) {
|
|
try {
|
|
await withNestedRepositoryBaseLock(worktree.base, async () => {
|
|
if (!await missing(worktree.checkout)) return;
|
|
await removeStaleNestedRegistrations(worktree.base, worktree.checkout);
|
|
const refs = (await git(worktree.base, ["for-each-ref", "--format=%(objectname) %(refname)", `refs/heads/${worktree.branchPrefix}`]))
|
|
.split("\n").map(line => line.trim().split(" ")).filter(([sha, ref]) => /^[a-f0-9]{40}$/.test(sha ?? "") && ref);
|
|
const held = await checkedOutBranches(worktree.base);
|
|
for (const [sha, ref] of refs) {
|
|
if (held.has(ref!)) continue;
|
|
const unpushed = (await git(worktree.base, ["rev-list", "--max-count=1", sha!, "--not", "--remotes=origin"])).trim();
|
|
if (unpushed) continue;
|
|
await git(worktree.base, ["update-ref", "-d", ref!, sha!]);
|
|
}
|
|
});
|
|
} catch (error) {
|
|
warnings.push(`Could not release nested repository worktree "${worktree.checkout}": ${error instanceof Error ? error.message : String(error)}`);
|
|
}
|
|
}
|
|
return warnings;
|
|
}
|
|
|
|
export async function repairMovedNestedRepositoryWorktree(base: string, moved: string) {
|
|
await withNestedRepositoryBaseLock(base, () => git(base, ["worktree", "repair", moved]));
|
|
}
|
|
|
|
export async function moveNestedRepositoryWorktree(base: string, from: string, to: string) {
|
|
await withNestedRepositoryBaseLock(base, async () => {
|
|
await git(base, ["worktree", "move", from, to], { timeout: NESTED_WORKTREE_GIT_TIMEOUT_MS });
|
|
await git(base, ["worktree", "repair", to]);
|
|
});
|
|
}
|
|
|
|
export async function trackNestedWorktreeUpstream(base: string, branchName: string, upstream: string | null) {
|
|
if (!upstream || !(await git(base, ["rev-parse", "--verify", "-q", `refs/remotes/origin/${upstream}^{commit}`]).catch(() => "")).trim()) return;
|
|
await git(base, ["branch", "--quiet", `--set-upstream-to=origin/${upstream}`, branchName]);
|
|
}
|