fix(workspace-worktree): restore re-attaches existing branch instead of recreating with -b (#1742)

* fix(workspace-worktree): restore re-attaches existing branch instead of recreating with -b

Restoring a session whose worktree directory was cleaned up but whose
branch still existed locally would 422 with `fatal: a branch named <X>
already exists`. The recovery path in `restore()` unconditionally fell
through to `git worktree add -b`, even though `destroy()` deliberately
preserves session branches so the user's commits aren't lost.

When the local branch already exists, restore now clears any stale
worktree registration at the target path and retries `git worktree add
<path> <branch>` (no -b/-B). The existing -b fallback is preserved
verbatim for the case where the local branch is genuinely missing
(only the remote ref exists). -B is intentionally not used — it would
force-reset the branch back to the base ref and silently discard the
session's commits, which is the opposite of restore's intent.

Test coverage:
- 6 new unit tests covering the recovery path, cleanup tolerance,
  error propagation, and "no -b/-B" invariants
- 2 updated existing unit tests (now mock the new refExists check)
- 2 new integration tests exercising real git: branch preservation
  on clean restore, and recovery from a dirty teardown that left a
  stale registry entry

Closes #1741

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(workspace-worktree): explicitly create main branch in restore integration tests

CI runs git with a different `init.defaultBranch` than the local dev
environment, so the bare clone has no `main` branch when the test
attempts to push. Mirror the existing tests in this file (which also
call `git switch -c <branch>` before the first commit).

Fixes integration-test failures on PR #1742.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(workspace-worktree): rmSync stale workspace dir before retry

Follow-up to the previous commit on this branch. The recovery path
cleared the git worktree registry but didn't touch the filesystem, so
when restore was triggered by a stale junk directory at the workspace
path (workspace.exists() returns false because rev-parse fails on a
non-working-tree dir), the retry would fail with the same error:

    fatal: '<workspacePath>' already exists

Replace the inline `worktree prune` cleanup with a call to the existing
`clearStaleWorktreePath()` helper, which handles all three states:

  - dir gone → no-op
  - dir present and not registered → rmSync
  - dir present and still registered after prune → throws (safety:
    never delete a registered worktree)

This mirrors how create() already handles the same stale-state cases
upfront via clearStaleWorktreePath at the top of its flow.

Test coverage:
- 1 new unit test: rmSyncs a stale workspace directory before retry
- 1 new unit test: refuses to rmSync a still-registered worktree dir
  (data safety — error must propagate, not be swallowed)
- 1 new integration test on real git: dir physically present as
  non-working-tree leftover, restore must rmSync it before retry,
  and the session commit must survive

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(workspace-worktree): extract restore helpers, drop redundant prune

Addresses review feedback on PR #1742:

1. Extract two named helpers, reattachExistingBranch and
   createBranchFromBase, so restore()'s catch block reads as the
   bifurcation it actually is — 2 lines per branch, no nested
   try/catch hierarchy. Behavior is unchanged.

2. Drop the redundant `worktree prune` that ran inside the recovery
   path (via clearStaleWorktreePath). The entry-point prune in
   restore() is sufficient. reattachExistingBranch now inlines the
   existsSync + isRegisteredWorktree + rmSync sequence directly,
   keeping the data-safety guard ("refuse to rmSync a registered
   worktree") intact but skipping the second prune call.

The catch block shrinks from ~46 lines to 12 (the rest moves into
the helpers, where the docstrings can explain WHY each branch exists
without cluttering the call site).

Tests: same 64 unit + 13 integration tests still pass — the mock
sequences in the recovery-path tests no longer expect a second prune
call, since the new code doesn't make one.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(workspace-worktree): address Copilot review on PR #1742

Two real concerns flagged in inline review:

1. isRegisteredWorktree did strict string equality on paths. If
   `workspacePath` was passed in a non-canonical form (trailing slash,
   ".." segments) and git reported the canonical path, the check would
   false-negative — and the subsequent rmSync in cleanupStaleWorkspacePath
   would silently delete a still-registered worktree (data loss). Fix
   by resolve()-normalizing both sides before comparison.

2. createBranchFromBase (the "branch missing locally" recovery path)
   skipped the stale-path cleanup that reattachExistingBranch did. So
   if `workspacePath` had a stale dir AND the branch was missing,
   `git worktree add -b ...` would fail with the same "<path> already
   exists" error this PR was fixing for the re-attach case. Fix by
   factoring the cleanup into cleanupStaleWorkspacePath, called from
   both helpers.

Test coverage:
- Unit: path normalization safety — workspacePath with trailing slash
  vs canonical registered path must still throw "still registered"
  (proves rmSync is NOT called)
- Unit: createBranchFromBase clears stale dir before -b add
- Integration: branch missing locally + stale dir at workspacePath →
  restore must clean dir AND recreate branch from origin (commit
  preserved end-to-end on real git)

Existing 2 "branch missing" tests updated to mock the new cleanup
calls in createBranchFromBase.

66 unit tests + 14 integration tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Harshit Singh Bhandari 2026-05-09 22:07:32 +05:30 committed by GitHub
parent 13c5a50d02
commit a33b2ba0ef
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 674 additions and 26 deletions

View File

@ -0,0 +1,5 @@
---
"@aoagents/ao-plugin-workspace-worktree": patch
---
Restoring a session whose worktree directory was cleaned up but whose branch still existed locally would 422 with `fatal: a branch named <X> already exists`. The recovery path in `workspace.restore()` unconditionally fell through to `git worktree add -b`, even when the local branch was present (which `destroy()` deliberately preserves). The catch now checks for the local branch and re-attaches it without `-b`/`-B`, preserving the session's commits. (#1741)

View File

@ -206,6 +206,276 @@ describe("workspace-worktree (integration)", () => {
}
}, 30_000);
// Regression for https://github.com/ComposioHQ/agent-orchestrator/issues/1741.
// After a clean destroy(), the local session branch is intentionally kept so
// the user's commits aren't lost. restore() must re-attach that branch
// without recreating it (-b) or force-resetting it (-B), so the session's
// HEAD survives.
it("restore re-attaches existing session branch and preserves its commits", async () => {
const { bareDir, cloneParent, repoDir: isolatedRepoDir } = await createRepoClone();
const rawBase = await mkdtemp(join(tmpdir(), "ao-inttest-wt-restore-preserve-"));
const isolatedWorktreeBaseDir = await realpath(rawBase);
try {
await git(isolatedRepoDir, "switch", "-c", "main");
const mainSha = await createCommit(isolatedRepoDir, "base.txt", "main\n");
await git(isolatedRepoDir, "push", "-u", "origin", "main");
const isolatedWorkspace = worktreePlugin.create({ worktreeDir: isolatedWorktreeBaseDir });
const proj: ProjectConfig = {
name: "restore-preserve",
repo: "test/restore-preserve",
path: isolatedRepoDir,
defaultBranch: "main",
sessionPrefix: "ao",
};
const created = await isolatedWorkspace.create({
projectId: "restore-preserve",
sessionId: "ao-1",
project: proj,
branch: "session/ao-1",
});
// Simulate session work with a commit on the session branch.
await git(created.path, "config", "user.email", "test@test.com");
await git(created.path, "config", "user.name", "Test");
const sessionSha = await createCommit(created.path, "session.txt", "session work\n");
expect(sessionSha).not.toBe(mainSha);
// Tear down the worktree the way AO does — branch is preserved.
await isolatedWorkspace.destroy(created.path);
expect(existsSync(created.path)).toBe(false);
expect(await git(isolatedRepoDir, "rev-parse", "refs/heads/session/ao-1")).toBe(sessionSha);
// Restore — must re-attach session/ao-1 with its existing HEAD intact.
const restored = await isolatedWorkspace.restore!(
{
projectId: "restore-preserve",
sessionId: "ao-1",
project: proj,
branch: "session/ao-1",
},
created.path,
);
expect(restored.branch).toBe("session/ao-1");
expect(await git(restored.path, "rev-parse", "HEAD")).toBe(sessionSha);
expect(await git(isolatedRepoDir, "rev-parse", "refs/heads/session/ao-1")).toBe(sessionSha);
} finally {
await rm(isolatedWorktreeBaseDir, { recursive: true, force: true }).catch(() => {});
await rm(cloneParent, { recursive: true, force: true }).catch(() => {});
await rm(bareDir, { recursive: true, force: true }).catch(() => {});
}
}, 30_000);
// Same regression — direct repro of the failure surface in #1741. We force
// the first `git worktree add <path> <branch>` to fail by leaving a stale
// registered worktree at the same path, then verify restore recovers
// without using -b (which would fail with "branch already exists") or -B
// (which would discard the session's commits).
it("restore recovers when a stale worktree registration conflicts with the path", async () => {
const { bareDir, cloneParent, repoDir: isolatedRepoDir } = await createRepoClone();
const rawBase = await mkdtemp(join(tmpdir(), "ao-inttest-wt-restore-stale-"));
const isolatedWorktreeBaseDir = await realpath(rawBase);
try {
await git(isolatedRepoDir, "switch", "-c", "main");
const mainSha = await createCommit(isolatedRepoDir, "base.txt", "main\n");
await git(isolatedRepoDir, "push", "-u", "origin", "main");
const isolatedWorkspace = worktreePlugin.create({ worktreeDir: isolatedWorktreeBaseDir });
const proj: ProjectConfig = {
name: "restore-stale",
repo: "test/restore-stale",
path: isolatedRepoDir,
defaultBranch: "main",
sessionPrefix: "ao",
};
const created = await isolatedWorkspace.create({
projectId: "restore-stale",
sessionId: "ao-1",
project: proj,
branch: "session/ao-1",
});
await git(created.path, "config", "user.email", "test@test.com");
await git(created.path, "config", "user.name", "Test");
const sessionSha = await createCommit(created.path, "session.txt", "session work\n");
expect(sessionSha).not.toBe(mainSha);
// Simulate a dirty teardown: rmSync the dir but leave the worktree
// entry registered (this is the failure mode from #1562 that triggers
// the buggy fallback path in #1741).
await rm(created.path, { recursive: true, force: true });
// Worktree registration is still present — branch is still considered
// checked out at that (now-missing) path. Restore must handle this.
const restored = await isolatedWorkspace.restore!(
{
projectId: "restore-stale",
sessionId: "ao-1",
project: proj,
branch: "session/ao-1",
},
created.path,
);
expect(restored.branch).toBe("session/ao-1");
// Most importantly, the session commit must survive — anything that
// touched -B would have reset the branch back to mainSha.
expect(await git(restored.path, "rev-parse", "HEAD")).toBe(sessionSha);
expect(await git(isolatedRepoDir, "rev-parse", "refs/heads/session/ao-1")).toBe(sessionSha);
} finally {
await rm(isolatedWorktreeBaseDir, { recursive: true, force: true }).catch(() => {});
await rm(cloneParent, { recursive: true, force: true }).catch(() => {});
await rm(bareDir, { recursive: true, force: true }).catch(() => {});
}
}, 30_000);
// Direct repro of the user-reported failure on PR #1742: the workspace dir
// physically exists on disk but is no longer a valid git working tree
// (workspace.exists() returned false because rev-parse failed). The first
// `git worktree add <path> <branch>` fails with `'<path>' already exists`,
// so restore must rmSync the stale dir before retrying. Without this, my
// first fix attempt cleaned the registry but left the dir, so the retry
// failed identically.
it("restore recovers when a stale (non-worktree) directory exists at the path", async () => {
const { bareDir, cloneParent, repoDir: isolatedRepoDir } = await createRepoClone();
const rawBase = await mkdtemp(join(tmpdir(), "ao-inttest-wt-restore-junkdir-"));
const isolatedWorktreeBaseDir = await realpath(rawBase);
try {
await git(isolatedRepoDir, "switch", "-c", "main");
const mainSha = await createCommit(isolatedRepoDir, "base.txt", "main\n");
await git(isolatedRepoDir, "push", "-u", "origin", "main");
const isolatedWorkspace = worktreePlugin.create({ worktreeDir: isolatedWorktreeBaseDir });
const proj: ProjectConfig = {
name: "restore-junkdir",
repo: "test/restore-junkdir",
path: isolatedRepoDir,
defaultBranch: "main",
sessionPrefix: "ao",
};
const created = await isolatedWorkspace.create({
projectId: "restore-junkdir",
sessionId: "ao-1",
project: proj,
branch: "session/ao-1",
});
await git(created.path, "config", "user.email", "test@test.com");
await git(created.path, "config", "user.name", "Test");
const sessionSha = await createCommit(created.path, "session.txt", "session work\n");
expect(sessionSha).not.toBe(mainSha);
// Clean teardown — registry and dir both gone, branch preserved.
await isolatedWorkspace.destroy(created.path);
expect(existsSync(created.path)).toBe(false);
// Now simulate a partially-restored or hand-mucked state: the dir
// exists at workspacePath but is just leftover files, not a working
// tree. workspace.exists() will return false (rev-parse fails), so
// restore is invoked, and its first `worktree add` will fail with
// `'<path>' already exists`.
await execFileAsync("mkdir", ["-p", created.path]);
await writeFile(join(created.path, "stale.txt"), "junk\n");
expect(existsSync(created.path)).toBe(true);
const restored = await isolatedWorkspace.restore!(
{
projectId: "restore-junkdir",
sessionId: "ao-1",
project: proj,
branch: "session/ao-1",
},
created.path,
);
expect(restored.branch).toBe("session/ao-1");
// Junk file must be gone (restore rmSync'd the stale dir before retry).
expect(existsSync(join(created.path, "stale.txt"))).toBe(false);
// Session commit must survive — anything using -B would have lost it.
expect(await git(restored.path, "rev-parse", "HEAD")).toBe(sessionSha);
expect(await git(isolatedRepoDir, "rev-parse", "refs/heads/session/ao-1")).toBe(sessionSha);
} finally {
await rm(isolatedWorktreeBaseDir, { recursive: true, force: true }).catch(() => {});
await rm(cloneParent, { recursive: true, force: true }).catch(() => {});
await rm(bareDir, { recursive: true, force: true }).catch(() => {});
}
}, 30_000);
// Coverage for the createBranchFromBase recovery path (Copilot review on
// PR #1742): when the LOCAL branch is missing (only origin/<branch>
// exists) AND `workspacePath` has stale state, the -b fallback must also
// run the cleanup. Without it, `git worktree add -b ...` fails with
// `'<path>' already exists` exactly like the re-attach path used to.
it("restore recovers when local branch is missing and stale dir exists at the path", async () => {
const { bareDir, cloneParent, repoDir: isolatedRepoDir } = await createRepoClone();
const rawBase = await mkdtemp(join(tmpdir(), "ao-inttest-wt-restore-missing-branch-"));
const isolatedWorktreeBaseDir = await realpath(rawBase);
try {
await git(isolatedRepoDir, "switch", "-c", "main");
await createCommit(isolatedRepoDir, "base.txt", "main\n");
await git(isolatedRepoDir, "push", "-u", "origin", "main");
// Manually push a session branch to origin without keeping it locally,
// simulating a session whose local branch was pruned but origin still
// has it (e.g. fetched after a remote-only force-update).
await git(isolatedRepoDir, "switch", "-c", "session/ao-1");
const sessionSha = await createCommit(isolatedRepoDir, "session.txt", "session work\n");
await git(isolatedRepoDir, "push", "-u", "origin", "session/ao-1");
// Switch off session/ao-1 then delete the local branch — only origin has it now.
await git(isolatedRepoDir, "switch", "main");
await git(isolatedRepoDir, "branch", "-D", "session/ao-1");
const isolatedWorkspace = worktreePlugin.create({ worktreeDir: isolatedWorktreeBaseDir });
const proj: ProjectConfig = {
name: "restore-missing-branch",
repo: "test/restore-missing-branch",
path: isolatedRepoDir,
defaultBranch: "main",
sessionPrefix: "ao",
};
const workspacePath = join(isolatedWorktreeBaseDir, "restore-missing-branch", "ao-1");
// Plant a stale junk directory at workspacePath. workspace.exists()
// will return false (not a working tree) and restore is invoked.
// The first `worktree add` will fail with `'<path>' already exists`,
// refExists for refs/heads/session/ao-1 returns false (we deleted it),
// so createBranchFromBase runs and must clean the stale dir first.
await execFileAsync("mkdir", ["-p", workspacePath]);
await writeFile(join(workspacePath, "stale.txt"), "junk\n");
expect(existsSync(workspacePath)).toBe(true);
const restored = await isolatedWorkspace.restore!(
{
projectId: "restore-missing-branch",
sessionId: "ao-1",
project: proj,
branch: "session/ao-1",
},
workspacePath,
);
expect(restored.branch).toBe("session/ao-1");
// Junk file gone — cleanup ran before -b add.
expect(existsSync(join(workspacePath, "stale.txt"))).toBe(false);
// Local branch was recreated from origin/session/ao-1, preserving the
// session's commit (which only existed remotely before restore).
expect(await git(restored.path, "rev-parse", "HEAD")).toBe(sessionSha);
expect(await git(isolatedRepoDir, "rev-parse", "refs/heads/session/ao-1")).toBe(sessionSha);
} finally {
await rm(isolatedWorktreeBaseDir, { recursive: true, force: true }).catch(() => {});
await rm(cloneParent, { recursive: true, force: true }).catch(() => {});
await rm(bareDir, { recursive: true, force: true }).catch(() => {});
}
}, 30_000);
it("resets a stale session branch when origin default branch advances", async () => {
const { bareDir, cloneParent, repoDir: isolatedRepoDir } = await createRepoClone();
const rawBase = await mkdtemp(join(tmpdir(), "ao-inttest-wt-stale-origin-"));

View File

@ -713,6 +713,10 @@ describe("workspace.restore()", () => {
mockGitSuccess(""); // git worktree prune
mockOriginRemote();
mockGitError("fatal: invalid reference"); // git worktree add workspacePath cfg.branch fails
mockGitError("fatal: bad ref"); // refExists(refs/heads/feat/TEST-1) → false (branch missing)
// createBranchFromBase → cleanupStaleWorkspacePath
mockGitSuccess(""); // worktree remove --force <path> (best-effort)
mockExistsSync.mockReturnValueOnce(false); // no leftover dir, skip cleanup
mockGitSuccess(""); // git rev-parse --verify --quiet origin/feat/TEST-1
mockGitSuccess(""); // git worktree add -b cfg.branch workspacePath origin/feat/TEST-1
@ -740,6 +744,10 @@ describe("workspace.restore()", () => {
mockGitSuccess(""); // git worktree prune
mockGitError("fatal: not a git repository"); // git remote get-url origin fails
mockGitError("fatal: invalid reference"); // git worktree add workspacePath cfg.branch fails
mockGitError("fatal: bad ref"); // refExists(refs/heads/feat/TEST-1) → false (branch missing)
// createBranchFromBase → cleanupStaleWorkspacePath
mockGitSuccess(""); // worktree remove --force <path> (best-effort)
mockExistsSync.mockReturnValueOnce(false); // no leftover dir, skip cleanup
mockGitSuccess(""); // git rev-parse --verify --quiet refs/heads/main
mockGitSuccess(""); // git worktree add -b cfg.branch workspacePath refs/heads/main
@ -765,6 +773,282 @@ describe("workspace.restore()", () => {
projectId: "myproject",
});
});
// --- Regression coverage for #1741 ---------------------------------------
// When the local session branch already exists (destroy() preserves it on
// purpose), restore() must re-attach it instead of falling through to the
// -b path that would either fail ("branch already exists") or discard
// commits. See https://github.com/ComposioHQ/agent-orchestrator/issues/1741.
//
// The recovery sequence (in reattachExistingBranch):
// 1. `git worktree remove --force <path>` (best-effort: clears registry)
// 2. existsSync(<path>) — bail if dir already gone
// 3. `git worktree list --porcelain` (isRegisteredWorktree)
// 4. rmSync(<path>) if not still registered (else throw — data safety)
// 5. `git worktree add <path> <branch>` retry (no -b/-B)
//
// The entry-point prune in restore() is sufficient — no second prune in
// the recovery path.
it("re-attaches existing local branch when stale registry conflicts", async () => {
// Path was registered as a worktree but the dir was already cleaned up.
// worktree remove --force succeeds; the stale-dir cleanup short-circuits
// because existsSync returns false; retry succeeds.
const ws = create();
mockGitSuccess(""); // git worktree prune (entry-point)
mockOriginRemote();
mockGitError("fatal: 'feat/TEST-1' is already checked out"); // first worktree add fails
mockGitSuccess(""); // refExists(refs/heads/feat/TEST-1) → true
mockGitSuccess(""); // worktree remove --force <path>
mockExistsSync.mockReturnValueOnce(false); // no leftover dir, skip cleanup
mockGitSuccess(""); // RETRY: worktree add <path> <branch> succeeds
const info = await ws.restore!(makeCreateConfig(), "/mock-home/.worktrees/myproject/session-1");
// The recovery call must re-attach the existing branch — no -b, no -B.
expect(mockExecFileAsync).toHaveBeenLastCalledWith(
"git",
["worktree", "add", "/mock-home/.worktrees/myproject/session-1", "feat/TEST-1"],
{ cwd: "/repo/path", windowsHide: true, timeout: 30_000 },
);
// No -b or -B should ever appear when the branch already exists locally.
const calls = mockExecFileAsync.mock.calls;
for (const [, args] of calls) {
if (Array.isArray(args)) {
expect(args).not.toContain("-b");
expect(args).not.toContain("-B");
}
}
expect(info).toEqual({
path: "/mock-home/.worktrees/myproject/session-1",
branch: "feat/TEST-1",
sessionId: "session-1",
projectId: "myproject",
});
});
it("rmSyncs a stale workspace directory before retrying worktree add", async () => {
// Direct repro of the user's #1741 follow-on failure: the dir physically
// exists on disk (workspace.exists() returned false because it's not a
// git working tree, just leftover files). worktree add fails with
// "<path> already exists" — recovery must rmSync the stale dir, not loop.
const ws = create();
mockGitSuccess(""); // git worktree prune (entry-point)
mockOriginRemote();
mockGitError(
"fatal: '/mock-home/.worktrees/myproject/session-1' already exists",
); // first worktree add fails because dir exists
mockGitSuccess(""); // refExists → true
mockGitError("fatal: not a working tree"); // worktree remove --force fails (path not registered)
mockExistsSync.mockReturnValueOnce(true); // dir exists
mockGitSuccess("worktree /some/other\nbranch refs/heads/main"); // worktree list — no entry for our path
// rmSync called (mocked) — no second prune
mockGitSuccess(""); // RETRY: worktree add succeeds
const info = await ws.restore!(makeCreateConfig(), "/mock-home/.worktrees/myproject/session-1");
// The stale dir must have been removed.
expect(mockRmSync).toHaveBeenCalledWith("/mock-home/.worktrees/myproject/session-1", {
recursive: true,
force: true,
});
// Retry must be the no-flag form.
expect(mockExecFileAsync).toHaveBeenLastCalledWith(
"git",
["worktree", "add", "/mock-home/.worktrees/myproject/session-1", "feat/TEST-1"],
{ cwd: "/repo/path", windowsHide: true, timeout: 30_000 },
);
expect(info.branch).toBe("feat/TEST-1");
});
it("refuses to rmSync a still-registered worktree dir (data safety)", async () => {
// If after `worktree remove --force` the path is STILL registered,
// something is very wrong. reattachExistingBranch throws rather than
// rmSync a registered worktree (which could destroy the user's work).
// The error must propagate, not be swallowed.
const ws = create();
mockGitSuccess(""); // git worktree prune (entry-point)
mockOriginRemote();
mockGitError("fatal: 'feat/TEST-1' is already checked out"); // first worktree add fails
mockGitSuccess(""); // refExists → true
mockGitError("fatal: cannot remove"); // worktree remove --force fails
mockExistsSync.mockReturnValueOnce(true); // dir exists
// Path is still registered — isRegisteredWorktree returns our path
mockGitSuccess(
"worktree /mock-home/.worktrees/myproject/session-1\nbranch refs/heads/feat/TEST-1",
);
await expect(
ws.restore!(makeCreateConfig(), "/mock-home/.worktrees/myproject/session-1"),
).rejects.toThrow(/already exists and is still registered/);
// rmSync MUST NOT have been called — we never delete a registered worktree.
expect(mockRmSync).not.toHaveBeenCalled();
});
it("propagates retry error when worktree add fails after cleanup", async () => {
const ws = create();
mockGitSuccess(""); // prune (entry-point)
mockOriginRemote();
mockGitError("fatal: first failure"); // first worktree add fails
mockGitSuccess(""); // refExists → true
mockGitSuccess(""); // worktree remove --force
mockExistsSync.mockReturnValueOnce(false); // no leftover dir, skip cleanup
mockGitError("fatal: persistent failure"); // RETRY also fails
await expect(
ws.restore!(makeCreateConfig(), "/mock-home/.worktrees/myproject/session-1"),
).rejects.toThrow(/persistent failure/);
// Crucially, the failure surface is the underlying git error — NOT a
// misleading "branch already exists" from a -b fallback.
const calls = mockExecFileAsync.mock.calls;
for (const [, args] of calls) {
if (Array.isArray(args)) {
expect(args).not.toContain("-b");
expect(args).not.toContain("-B");
}
}
});
it("never force-resets an existing branch (preserves session commits)", async () => {
// Defense-in-depth: confirm restore() never uses -B even in the
// recovery path. -B would silently discard the user's commits,
// which is the opposite of what restore must do.
const ws = create();
mockGitSuccess(""); // prune (entry-point)
mockOriginRemote();
mockGitError("fatal: registry conflict"); // first worktree add fails
mockGitSuccess(""); // refExists → true
mockGitSuccess(""); // worktree remove --force
mockExistsSync.mockReturnValueOnce(false); // no leftover dir, skip cleanup
mockGitSuccess(""); // RETRY succeeds
await ws.restore!(makeCreateConfig(), "/mock-home/.worktrees/myproject/session-1");
const calls = mockExecFileAsync.mock.calls;
const dashBigB = calls.filter(([, args]) => Array.isArray(args) && args.includes("-B"));
expect(dashBigB).toHaveLength(0);
});
it("checks branch existence with rev-parse --verify --quiet refs/heads/<branch>", async () => {
// Lock in the exact ref form used. If someone later refactors refExists or
// forgets the refs/heads/ prefix, this regression test catches it.
const ws = create();
mockGitSuccess(""); // prune (entry-point)
mockOriginRemote();
mockGitError("fatal: first failure"); // first worktree add fails
mockGitSuccess(""); // refExists → true
mockGitSuccess(""); // worktree remove --force
mockExistsSync.mockReturnValueOnce(false); // no leftover dir, skip cleanup
mockGitSuccess(""); // RETRY succeeds
await ws.restore!(makeCreateConfig(), "/mock-home/.worktrees/myproject/session-1");
expect(mockExecFileAsync).toHaveBeenCalledWith(
"git",
["rev-parse", "--verify", "--quiet", "refs/heads/feat/TEST-1"],
{ cwd: "/repo/path", windowsHide: true, timeout: 30_000 },
);
});
it("matches registered worktree even when workspacePath has trailing slash", async () => {
// Path normalization safety: if `workspacePath` is passed in a non-canonical
// form (trailing slash, ".." segments) and git reports a canonical path,
// strict string equality false-negatives. That would mistakenly rmSync a
// still-registered worktree → DATA LOSS. Both sides must be resolve()d.
const ws = create();
mockGitSuccess(""); // entry-point prune
mockOriginRemote();
mockGitError("fatal: 'feat/TEST-1' is already checked out"); // first worktree add fails
mockGitSuccess(""); // refExists → true
// reattachExistingBranch → cleanupStaleWorkspacePath
mockGitError("fatal: cannot remove"); // worktree remove --force fails
mockExistsSync.mockReturnValueOnce(true); // dir exists
// git reports canonical path (no trailing slash); we call restore with trailing slash
mockGitSuccess(
"worktree /mock-home/.worktrees/myproject/session-1\nbranch refs/heads/feat/TEST-1",
);
await expect(
ws.restore!(makeCreateConfig(), "/mock-home/.worktrees/myproject/session-1/"),
).rejects.toThrow(/already exists and is still registered/);
// CRITICAL: rmSync MUST NOT have been called — the resolve() normalization
// correctly identified the path as still-registered despite the trailing slash.
expect(mockRmSync).not.toHaveBeenCalled();
});
it("createBranchFromBase also clears stale workspace dir before worktree add -b", async () => {
// Mirror of the re-attach path: when the local branch is MISSING and the
// workspacePath has stale state, createBranchFromBase must also do the
// cleanup. Otherwise `git worktree add -b ...` fails with the same
// "<path> already exists" error the re-attach path was fixed for.
const ws = create();
mockGitSuccess(""); // entry-point prune
mockOriginRemote();
mockGitError(
"fatal: '/mock-home/.worktrees/myproject/session-1' already exists",
); // first worktree add fails
mockGitError("fatal: bad ref"); // refExists → false (branch missing)
// createBranchFromBase → cleanupStaleWorkspacePath
mockGitError("fatal: not registered"); // worktree remove --force fails
mockExistsSync.mockReturnValueOnce(true); // dir exists as junk
mockGitSuccess("worktree /some/other\nbranch refs/heads/main"); // not registered
// rmSync called (mocked)
mockGitSuccess(""); // resolveBaseRef: rev-parse origin/feat/TEST-1
mockGitSuccess(""); // worktree add -b ... origin/feat/TEST-1 succeeds
const info = await ws.restore!(makeCreateConfig(), "/mock-home/.worktrees/myproject/session-1");
// Stale dir must have been removed before -b add.
expect(mockRmSync).toHaveBeenCalledWith("/mock-home/.worktrees/myproject/session-1", {
recursive: true,
force: true,
});
expect(info.branch).toBe("feat/TEST-1");
expect(mockExecFileAsync).toHaveBeenLastCalledWith(
"git",
[
"worktree",
"add",
"-b",
"feat/TEST-1",
"/mock-home/.worktrees/myproject/session-1",
"origin/feat/TEST-1",
],
{ cwd: "/repo/path", windowsHide: true, timeout: 30_000 },
);
});
it("happy path: restore re-attaches branch when first worktree add already succeeds", async () => {
// No catch path — the first attempt works. Confirms we don't accidentally
// run the cleanup/retry sequence in the common case.
const ws = create();
mockGitSuccess(""); // prune (entry-point)
mockOriginRemote();
mockGitSuccess(""); // worktree add <path> <branch> succeeds first try
const info = await ws.restore!(makeCreateConfig(), "/mock-home/.worktrees/myproject/session-1");
expect(info.branch).toBe("feat/TEST-1");
// Total calls: prune + remote get-url + fetch + worktree add = 4
expect(mockExecFileAsync).toHaveBeenCalledTimes(4);
});
});
describe("workspace.destroy()", () => {

View File

@ -135,13 +135,17 @@ async function resolveBaseRef(
async function isRegisteredWorktree(repoPath: string, worktreePath: string): Promise<boolean> {
try {
const output = await git(repoPath, "worktree", "list", "--porcelain");
const target = toComparablePath(worktreePath);
// Normalize both sides so non-canonical inputs don't false-negative
// and let a subsequent rmSync delete a still-registered worktree
// (data loss). resolve() collapses trailing-slash / ".." segments;
// toComparablePath handles Windows backslashes and drive case.
const target = toComparablePath(resolve(worktreePath));
return output
.split("\n")
.some(
(line) =>
line.startsWith("worktree ") &&
toComparablePath(line.slice("worktree ".length)) === target,
toComparablePath(resolve(line.slice("worktree ".length))) === target,
);
} catch {
return false;
@ -166,6 +170,105 @@ async function clearStaleWorktreePath(repoPath: string, worktreePath: string): P
rmSync(worktreePath, { recursive: true, force: true });
}
/**
* Restore recovery: clear any stale worktree registration and/or stale
* directory at `workspacePath` so a subsequent `git worktree add` can
* succeed. Both restore branches (re-attach existing branch, create from
* base) need this without it, an `<path> already exists` failure repeats.
*
* Refuses to rmSync the path if it's still a registered worktree, which
* would silently destroy the user's work. The entry-point `worktree prune`
* in restore() already ran, so we don't prune again here.
*/
async function cleanupStaleWorkspacePath(
repoPath: string,
workspacePath: string,
): Promise<void> {
// Force-remove any registered worktree at this path. Best-effort — the
// path may not be registered, in which case git errors and we fall
// through to the dir cleanup.
try {
await git(repoPath, "worktree", "remove", "--force", workspacePath);
} catch {
// Best-effort
}
if (existsSync(workspacePath)) {
if (await isRegisteredWorktree(repoPath, workspacePath)) {
throw new Error(
`Worktree path "${workspacePath}" already exists and is still registered with git`,
);
}
// Use removeDirWithRetry for Windows file-handle drain races (matches
// destroy()'s fallback). On Unix this is just rmSync.
await removeDirWithRetry(workspacePath);
}
}
/**
* Restore recovery: re-attach an existing local branch to a worktree at
* `workspacePath`. Used when the branch is already present (destroy()
* preserves it) but the first `git worktree add <path> <branch>` failed
* typically because `workspacePath` has a stale registry entry, a
* stale directory, or both.
*
* Never uses -b/-B: -b would fail with "branch already exists", and -B
* would force-reset the branch to a base ref and silently discard the
* session's commits, which is the opposite of restore's intent.
*/
async function reattachExistingBranch(
repoPath: string,
workspacePath: string,
branch: string,
): Promise<void> {
await cleanupStaleWorkspacePath(repoPath, workspacePath);
await git(repoPath, "worktree", "add", workspacePath, branch);
}
/**
* Restore recovery: create a fresh branch at `workspacePath` from the
* appropriate base ref. Used when the local branch is missing typically
* because only `origin/<branch>` exists and we need to materialize the
* local ref. Tries the remote ref first, then falls back to the local
* default branch.
*
* Runs the same stale-path cleanup as reattachExistingBranch so this path
* also recovers when `workspacePath` has a stale registry entry / dir.
*/
async function createBranchFromBase(
repoPath: string,
workspacePath: string,
branch: string,
defaultBranch: string,
hasOrigin: boolean,
): Promise<void> {
await cleanupStaleWorkspacePath(repoPath, workspacePath);
const baseRef = await resolveBaseRef(repoPath, defaultBranch, { branch, hasOrigin });
if (!baseRef.startsWith("origin/")) {
// No remote available — create from the local default branch
await git(repoPath, "worktree", "add", "-b", branch, workspacePath, baseRef);
return;
}
// Branch might not exist locally — try the remote ref first, then fall
// back to the local default branch if the remote ref is unavailable.
try {
await git(repoPath, "worktree", "add", "-b", branch, workspacePath, baseRef);
} catch {
await git(
repoPath,
"worktree",
"add",
"-b",
branch,
workspacePath,
`refs/heads/${defaultBranch}`,
);
}
}
interface WorktreeEntry {
path: string;
branch: string | null;
@ -453,34 +556,20 @@ export function create(config?: Record<string, unknown>): Workspace {
}
}
// Try to create worktree on the existing branch
// Try to create worktree on the existing branch.
try {
await git(repoPath, "worktree", "add", workspacePath, cfg.branch);
} catch {
const baseRef = await resolveBaseRef(repoPath, cfg.project.defaultBranch, {
branch: cfg.branch,
hasOrigin,
});
if (!baseRef.startsWith("origin/")) {
// No remote available — create from the local default branch
await git(repoPath, "worktree", "add", "-b", cfg.branch, workspacePath, baseRef);
if (await refExists(repoPath, `refs/heads/${cfg.branch}`)) {
await reattachExistingBranch(repoPath, workspacePath, cfg.branch);
} else {
// Branch might not exist locally — try the remote ref first, then fall back
// to the local default branch if the remote ref is unavailable.
try {
await git(repoPath, "worktree", "add", "-b", cfg.branch, workspacePath, baseRef);
} catch {
await git(
repoPath,
"worktree",
"add",
"-b",
cfg.branch,
workspacePath,
`refs/heads/${cfg.project.defaultBranch}`,
);
}
await createBranchFromBase(
repoPath,
workspacePath,
cfg.branch,
cfg.project.defaultBranch,
hasOrigin,
);
}
}