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
29 changes: 29 additions & 0 deletions apps/server/src/vcs/WorktreeRemovalGuard.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import * as NodeFs from "node:fs";
import * as NodeOs from "node:os";
import * as NodePath from "node:path";

import { assert, describe, it } from "@effect/vitest";
import * as Data from "effect/Data";
import * as Effect from "effect/Effect";
Expand Down Expand Up @@ -46,6 +50,31 @@ describe("ensureWorktreeRemovable", () => {
}),
);

// git names a worktree by its real path; a thread can record the same
// folder through a symlink, and must still be in the way.
it.effect("sees a thread that recorded the folder through a symlink", () => {
const base = NodeFs.realpathSync(NodeFs.mkdtempSync(NodePath.join(NodeOs.tmpdir(), "guard-")));
const real = NodePath.join(base, "feature");
const linked = NodePath.join(base, "linked");
NodeFs.mkdirSync(real);
NodeFs.symlinkSync(real, linked);
return Effect.gen(function* () {
const exit = yield* ensureWorktreeRemovable({
worktreePath: real,
readThreads: Effect.succeed([thread({ worktreePath: linked })]),
}).pipe(Effect.exit);
assert.isTrue(Exit.isFailure(exit));

// Another folder beside it is still free to go.
yield* ensureWorktreeRemovable({
worktreePath: NodePath.join(base, "other"),
readThreads: Effect.succeed([thread({ worktreePath: linked })]),
});
}).pipe(
Effect.ensuring(Effect.sync(() => NodeFs.rmSync(base, { recursive: true, force: true }))),
);
});

// The guard protects against a common mistake; it must not become a reason
// that deleting a folder stops working when an unrelated query fails.
it.effect("allows removal when the thread list cannot be read", () =>
Expand Down
38 changes: 35 additions & 3 deletions apps/server/src/vcs/WorktreeRemovalGuard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,17 @@
* skip the step that keeps the thread usable.
*
* The classification itself lives in `@threadlines/shared/worktreeUsage` so the
* client's cleanup list greys out exactly the folders this guard would refuse.
* client's cleanup list greys out the folders this guard would refuse. The
* guard goes one step further than the list can: it compares folders as the
* disk resolves them. git names a worktree by its real path, and a thread may
* have recorded the same folder through a symlink (a data folder under
* macOS's `/var`, which is `/private/var`); by spelling alone that thread
* would not be in the way.
*
* @module WorktreeRemovalGuard
*/
import * as NodeFs from "node:fs/promises";

import * as Effect from "effect/Effect";
import { VcsWorktreeInUseError } from "@threadlines/contracts";
import {
Expand Down Expand Up @@ -45,9 +52,34 @@ export const ensureWorktreeRemovable = <E>(input: {
}).pipe(Effect.as([] as ReadonlyArray<WorktreeUsageThread>)),
),
);
// Each distinct folder is resolved once; one that is gone keeps its spelling.
const resolved = new Map<string, string>();
const realPath = (path: string) =>
Effect.promise(async () => {
const known = resolved.get(path);
if (known !== undefined) return known;
const real = await NodeFs.realpath(path).catch(() => path);
resolved.set(path, real);
return real;
});
const realPathOrNull = (path: string | null | undefined) =>
typeof path === "string" && path.length > 0 ? realPath(path) : Effect.succeed(path);
const blockingThreads = findWorktreeBlockingThreads({
worktreePath: input.worktreePath,
threads,
worktreePath: yield* realPath(input.worktreePath),
threads: yield* Effect.forEach(threads, (thread) =>
Effect.gen(function* () {
const session = thread.session;
return {
...thread,
worktreePath: (yield* realPathOrNull(thread.worktreePath)) ?? null,
effectiveCwd: yield* realPathOrNull(thread.effectiveCwd),
session:
session === null || session === undefined
? session
: { ...session, checkoutCwd: yield* realPathOrNull(session.checkoutCwd) },
} satisfies WorktreeUsageThread;
}),
),
});
if (blockingThreads.length === 0) {
return;
Expand Down
Loading