Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
223 changes: 223 additions & 0 deletions apps/desktop/src/main/services/lanes/laneService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -679,6 +679,121 @@ describe("laneService create", () => {
}
});

it("does not let a concurrent list adopt a half-created worktree as a duplicate lane", async () => {
const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-service-create-race-"));
const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger());
const now = "2026-03-11T12:00:00.000Z";

// Hoisted so the finally can always release the gate and drain the create,
// even if an assertion throws mid-test.
let releaseWorktreeAdd: () => void = () => {};
let createPromise: Promise<unknown> = Promise.resolve();

try {
db.run(
"insert into projects(id, root_path, display_name, default_base_ref, created_at, last_opened_at) values (?, ?, ?, ?, ?, ?)",
["proj-create-race", repoRoot, "demo", "main", now, now],
);
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-main", "proj-create-race", "Main", null, "primary", "main", "main", repoRoot, null, 1, null, null, null, null, "active", now, null],
);

// `git worktree list` reports a new worktree the moment `worktree add`
// registers it, before checkout completes. Park the add on a gate so a
// list() can run inside that window.
let pendingWorktree: { path: string; branch: string } | null = null;
const worktreeAddGate = new Promise<void>((resolve) => {
releaseWorktreeAdd = resolve;
});

vi.mocked(runGitOrThrow).mockImplementation(async (args: string[]) => {
if (args[0] === "worktree" && args[1] === "add") {
pendingWorktree = { branch: args[3], path: args[4] };
await worktreeAddGate;
return { exitCode: 0, stdout: "", stderr: "" } as any;
}
if (args[0] === "worktree" && args[1] === "list") {
const blocks = [`worktree ${repoRoot}\nbranch refs/heads/main`];
if (pendingWorktree) {
blocks.push(`worktree ${pendingWorktree.path}\nbranch refs/heads/${pendingWorktree.branch}`);
}
return { exitCode: 0, stdout: `${blocks.join("\n\n")}\n`, stderr: "" } as any;
}
throw new Error(`Unexpected git call: ${args.join(" ")}`);
});

vi.mocked(runGit).mockImplementation(async (args: string[]) => {
const laneBranchGitStub = defaultLaneBranchGitStub(args);
if (laneBranchGitStub) return laneBranchGitStub;
if (args[0] === "rev-parse" && args[1] === "--abbrev-ref" && args[2] === "HEAD") {
return { exitCode: 0, stdout: "main\n", stderr: "" };
}
if (args[0] === "rev-parse" && args[1] === "main") {
return { exitCode: 0, stdout: "sha-main\n", stderr: "" };
}
if (args[0] === "push" && args[1] === "-u") {
return { exitCode: 0, stdout: "", stderr: "" };
}
if (args[0] === "status" && args[1] === "--porcelain=v1") {
return { exitCode: 0, stdout: "", stderr: "" };
}
if (args[0] === "rev-list" && args[1] === "--left-right" && args[2] === "--count") {
return { exitCode: 0, stdout: "0\t0\n", stderr: "" };
}
if (
args[0] === "rev-parse"
&& args[1] === "--abbrev-ref"
&& args[2] === "--symbolic-full-name"
&& args[3] === "@{upstream}"
) {
return { exitCode: 1, stdout: "", stderr: "fatal: no upstream configured" };
}
if (args[0] === "rev-parse" && args[1] === "--path-format=absolute" && args[2] === "--git-dir") {
return { exitCode: 1, stdout: "", stderr: "fatal: no git dir" };
}
throw new Error(`Unexpected git call: ${args.join(" ")}`);
});

const service = createLaneService({
db,
projectRoot: repoRoot,
projectId: "proj-create-race",
defaultBaseRef: "main",
worktreesDir: path.join(repoRoot, "worktrees"),
});

createPromise = service.create({ name: "Race lane", baseBranch: "main" });
await vi.waitFor(() => {
expect(pendingWorktree).not.toBeNull();
});

// List while the create's checkout is still running: recovery must not
// adopt the pending worktree.
const lanesDuringCreate = await service.list({ includeStatus: false });
expect(lanesDuringCreate.some((lane) => lane.worktreePath === pendingWorktree!.path)).toBe(false);

releaseWorktreeAdd();
const lane = (await createPromise) as { id: string; worktreePath: string };

const lanesAfterCreate = await service.list({ includeStatus: false });
const lanesOnPath = lanesAfterCreate.filter((entry) => entry.worktreePath === lane.worktreePath);
expect(lanesOnPath.map((entry) => entry.id)).toEqual([lane.id]);
} finally {
// Always release the gate and drain the create so an assertion failure
// before line 777 cannot leave `createPromise` blocked and hang CI.
releaseWorktreeAdd();
await Promise.resolve(createPromise).catch(() => undefined);
db.close();
fs.rmSync(repoRoot, { recursive: true, force: true });
}
});

it("cleans up the row, worktree, and branch when VM lane wiring fails", async () => {
const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-service-create-vm-fail-"));
const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger());
Expand Down Expand Up @@ -1136,6 +1251,114 @@ describe("laneService list repairs", () => {
fs.rmSync(repoRoot, { recursive: true, force: true });
}
});

it("dedupes duplicate lane rows sharing a managed worktree, keeping the creator row", async () => {
const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-service-repair-dup-"));
const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger());
const worktreesDir = path.join(repoRoot, "worktrees");
const sharedWorktreePath = path.join(worktreesDir, "claude-pre-launch-review-ba241a46");
// The creator row's id matches the suffix embedded in the worktree dir name.
const keeperId = "ba241a46-0000-4000-8000-000000000001";
// The adoption artifact raced in first, so created_at alone would keep the wrong row.
const artifactId = "f2862e51-0000-4000-8000-000000000002";

try {
db.run(
"insert into projects(id, root_path, display_name, default_base_ref, created_at, last_opened_at) values (?, ?, ?, ?, ?, ?)",
["proj-repair-dup", repoRoot, "demo", "main", "2026-06-11T17:41:00.000Z", "2026-06-11T17:41:00.000Z"],
);
const insertLane = `
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`;
db.run(insertLane, ["lane-main", "proj-repair-dup", "Main", null, "primary", "main", "main", repoRoot, null, 1, null, null, null, null, "active", "2026-06-11T17:41:00.000Z", null]);
db.run(insertLane, [artifactId, "proj-repair-dup", "claude pre launch review", null, "worktree", "main", "ade/claude-pre-launch-review-ba241a46", sharedWorktreePath, null, 0, null, null, null, null, "active", "2026-06-11T17:41:33.000Z", null]);
db.run(insertLane, [keeperId, "proj-repair-dup", "claude pre-launch review", null, "worktree", "main", "ade/claude-pre-launch-review-ba241a46", sharedWorktreePath, null, 0, null, null, null, null, "active", "2026-06-11T17:41:33.933Z", null]);
// Work that landed on the duplicate must survive the dedupe.
db.run(insertLane, ["lane-dup-child", "proj-repair-dup", "Child of duplicate", null, "worktree", "ade/claude-pre-launch-review-ba241a46", "ade/dup-child", path.join(worktreesDir, "dup-child-11112222"), null, 0, artifactId, null, null, null, "active", "2026-06-11T18:00:00.000Z", null]);
db.run(
`
insert into terminal_sessions(id, lane_id, title, started_at, transcript_path, status)
values (?, ?, ?, ?, ?, ?)
`,
["session-on-dup", artifactId, "shell", "2026-06-11T17:50:00.000Z", "/tmp/transcript.jsonl", "running"],
);
// A Linear issue link the user attached while the duplicate (artifact)
// row was the one surfaced in the Lanes tab must survive onto the keeper.
// `referenced` is a different role than the keeper's `worked` for the same
// issue, so it must be preserved (role-aware dedupe), while a same-role
// collision is dropped.
const insertLink = `
insert into lane_linear_issue_links(
id, project_id, lane_id, issue_id, issue_json, role, source, created_at, updated_at
) values (?, ?, ?, ?, ?, ?, ?, ?, ?)
`;
db.run(insertLink, ["link-keeper-worked", "proj-repair-dup", keeperId, "ISS-99", "{}", "worked", "chat_attach", "2026-06-11T17:54:00.000Z", "2026-06-11T17:54:00.000Z"]);
db.run(insertLink, ["link-dup-referenced", "proj-repair-dup", artifactId, "ISS-99", "{}", "referenced", "chat_attach", "2026-06-11T17:55:00.000Z", "2026-06-11T17:55:00.000Z"]);
db.run(insertLink, ["link-dup-worked-dupe", "proj-repair-dup", artifactId, "ISS-99", "{}", "worked", "chat_attach", "2026-06-11T17:56:00.000Z", "2026-06-11T17:56:00.000Z"]);
// A session-scoped Linear issue attached directly to the duplicate lane
// must move to the keeper, not be cascade-deleted.
db.run(
`
insert into session_linear_issues(
id, project_id, session_id, lane_id, issue_id, issue_json, role, source, created_at, updated_at
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
["sess-link-on-dup", "proj-repair-dup", "session-on-dup", artifactId, "ISS-77", "{}", "worked", "chat_attach", "2026-06-11T17:57:00.000Z", "2026-06-11T17:57:00.000Z"],
);

vi.mocked(runGitOrThrow).mockImplementation(async (args: string[]) => {
if (args[0] === "worktree" && args[1] === "list") {
return { exitCode: 0, stdout: "", stderr: "" } as any;
}
throw new Error(`Unexpected git call: ${args.join(" ")}`);
});
vi.mocked(runGit).mockImplementation(async (args: string[]) => {
const laneBranchGitStub = defaultLaneBranchGitStub(args);
if (laneBranchGitStub) return laneBranchGitStub;
if (args[0] === "rev-parse" && args[1] === "--abbrev-ref" && args[2] === "HEAD") {
return { exitCode: 0, stdout: "main\n", stderr: "" };
}
throw new Error(`Unexpected git call: ${args.join(" ")}`);
});

const service = createLaneService({
db,
projectRoot: repoRoot,
projectId: "proj-repair-dup",
defaultBaseRef: "main",
worktreesDir,
});

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

const sharedPathLanes = lanes.filter((lane) => lane.worktreePath === sharedWorktreePath);
expect(sharedPathLanes.map((lane) => lane.id)).toEqual([keeperId]);
expect(lanes.some((lane) => lane.id === artifactId)).toBe(false);
expect(lanes.find((lane) => lane.id === "lane-dup-child")?.parentLaneId).toBe(keeperId);
expect(
db.get<{ lane_id: string }>("select lane_id from terminal_sessions where id = ?", ["session-on-dup"])?.lane_id,
).toBe(keeperId);
// The differently-roled link survives on the keeper; the same-role
// collision is dropped (keeper's own `worked` wins). The keeper ends up
// with exactly one `worked` and one `referenced` row for the issue.
const keeperLinks = db.all<{ id: string; role: string }>(
"select id, role from lane_linear_issue_links where lane_id = ? and issue_id = ? order by role",
[keeperId, "ISS-99"],
);
expect(keeperLinks.map((r) => r.role)).toEqual(["referenced", "worked"]);
expect(db.get<{ id: string }>("select id from lane_linear_issue_links where id = ?", ["link-dup-worked-dupe"])).toBeNull();
// The session-scoped Linear issue moved to the keeper instead of being deleted.
expect(
db.get<{ lane_id: string }>("select lane_id from session_linear_issues where id = ?", ["sess-link-on-dup"])?.lane_id,
).toBe(keeperId);
} finally {
db.close();
fs.rmSync(repoRoot, { recursive: true, force: true });
}
});
});

describe("laneService importBranch", () => {
Expand Down
Loading