From 66079c49f3e7749c9af59760179a3d84e4b5ae67 Mon Sep 17 00:00:00 2001 From: badcuban <108198679+badcuban@users.noreply.github.com> Date: Sun, 6 Sep 2026 04:49:14 -0400 Subject: [PATCH] fix(server): worktree cleanup removes half-deleted checkouts A worktree removal that died halfway (a long-path failure before core.longpaths, a kill mid-delete) leaves the folder without its .git file. Git then lists the registration as prunable and refuses to remove it ("validation failed ... '.git' does not exist"), so the cleanup view offered the worktree for deletion but every attempt failed. removeWorktree now asks git how it sees the registration when the remove command fails. A prunable entry is pruned and the leftover folder is deleted by the server, reusing the retry loop that already handles folders a shell still holds open. Live registrations and unrelated failures still surface as before. --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 29 +++++++++ apps/server/src/vcs/GitVcsDriverCore.ts | 68 ++++++++++++++------ 2 files changed, 78 insertions(+), 19 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 72de1564..74095cf3 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -1003,6 +1003,35 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { assert.equal(yield* git(worktreePath, ["branch", "--show-current"]), "feature/doomed"); }), ); + + // A removal that died halfway (a long-path failure, a kill mid-delete) + // leaves the folder without its `.git` file. Git lists the registration + // as prunable and refuses to remove it ("validation failed"), so the + // driver has to prune the dead registration and delete the leftovers. + it.effect("removes a half-deleted worktree whose .git file is gone", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const pathService = yield* Path.Path; + const worktreePath = pathService.join(yield* makeTmpDir("git-worktrees-"), "half-gone"); + const driver = yield* GitVcsDriver.GitVcsDriver; + const fileSystem = yield* FileSystem.FileSystem; + + yield* driver.createWorktree({ + cwd, + path: worktreePath, + refName: initialBranch, + newRefName: "feature/half-gone", + }); + yield* fileSystem.remove(pathService.join(worktreePath, ".git")); + assert.match(yield* git(cwd, ["worktree", "list", "--porcelain"]), /^prunable /mu); + + yield* driver.removeWorktree({ cwd, path: worktreePath, force: true }); + + assert.equal(yield* fileSystem.exists(worktreePath), false); + assert.notMatch(yield* git(cwd, ["worktree", "list", "--porcelain"]), /half-gone/u); + }), + ); }); describe("commit context", () => { diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 58481b2b..66670271 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -4609,11 +4609,15 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* // beat after removal starts, and until it exits its working directory // pins the folder. Finish the deletion ourselves once the holder lets go, // but only when git really did unregister the worktree. - const finishInterruptedRemoval = (cause: GitCommandError) => - Effect.gen(function* () { - if (yield* isWorktreeRegistered(input.cwd, input.path)) { - return yield* Effect.fail(cause); - } + // + // A removal that died halfway (a long-path failure before core.longpaths, + // a kill mid-delete) leaves the folder without its `.git` file. Git then + // lists the registration as prunable and refuses to remove it + // ("validation failed ... '.git' does not exist"). Nothing left in that + // folder is a checkout anymore, so drop the dead registration and delete + // the leftovers ourselves. + const deleteLeftoverFolder = Effect.fn("GitVcsDriver.removeWorktree.deleteLeftover")( + function* () { for (let attempt = 0; attempt < LEFTOVER_WORKTREE_REMOVE_ATTEMPTS; attempt += 1) { if (attempt > 0) { yield* Effect.sleep(LEFTOVER_WORKTREE_REMOVE_DELAY); @@ -4625,20 +4629,34 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* .exists(input.path) .pipe(Effect.catch(() => Effect.succeed(false))); if (!stillThere) { - return; + return true; } } - return yield* Effect.fail(cause); + return false; + }, + ); + + const recoverFailedRemoval = (cause: GitCommandError) => + Effect.gen(function* () { + const registration = yield* readWorktreeRegistration(input.cwd, input.path); + if (registration === "prunable") { + yield* executeGit("GitVcsDriver.removeWorktree.prune", input.cwd, ["worktree", "prune"], { + timeoutMs: 15_000, + fallbackErrorMessage: "git worktree prune failed", + }); + } else if (registration !== "absent" || !/failed to delete/iu.test(cause.detail ?? "")) { + return yield* Effect.fail(cause); + } + if (!(yield* deleteLeftoverFolder())) { + return yield* Effect.fail(cause); + } }); yield* executeGit("GitVcsDriver.removeWorktree", input.cwd, args, { timeoutMs: 15_000, fallbackErrorMessage: "git worktree remove failed", }).pipe( - Effect.catchIf( - (error) => /failed to delete/iu.test(error.detail ?? ""), - finishInterruptedRemoval, - ), + Effect.catch(recoverFailedRemoval), Effect.mapError((error) => createGitCommandError( "GitVcsDriver.removeWorktree", @@ -4651,8 +4669,13 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ); }); - /** Whether `git worktree list` still knows the checkout at `worktreePath`. */ - const isWorktreeRegistered = Effect.fn("isWorktreeRegistered")(function* ( + /** + * How `git worktree list` sees the checkout at `worktreePath`: a live + * registration, a dead one git would prune (its `.git` link is gone), or + * none at all. A listing that fails reads as "registered" so no caller + * treats a checkout as gone on a hunch. + */ + const readWorktreeRegistration = Effect.fn("readWorktreeRegistration")(function* ( cwd: string, worktreePath: string, ) { @@ -4663,19 +4686,26 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* : forwardSlashes; }; const result = yield* executeGit( - "GitVcsDriver.isWorktreeRegistered", + "GitVcsDriver.readWorktreeRegistration", cwd, ["worktree", "list", "--porcelain"], { timeoutMs: 5_000, allowNonZeroExit: true }, ).pipe(Effect.catch(() => Effect.succeed(null))); if (result === null || result.exitCode !== 0) { - return true; + return "registered" as const; } const target = normalize(worktreePath); - return result.stdout - .split("\n") - .filter((line) => line.startsWith("worktree ")) - .some((line) => normalize(line.slice("worktree ".length)) === target); + for (const block of result.stdout.split("\n\n")) { + const lines = block.split("\n"); + const header = lines.find((line) => line.startsWith("worktree ")); + if (header === undefined || normalize(header.slice("worktree ".length)) !== target) { + continue; + } + return lines.some((line) => line.startsWith("prunable")) + ? ("prunable" as const) + : ("registered" as const); + } + return "absent" as const; }); const renameBranch: GitVcsDriver.GitVcsDriverShape["renameBranch"] = Effect.fn("renameBranch")(