Skip to content
Open
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 @@ -141,6 +141,7 @@ function startWithMissingWorktree(
Layer.mock(TurnItemPositionStore.TurnItemPositionStoreV2)({}),
Layer.succeed(FileSystem.FileSystem, {
exists: () => Effect.succeed(false),
remove: () => Effect.void,
realPath: (target: string) =>
normalize(target) in realPaths
? Effect.succeed(realPaths[normalize(target)])
Expand Down Expand Up @@ -1113,6 +1114,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 @@ -1147,7 +1150,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
4 changes: 3 additions & 1 deletion apps/server/src/orchestration-v2/ProviderTurnStartService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,7 @@ export const layer: Layer.Layer<
readonly branch: string;
}) {
const { cwd, worktreePath, branch } = input;
yield* fileSystem.remove(worktreePath, { recursive: true, force: true });

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.

🔴 Critical orchestration-v2/ProviderTurnStartService.ts:222

When a managed worktree has lost only its .git entry, the next turn recursively deletes the entire worktreePath, permanently removing any remaining modified, untracked, or ignored files. The .git check detects the missing checkout metadata, while isManagedWorktreePath verifies only the path location; preserve the directory by moving it aside before recreation, or refuse recovery when its contents are not safe to discard.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/server/src/orchestration-v2/ProviderTurnStartService.ts around line 222:

When a managed worktree has lost only its `.git` entry, the next turn recursively deletes the entire `worktreePath`, permanently removing any remaining modified, untracked, or ignored files. The `.git` check detects the missing checkout metadata, while `isManagedWorktreePath` verifies only the path location; preserve the directory by moving it aside before recreation, or refuse recovery when its contents are not safe to discard.

yield* gitWorkflow.pruneWorktrees({ cwd });
const localBranchNames = yield* gitWorkflow.listLocalBranchNames(cwd);
if (localBranchNames.includes(branch)) {
Expand Down Expand Up @@ -651,8 +652,9 @@ export const layer: Layer.Layer<
}
const { worktreePath, branch } = projection.thread;
if (worktreePath !== null && branch !== null) {
// A failed removal can leave the folder behind without its checkout.
const exists = yield* fileSystem
.exists(worktreePath)
.exists(path.join(worktreePath, ".git"))
.pipe(Effect.orElseSucceed(() => true));
if (!exists && (yield* isManagedWorktreePath(worktreePath))) {
const project = yield* projects.getById(projection.thread.projectId).pipe(
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 @@ -2107,6 +2107,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 @@ -944,8 +944,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
Loading