diff --git a/apps/ade-cli/src/adeRpcServer.ts b/apps/ade-cli/src/adeRpcServer.ts index 53000b8a4..453a278f6 100644 --- a/apps/ade-cli/src/adeRpcServer.ts +++ b/apps/ade-cli/src/adeRpcServer.ts @@ -700,14 +700,18 @@ const TOOL_SPECS: ToolSpec[] = [ }, { name: "git_checkout_branch", - description: "Checkout an existing branch in a lane checkout.", + description: "Switch a lane checkout to an existing branch or create a new branch in that lane.", inputSchema: { type: "object", required: ["branchName"], additionalProperties: false, properties: { laneId: { type: "string", minLength: 1 }, - branchName: { type: "string", minLength: 1 } + branchName: { type: "string", minLength: 1 }, + mode: { type: "string", enum: ["existing", "create"] }, + startPoint: { type: "string", minLength: 1 }, + baseRef: { type: "string", minLength: 1 }, + acknowledgeActiveWork: { type: "boolean" } } } }, @@ -2006,8 +2010,11 @@ function sanitizeToolSchema(schema: unknown): unknown { } if (out.type === "object" && isRecord(out.properties)) { const propKeys = Object.keys(out.properties); - if (propKeys.length && (!Array.isArray(out.required) || !propKeys.every((k) => (out.required as string[]).includes(k)))) { - out.required = propKeys; + if (propKeys.length && !Array.isArray(out.required)) { + // Default to no required fields when none declared; preserve any + // explicit `required` array exactly as written so optional properties + // stay optional. + out.required = []; } const sanitizedProps: Record = {}; for (const [key, val] of Object.entries(out.properties)) { @@ -5258,7 +5265,29 @@ async function runTool(args: { if (name === "git_checkout_branch") { const laneId = requireLaneIdForTool(runtime, session, toolArgs, "git_checkout_branch"); const branchName = assertNonEmptyString(toolArgs.branchName, "branchName"); - const action = await runtime.gitService.checkoutBranch({ laneId, branchName }); + const rawMode = toolArgs.mode; + let mode: "existing" | "create" | undefined; + if (rawMode === undefined || rawMode === null) { + mode = undefined; + } else if (rawMode === "existing" || rawMode === "create") { + mode = rawMode; + } else { + throw new JsonRpcError( + JsonRpcErrorCode.invalidParams, + `mode must be either "existing" or "create"` + ); + } + const startPoint = typeof toolArgs.startPoint === "string" ? toolArgs.startPoint : undefined; + const baseRef = typeof toolArgs.baseRef === "string" ? toolArgs.baseRef : undefined; + const acknowledgeActiveWork = typeof toolArgs.acknowledgeActiveWork === "boolean" ? toolArgs.acknowledgeActiveWork : undefined; + const action = await runtime.gitService.checkoutBranch({ + laneId, + branchName, + mode: mode ?? "existing", + startPoint, + baseRef, + acknowledgeActiveWork, + }); return { laneId, branchName, action }; } diff --git a/apps/ade-cli/src/cli.test.ts b/apps/ade-cli/src/cli.test.ts index 97ca66fd8..7299dad5b 100644 --- a/apps/ade-cli/src/cli.test.ts +++ b/apps/ade-cli/src/cli.test.ts @@ -519,6 +519,71 @@ describe("ADE CLI", () => { }); }); + it("maps `git checkout ` to git_checkout_branch with mode=existing by default", () => { + const plan = buildCliPlan(["git", "checkout", "feature/foo", "--lane", "lane-1"]); + expect(plan.kind).toBe("execute"); + if (plan.kind !== "execute") return; + + expect(plan.steps[0]?.params).toEqual({ + name: "git_checkout_branch", + arguments: { + laneId: "lane-1", + branchName: "feature/foo", + mode: "existing", + acknowledgeActiveWork: false, + }, + }); + }); + + it("maps `git checkout --create` to mode=create with optional --from/--base", () => { + const plan = buildCliPlan([ + "git", "checkout", + "feature/new", + "--lane", "lane-1", + "--create", + "--from", "main", + "--base", "main", + "--ack-active-work", + ]); + expect(plan.kind).toBe("execute"); + if (plan.kind !== "execute") return; + + expect(plan.steps[0]?.params).toEqual({ + name: "git_checkout_branch", + arguments: { + laneId: "lane-1", + branchName: "feature/new", + mode: "create", + startPoint: "main", + baseRef: "main", + acknowledgeActiveWork: true, + }, + }); + }); + + it("accepts the `-b` short flag as an alias for --create", () => { + const plan = buildCliPlan(["git", "checkout", "topic-1", "--lane", "lane-1", "-b"]); + expect(plan.kind).toBe("execute"); + if (plan.kind !== "execute") return; + const args = (plan.steps[0]?.params as { arguments: Record }).arguments; + expect(args.mode).toBe("create"); + expect(args.branchName).toBe("topic-1"); + }); + + it("omits startPoint and baseRef from the call when not supplied", () => { + const plan = buildCliPlan(["git", "checkout", "feature/x", "--lane", "lane-1"]); + expect(plan.kind).toBe("execute"); + if (plan.kind !== "execute") return; + const args = (plan.steps[0]?.params as { arguments: Record }).arguments; + expect(args).not.toHaveProperty("startPoint"); + expect(args).not.toHaveProperty("baseRef"); + }); + + it("rejects `git checkout` without a branch name", () => { + expect(() => buildCliPlan(["git", "checkout", "--lane", "lane-1"])) + .toThrow(/branchName/); + }); + it("shows command help from subcommand help flags", () => { const prsHelp = buildCliPlan(["prs", "create", "--help"]); expect(prsHelp.kind).toBe("help"); diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index bd2104e28..ba4be8b46 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -1041,7 +1041,21 @@ function buildGitPlan(args: string[]): CliPlan { if (sub === "branches" || sub === "branch") return { kind: "execute", label: "git branches", steps: [actionCallStep("result", "git_list_branches", withLane())] }; if (sub === "checkout") { const branchName = requireValue(readValue(args, ["--branch", "--branch-name"]) ?? firstPositional(args), "branchName"); - return { kind: "execute", label: "git checkout", steps: [actionCallStep("result", "git_checkout_branch", withLane({ branchName }))] }; + const create = readFlag(args, ["--create", "-b"]); + const startPoint = readValue(args, ["--start-point", "--from"]); + const baseRef = readValue(args, ["--base", "--base-ref"]); + const acknowledgeActiveWork = readFlag(args, ["--ack-active-work"]); + return { + kind: "execute", + label: "git checkout", + steps: [actionCallStep("result", "git_checkout_branch", withLane({ + branchName, + mode: create ? "create" : "existing", + ...(startPoint ? { startPoint } : {}), + ...(baseRef ? { baseRef } : {}), + acknowledgeActiveWork, + }))] + }; } if (sub === "conflicts") return { kind: "execute", label: "git conflicts", steps: [actionCallStep("result", "get_lane_conflict_state", withLane())] }; if (sub === "rebase") { @@ -2031,13 +2045,13 @@ const VALUE_CARRIER_FLAGS: ReadonlySet = new Set([ "-b", "-m", "-q", "-t", "--additional-instructions", "--app", "--arg", "--arg-json", "--arg-value", "--arg-value-json", "--args-list-json", "--attempt", "--attempt-id", - "--automation", "--base", "--base-branch", "--body", "--branch", + "--automation", "--base", "--base-branch", "--base-ref", "--body", "--branch", "--branch-name", "--branch-ref", "--category", "--color", "--cols", "--command", "--comment", "--comment-id", "--commit", "--compare-ref", "--caption", "--compare-to", "--content", "--context-file", "--cwd", "--data", "--depth", "--desc", "--description", "--domain", "--duration-sec", "--enabled", "--event", - "--from-file", "--group", "--group-id", "--head", "--icon", "--id", + "--from", "--from-file", "--group", "--group-id", "--head", "--icon", "--id", "--input", "--input-json", "--instructions", "--json-input", "--lane", "--lane-id", "--limit", "--max-bytes", "--max-log-bytes", "--max-prompt-chars", "--max-rounds", "--memory", @@ -2052,7 +2066,7 @@ const VALUE_CARRIER_FLAGS: ReadonlySet = new Set([ "--root-lane", "--round", "--rounds", "--rows", "--rule", "--run", "--run-id", "--scalar", "--scalar-json", "--scope", "--seconds", "--session", "--session-id", "--set", "--set-json", "--sha", "--source", "--source-lane", "--stack", "--stack-id", - "--stash-ref", "--step", "--step-id", "--suite", "--suite-id", "--surface", + "--start-point", "--stash-ref", "--step", "--step-id", "--suite", "--suite-id", "--surface", "--thread", "--thread-id", "--timeout-ms", "--title", "--tool-type", "--url", "--workspace", "--workspace-id", "--workspace-root", ]); diff --git a/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts index 94c38faa4..23432f04d 100644 --- a/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts +++ b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts @@ -2649,8 +2649,22 @@ export function createCtoOperatorTools(deps: CtoOperatorToolDeps): Record gitGuard(() => deps.gitService!.checkoutBranch({ laneId: resolveLaneId(laneId), branch, create })), + inputSchema: z.object({ + laneId: z.string().optional(), + branch: z.string().min(1), + create: z.boolean().optional().default(false), + startPoint: z.string().optional(), + baseRef: z.string().optional(), + acknowledgeActiveWork: z.boolean().optional().default(false), + }), + execute: ({ laneId, branch, create, startPoint, baseRef, acknowledgeActiveWork }) => gitGuard(() => deps.gitService!.checkoutBranch({ + laneId: resolveLaneId(laneId), + branchName: branch, + mode: create ? "create" : "existing", + startPoint, + baseRef, + acknowledgeActiveWork, + })), }); tools.gitStashPush = tool({ diff --git a/apps/desktop/src/main/services/git/gitOperationsService.branchSwitch.test.ts b/apps/desktop/src/main/services/git/gitOperationsService.branchSwitch.test.ts new file mode 100644 index 000000000..9d552d62e --- /dev/null +++ b/apps/desktop/src/main/services/git/gitOperationsService.branchSwitch.test.ts @@ -0,0 +1,297 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockGit = vi.hoisted(() => ({ + runGit: vi.fn(), + runGitOrThrow: vi.fn(), + getHeadSha: vi.fn(), +})); + +vi.mock("./git", () => ({ + runGit: (...args: unknown[]) => mockGit.runGit(...args), + runGitOrThrow: (...args: unknown[]) => mockGit.runGitOrThrow(...args), + getHeadSha: (...args: unknown[]) => mockGit.getHeadSha(...args), +})); + +import { createGitOperationsService } from "./gitOperationsService"; + +function makeStubLogger() { + return { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + } as any; +} + +function makeServiceWithLanes(opts: { + branchProfiles?: Array<{ branchRef: string }>; + lanes?: Array<{ id: string; name: string; branchRef: string; laneType: string }>; + switchBranch?: ReturnType; + listBranchProfilesThrows?: boolean; + listThrows?: boolean; +}) { + const switchBranchMock = opts.switchBranch ?? vi.fn().mockResolvedValue({ lane: { id: "lane-1" }, previousBranchRef: "feature/old", activeWork: [] }); + + const listBranchProfiles = opts.listBranchProfilesThrows + ? vi.fn(() => { throw new Error("profile lookup failed"); }) + : vi.fn().mockReturnValue(opts.branchProfiles ?? []); + + const lanes = opts.lanes ?? []; + const listBranchOwners = opts.listThrows + ? vi.fn(() => { throw new Error("owner lookup failed"); }) + : vi.fn(({ excludeLaneId }: { excludeLaneId?: string } = {}) => + lanes + .filter((l) => l.laneType !== "primary" && l.id !== excludeLaneId) + .map((l) => ({ id: l.id, name: l.name, branchRef: l.branchRef })), + ); + + const service = createGitOperationsService({ + laneService: { + getLaneBaseAndBranch: vi.fn().mockReturnValue({ + baseRef: "main", + branchRef: "feature/source", + worktreePath: "/tmp/ade-lane", + laneType: "worktree", + }), + listBranchProfiles, + listBranchOwners, + switchBranch: switchBranchMock, + } as any, + operationService: { + start: vi.fn().mockReturnValue({ operationId: "op-1" }), + finish: vi.fn(), + } as any, + projectConfigService: { + get: () => ({ effective: { ai: {} } }), + } as any, + aiIntegrationService: { + getFeatureFlag: () => false, + getStatus: vi.fn(async () => ({ availableModelIds: [] })), + generateCommitMessage: vi.fn(), + } as any, + logger: makeStubLogger(), + }); + + return { service, switchBranchMock, listBranchProfiles, listBranchOwners }; +} + +describe("gitOperationsService.listBranches annotations", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("annotates branches with profile-in-lane and active owner metadata", async () => { + mockGit.runGitOrThrow.mockResolvedValue( + [ + "refs/heads/main\tmain\t \torigin/main", + "refs/heads/feature/source\tfeature/source\t*\t", + "refs/heads/feature/owned\tfeature/owned\t \t", + "refs/remotes/origin/feature/remote-only\torigin/feature/remote-only\t \t", + "refs/remotes/origin/main\torigin/main\t \t", + ].join("\n"), + ); + + const { service, listBranchProfiles, listBranchOwners } = makeServiceWithLanes({ + branchProfiles: [ + { branchRef: "feature/source" }, + { branchRef: "feature/profiled-but-no-local" }, + ], + lanes: [ + { id: "lane-1", name: "Source", branchRef: "feature/source", laneType: "worktree" }, + { id: "lane-2", name: "Owner Lane", branchRef: "feature/owned", laneType: "worktree" }, + { id: "lane-primary", name: "Primary", branchRef: "main", laneType: "primary" }, + ], + }); + + const branches = await service.listBranches({ laneId: "lane-1" }); + expect(listBranchProfiles).toHaveBeenCalledWith("lane-1"); + expect(listBranchOwners).toHaveBeenCalledWith({ excludeLaneId: "lane-1" }); + + const byName = new Map(branches.map((b) => [b.name, b])); + + // current branch (lane-1's own branch) is profiled in lane-1, owner skipped + // because it equals the calling lane's id. + const source = byName.get("feature/source"); + expect(source).toBeDefined(); + expect(source!.profiledInCurrentLane).toBe(true); + expect(source!.ownedByLaneId).toBeNull(); + expect(source!.ownedByLaneName).toBeNull(); + expect(source!.isCurrent).toBe(true); + + // lane-2 owns "feature/owned" + const owned = byName.get("feature/owned"); + expect(owned).toBeDefined(); + expect(owned!.ownedByLaneId).toBe("lane-2"); + expect(owned!.ownedByLaneName).toBe("Owner Lane"); + expect(owned!.profiledInCurrentLane).toBe(false); + + // primary lane branches are excluded from the active-owner map (so main is not "owned") + const main = byName.get("main"); + expect(main).toBeDefined(); + expect(main!.ownedByLaneId).toBeNull(); + expect(main!.profiledInCurrentLane).toBe(false); + + // remote-only branch is preserved and annotated; localBranchNameFromRemoteRef + // strips the remote name ("origin/feature/remote-only" → "feature/remote-only"). + const remoteOnly = byName.get("origin/feature/remote-only"); + expect(remoteOnly).toBeDefined(); + expect(remoteOnly!.isRemote).toBe(true); + expect(remoteOnly!.profiledInCurrentLane).toBe(false); + }); + + it("still returns branches when listing lanes throws (best-effort owner lookup)", async () => { + mockGit.runGitOrThrow.mockResolvedValue( + "refs/heads/main\tmain\t*\t\nrefs/heads/feature/x\tfeature/x\t \t", + ); + const { service } = makeServiceWithLanes({ listThrows: true }); + + const branches = await service.listBranches({ laneId: "lane-1" }); + expect(branches.length).toBeGreaterThan(0); + for (const branch of branches) { + expect(branch.ownedByLaneId).toBeNull(); + } + }); + + it("dedupes a remote ref when its local counterpart already exists", async () => { + mockGit.runGitOrThrow.mockResolvedValue( + [ + "refs/heads/feature/dup\tfeature/dup\t*\t", + "refs/remotes/origin/feature/dup\torigin/feature/dup\t \t", + ].join("\n"), + ); + const { service } = makeServiceWithLanes({}); + + const branches = await service.listBranches({ laneId: "lane-1" }); + // Only the local copy should appear; the remote duplicate is filtered out. + expect(branches.filter((b) => b.name === "feature/dup")).toHaveLength(1); + expect(branches.find((b) => b.name === "origin/feature/dup")).toBeUndefined(); + }); + + it("filters refs/remotes/.../HEAD entries out of the result", async () => { + mockGit.runGitOrThrow.mockResolvedValue( + [ + "refs/heads/main\tmain\t*\t", + "refs/remotes/origin/HEAD\torigin/HEAD\t \t", + ].join("\n"), + ); + const { service } = makeServiceWithLanes({}); + + const branches = await service.listBranches({ laneId: "lane-1" }); + expect(branches.find((b) => b.name === "origin/HEAD")).toBeUndefined(); + }); +}); + +describe("gitOperationsService.checkoutBranch", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("rejects empty branch names", async () => { + const { service } = makeServiceWithLanes({}); + await expect(service.checkoutBranch({ laneId: "lane-1", branchName: " " })) + .rejects.toThrow(/Branch name is required/); + }); + + it("delegates to laneService.switchBranch and forwards mode/startPoint/baseRef in op metadata", async () => { + mockGit.getHeadSha.mockResolvedValue("sha-pre"); + const operationStart = vi.fn().mockReturnValue({ operationId: "op-99" }); + const operationFinish = vi.fn(); + const switchBranch = vi.fn().mockResolvedValue({ lane: { id: "lane-1" }, previousBranchRef: "feature/old", activeWork: [] }); + + const service = createGitOperationsService({ + laneService: { + getLaneBaseAndBranch: vi.fn().mockReturnValue({ + baseRef: "main", + branchRef: "feature/old", + worktreePath: "/tmp/ade-lane", + laneType: "worktree", + }), + listBranchProfiles: vi.fn().mockReturnValue([]), + listBranchOwners: vi.fn().mockReturnValue([]), + switchBranch, + } as any, + operationService: { start: operationStart, finish: operationFinish } as any, + projectConfigService: { get: () => ({ effective: { ai: {} } }) } as any, + aiIntegrationService: { + getFeatureFlag: () => false, + getStatus: vi.fn(async () => ({ availableModelIds: [] })), + generateCommitMessage: vi.fn(), + } as any, + logger: makeStubLogger(), + }); + + await service.checkoutBranch({ + laneId: "lane-1", + branchName: "feature/new", + mode: "create", + startPoint: "main", + baseRef: "main", + acknowledgeActiveWork: true, + }); + + expect(switchBranch).toHaveBeenCalledWith({ + laneId: "lane-1", + branchName: "feature/new", + mode: "create", + startPoint: "main", + baseRef: "main", + acknowledgeActiveWork: true, + }); + + expect(operationStart).toHaveBeenCalledWith( + expect.objectContaining({ + laneId: "lane-1", + kind: "git_checkout_branch", + metadata: expect.objectContaining({ + reason: "checkout_branch", + branchName: "feature/new", + mode: "create", + startPoint: "main", + baseRef: "main", + }), + }), + ); + expect(operationFinish).toHaveBeenCalledWith( + expect.objectContaining({ operationId: "op-99", status: "succeeded" }), + ); + }); + + it("defaults mode to 'existing' and nulls metadata for omitted optional args", async () => { + mockGit.getHeadSha.mockResolvedValue("sha-pre"); + const operationStart = vi.fn().mockReturnValue({ operationId: "op-1" }); + const switchBranch = vi.fn().mockResolvedValue({ lane: { id: "lane-1" }, previousBranchRef: "main", activeWork: [] }); + + const service = createGitOperationsService({ + laneService: { + getLaneBaseAndBranch: vi.fn().mockReturnValue({ + baseRef: "main", branchRef: "main", worktreePath: "/tmp/ade-lane", laneType: "worktree", + }), + listBranchProfiles: vi.fn().mockReturnValue([]), + listBranchOwners: vi.fn().mockReturnValue([]), + switchBranch, + } as any, + operationService: { start: operationStart, finish: vi.fn() } as any, + projectConfigService: { get: () => ({ effective: { ai: {} } }) } as any, + aiIntegrationService: { + getFeatureFlag: () => false, + getStatus: vi.fn(async () => ({ availableModelIds: [] })), + generateCommitMessage: vi.fn(), + } as any, + logger: makeStubLogger(), + }); + + await service.checkoutBranch({ laneId: "lane-1", branchName: "feature/foo" }); + + expect(operationStart).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: expect.objectContaining({ + mode: "existing", + startPoint: null, + baseRef: null, + }), + }), + ); + // switchBranch still receives the raw args (no defaults injected upstream). + expect(switchBranch).toHaveBeenCalledWith({ laneId: "lane-1", branchName: "feature/foo" }); + }); +}); diff --git a/apps/desktop/src/main/services/git/gitOperationsService.ts b/apps/desktop/src/main/services/git/gitOperationsService.ts index 0c890d1f6..59aa949ec 100644 --- a/apps/desktop/src/main/services/git/gitOperationsService.ts +++ b/apps/desktop/src/main/services/git/gitOperationsService.ts @@ -5,6 +5,7 @@ import type { GitActionResult, GitBatchFileActionArgs, GitBranchSummary, + GitCheckoutBranchArgs, GitCherryPickArgs, GitCommitArgs, GitGenerateCommitMessageArgs, @@ -1094,9 +1095,36 @@ export function createGitOperationsService({ ["for-each-ref", "--sort=refname", "--format=%(refname)\t%(refname:short)\t%(HEAD)\t%(upstream:short)", "refs/heads", "refs/remotes"], { cwd: lane.worktreePath, timeoutMs: 15_000 } ); + const branchProfiles = new Set(); + try { + for (const profile of laneService.listBranchProfiles(args.laneId)) { + branchProfiles.add(profile.branchRef); + } + } catch { + // Branch listing should still work even if profile bookkeeping fails. + } + const activeLaneOwners = new Map(); + try { + const owners = laneService.listBranchOwners({ excludeLaneId: args.laneId }); + for (const owner of owners) { + activeLaneOwners.set(owner.branchRef, { id: owner.id, name: owner.name }); + } + } catch { + // Branch listing should still work if lane summaries are temporarily unavailable. + } const localBranches = new Map(); const remoteBranches: GitBranchSummary[] = []; + const annotate = (summary: GitBranchSummary): GitBranchSummary => { + const localName = summary.isRemote ? localBranchNameFromRemoteRef(summary.name) : summary.name; + const owner = activeLaneOwners.get(localName) ?? null; + return { + ...summary, + ownedByLaneId: owner?.id ?? null, + ownedByLaneName: owner?.name ?? null, + profiledInCurrentLane: branchProfiles.has(localName), + }; + }; out .split("\n") @@ -1111,18 +1139,18 @@ export function createGitOperationsService({ if (fullRef.startsWith("refs/heads/")) { const isCurrent = (parts[2]?.trim() ?? "") === "*"; const upstream = parts[3]?.trim() || null; - localBranches.set(shortRef, { name: shortRef, isCurrent, isRemote: false, upstream }); + localBranches.set(shortRef, annotate({ name: shortRef, isCurrent, isRemote: false, upstream })); return; } if (fullRef.startsWith("refs/remotes/")) { if (shortRef.endsWith("/HEAD")) return; - remoteBranches.push({ + remoteBranches.push(annotate({ name: shortRef, isCurrent: false, isRemote: true, upstream: null - }); + })); } }); @@ -1141,40 +1169,22 @@ export function createGitOperationsService({ return [...sortedLocals, ...sortedRemotes]; }, - async checkoutBranch(args: { laneId: string; branchName: string }): Promise { + async checkoutBranch(args: GitCheckoutBranchArgs): Promise { const branchName = args.branchName.trim(); if (!branchName.length) throw new Error("Branch name is required"); - const lane = laneService.getLaneBaseAndBranch(args.laneId); - if (lane.laneType !== "primary") { - throw new Error("Branch checkout is only supported on the primary lane"); - } - - const localExists = await runGit(["show-ref", "--verify", "--quiet", `refs/heads/${branchName}`], { - cwd: lane.worktreePath, - timeoutMs: 8_000 - }).then((res) => res.exitCode === 0); - const remoteExists = !localExists - ? await runGit(["show-ref", "--verify", "--quiet", `refs/remotes/${branchName}`], { - cwd: lane.worktreePath, - timeoutMs: 8_000 - }).then((res) => res.exitCode === 0) - : false; - - const trackRemoteBranch = !localExists && remoteExists; - const resolvedBranchRef = trackRemoteBranch ? localBranchNameFromRemoteRef(branchName) : branchName; - const { action } = await runLaneOperation({ laneId: args.laneId, kind: "git_checkout_branch", reason: "checkout_branch", - metadata: { branchName, trackRemoteBranch }, - fn: async (l) => { - const checkoutCmd = trackRemoteBranch - ? ["checkout", "--track", "--ignore-other-worktrees", branchName] - : ["checkout", "--ignore-other-worktrees", branchName]; - await runGitOrThrow(checkoutCmd, { cwd: l.worktreePath, timeoutMs: 60_000 }); - laneService.updateBranchRef(args.laneId, resolvedBranchRef); + metadata: { + branchName, + mode: args.mode ?? "existing", + startPoint: args.startPoint ?? null, + baseRef: args.baseRef ?? null, + }, + fn: async () => { + await laneService.switchBranch(args); } }); return action; diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index fcd05aa6d..6f048bb98 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -58,6 +58,9 @@ import type { CreateLaneArgs, CreateChildLaneArgs, CreateLaneFromUnstagedArgs, + LaneBranchSwitchArgs, + LaneBranchSwitchPreview, + LaneBranchSwitchResult, DeleteLaneArgs, DockLayout, GraphPersistedState, @@ -3625,7 +3628,12 @@ export function registerIpc({ ipcMain.handle(IPC.lanesCreate, async (_event, arg: CreateLaneArgs): Promise => { const ctx = getCtx(); - const lane = await ctx.laneService.create({ name: arg.name, description: arg.description, parentLaneId: arg.parentLaneId }); + const lane = await ctx.laneService.create({ + name: arg.name, + description: arg.description, + parentLaneId: arg.parentLaneId, + baseBranch: arg.baseBranch, + }); await ensureLanePortLease(ctx, lane.id); notifyLaneCreated(ctx, lane); triggerAutoContextDocs(ctx, { @@ -3671,6 +3679,16 @@ export function registerIpc({ return lane; }); + ipcMain.handle(IPC.lanesPreviewBranchSwitch, async (_event, arg: LaneBranchSwitchArgs): Promise => { + const ctx = getCtx(); + return await ctx.laneService.previewBranchSwitch(arg); + }); + + ipcMain.handle(IPC.lanesSwitchBranch, async (_event, arg: LaneBranchSwitchArgs): Promise => { + const ctx = getCtx(); + return await ctx.laneService.switchBranch(arg); + }); + ipcMain.handle(IPC.lanesAttach, async (_event, arg: AttachLaneArgs): Promise => { const ctx = getCtx(); const lane = await ctx.laneService.attach(arg); diff --git a/apps/desktop/src/main/services/lanes/laneService.branchSwitch.test.ts b/apps/desktop/src/main/services/lanes/laneService.branchSwitch.test.ts new file mode 100644 index 000000000..75507dfc2 --- /dev/null +++ b/apps/desktop/src/main/services/lanes/laneService.branchSwitch.test.ts @@ -0,0 +1,634 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { openKvDb } from "../state/kvDb"; +import { createLaneService } from "./laneService"; + +vi.mock("../git/git", () => ({ + getHeadSha: vi.fn(), + runGit: vi.fn(), + runGitOrThrow: vi.fn(), +})); + +import { getHeadSha, runGit, runGitOrThrow } from "../git/git"; + +function createLogger() { + return { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + } as any; +} + +const NOW = "2026-04-25T10:00:00.000Z"; + +function seedProject(db: any, args: { projectId: string; repoRoot: string }) { + db.run( + "insert into projects(id, root_path, display_name, default_base_ref, created_at, last_opened_at) values (?, ?, ?, ?, ?, ?)", + [args.projectId, args.repoRoot, "demo", "main", NOW, NOW], + ); +} + +function insertLane(db: any, args: { + id: string; + projectId: string; + name: string; + laneType: "primary" | "worktree"; + branchRef: string; + baseRef?: string; + worktreePath: string; + parentLaneId?: string | null; + status?: string; +}) { + 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + args.id, + args.projectId, + args.name, + null, + args.laneType, + args.baseRef ?? "main", + args.branchRef, + args.worktreePath, + null, + args.laneType === "primary" ? 1 : 0, + args.parentLaneId ?? null, + null, + null, + null, + args.status ?? "active", + NOW, + args.status === "archived" ? NOW : null, + ], + ); +} + +/** + * Make a generic runGit responder that returns success for the most common + * read-only ancillary calls performed by listLanes(). Test-specific behaviour + * can be layered on top. + */ +function makeRunGitResponder(custom?: (args: string[], opts: any) => { exitCode: number; stdout: string; stderr: string } | null) { + return async (args: string[], opts: any = {}) => { + if (custom) { + const v = custom(args, opts); + if (v) return v; + } + 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] === "--path-format=absolute" && args[2] === "--git-dir") { + return { exitCode: 1, stdout: "", stderr: "fatal: no git dir" }; + } + if (args[0] === "rev-parse" && args[1] === "--abbrev-ref" && args[2] === "--symbolic-full-name" && args[3] === "@{upstream}") { + return { exitCode: 1, stdout: "", stderr: "no upstream" }; + } + if (args[0] === "rev-parse" && args[1] === "@{upstream}") { + return { exitCode: 1, stdout: "", stderr: "no upstream" }; + } + if (args[0] === "rev-list" && args[1] === "--left-right" && args[2] === "--count") { + return { exitCode: 0, stdout: "0\t0\n", stderr: "" }; + } + if (args[0] === "status" && args[1] === "--porcelain=v1") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + return { exitCode: 1, stdout: "", stderr: `unhandled: ${args.join(" ")}` }; + }; +} + +function makeService(db: any, projectRoot: string, projectId: string) { + return createLaneService({ + db, + projectRoot, + projectId, + defaultBaseRef: "main", + worktreesDir: path.join(projectRoot, "worktrees"), + logger: createLogger(), + }); +} + +describe("laneService.listBranchProfiles", () => { + beforeEach(() => { + vi.mocked(getHeadSha).mockReset(); + vi.mocked(runGit).mockReset(); + vi.mocked(runGitOrThrow).mockReset(); + }); + + it("ensures and returns a profile for the lane's current branch_ref", async () => { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-bsw-list-profile-")); + const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); + try { + seedProject(db, { projectId: "proj-1", repoRoot }); + insertLane(db, { id: "lane-a", projectId: "proj-1", name: "Lane A", laneType: "worktree", branchRef: "feature/lane-a", worktreePath: path.join(repoRoot, "lane-a") }); + + vi.mocked(runGit).mockImplementation(makeRunGitResponder() as any); + + const service = makeService(db, repoRoot, "proj-1"); + const profiles = service.listBranchProfiles("lane-a"); + + expect(profiles).toHaveLength(1); + expect(profiles[0]?.branchRef).toBe("feature/lane-a"); + expect(profiles[0]?.laneId).toBe("lane-a"); + expect(profiles[0]?.baseRef).toBe("main"); + + // Calling again should not duplicate. + const second = service.listBranchProfiles("lane-a"); + expect(second).toHaveLength(1); + expect(second[0]?.id).toBe(profiles[0]?.id); + } finally { + db.close(); + fs.rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it("throws when the lane is missing", async () => { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-bsw-list-missing-")); + const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); + try { + seedProject(db, { projectId: "proj-1", repoRoot }); + const service = makeService(db, repoRoot, "proj-1"); + expect(() => service.listBranchProfiles("nonexistent")).toThrow(/Lane not found/); + } finally { + db.close(); + fs.rmSync(repoRoot, { recursive: true, force: true }); + } + }); +}); + +describe("laneService.updateBranchRef", () => { + beforeEach(() => { + vi.mocked(getHeadSha).mockReset(); + vi.mocked(runGit).mockReset(); + vi.mocked(runGitOrThrow).mockReset(); + }); + + it("updates the lane's branch_ref AND upserts a matching branch profile", async () => { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-bsw-update-bref-")); + const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); + try { + seedProject(db, { projectId: "proj-1", repoRoot }); + insertLane(db, { id: "lane-a", projectId: "proj-1", name: "Lane A", laneType: "worktree", branchRef: "feature/lane-a", worktreePath: path.join(repoRoot, "lane-a") }); + + vi.mocked(runGit).mockImplementation(makeRunGitResponder() as any); + + const service = makeService(db, repoRoot, "proj-1"); + + service.updateBranchRef("lane-a", "feature/renamed"); + + const updated = db.get<{ branch_ref: string }>( + "select branch_ref from lanes where id = ? and project_id = ?", + ["lane-a", "proj-1"], + ); + expect(updated?.branch_ref).toBe("feature/renamed"); + + const profiles = service.listBranchProfiles("lane-a"); + const refs = profiles.map((p) => p.branchRef); + expect(refs).toContain("feature/renamed"); + const renamed = profiles.find((p) => p.branchRef === "feature/renamed"); + expect(renamed?.lastCheckedOutAt).toBeTruthy(); + } finally { + db.close(); + fs.rmSync(repoRoot, { recursive: true, force: true }); + } + }); +}); + +describe("laneService.previewBranchSwitch", () => { + beforeEach(() => { + vi.mocked(getHeadSha).mockReset(); + vi.mocked(runGit).mockReset(); + vi.mocked(runGitOrThrow).mockReset(); + }); + + it("rejects when laneId is empty", async () => { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-bsw-prev-laneid-")); + const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); + try { + seedProject(db, { projectId: "proj-1", repoRoot }); + const service = makeService(db, repoRoot, "proj-1"); + await expect(service.previewBranchSwitch({ laneId: "", branchName: "x" })).rejects.toThrow(/laneId is required/); + } finally { + db.close(); + fs.rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it("rejects when branchName is empty", async () => { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-bsw-prev-branchname-")); + const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); + try { + seedProject(db, { projectId: "proj-1", repoRoot }); + insertLane(db, { id: "lane-a", projectId: "proj-1", name: "Lane A", laneType: "worktree", branchRef: "feature/lane-a", worktreePath: path.join(repoRoot, "lane-a") }); + const service = makeService(db, repoRoot, "proj-1"); + await expect(service.previewBranchSwitch({ laneId: "lane-a", branchName: " " })).rejects.toThrow(/Branch name is required/); + } finally { + db.close(); + fs.rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it("rejects when the lane is archived", async () => { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-bsw-prev-archived-")); + const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); + try { + seedProject(db, { projectId: "proj-1", repoRoot }); + insertLane(db, { + id: "lane-archived", projectId: "proj-1", name: "Archived", laneType: "worktree", + branchRef: "feature/old", worktreePath: path.join(repoRoot, "old"), status: "archived", + }); + const service = makeService(db, repoRoot, "proj-1"); + await expect(service.previewBranchSwitch({ laneId: "lane-archived", branchName: "main" })).rejects.toThrow(/archived/); + } finally { + db.close(); + fs.rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it("flags dirty worktree, duplicate owner, and active terminal sessions", async () => { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-bsw-prev-flags-")); + const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); + try { + seedProject(db, { projectId: "proj-1", repoRoot }); + insertLane(db, { id: "lane-src", projectId: "proj-1", name: "Source", laneType: "worktree", branchRef: "feature/src", worktreePath: path.join(repoRoot, "src") }); + insertLane(db, { id: "lane-other", projectId: "proj-1", name: "Other Lane", laneType: "worktree", branchRef: "feature/target", worktreePath: path.join(repoRoot, "other") }); + + // Active terminal session on lane-src. + db.run( + `insert into terminal_sessions(id, lane_id, tracked, title, started_at, status, transcript_path) + values (?, ?, ?, ?, ?, ?, ?)`, + ["term-1", "lane-src", 1, "shell", NOW, "running", path.join(repoRoot, "t.log")], + ); + // Active process_runtime row on lane-src. + db.run( + `insert into process_runtime(project_id, lane_id, process_key, status, readiness, updated_at) + values (?, ?, ?, ?, ?, ?)`, + ["proj-1", "lane-src", "vite", "running", "ready", NOW], + ); + + // Dirty worktree; target branch resolves locally to keep the same key as lane-other. + vi.mocked(runGit).mockImplementation(makeRunGitResponder((args, opts) => { + if (args[0] === "show-ref" && args[1] === "--verify" && args[2] === "--quiet") { + if (args[3] === "refs/heads/feature/target") return { exitCode: 0, stdout: "", stderr: "" }; + } + if (args[0] === "status" && args[1] === "--porcelain=v1" && opts.cwd === path.join(repoRoot, "src")) { + return { exitCode: 0, stdout: " M file.ts\n", stderr: "" }; + } + return null; + }) as any); + + const service = makeService(db, repoRoot, "proj-1"); + const preview = await service.previewBranchSwitch({ laneId: "lane-src", branchName: "feature/target" }); + + expect(preview.laneId).toBe("lane-src"); + expect(preview.dirty).toBe(true); + expect(preview.duplicateLaneId).toBe("lane-other"); + expect(preview.duplicateLaneName).toBe("Other Lane"); + expect(preview.activeWork.length).toBeGreaterThanOrEqual(2); + expect(preview.activeWork.some((w) => w.kind === "terminal")).toBe(true); + expect(preview.activeWork.some((w) => w.kind === "process")).toBe(true); + expect(preview.targetBranchRef).toBe("feature/target"); + expect(preview.mode).toBe("existing"); + } finally { + db.close(); + fs.rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it("strips remote prefix when only the remote ref exists", async () => { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-bsw-prev-remote-")); + const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); + try { + seedProject(db, { projectId: "proj-1", repoRoot }); + insertLane(db, { id: "lane-x", projectId: "proj-1", name: "X", laneType: "worktree", branchRef: "feature/x", worktreePath: path.join(repoRoot, "x") }); + + vi.mocked(runGit).mockImplementation(makeRunGitResponder((args) => { + if (args[0] === "show-ref" && args[1] === "--verify" && args[2] === "--quiet") { + if (args[3] === "refs/heads/origin/foo") return { exitCode: 1, stdout: "", stderr: "" }; + if (args[3] === "refs/remotes/origin/foo") return { exitCode: 0, stdout: "", stderr: "" }; + } + return null; + }) as any); + + const service = makeService(db, repoRoot, "proj-1"); + const preview = await service.previewBranchSwitch({ laneId: "lane-x", branchName: "origin/foo" }); + expect(preview.targetBranchRef).toBe("foo"); + expect(preview.dirty).toBe(false); + expect(preview.duplicateLaneId).toBeNull(); + } finally { + db.close(); + fs.rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it("returns mode=create without consulting refs when explicitly requested", async () => { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-bsw-prev-create-")); + const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); + try { + seedProject(db, { projectId: "proj-1", repoRoot }); + insertLane(db, { id: "lane-y", projectId: "proj-1", name: "Y", laneType: "worktree", branchRef: "feature/y", worktreePath: path.join(repoRoot, "y") }); + + const showRefCalls: string[] = []; + vi.mocked(runGit).mockImplementation(makeRunGitResponder((args) => { + if (args[0] === "show-ref") showRefCalls.push(args.join(" ")); + return null; + }) as any); + + const service = makeService(db, repoRoot, "proj-1"); + const preview = await service.previewBranchSwitch({ laneId: "lane-y", branchName: "feature/new", mode: "create" }); + + expect(preview.mode).toBe("create"); + expect(preview.targetBranchRef).toBe("feature/new"); + // create mode should NOT probe local/remote refs to resolve an existing branch. + expect(showRefCalls).toHaveLength(0); + } finally { + db.close(); + fs.rmSync(repoRoot, { recursive: true, force: true }); + } + }); +}); + +describe("laneService.switchBranch", () => { + beforeEach(() => { + vi.mocked(getHeadSha).mockReset(); + vi.mocked(runGit).mockReset(); + vi.mocked(runGitOrThrow).mockReset(); + }); + + it("refuses to switch when the lane has uncommitted changes", async () => { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-bsw-switch-dirty-")); + const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); + try { + seedProject(db, { projectId: "proj-1", repoRoot }); + insertLane(db, { id: "lane-d", projectId: "proj-1", name: "D", laneType: "worktree", branchRef: "feature/d", worktreePath: path.join(repoRoot, "d") }); + + vi.mocked(runGit).mockImplementation(makeRunGitResponder((args, opts) => { + if (args[0] === "show-ref" && args[3] === "refs/heads/main") return { exitCode: 0, stdout: "", stderr: "" }; + if (args[0] === "status" && args[1] === "--porcelain=v1" && opts.cwd === path.join(repoRoot, "d")) { + return { exitCode: 0, stdout: " M src/foo.ts\n", stderr: "" }; + } + return null; + }) as any); + + const service = makeService(db, repoRoot, "proj-1"); + await expect(service.switchBranch({ laneId: "lane-d", branchName: "main" })) + .rejects.toThrow(/uncommitted changes/); + } finally { + db.close(); + fs.rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it("refuses to switch to a branch that is already active in another lane", async () => { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-bsw-switch-dup-")); + const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); + try { + seedProject(db, { projectId: "proj-1", repoRoot }); + insertLane(db, { id: "lane-src", projectId: "proj-1", name: "Source", laneType: "worktree", branchRef: "feature/src", worktreePath: path.join(repoRoot, "src") }); + insertLane(db, { id: "lane-other", projectId: "proj-1", name: "Other Lane", laneType: "worktree", branchRef: "feature/duplicate", worktreePath: path.join(repoRoot, "other") }); + + vi.mocked(runGit).mockImplementation(makeRunGitResponder((args) => { + if (args[0] === "show-ref" && args[3] === "refs/heads/feature/duplicate") return { exitCode: 0, stdout: "", stderr: "" }; + return null; + }) as any); + + const service = makeService(db, repoRoot, "proj-1"); + await expect(service.switchBranch({ laneId: "lane-src", branchName: "feature/duplicate" })) + .rejects.toThrow(/already active in lane 'Other Lane'/); + } finally { + db.close(); + fs.rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it("refuses to switch when active work exists and acknowledgeActiveWork is not set", async () => { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-bsw-switch-active-")); + const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); + try { + seedProject(db, { projectId: "proj-1", repoRoot }); + insertLane(db, { id: "lane-a", projectId: "proj-1", name: "A", laneType: "worktree", branchRef: "feature/a", worktreePath: path.join(repoRoot, "a") }); + + db.run( + `insert into terminal_sessions(id, lane_id, tracked, title, started_at, status, transcript_path) + values (?, ?, ?, ?, ?, ?, ?)`, + ["t-1", "lane-a", 1, "shell", NOW, "running", path.join(repoRoot, "t.log")], + ); + + vi.mocked(runGit).mockImplementation(makeRunGitResponder((args) => { + if (args[0] === "show-ref" && args[3] === "refs/heads/main") return { exitCode: 0, stdout: "", stderr: "" }; + return null; + }) as any); + + const service = makeService(db, repoRoot, "proj-1"); + await expect(service.switchBranch({ laneId: "lane-a", branchName: "main" })) + .rejects.toThrow(/active sessions or processes/); + } finally { + db.close(); + fs.rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it("checks out an existing local branch and updates the lane row + branch profile", async () => { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-bsw-switch-existing-")); + const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); + try { + seedProject(db, { projectId: "proj-1", repoRoot }); + insertLane(db, { id: "lane-main", projectId: "proj-1", name: "Main", laneType: "primary", branchRef: "main", worktreePath: repoRoot }); + insertLane(db, { id: "lane-a", projectId: "proj-1", name: "A", laneType: "worktree", branchRef: "feature/a", worktreePath: path.join(repoRoot, "a") }); + + const checkoutCalls: string[][] = []; + vi.mocked(runGitOrThrow).mockImplementation(async (args: string[]) => { + if (args[0] === "checkout") checkoutCalls.push(args); + return { exitCode: 0, stdout: "", stderr: "" } as any; + }); + vi.mocked(runGit).mockImplementation(makeRunGitResponder((args) => { + if (args[0] === "show-ref" && args[3] === "refs/heads/feature/b") return { exitCode: 0, stdout: "", stderr: "" }; + return null; + }) as any); + + const service = makeService(db, repoRoot, "proj-1"); + const result = await service.switchBranch({ laneId: "lane-a", branchName: "feature/b" }); + + expect(result.previousBranchRef).toBe("feature/a"); + expect(result.lane.branchRef).toBe("feature/b"); + expect(result.lane.id).toBe("lane-a"); + expect(checkoutCalls.some((cmd) => cmd.includes("feature/b") && !cmd.includes("--track"))).toBe(true); + + const row = db.get<{ branch_ref: string }>( + "select branch_ref from lanes where id = ? and project_id = ?", + ["lane-a", "proj-1"], + ); + expect(row?.branch_ref).toBe("feature/b"); + + const profiles = service.listBranchProfiles("lane-a"); + expect(profiles.map((p) => p.branchRef)).toEqual(expect.arrayContaining(["feature/a", "feature/b"])); + } finally { + db.close(); + fs.rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it("creates a new branch via 'checkout -b' when mode='create'", async () => { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-bsw-switch-create-")); + const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); + try { + seedProject(db, { projectId: "proj-1", repoRoot }); + insertLane(db, { id: "lane-main", projectId: "proj-1", name: "Main", laneType: "primary", branchRef: "main", worktreePath: repoRoot }); + insertLane(db, { id: "lane-c", projectId: "proj-1", name: "C", laneType: "worktree", branchRef: "feature/c", worktreePath: path.join(repoRoot, "c") }); + + const checkoutCalls: string[][] = []; + vi.mocked(runGitOrThrow).mockImplementation(async (args: string[]) => { + if (args[0] === "checkout") checkoutCalls.push(args); + return { exitCode: 0, stdout: "", stderr: "" } as any; + }); + + vi.mocked(runGit).mockImplementation(makeRunGitResponder((args) => { + if (args[0] === "rev-parse" && args[1] === "--verify" && args[2] === "main") { + return { exitCode: 0, stdout: "sha-main\n", stderr: "" }; + } + if (args[0] === "rev-parse" && args[1] === "--verify" && args[2] === "feature/c") { + return { exitCode: 0, stdout: "sha-c\n", stderr: "" }; + } + if (args[0] === "show-ref" && args[3] === "refs/heads/feature/new") { + return { exitCode: 1, stdout: "", stderr: "" }; // does not exist yet + } + return null; + }) as any); + + const service = makeService(db, repoRoot, "proj-1"); + const result = await service.switchBranch({ + laneId: "lane-c", + branchName: "feature/new", + mode: "create", + baseRef: "main", + }); + + expect(result.previousBranchRef).toBe("feature/c"); + expect(result.lane.branchRef).toBe("feature/new"); + expect(checkoutCalls.some((cmd) => cmd[0] === "checkout" && cmd[1] === "-b" && cmd[2] === "feature/new")).toBe(true); + + const profile = service.listBranchProfiles("lane-c").find((p) => p.branchRef === "feature/new"); + expect(profile?.sourceBranchRef).toBe("feature/c"); + expect(profile?.baseRef).toBe("main"); + } finally { + db.close(); + fs.rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it("rejects mode='create' when baseRef is missing", async () => { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-bsw-switch-create-no-base-")); + const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); + try { + seedProject(db, { projectId: "proj-1", repoRoot }); + insertLane(db, { id: "lane-c", projectId: "proj-1", name: "C", laneType: "worktree", branchRef: "feature/c", worktreePath: path.join(repoRoot, "c") }); + + vi.mocked(runGit).mockImplementation(makeRunGitResponder() as any); + + const service = makeService(db, repoRoot, "proj-1"); + await expect(service.switchBranch({ laneId: "lane-c", branchName: "feature/new", mode: "create" })) + .rejects.toThrow(/Base branch is required/); + } finally { + db.close(); + fs.rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it("rejects mode='create' when the target branch already exists locally", async () => { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-bsw-switch-create-exists-")); + const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); + try { + seedProject(db, { projectId: "proj-1", repoRoot }); + insertLane(db, { id: "lane-c", projectId: "proj-1", name: "C", laneType: "worktree", branchRef: "feature/c", worktreePath: path.join(repoRoot, "c") }); + + vi.mocked(runGit).mockImplementation(makeRunGitResponder((args) => { + if (args[0] === "rev-parse" && args[1] === "--verify" && args[2] === "main") { + return { exitCode: 0, stdout: "sha\n", stderr: "" }; + } + if (args[0] === "show-ref" && args[3] === "refs/heads/feature/existing") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + return null; + }) as any); + + const service = makeService(db, repoRoot, "proj-1"); + await expect(service.switchBranch({ + laneId: "lane-c", + branchName: "feature/existing", + mode: "create", + baseRef: "main", + })).rejects.toThrow(/already exists/); + } finally { + db.close(); + fs.rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it("preserves PR rows whose head_branch matches the new branch and deletes stale ones", async () => { + // pull_requests.lane_id is NOT NULL, so stale PR rows whose head_branch + // no longer matches the lane's branch are DELETED (along with their + // child rows in pr_convergence_state / pr_pipeline_settings / + // pr_issue_inventory / pr_group_members), not nulled. + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-bsw-switch-pr-detach-")); + const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); + try { + seedProject(db, { projectId: "proj-1", repoRoot }); + insertLane(db, { id: "lane-main", projectId: "proj-1", name: "Main", laneType: "primary", branchRef: "main", worktreePath: repoRoot }); + insertLane(db, { id: "lane-a", projectId: "proj-1", name: "A", laneType: "worktree", branchRef: "feature/a", worktreePath: path.join(repoRoot, "a") }); + + // PR already keyed to the new branch — should be left untouched. + db.run( + `insert into pull_requests( + id, project_id, lane_id, repo_owner, repo_name, github_pr_number, github_url, + title, state, base_branch, head_branch, additions, deletions, created_at, updated_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + "pr-keep", "proj-1", "lane-a", "acme", "ade", 1, "https://example.com/pr/1", + "keep", "open", "main", "feature/b", 0, 0, NOW, NOW, + ], + ); + // PR for the previous branch — should be deleted on switch. + db.run( + `insert into pull_requests( + id, project_id, lane_id, repo_owner, repo_name, github_pr_number, github_url, + title, state, base_branch, head_branch, additions, deletions, created_at, updated_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + "pr-stale", "proj-1", "lane-a", "acme", "ade", 2, "https://example.com/pr/2", + "stale", "open", "main", "feature/a", 0, 0, NOW, NOW, + ], + ); + + vi.mocked(runGitOrThrow).mockImplementation(async () => ({ exitCode: 0, stdout: "", stderr: "" } as any)); + vi.mocked(runGit).mockImplementation(makeRunGitResponder((args) => { + if (args[0] === "show-ref" && args[3] === "refs/heads/feature/b") return { exitCode: 0, stdout: "", stderr: "" }; + return null; + }) as any); + + const service = makeService(db, repoRoot, "proj-1"); + const result = await service.switchBranch({ laneId: "lane-a", branchName: "feature/b" }); + + expect(result.lane.branchRef).toBe("feature/b"); + + const keep = db.get<{ lane_id: string | null }>( + "select lane_id from pull_requests where id = ?", + ["pr-keep"], + ); + expect(keep?.lane_id).toBe("lane-a"); + + const stale = db.get<{ lane_id: string | null }>( + "select lane_id from pull_requests where id = ?", + ["pr-stale"], + ); + expect(stale).toBeNull(); + } finally { + db.close(); + fs.rmSync(repoRoot, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/desktop/src/main/services/lanes/laneService.ts b/apps/desktop/src/main/services/lanes/laneService.ts index 0b8cb2ccd..e58bccd87 100644 --- a/apps/desktop/src/main/services/lanes/laneService.ts +++ b/apps/desktop/src/main/services/lanes/laneService.ts @@ -17,6 +17,11 @@ import type { CreateLaneFromUnstagedArgs, DeleteLaneArgs, LaneIcon, + LaneBranchActiveWorkItem, + LaneBranchProfile, + LaneBranchSwitchArgs, + LaneBranchSwitchPreview, + LaneBranchSwitchResult, MissionLaneRole, LaneStateSnapshotSummary, LaneStatus, @@ -71,6 +76,20 @@ type LaneStateSnapshotRow = { updated_at: string | null; }; +type LaneBranchProfileRow = { + id: string; + project_id: string; + lane_id: string; + branch_ref: string; + normalized_branch_ref: string; + base_ref: string; + parent_lane_id: string | null; + source_branch_ref: string | null; + created_at: string; + updated_at: string; + last_checked_out_at: string | null; +}; + const DEFAULT_LANE_STATUS: LaneStatus = { dirty: false, ahead: 0, behind: 0, remoteBehind: -1, rebaseInProgress: false }; const LANE_LIST_CACHE_TTL_MS = 10_000; @@ -89,7 +108,8 @@ function cloneLaneSummary(summary: LaneSummary): LaneSummary { ...summary, status: cloneLaneStatus(summary.status), parentStatus: summary.parentStatus ? cloneLaneStatus(summary.parentStatus) : null, - tags: [...summary.tags] + tags: [...summary.tags], + activeBranchProfile: summary.activeBranchProfile ? { ...summary.activeBranchProfile } : null }; } @@ -147,8 +167,9 @@ function toLaneSummary(args: { parentStatus: LaneStatus | null; childCount: number; stackDepth: number; + activeBranchProfile?: LaneBranchProfile | null; }): LaneSummary { - const { row, status, parentStatus, childCount, stackDepth } = args; + const { row, status, parentStatus, childCount, stackDepth, activeBranchProfile } = args; return { id: row.id, name: row.name, @@ -171,7 +192,8 @@ function toLaneSummary(args: { missionId: row.mission_id, laneRole: row.lane_role, createdAt: row.created_at, - archivedAt: row.archived_at + archivedAt: row.archived_at, + activeBranchProfile: activeBranchProfile ?? null }; } @@ -593,6 +615,185 @@ export function createLaneService({ laneListCache.clear(); }; + const normalizeBranchKey = (ref: string): string => + normalizeBranchName(ref).trim(); + + const toLaneBranchProfile = (row: LaneBranchProfileRow): LaneBranchProfile => ({ + id: row.id, + laneId: row.lane_id, + branchRef: row.branch_ref, + baseRef: row.base_ref, + parentLaneId: row.parent_lane_id, + sourceBranchRef: row.source_branch_ref, + createdAt: row.created_at, + updatedAt: row.updated_at, + lastCheckedOutAt: row.last_checked_out_at, + }); + + const getBranchProfileRow = (laneId: string, branchRef: string): LaneBranchProfileRow | null => { + const normalized = normalizeBranchKey(branchRef); + if (!normalized) return null; + return db.get( + ` + select * + from lane_branch_profiles + where project_id = ? + and lane_id = ? + and normalized_branch_ref = ? + limit 1 + `, + [projectId, laneId, normalized], + ) ?? null; + }; + + const upsertBranchProfileForRow = ( + row: LaneRow, + options: { + branchRef?: string; + baseRef?: string; + parentLaneId?: string | null; + sourceBranchRef?: string | null; + lastCheckedOutAt?: string | null; + } = {}, + ): LaneBranchProfile => { + const branchRef = normalizeBranchKey(options.branchRef ?? row.branch_ref); + if (!branchRef) throw new Error("Branch ref is required."); + const existing = getBranchProfileRow(row.id, branchRef); + const now = new Date().toISOString(); + const profile: LaneBranchProfileRow = { + id: existing?.id ?? randomUUID(), + project_id: projectId, + lane_id: row.id, + branch_ref: branchRef, + normalized_branch_ref: branchRef, + base_ref: options.baseRef?.trim() || existing?.base_ref || row.base_ref || defaultBaseRef, + parent_lane_id: options.parentLaneId !== undefined ? options.parentLaneId : (existing?.parent_lane_id ?? row.parent_lane_id), + source_branch_ref: options.sourceBranchRef !== undefined ? options.sourceBranchRef : (existing?.source_branch_ref ?? null), + created_at: existing?.created_at ?? now, + updated_at: now, + last_checked_out_at: options.lastCheckedOutAt !== undefined ? options.lastCheckedOutAt : existing?.last_checked_out_at ?? null, + }; + if (existing) { + db.run( + ` + update lane_branch_profiles + set branch_ref = ?, + base_ref = ?, + parent_lane_id = ?, + source_branch_ref = ?, + updated_at = ?, + last_checked_out_at = ? + where id = ? + and project_id = ? + `, + [ + profile.branch_ref, + profile.base_ref, + profile.parent_lane_id, + profile.source_branch_ref, + profile.updated_at, + profile.last_checked_out_at, + profile.id, + projectId, + ], + ); + } else { + db.run( + ` + insert into lane_branch_profiles( + id, project_id, lane_id, branch_ref, normalized_branch_ref, base_ref, + parent_lane_id, source_branch_ref, created_at, updated_at, last_checked_out_at + ) + values(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + [ + profile.id, + profile.project_id, + profile.lane_id, + profile.branch_ref, + profile.normalized_branch_ref, + profile.base_ref, + profile.parent_lane_id, + profile.source_branch_ref, + profile.created_at, + profile.updated_at, + profile.last_checked_out_at, + ], + ); + } + return toLaneBranchProfile(profile); + }; + + const ensureBranchProfileForRow = (row: LaneRow): LaneBranchProfile => + upsertBranchProfileForRow(row); + + const backfillLaneBranchProfiles = (): void => { + for (const row of getAllLaneRows(true)) { + if (!row.branch_ref.trim()) continue; + upsertBranchProfileForRow(row); + } + }; + + const getActiveWorkForLane = (laneId: string): LaneBranchActiveWorkItem[] => { + const terminalRows = db.all<{ id: string; title: string; status: string }>( + ` + select id, title, status + from terminal_sessions + where lane_id = ? + and archived_at is null + and ended_at is null + order by started_at desc + limit 10 + `, + [laneId], + ); + const processRows = db.all<{ process_key: string; status: string }>( + ` + select process_key, status + from process_runtime + where project_id = ? + and lane_id = ? + and status in ('starting', 'running', 'ready', 'unhealthy') + order by updated_at desc + limit 10 + `, + [projectId, laneId], + ); + return [ + ...terminalRows.map((row) => ({ + id: row.id, + kind: "terminal" as const, + title: row.title || row.id, + status: row.status, + })), + ...processRows.map((row) => ({ + id: row.process_key, + kind: "process" as const, + title: row.process_key, + status: row.status, + })), + ]; + }; + + const findActiveBranchOwner = (branchRef: string, laneId: string): { id: string; name: string } | null => { + const normalized = normalizeBranchKey(branchRef); + if (!normalized) return null; + const row = db.get<{ id: string; name: string }>( + ` + select id, name + from lanes + where project_id = ? + and id != ? + and lane_type != 'primary' + and status != 'archived' + and branch_ref = ? + limit 1 + `, + [projectId, laneId, normalized], + ); + return row ? { id: row.id, name: row.name } : null; + }; + const cloneRebaseRunLane = (lane: RebaseRunLane): RebaseRunLane => ({ ...lane, conflictingFiles: [...lane.conflictingFiles] @@ -767,6 +968,15 @@ export function createLaneService({ "update lanes set branch_ref = ? where id = ? and project_id = ?", [detectedBranchRef, primary.id, projectId] ); + const row = getLaneRow(primary.id); + if (row) { + upsertBranchProfileForRow(row, { + branchRef: detectedBranchRef, + baseRef: primary.base_ref, + parentLaneId: null, + lastCheckedOutAt: new Date().toISOString(), + }); + } invalidateLaneListCache(); }; @@ -876,6 +1086,11 @@ export function createLaneService({ } catch (err) { logger.warn("laneService.repairLegacyPrimaryBaseRootLanes_failed", { error: err instanceof Error ? err.message : String(err) }); } + try { + backfillLaneBranchProfiles(); + } catch (err) { + logger.warn("laneService.backfillLaneBranchProfiles_failed", { error: err instanceof Error ? err.message : String(err) }); + } const cacheKey = `arch:${includeArchived ? 1 : 0}|status:${includeStatus ? 1 : 0}`; const cached = laneListCache.get(cacheKey); @@ -995,7 +1210,8 @@ export function createLaneService({ status, parentStatus, childCount: childCountMap.get(row.id) ?? 0, - stackDepth + stackDepth, + activeBranchProfile: ensureBranchProfileForRow(row) }) ); if (includeStatus) { @@ -1092,7 +1308,8 @@ export function createLaneService({ status, parentStatus, childCount: 0, - stackDepth: computeStackDepth({ laneId: laneId, rowsById, memo: new Map() }) + stackDepth: computeStackDepth({ laneId: laneId, rowsById, memo: new Map() }), + activeBranchProfile: ensureBranchProfileForRow(row) }); }; @@ -1104,6 +1321,56 @@ export function createLaneService({ } catch (err) { logger.warn("laneService.initial_repairLegacyPrimaryBaseRootLanes_failed", { error: err instanceof Error ? err.message : String(err) }); } + try { + backfillLaneBranchProfiles(); + } catch (err) { + logger.warn("laneService.initial_backfillLaneBranchProfiles_failed", { error: err instanceof Error ? err.message : String(err) }); + } + + const previewBranchSwitch = async (args: LaneBranchSwitchArgs): Promise => { + const laneId = args.laneId.trim(); + if (!laneId) throw new Error("laneId is required."); + const row = getLaneRow(laneId); + if (!row) throw new Error(`Lane not found: ${laneId}`); + if (row.status === "archived") throw new Error("Lane is archived."); + + const mode = args.mode ?? "existing"; + const rawBranchName = args.branchName.trim(); + if (!rawBranchName) throw new Error("Branch name is required."); + let targetBranchRef = normalizeBranchKey(rawBranchName); + if (mode === "existing") { + const localExists = await runGit(["show-ref", "--verify", "--quiet", `refs/heads/${rawBranchName}`], { + cwd: row.worktree_path, + timeoutMs: 8_000, + }).then((res) => res.exitCode === 0); + const remoteExists = !localExists && await runGit(["show-ref", "--verify", "--quiet", `refs/remotes/${rawBranchName}`], { + cwd: row.worktree_path, + timeoutMs: 8_000, + }).then((res) => res.exitCode === 0); + if (remoteExists) { + targetBranchRef = localBranchNameFromRemoteRef(rawBranchName); + } + } + if (!targetBranchRef) throw new Error("Branch name is required."); + + const status = await runGit(["status", "--porcelain=v1"], { cwd: row.worktree_path, timeoutMs: 8_000 }); + const dirty = status.exitCode === 0 && status.stdout.trim().length > 0; + const duplicate = findActiveBranchOwner(targetBranchRef, row.id); + const activeWork = getActiveWorkForLane(row.id); + const targetProfile = getBranchProfileRow(row.id, targetBranchRef); + + return { + laneId: row.id, + currentBranchRef: row.branch_ref, + targetBranchRef, + mode, + dirty, + duplicateLaneId: duplicate?.id ?? null, + duplicateLaneName: duplicate?.name ?? null, + activeWork, + targetProfile: targetProfile ? toLaneBranchProfile(targetProfile) : null, + }; + }; const isDescendant = (rowsById: Map, laneId: string, possibleDescendantId: string): boolean => { const queue = [laneId]; @@ -1633,7 +1900,8 @@ export function createLaneService({ status, parentStatus, childCount: 0, - stackDepth: computeStackDepth({ laneId, rowsById, memo: new Map() }) + stackDepth: computeStackDepth({ laneId, rowsById, memo: new Map() }), + activeBranchProfile: ensureBranchProfileForRow(row) }); } catch (error) { if (laneInserted) { @@ -1712,6 +1980,235 @@ export function createLaneService({ } }, + listBranchProfiles(laneId: string): LaneBranchProfile[] { + const row = getLaneRow(laneId); + if (!row) throw new Error(`Lane not found: ${laneId}`); + ensureBranchProfileForRow(row); + return db.all( + ` + select * + from lane_branch_profiles + where project_id = ? + and lane_id = ? + order by coalesce(last_checked_out_at, updated_at) desc, branch_ref asc + `, + [projectId, laneId], + ).map(toLaneBranchProfile); + }, + + /** + * Lightweight branch ownership lookup that avoids the full `list()` work + * (status resolution, queue rebase overrides, primary-lane bootstrap). + * Returns a map of branch ref → owning lane info for active, non-primary + * lanes other than `excludeLaneId`. Used by the branch picker to flag + * branches that another lane already owns. + */ + listBranchOwners(args: { excludeLaneId?: string } = {}): Array<{ id: string; name: string; branchRef: string }> { + const exclude = args.excludeLaneId?.trim() ?? ""; + const rows = db.all<{ id: string; name: string; branch_ref: string }>( + ` + select id, name, branch_ref + from lanes + where project_id = ? + and status != 'archived' + and lane_type != 'primary' + and branch_ref is not null + and branch_ref != '' + `, + [projectId], + ); + return rows + .filter((row) => row.id !== exclude) + .map((row) => ({ id: row.id, name: row.name, branchRef: row.branch_ref })); + }, + + async previewBranchSwitch(args: LaneBranchSwitchArgs): Promise { + return await previewBranchSwitch(args); + }, + + async switchBranch(args: LaneBranchSwitchArgs): Promise { + const laneId = args.laneId.trim(); + if (!laneId) throw new Error("laneId is required."); + const row = getLaneRow(laneId); + if (!row) throw new Error(`Lane not found: ${laneId}`); + if (row.status === "archived") throw new Error("Lane is archived."); + + const mode = args.mode ?? "existing"; + const rawBranchName = args.branchName.trim(); + if (!rawBranchName) throw new Error("Branch name is required."); + + const preview = await previewBranchSwitch(args); + if (preview.dirty) { + throw new Error("This lane has uncommitted changes. Commit or stash them before switching branches."); + } + if (preview.duplicateLaneId) { + throw new Error(`Branch '${preview.targetBranchRef}' is already active in lane '${preview.duplicateLaneName ?? preview.duplicateLaneId}'.`); + } + if (preview.activeWork.length > 0 && !args.acknowledgeActiveWork) { + throw new Error("This lane has active sessions or processes. Confirm the branch switch to continue."); + } + + const previousBranchRef = row.branch_ref; + upsertBranchProfileForRow(row); + + let targetBranchRef = preview.targetBranchRef; + let targetProfileRow = getBranchProfileRow(row.id, targetBranchRef); + const now = new Date().toISOString(); + let pendingProfileUpsert: { + branchRef: string; + baseRef: string; + parentLaneId: string | null; + sourceBranchRef: string | null; + lastCheckedOutAt: string; + } | null = null; + + if (mode === "create") { + const baseRef = args.baseRef?.trim(); + if (!baseRef) { + throw new Error("Base branch is required when creating a branch inside a lane."); + } + const baseRefRes = await runGit(["rev-parse", "--verify", baseRef], { + cwd: row.worktree_path, + timeoutMs: 10_000, + }); + if (baseRefRes.exitCode !== 0 || !baseRefRes.stdout.trim()) { + throw new Error(`Base branch '${baseRef}' was not found.`); + } + const branchExists = await runGit(["show-ref", "--verify", "--quiet", `refs/heads/${targetBranchRef}`], { + cwd: row.worktree_path, + timeoutMs: 8_000, + }).then((res) => res.exitCode === 0); + if (branchExists) { + throw new Error(`Branch '${targetBranchRef}' already exists. Switch to it instead of creating it.`); + } + const startPoint = args.startPoint?.trim() || row.branch_ref; + const startPointRes = await runGit(["rev-parse", "--verify", startPoint], { + cwd: row.worktree_path, + timeoutMs: 10_000, + }); + if (startPointRes.exitCode !== 0 || !startPointRes.stdout.trim()) { + throw new Error(`Start point '${startPoint}' was not found.`); + } + await runGitOrThrow(["checkout", "-b", targetBranchRef, startPoint], { + cwd: row.worktree_path, + timeoutMs: 60_000, + }); + targetProfileRow = null; + pendingProfileUpsert = { + branchRef: targetBranchRef, + baseRef, + parentLaneId: null, + sourceBranchRef: startPoint, + lastCheckedOutAt: now, + }; + } else { + const localExists = await runGit(["show-ref", "--verify", "--quiet", `refs/heads/${rawBranchName}`], { + cwd: row.worktree_path, + timeoutMs: 8_000, + }).then((res) => res.exitCode === 0); + let remoteRef: string | null = null; + if (!localExists) { + const remoteExists = await runGit(["show-ref", "--verify", "--quiet", `refs/remotes/${rawBranchName}`], { + cwd: row.worktree_path, + timeoutMs: 8_000, + }).then((res) => res.exitCode === 0); + if (remoteExists) { + remoteRef = rawBranchName; + targetBranchRef = localBranchNameFromRemoteRef(rawBranchName); + } else { + const resolved = await resolveImportBranchTarget({ projectRoot, rawRef: rawBranchName }); + remoteRef = resolved.remoteRef; + targetBranchRef = resolved.localBranchName; + } + } + + const checkoutCmd = remoteRef + ? ["checkout", "--track", "--ignore-other-worktrees", remoteRef] + : ["checkout", "--ignore-other-worktrees", targetBranchRef]; + await runGitOrThrow(checkoutCmd, { cwd: row.worktree_path, timeoutMs: 60_000 }); + + const existingProfile = targetProfileRow ? toLaneBranchProfile(targetProfileRow) : null; + pendingProfileUpsert = { + branchRef: targetBranchRef, + baseRef: args.baseRef?.trim() || existingProfile?.baseRef || defaultBaseRef, + parentLaneId: existingProfile?.parentLaneId ?? null, + sourceBranchRef: existingProfile?.sourceBranchRef ?? null, + lastCheckedOutAt: now, + }; + } + + // Wrap the profile upsert + lanes update + stale-PR cleanup in a single + // transaction so a partial failure can't leave the lane row referencing + // the new branch while the orphaned PR rows linger (or vice versa), or + // leave the post-checkout profile written without the matching lanes + // row update. + db.run("begin"); + try { + if (pendingProfileUpsert) { + upsertBranchProfileForRow(row, pendingProfileUpsert); + } + const targetProfile = getBranchProfileRow(row.id, targetBranchRef); + const baseRef = targetProfile?.base_ref ?? args.baseRef?.trim() ?? defaultBaseRef; + const parentLaneId = targetProfile?.parent_lane_id ?? null; + db.run( + ` + update lanes + set branch_ref = ?, + base_ref = ?, + parent_lane_id = ? + where id = ? + and project_id = ? + `, + [targetBranchRef, baseRef, parentLaneId, row.id, projectId], + ); + // Drop any PR rows still associated with this lane whose head_branch + // no longer matches the lane's current branch — those references are + // stale after a branch switch and must not bleed into PR lookups. + // pull_requests.lane_id is NOT NULL, so we DELETE (mirrors the explicit + // child-row cleanup used by the lane-delete path; CRR conversion can + // strip FK cascades). + const stalePrRows = db.all<{ id: string }>( + ` + select id from pull_requests + where lane_id = ? + and project_id = ? + and head_branch <> ? + `, + [row.id, projectId, targetBranchRef], + ); + if (stalePrRows.length > 0) { + const placeholders = stalePrRows.map(() => "?").join(", "); + const stalePrIds = stalePrRows.map((r) => r.id); + db.run(`delete from pr_convergence_state where pr_id in (${placeholders})`, stalePrIds); + db.run(`delete from pr_pipeline_settings where pr_id in (${placeholders})`, stalePrIds); + db.run(`delete from pr_issue_inventory where pr_id in (${placeholders})`, stalePrIds); + db.run(`delete from pr_group_members where pr_id in (${placeholders})`, stalePrIds); + db.run( + ` + delete from pull_requests + where lane_id = ? + and project_id = ? + and head_branch <> ? + `, + [row.id, projectId, targetBranchRef], + ); + } + db.run("commit"); + } catch (err) { + try { db.run("rollback"); } catch { /* swallow rollback failures */ } + throw err; + } + invalidateLaneListCache(); + + const refreshed = (await listLanes({ includeArchived: false, includeStatus: true })).find((lane) => lane.id === row.id); + if (!refreshed) throw new Error(`Lane not found after branch switch: ${row.id}`); + return { + lane: refreshed, + previousBranchRef, + activeWork: preview.activeWork, + }; + }, + async getChildren(laneId: string): Promise { // Query only children rows directly instead of fetching and filtering all lanes. const childRows = getChildrenRows(laneId, false); @@ -1766,6 +2263,7 @@ export function createLaneService({ parentStatus, childCount: childCountMap.get(row.id) ?? 0, stackDepth: computeStackDepth({ laneId: row.id, rowsById, memo: depthMemo }), + activeBranchProfile: ensureBranchProfileForRow(row), }) ); } @@ -2573,7 +3071,16 @@ export function createLaneService({ }, updateBranchRef(laneId: string, branchRef: string): void { + const row = getLaneRow(laneId); db.run("update lanes set branch_ref = ? where id = ? and project_id = ?", [branchRef, laneId, projectId]); + if (row) { + upsertBranchProfileForRow(row, { + branchRef, + baseRef: row.base_ref, + parentLaneId: row.parent_lane_id, + lastCheckedOutAt: new Date().toISOString(), + }); + } invalidateLaneListCache(); }, diff --git a/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts b/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts index 73f614f0d..742bed411 100644 --- a/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts +++ b/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts @@ -3278,7 +3278,9 @@ describe("aiOrchestratorService", () => { }) as typeof fixture.orchestratorService.onTrackedSessionEnded; const firstSweep = fixture.aiOrchestratorService.runHealthSweep("overlap-owner"); - for (let tries = 0; tries < 40 && reconcileCalls === 0; tries += 1) { + // CI runners can be heavily loaded; give the first sweep up to ~5s to reach + // the gated `onTrackedSessionEnded` call before we assert it was invoked. + for (let tries = 0; tries < 200 && reconcileCalls === 0; tries += 1) { await new Promise((resolve) => setTimeout(resolve, 25)); } expect(reconcileCalls).toBe(1); @@ -3293,7 +3295,7 @@ describe("aiOrchestratorService", () => { releaseFirstSweep(); fixture.dispose(); } - }, 10_000); + }, 15_000); it("skips background health sweeps for runs blocked on open interventions", async () => { const fixture = await createFixture(); diff --git a/apps/desktop/src/main/services/prs/prService.ts b/apps/desktop/src/main/services/prs/prService.ts index 61fe54e5d..b190cd265 100644 --- a/apps/desktop/src/main/services/prs/prService.ts +++ b/apps/desktop/src/main/services/prs/prService.ts @@ -865,6 +865,27 @@ export function createPrService({ [laneId, projectId] ); + const getRowForLaneBranch = (laneId: string, headBranch: string): PullRequestRow | null => { + const normalizedHead = normalizeBranchName(headBranch).trim(); + // When the caller has no usable head branch (e.g. mid-checkout, lane row + // not yet hydrated, or a freshly created lane without a synced ref) fall + // back to the lane-level lookup. Otherwise the PR row will look "missing" + // and downstream callers will redundantly create or refetch state. + if (!normalizedHead) return getRowForLane(laneId); + return db.get( + ` + select ${PR_COLUMNS} + from pull_requests + where lane_id = ? + and project_id = ? + and head_branch = ? + order by updated_at desc + limit 1 + `, + [laneId, projectId, normalizedHead], + ); + }; + const listRows = (): PullRequestRow[] => db.all( `select ${PR_COLUMNS} from pull_requests where project_id = ? order by updated_at desc`, @@ -1260,9 +1281,9 @@ export function createPrService({ // legitimate use of the repo/PR-number fallback; it opts in via // `allowRepoPrAdoption: true`. const existing = options?.allowRepoPrAdoption - ? getRowForLane(summary.laneId) + ? getRowForLaneBranch(summary.laneId, summary.headBranch) ?? getRowForRepoPr(summary.repoOwner, summary.repoName, summary.githubPrNumber) - : getRowForLane(summary.laneId); + : getRowForLaneBranch(summary.laneId, summary.headBranch); if (existing) { if (existing.lane_id !== summary.laneId) { db.run(`delete from pr_group_members where pr_id = ?`, [existing.id]); diff --git a/apps/desktop/src/main/services/state/kvDb.ts b/apps/desktop/src/main/services/state/kvDb.ts index 61c0a4349..e2715a519 100644 --- a/apps/desktop/src/main/services/state/kvDb.ts +++ b/apps/desktop/src/main/services/state/kvDb.ts @@ -776,6 +776,58 @@ function migrate(db: MigrationDb) { db.run("create index if not exists idx_lanes_project_mission on lanes(project_id, mission_id)"); db.run("create index if not exists idx_lanes_project_role on lanes(project_id, lane_role)"); + db.run(` + create table if not exists lane_branch_profiles ( + id text primary key, + project_id text not null, + lane_id text not null, + branch_ref text not null, + normalized_branch_ref text not null, + base_ref text not null, + parent_lane_id text, + source_branch_ref text, + created_at text not null, + updated_at text not null, + last_checked_out_at text, + foreign key(project_id) references projects(id), + foreign key(lane_id) references lanes(id), + foreign key(parent_lane_id) references lanes(id) + ) + `); + db.run("create index if not exists idx_lane_branch_profiles_lane on lane_branch_profiles(project_id, lane_id)"); + db.run("create index if not exists idx_lane_branch_profiles_project_branch on lane_branch_profiles(project_id, normalized_branch_ref)"); + // NOTE: CRR-converted tables cannot carry UNIQUE indices besides the + // primary key (`crsql_as_crr` rejects them with "Table … has unique + // indices besides the primary key. This is not allowed for CRRs"), so + // uniqueness on (project_id, lane_id, normalized_branch_ref) is enforced + // at the application layer inside `upsertBranchProfileForRow` (check-then- + // insert) and via the duplicate sweep below. Coalesce duplicates from + // older dev builds — keep the most recently updated row per (project, + // lane, normalized branch) and delete the rest. This runs on every + // bootstrap so the app-layer check has a clean slate even after a + // multi-writer race produced extras. + try { + db.run(` + delete from lane_branch_profiles + where rowid not in ( + select rowid from lane_branch_profiles as keep + where keep.id = ( + select id from lane_branch_profiles inner_p + where inner_p.project_id = keep.project_id + and inner_p.lane_id = keep.lane_id + and inner_p.normalized_branch_ref = keep.normalized_branch_ref + order by coalesce(inner_p.last_checked_out_at, inner_p.updated_at) desc, + inner_p.updated_at desc, + inner_p.id asc + limit 1 + ) + ) + `); + } catch { + // best-effort migration; duplicates will be coalesced on the next + // upsert via the existing check-then-insert path. + } + db.run(` create table if not exists lane_state_snapshots ( lane_id text primary key, diff --git a/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts b/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts index 121354c67..3a29e2f95 100644 --- a/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts +++ b/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts @@ -173,6 +173,17 @@ function createMockLaneService() { createChild: vi.fn().mockResolvedValue({ id: "child-1" }), createFromUnstaged: vi.fn().mockResolvedValue({ id: "unstaged-1" }), importBranch: vi.fn().mockResolvedValue({ id: "imported-1" }), + previewBranchSwitch: vi.fn().mockResolvedValue({ + laneId: "lane-1", + currentBranchRef: "main", + targetBranchRef: "feature/foo", + mode: "existing", + dirty: false, + duplicateLaneId: null, + duplicateLaneName: null, + activeWork: [], + targetProfile: null, + }), attach: vi.fn().mockResolvedValue({ id: "attached-1" }), adoptAttached: vi.fn().mockResolvedValue({ ok: true }), rename: vi.fn(), @@ -699,6 +710,32 @@ describe("createSyncRemoteCommandService", () => { .rejects.toThrow("lanes.importBranch requires branchRef."); }); + it("lanes.previewBranchSwitch routes to laneService.previewBranchSwitch with optional fields", async () => { + const result = await service.execute(makePayload("lanes.previewBranchSwitch", { + laneId: "lane-1", + branchName: "feature/foo", + mode: "create", + startPoint: "main", + baseRef: "main", + acknowledgeActiveWork: true, + })); + expect(laneService.previewBranchSwitch).toHaveBeenCalledWith({ + laneId: "lane-1", + branchName: "feature/foo", + mode: "create", + startPoint: "main", + baseRef: "main", + acknowledgeActiveWork: true, + }); + // The mock returns a preview shape; the result should be the same object. + expect(result).toMatchObject({ laneId: "lane-1", mode: "existing" }); + }); + + it("lanes.previewBranchSwitch requires branchName", async () => { + await expect(service.execute(makePayload("lanes.previewBranchSwitch", { laneId: "lane-1" }))) + .rejects.toThrow(/branchName/); + }); + it("lanes.rename parses laneId and name", async () => { await service.execute(makePayload("lanes.rename", { laneId: "lane-1", @@ -1011,6 +1048,38 @@ describe("createSyncRemoteCommandService", () => { await expect(service.execute(makePayload("git.checkoutBranch", { laneId: "lane-1" }))) .rejects.toThrow("git.checkoutBranch requires branchName."); }); + + it("git.checkoutBranch forwards optional mode/startPoint/baseRef/acknowledgeActiveWork", async () => { + await service.execute(makePayload("git.checkoutBranch", { + laneId: "lane-1", + branchName: "feature/new", + mode: "create", + startPoint: "main", + baseRef: "main", + acknowledgeActiveWork: true, + })); + expect(gitService.checkoutBranch).toHaveBeenCalledWith({ + laneId: "lane-1", + branchName: "feature/new", + mode: "create", + startPoint: "main", + baseRef: "main", + acknowledgeActiveWork: true, + }); + }); + + it("git.checkoutBranch omits optional fields when payload provides only required ones", async () => { + await service.execute(makePayload("git.checkoutBranch", { + laneId: "lane-1", + branchName: "feature/clean", + })); + const lastCall = gitService.checkoutBranch.mock.calls.at(-1)?.[0]; + expect(lastCall).toEqual({ laneId: "lane-1", branchName: "feature/clean" }); + expect(lastCall).not.toHaveProperty("mode"); + expect(lastCall).not.toHaveProperty("startPoint"); + expect(lastCall).not.toHaveProperty("baseRef"); + expect(lastCall).not.toHaveProperty("acknowledgeActiveWork"); + }); }); // --------------------------------------------------------------- diff --git a/apps/desktop/src/main/services/sync/syncRemoteCommandService.ts b/apps/desktop/src/main/services/sync/syncRemoteCommandService.ts index 986873602..fdd07e5b9 100644 --- a/apps/desktop/src/main/services/sync/syncRemoteCommandService.ts +++ b/apps/desktop/src/main/services/sync/syncRemoteCommandService.ts @@ -48,6 +48,7 @@ import type { GitGenerateCommitMessageArgs, GitGetCommitMessageArgs, GitGetFileHistoryArgs, + GitCheckoutBranchArgs, GitListBranchesArgs, GitListCommitFilesArgs, GitPushArgs, @@ -817,10 +818,16 @@ function parseGitListBranchesArgs(value: Record): GitListBranch }; } -function parseGitCheckoutBranchArgs(value: Record): { laneId: string; branchName: string } { +function parseGitCheckoutBranchArgs(value: Record): GitCheckoutBranchArgs { return { laneId: requireString(value.laneId, "git.checkoutBranch requires laneId."), branchName: requireString(value.branchName, "git.checkoutBranch requires branchName."), + ...(asTrimmedString(value.mode) ? { mode: value.mode as GitCheckoutBranchArgs["mode"] } : {}), + ...(asTrimmedString(value.startPoint) ? { startPoint: asTrimmedString(value.startPoint)! } : {}), + ...(asTrimmedString(value.baseRef) ? { baseRef: asTrimmedString(value.baseRef)! } : {}), + ...(asOptionalBoolean(value.acknowledgeActiveWork) !== undefined + ? { acknowledgeActiveWork: asOptionalBoolean(value.acknowledgeActiveWork) } + : {}), }; } @@ -1480,6 +1487,8 @@ export function createSyncRemoteCommandService(args: SyncRemoteCommandServiceArg args.laneService.createFromUnstaged(parseCreateLaneFromUnstagedArgs(payload))); register("lanes.importBranch", { viewerAllowed: true, queueable: true }, async (payload) => args.laneService.importBranch(parseImportBranchArgs(payload))); + register("lanes.previewBranchSwitch", { viewerAllowed: true }, async (payload) => + args.laneService.previewBranchSwitch(parseGitCheckoutBranchArgs(payload))); register("lanes.attach", { viewerAllowed: true, queueable: true }, async (payload) => args.laneService.attach(parseAttachLaneArgs(payload))); register("lanes.adoptAttached", { viewerAllowed: true, queueable: true }, async (payload) => args.laneService.adoptAttached({ laneId: requireString(payload.laneId, "lanes.adoptAttached requires laneId.") })); diff --git a/apps/desktop/src/preload/global.d.ts b/apps/desktop/src/preload/global.d.ts index 89152b729..e1f219c00 100644 --- a/apps/desktop/src/preload/global.d.ts +++ b/apps/desktop/src/preload/global.d.ts @@ -32,6 +32,9 @@ import type { CreateLaneArgs, CreateChildLaneArgs, CreateLaneFromUnstagedArgs, + LaneBranchSwitchArgs, + LaneBranchSwitchPreview, + LaneBranchSwitchResult, DeleteLaneArgs, DeleteMissionArgs, DevToolsCheckResult, @@ -1027,6 +1030,12 @@ declare global { args: CreateLaneFromUnstagedArgs, ) => Promise; importBranch: (args: ImportBranchLaneArgs) => Promise; + previewBranchSwitch: ( + args: LaneBranchSwitchArgs, + ) => Promise; + switchBranch: ( + args: LaneBranchSwitchArgs, + ) => Promise; attach: (args: AttachLaneArgs) => Promise; listUnregisteredWorktrees: () => Promise; adoptAttached: (args: AdoptAttachedLaneArgs) => Promise; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 53c268957..e186ba956 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -143,6 +143,9 @@ import type { CreateLaneArgs, CreateChildLaneArgs, CreateLaneFromUnstagedArgs, + LaneBranchSwitchArgs, + LaneBranchSwitchPreview, + LaneBranchSwitchResult, DeleteLaneArgs, DevToolsCheckResult, DiffChanges, @@ -1366,6 +1369,14 @@ contextBridge.exposeInMainWorld("ade", { ipcRenderer.invoke(IPC.lanesCreateFromUnstaged, args), importBranch: async (args: ImportBranchLaneArgs): Promise => ipcRenderer.invoke(IPC.lanesImportBranch, args), + previewBranchSwitch: async ( + args: LaneBranchSwitchArgs, + ): Promise => + ipcRenderer.invoke(IPC.lanesPreviewBranchSwitch, args), + switchBranch: async ( + args: LaneBranchSwitchArgs, + ): Promise => + ipcRenderer.invoke(IPC.lanesSwitchBranch, args), attach: async (args: AttachLaneArgs): Promise => ipcRenderer.invoke(IPC.lanesAttach, args), listUnregisteredWorktrees: async (): Promise => diff --git a/apps/desktop/src/renderer/browserMock.ts b/apps/desktop/src/renderer/browserMock.ts index 797d97514..e4b3dacc4 100644 --- a/apps/desktop/src/renderer/browserMock.ts +++ b/apps/desktop/src/renderer/browserMock.ts @@ -2449,6 +2449,22 @@ if (typeof window !== "undefined" && !(window as any).ade) { create: resolvedArg({ id: "mock", name: "mock" }), createChild: resolvedArg({ id: "mock", name: "mock" }), importBranch: resolvedArg({ id: "mock", name: "mock" }), + previewBranchSwitch: resolvedArg({ + laneId: "mock", + currentBranchRef: "main", + targetBranchRef: "main", + mode: "existing", + dirty: false, + duplicateLaneId: null, + duplicateLaneName: null, + activeWork: [], + targetProfile: null, + }), + switchBranch: resolvedArg({ + lane: MOCK_LANES[0], + previousBranchRef: "main", + activeWork: [], + }), attach: resolvedArg({ id: "mock", name: "mock" }), adoptAttached: resolvedArg({ id: "mock", name: "mock" }), rename: resolvedArg(undefined), diff --git a/apps/desktop/src/renderer/components/lanes/LaneStackPane.tsx b/apps/desktop/src/renderer/components/lanes/LaneStackPane.tsx index e292e20c0..f6553d2d3 100644 --- a/apps/desktop/src/renderer/components/lanes/LaneStackPane.tsx +++ b/apps/desktop/src/renderer/components/lanes/LaneStackPane.tsx @@ -7,7 +7,7 @@ import { COLORS, LABEL_STYLE, MONO_FONT, SANS_FONT, outlineButton } from "./lane import { logRendererDebugEvent } from "../../lib/debugLog"; import { SmartTooltip } from "../ui/SmartTooltip"; -const TREE_ROW_H = 28; +const TREE_ROW_H = 34; const TREE_INDENT = 22; const TREE_LEFT_PAD = 16; const TREE_DOT_R = 5; @@ -256,12 +256,25 @@ function StackGraph({ }} > - 0 ? 120 : 160, - fontFamily: SANS_FONT, - fontSize: 11, - fontWeight: isSelected ? 600 : 500, - }}>{lane.name} + lineHeight: 1.05, + overflow: "hidden", + }}> + {lane.name} + {lane.branchRef} + {integrationSources.length > 0 ? ( (null); const [rebasePushReview, setRebasePushReview] = useState(null); - const [primaryBranches, setPrimaryBranches] = useState([]); + const [laneBranches, setLaneBranches] = useState([]); + const [laneBranchesLoading, setLaneBranchesLoading] = useState(false); const [branchDropdownOpen, setBranchDropdownOpen] = useState(false); const [branchCheckoutBusy, setBranchCheckoutBusy] = useState(false); const [branchCheckoutError, setBranchCheckoutError] = useState(null); const [branchSearchQuery, setBranchSearchQuery] = useState(""); + const [newBranchName, setNewBranchName] = useState(""); + const [newBranchStartPoint, setNewBranchStartPoint] = useState(""); + const [newBranchBaseRef, setNewBranchBaseRef] = useState(""); + const [pendingBranchSwitch, setPendingBranchSwitch] = useState<{ + branchName: string; + mode: "existing" | "create"; + startPoint?: string; + baseRef?: string; + activeWork: LaneBranchActiveWorkItem[]; + } | null>(null); const branchSearchInputRef = useRef(null); const branchDropdownRef = useRef(null); @@ -495,29 +511,55 @@ export function LanesPage() { const adoptTargetLane = adoptTargetLaneId ? lanesById.get(adoptTargetLaneId) ?? null : null; const primaryLane = useMemo(() => lanes.find((l) => l.laneType === "primary") ?? null, [lanes]); - - /* ---- Primary branch management ---- */ + const branchLane = useMemo(() => { + const candidate = selectedLaneId ? lanesById.get(selectedLaneId) ?? primaryLane : primaryLane; + return candidate ?? null; + }, [selectedLaneId, lanesById, primaryLane]); + const branchLaneSwitchDisabledReason = useMemo(() => { + if (!branchLane) return null; + if (branchLane.laneType === "attached") return "Branch switching is disabled for attached lanes — manage this worktree with your own tools."; + if (isMissionResultLane(branchLane)) return "Branch switching is disabled for mission result lanes to keep their output stable."; + if (isMissionLaneHiddenByDefault(branchLane)) return "Branch switching isn't available on mission worker lanes."; + return null; + }, [branchLane]); + const canSwitchBranchLane = branchLane !== null && branchLaneSwitchDisabledReason === null; + + /* ---- Lane branch management ---- */ useEffect(() => { - if (!primaryLane || !branchDropdownOpen) return; - window.ade.git.listBranches({ laneId: primaryLane.id }) - .then(setPrimaryBranches) - .catch(() => {}); - }, [branchDropdownOpen, primaryLane?.id]); + // Always clear stale results when the target lane (or open state) changes + // — otherwise lane A's branches linger when the user switches to lane B + // before the new fetch resolves. + setLaneBranches([]); + if (!branchLane || !branchDropdownOpen) return; + let cancelled = false; + setLaneBranchesLoading(true); + window.ade.git.listBranches({ laneId: branchLane.id }) + .then((result) => { if (!cancelled) setLaneBranches(result); }) + .catch(() => { if (!cancelled) setLaneBranches([]); }) + .finally(() => { if (!cancelled) setLaneBranchesLoading(false); }); + return () => { cancelled = true; }; + }, [branchDropdownOpen, branchLane?.id]); useEffect(() => { - if (!primaryLane) return; - const current = primaryBranches.find((branch) => branch.isCurrent && !branch.isRemote)?.name ?? null; - if (!current || current === primaryLane.branchRef) return; + if (!branchLane) return; + const current = laneBranches.find((branch) => branch.isCurrent && !branch.isRemote)?.name ?? null; + if (!current || current === branchLane.branchRef) return; refreshLanes().catch(() => {}); - }, [primaryBranches, primaryLane?.id, primaryLane?.branchRef, refreshLanes]); + }, [laneBranches, branchLane?.id, branchLane?.branchRef, refreshLanes]); useEffect(() => { if (branchDropdownOpen) { setBranchSearchQuery(""); + setPendingBranchSwitch(null); + // Reset the new-branch-creation form whenever the picker (re)opens or + // the target lane changes so stale start-point/base-ref values from a + // previous lane don't carry over. + setNewBranchStartPoint(""); + setNewBranchBaseRef(""); setTimeout(() => branchSearchInputRef.current?.focus(), 0); } - }, [branchDropdownOpen]); + }, [branchDropdownOpen, branchLane?.id]); useClickOutside(branchDropdownRef, () => setBranchDropdownOpen(false), branchDropdownOpen); useClickOutside(addLaneDropdownRef, () => setAddLaneDropdownOpen(false), addLaneDropdownOpen); @@ -844,18 +886,48 @@ export function LanesPage() { /* ---- Lane management actions ---- */ - const currentPrimaryBranch = useMemo( - () => primaryBranches.find((branch) => branch.isCurrent)?.name ?? primaryLane?.branchRef ?? "", - [primaryBranches, primaryLane?.branchRef] + const currentLaneBranch = useMemo( + () => laneBranches.find((branch) => branch.isCurrent)?.name ?? branchLane?.branchRef ?? "", + [laneBranches, branchLane?.branchRef] ); - const localPrimaryBranches = useMemo(() => { + const localLaneBranches = useMemo(() => { const q = branchSearchQuery.toLowerCase(); - return primaryBranches.filter((branch) => !branch.isRemote && (!q || branch.name.toLowerCase().includes(q))); - }, [primaryBranches, branchSearchQuery]); - const remotePrimaryBranches = useMemo(() => { + return laneBranches.filter((branch) => !branch.isRemote && (!q || branch.name.toLowerCase().includes(q))); + }, [laneBranches, branchSearchQuery]); + const remoteLaneBranches = useMemo(() => { const q = branchSearchQuery.toLowerCase(); - return primaryBranches.filter((branch) => branch.isRemote && (!q || branch.name.toLowerCase().includes(q))); - }, [primaryBranches, branchSearchQuery]); + return laneBranches.filter((branch) => branch.isRemote && (!q || branch.name.toLowerCase().includes(q))); + }, [laneBranches, branchSearchQuery]); + const startPointOptions = useMemo(() => { + type StartOption = { value: string; label: string; group: "lane" | "local" | "remote" }; + const map = new Map(); + if (branchLane?.branchRef) { + map.set(branchLane.branchRef, { value: branchLane.branchRef, label: branchLane.branchRef, group: "lane" }); + } + for (const branch of laneBranches) { + if (branch.isRemote) continue; + if (!map.has(branch.name)) map.set(branch.name, { value: branch.name, label: branch.name, group: "local" }); + } + for (const branch of laneBranches) { + if (!branch.isRemote) continue; + const local = stripRemotePrefix(branch.name); + if (map.has(local)) continue; + if (!map.has(branch.name)) { + map.set(branch.name, { value: branch.name, label: `${branch.name} (remote)`, group: "remote" }); + } + } + return Array.from(map.values()).sort((a, b) => a.label.localeCompare(b.label)); + }, [branchLane?.branchRef, laneBranches]); + const baseRefOptions = useMemo(() => { + const names = new Set(); + if (primaryLane?.branchRef) names.add(primaryLane.branchRef); + for (const opt of startPointOptions) names.add(opt.value); + return Array.from(names).sort((a, b) => a.localeCompare(b)); + }, [startPointOptions, primaryLane?.branchRef]); + const branchNameValidation = useMemo( + () => (newBranchName.trim() ? validateBranchName(newBranchName) : { ok: false }), + [newBranchName], + ); const runLaneAction = async ( fn: () => Promise, @@ -921,29 +993,72 @@ export function LanesPage() { } }, [adoptTargetLaneId, refreshLanes, selectLane]); - const checkoutPrimaryBranch = useCallback(async (branchName: string) => { - if (!primaryLane) return; - if (primaryLane.status.dirty) { - setBranchCheckoutError("Cannot switch branches while primary lane has uncommitted changes. Commit, stash, or discard changes first."); + const checkoutLaneBranch = useCallback(async (request: { + branchName: string; + mode?: "existing" | "create"; + startPoint?: string; + baseRef?: string; + acknowledgeActiveWork?: boolean; + }) => { + if (!branchLane) return; + if (branchLane.status.dirty) { + setBranchCheckoutError(`Cannot switch branches while ${branchLane.name} has uncommitted changes. Commit, stash, or discard changes first.`); return; } + const mode = request.mode ?? "existing"; + const branchName = request.branchName.trim(); + if (!branchName) return; setBranchCheckoutBusy(true); setBranchCheckoutError(null); let succeeded = false; try { - await window.ade.git.checkoutBranch({ laneId: primaryLane.id, branchName }); + if (!request.acknowledgeActiveWork) { + const preview = await window.ade.lanes.previewBranchSwitch({ + laneId: branchLane.id, + branchName, + mode, + startPoint: request.startPoint, + baseRef: request.baseRef, + }); + if (preview.duplicateLaneId) { + throw new Error(`Branch '${preview.targetBranchRef}' is already active in ${preview.duplicateLaneName ?? "another lane"}.`); + } + if (preview.dirty) { + throw new Error(`Cannot switch branches while ${branchLane.name} has uncommitted changes.`); + } + if (preview.activeWork.length > 0) { + setPendingBranchSwitch({ + branchName, + mode, + startPoint: request.startPoint, + baseRef: request.baseRef, + activeWork: preview.activeWork, + }); + return; + } + } + await window.ade.git.checkoutBranch({ + laneId: branchLane.id, + branchName, + mode, + startPoint: request.startPoint, + baseRef: request.baseRef, + acknowledgeActiveWork: request.acknowledgeActiveWork, + }); await refreshLanes(); - const updated = await window.ade.git.listBranches({ laneId: primaryLane.id }); - setPrimaryBranches(updated); + const updated = await window.ade.git.listBranches({ laneId: branchLane.id }); + setLaneBranches(updated); + setPendingBranchSwitch(null); + setNewBranchName(""); succeeded = true; } catch (err) { const raw = err instanceof Error ? err.message : String(err); - setBranchCheckoutError(formatBranchCheckoutError(raw)); + setBranchCheckoutError(formatBranchCheckoutError(raw, branchLane.name)); } finally { setBranchCheckoutBusy(false); if (succeeded) setBranchDropdownOpen(false); } - }, [primaryLane, refreshLanes]); + }, [branchLane, refreshLanes]); const archiveManagedLanes = async () => { const targets = isBatchManage ? managedLanes : managedLane ? [managedLane] : []; @@ -1735,29 +1850,43 @@ export function LanesPage() { {/* Branch selector */} - {primaryLane && selectedLaneId === primaryLane.id ? ( + {branchLane ? (
- + - - {branchDropdownOpen ? ( -
+ {branchDropdownOpen && canSwitchBranchLane ? ( +
{ e.currentTarget.style.borderColor = COLORS.outlineBorder; }} />
+
+
+
NEW BRANCH
+ setNewBranchName(e.target.value)} + aria-invalid={Boolean(newBranchName.trim()) && !branchNameValidation.ok} + style={{ + width: "100%", padding: "6px 8px", fontSize: 12, fontFamily: MONO_FONT, + color: COLORS.textPrimary, background: "rgba(255,255,255,0.04)", + border: `1px solid ${ + newBranchName.trim() && !branchNameValidation.ok ? COLORS.danger : COLORS.outlineBorder + }`, + borderRadius: 6, outline: "none", + }} + /> + {newBranchName.trim() && branchNameValidation.reason ? ( +
{branchNameValidation.reason}
+ ) : null} + + + +
+
+ {pendingBranchSwitch ? ( +
+
This lane has active work.
+
Terminals and processes stay attached to this lane and will keep running on the new branch's worktree.
+
+ {pendingBranchSwitch.activeWork.slice(0, 3).map((item) => ( +
+ {item.kind === "terminal" ? "Terminal" : "Process"}: {item.title} +
+ ))} + {pendingBranchSwitch.activeWork.length > 3 ? ( +
+ {pendingBranchSwitch.activeWork.length - 3} more
+ ) : null} +
+
+ + +
+
+ ) : null}
+ {laneBranchesLoading && laneBranches.length === 0 ? ( +
Loading branches…
+ ) : null}
LOCAL BRANCHES
- {localPrimaryBranches.map((branch) => ( + {localLaneBranches.map((branch) => { + const owned = Boolean(branch.ownedByLaneId); + return ( - ))} - {remotePrimaryBranches.length > 0 ? ( + ); + })} + {remoteLaneBranches.length > 0 ? ( <>
REMOTE BRANCHES
- {remotePrimaryBranches.map((branch) => ( + {remoteLaneBranches.map((branch) => { + const owned = Boolean(branch.ownedByLaneId); + return ( - ))} + ); + })} ) : null} - {localPrimaryBranches.length === 0 && remotePrimaryBranches.length === 0 ? ( + {!laneBranchesLoading && localLaneBranches.length === 0 && remoteLaneBranches.length === 0 ? (
{branchSearchQuery ? "No matching branches." : "No branches found."}
) : null} {branchCheckoutError ? ( @@ -1837,7 +2101,7 @@ export function LanesPage() { ) : null}
) : null} - {branchCheckoutError && primaryLane && selectedLaneId === primaryLane.id ? ( + {branchCheckoutError && branchLane && !branchDropdownOpen ? (
{branchCheckoutError}