Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { existsSync } from "node:fs";
import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm";
import { z } from "zod";
Expand Down Expand Up @@ -267,7 +268,9 @@ async function runDestroy(ctx: HostServiceContext, input: DestroyInput) {
warnings.push(`Failed to dispose terminal sessions: ${message}`);
}

// 3b. Worktree (always --force: we're past the commit point)
// 3b. Worktree (always --force --force: we're past the commit point,
// and double-force unlocks the rare locked-worktree case the user
// can hit by manually `rm -rf`-ing a worktree that ended up locked.)
// 3c. Optional branch delete
let worktreeRemoved = false;
let branchDeleted = false;
Expand All @@ -287,11 +290,26 @@ async function runDestroy(ctx: HostServiceContext, input: DestroyInput) {

if (git) {
try {
await git.raw(["worktree", "remove", "--force", local.worktreePath]);
await git.raw([
"worktree",
"remove",
"--force",
"--force",
local.worktreePath,
]);
worktreeRemoved = true;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (
// If the worktree dir is already gone, the user's goal is met
// regardless of what git complains about — locale-translated
// messages, future git phrasing, or "locked working tree" with
// the dir already rm'd. The substring matcher below stays as
// belt-and-braces for the rare race where the dir disappears
// between this check and the git invocation, but `existsSync`
// is the authoritative signal.
if (!existsSync(local.worktreePath)) {
worktreeRemoved = true;
} else if (
message.includes("is not a working tree") ||
message.includes("No such file or directory") ||
message.includes("does not exist") ||
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,34 @@ describe("workspaceCleanup.destroy integration", () => {
expect(worktreeList).toContain(otherWorktreePath);
});

test("missing worktree that was locked is still removed without warnings", async () => {
// A locked worktree whose dir was manually deleted is the scenario
// that breaks the substring-based error matcher: git says
// "fatal: cannot remove a locked working tree" and single `--force`
// is not enough. `--force --force` plus the existsSync fallback
// closes the loop so the user always gets a clean delete.
Comment on lines +196 to +200
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The test comment implies that the existsSync fallback is what resolves the locked+missing case, but with --force --force in place git actually succeeds and removes its internal metadata cleanly — so the test exercises the success branch, not the fallback. The fallback (and the scenario where git's .git/worktrees/ metadata might be left stale) goes untested. Worth a small wording tweak so future readers don't misread which branch they're actually covering.

Suggested change
// A locked worktree whose dir was manually deleted is the scenario
// that breaks the substring-based error matcher: git says
// "fatal: cannot remove a locked working tree" and single `--force`
// is not enough. `--force --force` plus the existsSync fallback
// closes the loop so the user always gets a clean delete.
// A locked worktree whose dir was manually deleted is the scenario
// that breaks the substring-based error matcher: git says
// "fatal: cannot remove a locked working tree" and single `--force`
// is not enough. `--force --force` overrides the lock and removes
// git's internal metadata cleanly (the success path). The existsSync
// fallback in the catch block is an additional safety net for any
// future git failure where the dir is already gone but git still
// throws; that path is not exercised here.
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/host-service/test/integration/workspace-cleanup.integration.test.ts
Line: 196-200

Comment:
The test comment implies that the `existsSync` fallback is what resolves the locked+missing case, but with `--force --force` in place git actually succeeds and removes its internal metadata cleanly — so the test exercises the success branch, not the fallback. The fallback (and the scenario where git's `.git/worktrees/` metadata might be left stale) goes untested. Worth a small wording tweak so future readers don't misread which branch they're actually covering.

```suggestion
		// A locked worktree whose dir was manually deleted is the scenario
		// that breaks the substring-based error matcher: git says
		// "fatal: cannot remove a locked working tree" and single `--force`
		// is not enough. `--force --force` overrides the lock and removes
		// git's internal metadata cleanly (the success path). The existsSync
		// fallback in the catch block is an additional safety net for any
		// future git failure where the dir is already gone but git still
		// throws; that path is not exercised here.
```

How can I resolve this? If you propose a fix, please make it concise.

await scenario.repo.git.raw(["worktree", "lock", scenario.worktreePath]);
rmSync(scenario.worktreePath, { recursive: true, force: true });

const result = await scenario.host.trpc.workspaceCleanup.destroy.mutate({
workspaceId: scenario.featureWorkspaceId,
deleteBranch: true,
});
expect(result.success).toBe(true);
expect(result.worktreeRemoved).toBe(true);
expect(result.branchDeleted).toBe(true);
expect(result.warnings).toEqual([]);

const worktreeList = await scenario.repo.git.raw([
"worktree",
"list",
"--porcelain",
]);
expect(worktreeList).not.toContain(scenario.worktreePath);
const branches = await scenario.repo.git.branchLocal();
expect(branches.all).not.toContain(scenario.branch);
});

test("returns success when no local workspace row exists, still calls cloud delete", async () => {
await scenario.dispose();
const fresh = await createBasicScenario({
Expand Down
Loading