From 032808755132fd48512d24c686d69b8d6b9bd554 Mon Sep 17 00:00:00 2001 From: Nei Cardoso de Oliveira Neto Date: Tue, 10 Nov 2026 17:53:29 -0300 Subject: [PATCH 1/4] fix(server): remove worktrees of git-annex repositories git-annex replaces a linked worktree's `.git` file with a symlink to its admin directory. `git worktree remove` rejects that at any `--force` level, and storage cleanup kept these worktrees as "not a linked worktree" and left them out of the orphan report. - Put the `gitdir:` file back before removal, so Git still makes its own safety checks. - Recognise the symlink as a linked worktree in storage cleanup. Claude Fable 5.2 via Claude Code Co-authored-by: Claude Fable 5.2 --- apps/server/src/storageCleanup.test.ts | 23 +++++++- apps/server/src/storageCleanup.ts | 16 ++--- apps/server/src/vcs/GitVcsDriverCore.test.ts | 27 +++++++++ apps/server/src/vcs/worktreeRemoval.ts | 61 +++++++++++++++++--- 4 files changed, 109 insertions(+), 18 deletions(-) diff --git a/apps/server/src/storageCleanup.test.ts b/apps/server/src/storageCleanup.test.ts index b0641a20428ec..8dedabf884b16 100644 --- a/apps/server/src/storageCleanup.test.ts +++ b/apps/server/src/storageCleanup.test.ts @@ -600,6 +600,15 @@ const cleanupTestLayer = GitVcsDriver.layer.pipe( Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "storage-cleanup-" })), Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ); +const annexWorktree = Effect.fnUntraced(function* ( + fixture: Effect.Success>, + worktree: string, +) { + const adminDir = (yield* fixture.command(worktree, ["rev-parse", "--absolute-git-dir"])).stdout; + yield* fixture.fs.remove(`${worktree}/.git`); + yield* fixture.fs.symlink(adminDir.trim(), `${worktree}/.git`); +}); + const runCleanupTest = ( effect: Effect.Effect< A, @@ -1809,7 +1818,7 @@ describe("orphan worktree reports", () => { ), ); - it.live.each(["nested", "direct"] as const)( + it.live.each(["nested", "direct", "annexed"] as const)( "reports a linked orphan's size without removing its files (%s)", (location) => runCleanupTest( @@ -1818,6 +1827,7 @@ describe("orphan worktree reports", () => { fixture.setThreads([]); const orphan = `${fixture.config.worktreesDir}/${location === "nested" ? "repository/" : ""}orphan`; yield* fixture.command(fixture.repo, ["worktree", "add", "-b", "orphan", orphan]); + if (location === "annexed") yield* annexWorktree(fixture, orphan); const sizes = yield* Effect.forEach(yield* fixture.fs.readDirectory(orphan), (name) => allocatedBytes(fixture.fs, `${orphan}/${name}`), ); @@ -2582,6 +2592,17 @@ describe("storage cleanup reports and local file policies", () => { }), ), ); + it.live("removes a worktree whose .git file git-annex replaced with a symlink", () => + runCleanupTest( + Effect.gen(function* () { + const fixture = yield* cleanupFixture(); + const { service, worktree, fs } = fixture; + yield* annexWorktree(fixture, worktree); + expect((yield* service.runNow).entries[0]).toMatchObject({ outcome: "removed" }); + expect(yield* fs.exists(worktree)).toBe(false); + }), + ), + ); it.live("keeps a worktree when the inspection cannot read its directories", () => runCleanupTest( Effect.gen(function* () { diff --git a/apps/server/src/storageCleanup.ts b/apps/server/src/storageCleanup.ts index 99097501d49d7..2331827e9b7d5 100644 --- a/apps/server/src/storageCleanup.ts +++ b/apps/server/src/storageCleanup.ts @@ -52,7 +52,7 @@ import * as TerminalManager from "./terminal/Manager.ts"; import { isFilesystemRoot, managedWorktreesDirectories } from "./worktreesDirectory.ts"; import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; import { isNonRepositoryGitStderr } from "./vcs/GitVcsDriverCore.ts"; -import { removeLinkedWorktree } from "./vcs/worktreeRemoval.ts"; +import { isLinkedWorktree, removeLinkedWorktree } from "./vcs/worktreeRemoval.ts"; import { withWorkspaceLease } from "./workspace/workspaceLease.ts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; @@ -579,7 +579,7 @@ const make = Effect.gen(function* () { const modified = Option.getOrNull((yield* fs.stat(worktreePath)).mtime); if (modified !== null && modified.getTime() > now - ORPHAN_WORKTREE_GRACE_MS) return; const gitPath = path.join(worktreePath, ".git"); - const linked = (yield* fs.exists(gitPath)) && (yield* fs.stat(gitPath)).type === "File"; + const linked = (yield* fs.exists(gitPath)) && (yield* isLinkedWorktree(worktreePath)); reported.add(key); const remaining = measurementDeadline - (yield* Clock.currentTimeMillis); if (!orphanSizes.has(key) && (firstMeasurement || remaining > 0)) { @@ -622,7 +622,7 @@ const make = Effect.gen(function* () { (worktree) => Effect.gen(function* () { if (pathKey(yield* fs.realPath(worktree)) !== pathKey(worktree)) return false; - return (yield* fs.stat(path.join(worktree, ".git"))).type === "File"; + return yield* isLinkedWorktree(worktree); }).pipe(Effect.orElseSucceed(() => false)), ); }); @@ -633,7 +633,7 @@ const make = Effect.gen(function* () { if (referenced.has(pathKey(projectDirectory))) return; const gitPath = path.join(projectDirectory, ".git"); if (yield* fs.exists(gitPath)) { - if ((yield* fs.stat(gitPath)).type === "File") yield* report(projectDirectory); + if (yield* isLinkedWorktree(projectDirectory)) yield* report(projectDirectory); return; } for (const worktreePath of yield* directories(projectDirectory, root)) { @@ -1010,10 +1010,10 @@ const make = Effect.gen(function* () { return "worktree is a symbolic link"; if (!roots.some((root) => inside(root, realPath))) return "outside the managed worktree folder"; - const gitLink = yield* fs - .stat(path.join(worktreePath, ".git")) - .pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.undefined)); - if (gitLink?.type !== "File") return "not a linked worktree"; + const linked = yield* isLinkedWorktree(worktreePath).pipe( + Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(false)), + ); + if (!linked) return "not a linked worktree"; return null; }); const deletedRows = yield* sql<{ payload_json: string; workspaceRoot: string }>` diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index d46666cb468d0..faca306445651 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -6132,6 +6132,33 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); + it.effect.skipIf(!symlinksSupported)( + "removes a worktree whose .git file git-annex replaced with a symlink", + () => + 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-"), "annexed"); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* driver.createWorktree({ + cwd, + path: worktreePath, + refName: initialBranch, + newRefName: "feature/annexed", + }); + const gitFile = pathService.join(worktreePath, ".git"); + const adminDir = (yield* git(worktreePath, ["rev-parse", "--absolute-git-dir"])).trim(); + NodeFS.rmSync(gitFile); + NodeFS.symlinkSync(adminDir, gitFile); + + yield* driver.removeWorktree({ cwd, path: worktreePath }); + + assert.isFalse(NodeFS.existsSync(worktreePath)); + assert.isFalse(NodeFS.existsSync(adminDir)); + }), + ); + it.effect("leaves a folder alone that only shares the worktree's short name", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); diff --git a/apps/server/src/vcs/worktreeRemoval.ts b/apps/server/src/vcs/worktreeRemoval.ts index f1140cdcc3a48..df311589105b0 100644 --- a/apps/server/src/vcs/worktreeRemoval.ts +++ b/apps/server/src/vcs/worktreeRemoval.ts @@ -9,6 +9,42 @@ import type * as GitVcsDriver from "./GitVcsDriver.ts"; const INDEX_LIST_MAX_BYTES = 64 * 1024 * 1024; +/** + * git-annex replaces a linked worktree's `.git` file with a symlink to its admin + * directory, so its symlinks into `.git/annex` resolve. Git then refuses to remove it. + */ +const annexedAdminDir = Effect.fn("WorktreeRemoval.annexedAdminDir")( + function* (worktreePath: string) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const adminDir = path.resolve( + worktreePath, + yield* fileSystem.readLink(path.join(worktreePath, ".git")), + ); + const pointsBackTo = path.dirname( + path.resolve( + adminDir, + (yield* fileSystem.readFileString(path.join(adminDir, "gitdir"))).trim(), + ), + ); + return (yield* fileSystem.realPath(pointsBackTo)) === (yield* fileSystem.realPath(worktreePath)) + ? Option.some(adminDir) + : Option.none(); + }, + Effect.orElseSucceed(() => Option.none()), +); + +export const isLinkedWorktree = Effect.fn("WorktreeRemoval.isLinkedWorktree")(function* ( + worktreePath: string, +) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + return ( + (yield* fileSystem.stat(path.join(worktreePath, ".git"))).type === "File" || + Option.isSome(yield* annexedAdminDir(worktreePath)) + ); +}); + /** * Git for Windows before 2.54 follows NTFS junctions; newer Git leaves them behind. * On Windows, mirror Git's removal checks, then delete with Node's `rm`, which @@ -33,6 +69,22 @@ export const removeLinkedWorktree = Effect.fn("WorktreeRemoval.removeLinkedWorkt // Git refuses to remove a worktree with untracked files unless forced, but // its check honors `status.showUntrackedFiles=no` and would delete them. const statusConfig = ["-c", "status.showUntrackedFiles=normal"]; + // Git's own refusals, worded the way it prints them. + const refuse = (message: string, cause?: unknown) => + new GitCommandError({ + operation: input.operation, + command: "git worktree remove", + cwd: input.cwd, + detail: `fatal: ${message}`, + ...(cause === undefined ? {} : { cause }), + }); + const annexed = yield* annexedAdminDir(worktreePath); + if (Option.isSome(annexed)) { + yield* fileSystem.remove(gitFile).pipe( + Effect.andThen(fileSystem.writeFileString(gitFile, `gitdir: ${annexed.value}\n`)), + Effect.mapError((cause) => refuse(`failed to restore '${gitFile}'`, cause)), + ); + } const linked = (yield* HostProcess.isWindows) && (yield* fileSystem.stat(gitFile).pipe( @@ -50,15 +102,6 @@ export const removeLinkedWorktree = Effect.fn("WorktreeRemoval.removeLinkedWorkt ]); } - // Git's own refusals, worded the way it prints them. - const refuse = (message: string, cause?: unknown) => - new GitCommandError({ - operation: input.operation, - command: "git worktree remove", - cwd: input.cwd, - detail: `fatal: ${message}`, - ...(cause === undefined ? {} : { cause }), - }); const gitOutput = (cwd: string, args: ReadonlyArray, env?: NodeJS.ProcessEnv) => input .execute({ From ae0b468ad4528b64118ce678912190e51468b416 Mon Sep 17 00:00:00 2001 From: Nei Cardoso de Oliveira Neto Date: Tue, 10 Nov 2026 18:25:40 -0300 Subject: [PATCH 2/4] fix(server): keep the annex symlink when removal is refused A refused removal left `.git` as a regular file, so the kept worktree's annexed files stopped resolving until git-annex ran there again. - Put the symlink back whenever the worktree survives. - Measure the orphan test's expected size without the `.git` link, which the report does not count. Claude Fable 5.2 via Claude Code Co-authored-by: Claude Fable 5.2 --- apps/server/src/storageCleanup.test.ts | 8 ++++++-- apps/server/src/vcs/GitVcsDriverCore.test.ts | 10 +++++++++- apps/server/src/vcs/worktreeRemoval.ts | 8 +++++++- 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/apps/server/src/storageCleanup.test.ts b/apps/server/src/storageCleanup.test.ts index 8dedabf884b16..d8ce73969ec2f 100644 --- a/apps/server/src/storageCleanup.test.ts +++ b/apps/server/src/storageCleanup.test.ts @@ -1828,8 +1828,12 @@ describe("orphan worktree reports", () => { const orphan = `${fixture.config.worktreesDir}/${location === "nested" ? "repository/" : ""}orphan`; yield* fixture.command(fixture.repo, ["worktree", "add", "-b", "orphan", orphan]); if (location === "annexed") yield* annexWorktree(fixture, orphan); - const sizes = yield* Effect.forEach(yield* fixture.fs.readDirectory(orphan), (name) => - allocatedBytes(fixture.fs, `${orphan}/${name}`), + const sizes = yield* Effect.forEach( + // Links are not measured, and the annexed `.git` is one. + (yield* fixture.fs.readDirectory(orphan)).filter( + (name) => location !== "annexed" || name !== ".git", + ), + (name) => allocatedBytes(fixture.fs, `${orphan}/${name}`), ); const outside = yield* fixture.fs.makeTempDirectoryScoped(); yield* fixture.fs.writeFileString(`${outside}/external`, "outside data"); diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index faca306445651..38fa374007e23 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -6133,7 +6133,7 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { ); it.effect.skipIf(!symlinksSupported)( - "removes a worktree whose .git file git-annex replaced with a symlink", + "removes a worktree whose .git file git-annex replaced with a symlink, keeping the symlink when refused", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); @@ -6151,6 +6151,14 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { const adminDir = (yield* git(worktreePath, ["rev-parse", "--absolute-git-dir"])).trim(); NodeFS.rmSync(gitFile); NodeFS.symlinkSync(adminDir, gitFile); + yield* writeTextFile(worktreePath, "notes.txt", "draft\n"); + + const refused = yield* Effect.result(driver.removeWorktree({ cwd, path: worktreePath })); + + assert.isTrue(Result.isFailure(refused)); + assert.isTrue(NodeFS.lstatSync(gitFile).isSymbolicLink()); + assert.equal(NodeFS.realpathSync(gitFile), NodeFS.realpathSync(adminDir)); + NodeFS.rmSync(pathService.join(worktreePath, "notes.txt")); yield* driver.removeWorktree({ cwd, path: worktreePath }); diff --git a/apps/server/src/vcs/worktreeRemoval.ts b/apps/server/src/vcs/worktreeRemoval.ts index df311589105b0..302bfe542b3d9 100644 --- a/apps/server/src/vcs/worktreeRemoval.ts +++ b/apps/server/src/vcs/worktreeRemoval.ts @@ -84,6 +84,12 @@ export const removeLinkedWorktree = Effect.fn("WorktreeRemoval.removeLinkedWorkt Effect.andThen(fileSystem.writeFileString(gitFile, `gitdir: ${annexed.value}\n`)), Effect.mapError((cause) => refuse(`failed to restore '${gitFile}'`, cause)), ); + // A refused removal keeps the worktree, whose annexed files resolve through the symlink. + yield* Effect.addFinalizer(() => + fileSystem + .remove(gitFile) + .pipe(Effect.andThen(fileSystem.symlink(annexed.value, gitFile)), Effect.ignore), + ); } const linked = (yield* HostProcess.isWindows) && @@ -196,4 +202,4 @@ export const removeLinkedWorktree = Effect.fn("WorktreeRemoval.removeLinkedWorkt ); // The folder is gone, so Git only drops the worktree's metadata. return yield* input.run(["worktree", "remove", "--force", "--force", "--", worktreePath]); -}); +}, Effect.scoped); From ad0a982f0ff7234993c23cca5bf977e7423fcf36 Mon Sep 17 00:00:00 2001 From: Nei Cardoso de Oliveira Neto Date: Tue, 10 Nov 2026 18:37:13 -0300 Subject: [PATCH 3/4] fix(server): keep a usable .git if the annex symlink fails When a refused removal could not recreate the symlink, the worktree was left with no `.git` at all. Write the `gitdir:` file back instead. Claude Fable 5.2 via Claude Code Co-authored-by: Claude Fable 5.2 --- apps/server/src/vcs/worktreeRemoval.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/server/src/vcs/worktreeRemoval.ts b/apps/server/src/vcs/worktreeRemoval.ts index 302bfe542b3d9..0d87ae2cefb23 100644 --- a/apps/server/src/vcs/worktreeRemoval.ts +++ b/apps/server/src/vcs/worktreeRemoval.ts @@ -80,15 +80,21 @@ export const removeLinkedWorktree = Effect.fn("WorktreeRemoval.removeLinkedWorkt }); const annexed = yield* annexedAdminDir(worktreePath); if (Option.isSome(annexed)) { + const restoreGitFile = fileSystem.writeFileString(gitFile, `gitdir: ${annexed.value}\n`); yield* fileSystem.remove(gitFile).pipe( - Effect.andThen(fileSystem.writeFileString(gitFile, `gitdir: ${annexed.value}\n`)), + Effect.andThen(restoreGitFile), Effect.mapError((cause) => refuse(`failed to restore '${gitFile}'`, cause)), ); // A refused removal keeps the worktree, whose annexed files resolve through the symlink. yield* Effect.addFinalizer(() => fileSystem .remove(gitFile) - .pipe(Effect.andThen(fileSystem.symlink(annexed.value, gitFile)), Effect.ignore), + .pipe( + Effect.andThen( + fileSystem.symlink(annexed.value, gitFile).pipe(Effect.catch(() => restoreGitFile)), + ), + Effect.ignore, + ), ); } const linked = From 9a292271668353dc6a59e29116aed5d14d2c0752 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 15 Nov 2026 02:56:25 +0000 Subject: [PATCH 4/4] fix(server): restore the annex symlink whenever a kept worktree lost it Register the restore before replacing the symlink, so a failed swap still leaves a usable .git, and skip it once the worktree is gone. The removal reports refusals as a nonzero exit, not a failure, so the restore is gated on the worktree still existing. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/vcs/worktreeRemoval.ts | 31 +++++++++++++++----------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/apps/server/src/vcs/worktreeRemoval.ts b/apps/server/src/vcs/worktreeRemoval.ts index 0d87ae2cefb23..b48994873b282 100644 --- a/apps/server/src/vcs/worktreeRemoval.ts +++ b/apps/server/src/vcs/worktreeRemoval.ts @@ -80,21 +80,26 @@ export const removeLinkedWorktree = Effect.fn("WorktreeRemoval.removeLinkedWorkt }); const annexed = yield* annexedAdminDir(worktreePath); if (Option.isSome(annexed)) { - const restoreGitFile = fileSystem.writeFileString(gitFile, `gitdir: ${annexed.value}\n`); - yield* fileSystem.remove(gitFile).pipe( - Effect.andThen(restoreGitFile), - Effect.mapError((cause) => refuse(`failed to restore '${gitFile}'`, cause)), - ); - // A refused removal keeps the worktree, whose annexed files resolve through the symlink. + const adminDir = annexed.value; + const writeGitFile = fileSystem.writeFileString(gitFile, `gitdir: ${adminDir}\n`); + // A kept worktree's annexed files resolve only through the symlink; a gitdir + // file is the fallback when it cannot be recreated. yield* Effect.addFinalizer(() => - fileSystem - .remove(gitFile) - .pipe( - Effect.andThen( - fileSystem.symlink(annexed.value, gitFile).pipe(Effect.catch(() => restoreGitFile)), - ), - Effect.ignore, + fileSystem.exists(worktreePath).pipe( + Effect.flatMap((kept) => + kept + ? fileSystem.remove(gitFile, { force: true }).pipe( + Effect.andThen(fileSystem.symlink(adminDir, gitFile)), + Effect.catch(() => writeGitFile), + ) + : Effect.void, ), + Effect.ignore, + ), + ); + yield* fileSystem.remove(gitFile).pipe( + Effect.andThen(writeGitFile), + Effect.mapError((cause) => refuse(`failed to replace '${gitFile}'`, cause)), ); } const linked =