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
Expand Up @@ -1127,6 +1127,8 @@ effectIt.effect(

const expectedTips = {
retained: { tip: threadTip, file: "thread.txt", upstream: null, base: "" },
// A failed removal can leave the folder behind with only dependencies inside.
leftover: { tip: threadTip, file: "thread.txt", upstream: null, base: "" },
deleted: { tip: projectTip, file: "project.txt", upstream: null, base: "project-current" },
pushed: {
tip: threadTip,
Expand Down Expand Up @@ -1171,7 +1173,12 @@ effectIt.effect(
yield* git(workspaceRoot, ["remote", "remove", "origin"]);
}
yield* fileSystem.remove(worktreePath, { recursive: true });
if (scenario !== "retained") {
if (scenario === "leftover") {
yield* fileSystem.makeDirectory(path.join(worktreePath, "node_modules"), {
recursive: true,
});
}
if (scenario !== "retained" && scenario !== "leftover") {
yield* git(workspaceRoot, ["worktree", "prune"]);
yield* git(workspaceRoot, ["branch", "-D", "feature/restore"]);
}
Expand Down Expand Up @@ -1220,6 +1227,27 @@ effectIt.effect(
}).pipe(Effect.scoped, Effect.provide(layerRealGit)),
);

effectIt.effect("leaves a managed worktree folder without .git that holds user files", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const worktreesDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-leftover-" });
const worktreePath = path.join(worktreesDir, "project", "restore");
yield* fileSystem.makeDirectory(path.join(worktreePath, "node_modules"), { recursive: true });
yield* fileSystem.writeFileString(path.join(worktreePath, "notes.md"), "keep me");
const harness = makeLocalCommandHarness({
text: "Continue",
worktreePath,
worktreesDir,
fileSystem,
failReadsAfterRunning: true,
});
yield* harness.start;
expect(harness.createWorktree).not.toHaveBeenCalled();
expect(yield* fileSystem.readFileString(path.join(worktreePath, "notes.md"))).toBe("keep me");
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);

effectIt.effect(
"preserves recreation interrupts and retries, then fails the final attempt before provider start",
() =>
Expand Down
18 changes: 18 additions & 0 deletions apps/server/src/storageCleanup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2332,6 +2332,24 @@ describe("storage cleanup reports and local file policies", () => {
}),
),
);
it.effect("keeps a thread's worktree folder that lost its .git link", () =>
runCleanupTest(
Effect.gen(function* () {
yield* TestClock.setTime(NOW_MS);
const fixture = yield* cleanupFixture();
fixture.setThreads([
shell({ branch: "feature", worktreePath: fixture.worktree, archivedAt: at(-DAY_MS) }),
]);
yield* fixture.fs.remove(`${fixture.worktree}/.git`);
yield* fixture.fs.makeDirectory(`${fixture.worktree}/node_modules`);
const report = yield* fixture.service.runNow;
expect(report.entries).toEqual([
expect.objectContaining({ path: fixture.worktree, outcome: "kept" }),
]);
expect(yield* fixture.fs.exists(`${fixture.worktree}/node_modules`)).toBe(true);
}),
),
);
it.live("skips a stored worktree path whose parent is a regular file", () =>
runCleanupTest(
Effect.gen(function* () {
Expand Down
6 changes: 4 additions & 2 deletions apps/server/src/storageCleanup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1010,8 +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";
if ((yield* fs.stat(path.join(worktreePath, ".git"))).type !== "File")
return "not a linked worktree";
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";
return null;
});
const deletedRows = yield* sql<{ payload_json: string; workspaceRoot: string }>`
Expand Down
33 changes: 31 additions & 2 deletions apps/server/src/workspace/restoreMissingWorktree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ class WorktreeRecreateNoBaseError extends Schema.TaggedError<WorktreeRecreateNoB
}
}

// `git worktree remove` skips pnpm junctions on Windows, leaving these behind without a checkout.
const LEFTOVER_WORKTREE_ENTRIES = new Set(["node_modules", ".next"]);

interface WorktreeThread {
readonly id: ThreadId;
readonly projectId: ProjectId;
Expand All @@ -42,10 +45,33 @@ interface WorktreeThread {

export const makeRestoreMissingWorktree = Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const gitWorkflow = yield* GitWorkflowService.GitWorkflowService;
const projects = yield* ProjectService.ProjectService;
const isManagedWorktreePath = yield* makeIsManagedWorktreePath;

// Only folders holding nothing but leftovers are cleared, never user files.
const isLeftoverFolder = (worktreePath: string) =>
fileSystem.exists(path.join(worktreePath, ".git")).pipe(
Effect.flatMap((hasGit) =>
hasGit
? Effect.succeed(false)
: fileSystem.readDirectory(worktreePath).pipe(
Effect.flatMap((entries) =>
Effect.forEach(entries, (entry) =>
LEFTOVER_WORKTREE_ENTRIES.has(entry)
? fileSystem
.stat(path.join(worktreePath, entry))
.pipe(Effect.map((info) => info.type === "Directory"))
: Effect.succeed(false),
),
),
Effect.map((leftovers) => leftovers.every(Boolean)),
),
),
Effect.orElseSucceed(() => false),
);

const resolveRecreateBase = Effect.fn("restoreMissingWorktree.resolveRecreateBase")(function* (
cwd: string,
remoteName: string | null,
Expand Down Expand Up @@ -116,7 +142,8 @@ export const makeRestoreMissingWorktree = Effect.gen(function* () {
const { worktreePath, branch } = thread;
if (worktreePath === null || branch === null) return;
const exists = yield* fileSystem.exists(worktreePath).pipe(Effect.orElseSucceed(() => true));
if (exists || !(yield* isManagedWorktreePath(worktreePath))) return;
const leftover = exists && (yield* isLeftoverFolder(worktreePath));
if ((exists && !leftover) || !(yield* isManagedWorktreePath(worktreePath))) return;
const project = yield* projects.getById(thread.projectId).pipe(
Effect.map(Option.getOrUndefined),
Effect.orElseSucceed(() => undefined),
Expand All @@ -127,7 +154,9 @@ export const makeRestoreMissingWorktree = Effect.gen(function* () {
worktreePath,
branch,
});
yield* recreateMissingWorktree({ cwd: project.workspaceRoot, worktreePath, branch }).pipe(
// `git worktree add` refuses a non-empty target.
yield* (leftover ? fileSystem.remove(worktreePath, { recursive: true }) : Effect.void).pipe(
Effect.andThen(recreateMissingWorktree({ cwd: project.workspaceRoot, worktreePath, branch })),
Effect.mapError((cause) =>
cause._tag === "WorktreeRecreateNoBaseError"
? cause
Expand Down
Loading