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
121 changes: 106 additions & 15 deletions apps/desktop/src/main/services/lanes/laneService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3155,18 +3155,21 @@ describe("laneService delete teardown + cancellation + streaming", () => {

async function setupWithLane(opts: { teardown: ReturnType<typeof makeFakeServices>; events: any[]; createWorktree?: boolean }) {
const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-delete-"));
const worktreesDir = path.join(repoRoot, "worktrees");
const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger());
const projectId = "proj-delete";
await seedProjectAndStack(db, { projectId, repoRoot });
db.run("update lanes set worktree_path = ? where id = ?", [path.join(worktreesDir, "parent"), "lane-parent"]);
db.run("update lanes set worktree_path = ? where id = ?", [path.join(worktreesDir, "child"), "lane-child"]);
// Materialize the lane-child worktree dir so the delete flow exercises git_worktree_remove.
const childPath = path.join(repoRoot, "child");
const childPath = path.join(worktreesDir, "child");
if (opts.createWorktree !== false) fs.mkdirSync(childPath, { recursive: true });
const service = createLaneService({
db,
projectRoot: repoRoot,
projectId,
defaultBaseRef: "main",
worktreesDir: path.join(repoRoot, "worktrees"),
worktreesDir,
onDeleteEvent: (event) => opts.events.push(event),
teardownDeps: {
processService: opts.teardown.processService,
Expand All @@ -3178,7 +3181,7 @@ describe("laneService delete teardown + cancellation + streaming", () => {
},
});
// First call (lane-child) has no children rows after the seed; we delete lane-child.
return { db, service, repoRoot };
return { db, service, repoRoot, worktreesDir, childPath };
}

it("runs teardown steps before git_worktree_remove and broadcasts per-step progress", async () => {
Expand Down Expand Up @@ -3228,8 +3231,7 @@ describe("laneService delete teardown + cancellation + streaming", () => {
it("removes residual worktree files before deleting the lane row", async () => {
const events: any[] = [];
const fake = makeFakeServices();
const { service, db, repoRoot } = await setupWithLane({ teardown: fake, events });
const childPath = path.join(repoRoot, "child");
const { service, db, childPath } = await setupWithLane({ teardown: fake, events });
fs.writeFileSync(path.join(childPath, "residual.log"), "left behind by git\n", "utf8");

vi.mocked(runGit).mockImplementation(async (args: string[]) => {
Expand Down Expand Up @@ -3257,8 +3259,7 @@ describe("laneService delete teardown + cancellation + streaming", () => {
it("recovers stale worktree directories with unreadable residual folders", async () => {
const events: any[] = [];
const fake = makeFakeServices();
const { service, repoRoot } = await setupWithLane({ teardown: fake, events });
const childPath = path.join(repoRoot, "child");
const { service, childPath } = await setupWithLane({ teardown: fake, events });
const guestTrashPath = path.join(childPath, ".Trashes");
fs.mkdirSync(guestTrashPath, { recursive: true });
fs.chmodSync(guestTrashPath, 0o311);
Expand Down Expand Up @@ -3290,11 +3291,10 @@ describe("laneService delete teardown + cancellation + streaming", () => {
expect(last.progress.steps.find((s: any) => s.name === "git_worktree_remove")?.detail).toContain("recovered from stale state");
});

it("deletes the lane row with a warning when only unregistered residual files remain", async () => {
it("deletes the lane row and records retryable cleanup when only unregistered residual files remain", async () => {
const events: any[] = [];
const fake = makeFakeServices();
const { service, db, repoRoot } = await setupWithLane({ teardown: fake, events });
const childPath = path.join(repoRoot, "child");
const { service, db, repoRoot, childPath } = await setupWithLane({ teardown: fake, events });
fs.writeFileSync(path.join(childPath, "residual.log"), "left behind by git\n", "utf8");
const realRm = fs.promises.rm.bind(fs.promises);
const rmSpy = vi.spyOn(fs.promises, "rm").mockImplementation(async (target: fs.PathLike, options?: Parameters<typeof fs.promises.rm>[1]) => {
Expand Down Expand Up @@ -3331,6 +3331,8 @@ describe("laneService delete teardown + cancellation + streaming", () => {
return "";
});

await service.list({ includeStatus: false });

try {
await service.delete({ laneId: "lane-child", deleteBranch: false, force: true });
} finally {
Expand All @@ -3339,11 +3341,100 @@ describe("laneService delete teardown + cancellation + streaming", () => {

expect(db.get<{ id: string }>("select id from lanes where id = ?", ["lane-child"])).toBeNull();
expect(fs.existsSync(childPath)).toBe(true);
expect(
db.get<{ lane_id: string; worktree_path: string; attempts: number; last_error: string }>(
"select lane_id, worktree_path, attempts, last_error from local_worktree_residual_cleanups where project_id = ? and worktree_path = ?",
["proj-delete", childPath],
),
).toMatchObject({
lane_id: "lane-child",
worktree_path: childPath,
attempts: 0,
});
const last = events[events.length - 1];
expect(last.progress.overallStatus).toBe("completed_with_warnings");
const wtStep = last.progress.steps.find((s: any) => s.name === "git_worktree_remove");
expect(wtStep?.status).toBe("completed");
expect(wtStep?.detail).toContain("manual cleanup failed");

await service.list({ includeStatus: false });

expect(fs.existsSync(childPath)).toBe(false);
expect(
db.get<{ lane_id: string }>(
"select lane_id from local_worktree_residual_cleanups where project_id = ? and worktree_path = ?",
["proj-delete", childPath],
),
).toBeNull();
});

it("does not clean up non-git directories still referenced by archived lanes", async () => {
const events: any[] = [];
const fake = makeFakeServices();
const { service, db, worktreesDir } = await setupWithLane({ teardown: fake, events });
const archivedPath = path.join(worktreesDir, "archived");
fs.mkdirSync(archivedPath, { recursive: true });
fs.writeFileSync(path.join(archivedPath, "keep.txt"), "archived lane files\n", "utf8");
const now = "2026-03-11T12:00:00.000Z";
db.run(
`
insert into lanes(
id, project_id, name, description, lane_type, base_ref, branch_ref, worktree_path,
attached_root_path, is_edit_protected, parent_lane_id, color, icon, tags_json, status, created_at, archived_at
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
["lane-archived", "proj-delete", "Archived", null, "worktree", "main", "feature/archived", archivedPath, null, 0, null, null, null, null, "archived", now, now],
);
db.run(
`
insert into local_worktree_residual_cleanups(
id, project_id, lane_id, branch_ref, worktree_path, reason, attempts, last_error, created_at, updated_at
) values (?, ?, ?, ?, ?, 'delete_residual', 0, ?, ?, ?)
`,
["cleanup-archived", "proj-delete", "lane-archived", "feature/archived", archivedPath, "stale cleanup debt", now, now],
);

vi.mocked(runGitOrThrow).mockImplementation(async (args: string[]) => {
if (args[0] === "worktree" && args[1] === "list") return "";
return "";
});

await service.list({ includeStatus: false });

expect(fs.existsSync(path.join(archivedPath, "keep.txt"))).toBe(true);
expect(
db.get<{ lane_id: string }>(
"select lane_id from local_worktree_residual_cleanups where project_id = ? and worktree_path = ?",
["proj-delete", archivedPath],
),
).not.toBeNull();
});

it("cleans only empty untracked directories and leaves unknown non-empty directories alone", async () => {
const events: any[] = [];
const fake = makeFakeServices();
const { service, worktreesDir } = await setupWithLane({ teardown: fake, events });
const emptyPath = path.join(worktreesDir, "empty-residual");
const youngEmptyPath = path.join(worktreesDir, "young-empty-residual");
const nonEmptyPath = path.join(worktreesDir, "non-empty-residual");
fs.mkdirSync(path.join(emptyPath, ".ade"), { recursive: true });
fs.mkdirSync(path.join(youngEmptyPath, ".ade"), { recursive: true });
fs.mkdirSync(nonEmptyPath, { recursive: true });
fs.writeFileSync(path.join(nonEmptyPath, "user-file.txt"), "not ours\n", "utf8");
const oldEnough = new Date(Date.now() - 15 * 60_000);
fs.utimesSync(path.join(emptyPath, ".ade"), oldEnough, oldEnough);
fs.utimesSync(emptyPath, oldEnough, oldEnough);

vi.mocked(runGitOrThrow).mockImplementation(async (args: string[]) => {
if (args[0] === "worktree" && args[1] === "list") return "";
return "";
});

await service.list({ includeStatus: false });

expect(fs.existsSync(emptyPath)).toBe(false);
expect(fs.existsSync(youngEmptyPath)).toBe(true);
expect(fs.existsSync(path.join(nonEmptyPath, "user-file.txt"))).toBe(true);
});

it("keeps retained delete progress queryable for remounted renderers", async () => {
Expand Down Expand Up @@ -3416,9 +3507,9 @@ describe("laneService delete teardown + cancellation + streaming", () => {
it("runs independent lane delete teardown concurrently", async () => {
const events: any[] = [];
const fake = makeFakeServices();
const { service, db, repoRoot } = await setupWithLane({ teardown: fake, events });
const { service, db, worktreesDir } = await setupWithLane({ teardown: fake, events });
const now = "2026-03-11T12:00:00.000Z";
const siblingPath = path.join(repoRoot, "sibling");
const siblingPath = path.join(worktreesDir, "sibling");
fs.mkdirSync(siblingPath, { recursive: true });
db.run(
`
Expand Down Expand Up @@ -3665,7 +3756,7 @@ describe("laneService delete teardown + cancellation + streaming", () => {
it("cleans lane-owned database state when deleting a lane", async () => {
const events: any[] = [];
const fake = makeFakeServices();
const { service, db, repoRoot } = await setupWithLane({ teardown: fake, events, createWorktree: false });
const { service, db, repoRoot, childPath } = await setupWithLane({ teardown: fake, events, createWorktree: false });
const projectId = "proj-delete";
const now = "2026-03-11T12:30:00.000Z";

Expand Down Expand Up @@ -3698,7 +3789,7 @@ describe("laneService delete teardown + cancellation + streaming", () => {
);
db.run(
"insert into files_workspaces(id, kind, lane_id, name, root_path, updated_at) values (?, ?, ?, ?, ?, ?)",
["workspace-child", "lane", "lane-child", "Child", path.join(repoRoot, "child"), now],
["workspace-child", "lane", "lane-child", "Child", childPath, now],
);
db.run(
"insert into file_directory_snapshots(workspace_id, parent_path, include_hidden, nodes_json, updated_at) values (?, ?, ?, ?, ?)",
Expand Down Expand Up @@ -3775,7 +3866,7 @@ describe("laneService delete teardown + cancellation + streaming", () => {
created_at, heartbeat_at, expires_at
) values (?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
["child-key", path.join(repoRoot, "child"), "lane-child", "pr", "PR #1", "token", now, now, now],
["child-key", childPath, "lane-child", "pr", "PR #1", "token", now, now, now],
);

db.run(
Expand Down
25 changes: 25 additions & 0 deletions apps/desktop/src/main/services/lanes/laneService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
} from "../../../shared/laneLinearIssue";
import type { createOperationService } from "../history/operationService";
import type { Logger } from "../logging/logger";
import { createWorktreeResidualCleanup } from "./worktreeResidualCleanup";
import type {
AdoptAttachedLaneArgs,
AttachLaneArgs,
Expand Down Expand Up @@ -1821,6 +1822,17 @@ export function createLaneService({
return parseGitWorktreePorcelain(worktreeStdout(result));
};

const residualWorktreeCleanup = createWorktreeResidualCleanup({
db,
projectId,
projectRoot,
worktreesDir,
logger,
listGitWorktrees,
isPendingWorktreeCreation: (worktreePath) => pendingWorktreeCreationPaths.has(normAbs(worktreePath)),
removeWorktreeDirectory: removeWorktreeDirectoryWithRecovery,
});

const findGitWorktreeForBranch = async (branchRef: string): Promise<GitWorktreeInfo | null> => {
const normalizedBranch = normalizeBranchKey(branchRef);
if (!normalizedBranch) return null;
Expand Down Expand Up @@ -2346,6 +2358,11 @@ export function createLaneService({
} catch (err) {
logger.warn("laneService.repairLegacyPrimaryBaseRootLanes_failed", { error: err instanceof Error ? err.message : String(err) });
}
try {
await residualWorktreeCleanup.retry();
} catch (err) {
logger.warn("laneService.residualWorktreeCleanup_failed", { error: err instanceof Error ? err.message : String(err) });
}
try {
await recoverManagedWorktreeRows();
} catch (err) {
Expand Down Expand Up @@ -5119,9 +5136,16 @@ export function createLaneService({
if (await isStillRegisteredWorktree()) {
throw new Error(fullMessage);
}
residualWorktreeCleanup.recordFailure({
laneId,
branchRef: row.branch_ref,
worktreePath: row.worktree_path,
error: fullMessage,
});
recordNonFatalFailure("git_worktree_remove", fullMessage);
return { detail: `${detail}; warning: ${fullMessage}` };
}
residualWorktreeCleanup.deleteRow(row.worktree_path);
const pruneFailure = await pruneWorktreesBestEffort();
if (pruneFailure) {
const message = `git worktree prune failed: ${pruneFailure}`;
Expand All @@ -5141,6 +5165,7 @@ export function createLaneService({
if (fs.existsSync(row.worktree_path)) {
return removeResidualDirectory(`${row.worktree_path} (removed residual files)`);
}
residualWorktreeCleanup.deleteRow(row.worktree_path);
return { detail: row.worktree_path };
}
// Recovery path: a previous failed delete (or this one's first attempt)
Expand Down
Loading
Loading