From 86e28868687d1ca9df51a466b57efab44cbe337a Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sun, 26 Apr 2026 18:02:31 -0400 Subject: [PATCH 1/3] ship: checkpoint before automate/finalize Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/ade-cli/src/adeRpcServer.ts | 21 +- apps/ade-cli/src/cli.ts | 16 +- .../services/ai/tools/ctoOperatorTools.ts | 18 +- .../main/services/git/gitOperationsService.ts | 66 +-- .../src/main/services/ipc/registerIpc.ts | 20 +- .../src/main/services/lanes/laneService.ts | 452 +++++++++++++++- .../src/main/services/prs/prService.ts | 21 +- apps/desktop/src/main/services/state/kvDb.ts | 21 + .../services/sync/syncRemoteCommandService.ts | 11 +- apps/desktop/src/preload/global.d.ts | 9 + apps/desktop/src/preload/preload.ts | 11 + apps/desktop/src/renderer/browserMock.ts | 16 + .../components/lanes/LaneStackPane.tsx | 25 +- .../renderer/components/lanes/LanesPage.tsx | 386 +++++++++++--- .../components/lanes/laneUtils.test.ts | 83 +++ .../renderer/components/lanes/laneUtils.ts | 45 +- apps/desktop/src/shared/ipc.ts | 2 + apps/desktop/src/shared/types/git.ts | 8 + apps/desktop/src/shared/types/lanes.ts | 49 ++ apps/desktop/src/shared/types/sync.ts | 1 + apps/ios/ADE/Models/RemoteModels.swift | 37 ++ apps/ios/ADE/Resources/DatabaseBootstrap.sql | 21 + apps/ios/ADE/Services/SyncService.swift | 36 +- .../ios/ADE/Views/Lanes/LaneActionsCard.swift | 94 +++- .../Views/Lanes/LaneBranchPickerSheet.swift | 497 +++++++++++++++++- .../Views/Lanes/LaneDetailGitSection.swift | 3 + .../ADE/Views/Lanes/LaneListViewParts.swift | 28 +- 27 files changed, 1846 insertions(+), 151 deletions(-) diff --git a/apps/ade-cli/src/adeRpcServer.ts b/apps/ade-cli/src/adeRpcServer.ts index 53000b8a4..c2651c645 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" } } } }, @@ -5258,7 +5262,18 @@ 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 mode = typeof toolArgs.mode === "string" ? toolArgs.mode : undefined; + 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 === "create" ? "create" : "existing", + startPoint, + baseRef, + acknowledgeActiveWork, + }); return { laneId, branchName, action }; } diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index bd2104e28..bc86e794e 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") { 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.ts b/apps/desktop/src/main/services/git/gitOperationsService.ts index 0c890d1f6..d2a546307 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,32 @@ 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( + laneService.listBranchProfiles(args.laneId).map((profile) => profile.branchRef) + ); + const activeLaneOwners = new Map(); + try { + const lanes = await laneService.list({ includeArchived: false, includeStatus: false }); + for (const entry of lanes) { + if (entry.id === args.laneId || entry.laneType === "primary") continue; + activeLaneOwners.set(entry.branchRef, { id: entry.id, name: entry.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 +1135,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 +1165,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.ts b/apps/desktop/src/main/services/lanes/laneService.ts index 0b8cb2ccd..b117e8d21 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,168 @@ 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); + }, + + 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(); + + 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; + upsertBranchProfileForRow(row, { + 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; + upsertBranchProfileForRow(row, { + branchRef: targetBranchRef, + baseRef: args.baseRef?.trim() || existingProfile?.baseRef || defaultBaseRef, + parentLaneId: existingProfile?.parentLaneId ?? null, + sourceBranchRef: existingProfile?.sourceBranchRef ?? null, + lastCheckedOutAt: now, + }); + } + + 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], + ); + // Detach 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. + db.run( + ` + update pull_requests + set lane_id = null + where lane_id = ? + and project_id = ? + and head_branch <> ? + `, + [row.id, projectId, targetBranchRef], + ); + 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 +2196,7 @@ export function createLaneService({ parentStatus, childCount: childCountMap.get(row.id) ?? 0, stackDepth: computeStackDepth({ laneId: row.id, rowsById, memo: depthMemo }), + activeBranchProfile: ensureBranchProfileForRow(row), }) ); } @@ -2573,7 +3004,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/prs/prService.ts b/apps/desktop/src/main/services/prs/prService.ts index 61fe54e5d..65c4696de 100644 --- a/apps/desktop/src/main/services/prs/prService.ts +++ b/apps/desktop/src/main/services/prs/prService.ts @@ -865,6 +865,23 @@ export function createPrService({ [laneId, projectId] ); + const getRowForLaneBranch = (laneId: string, headBranch: string): PullRequestRow | null => { + const normalizedHead = normalizeBranchName(headBranch).trim(); + if (!normalizedHead) return null; + 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 +1277,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..f70291b6d 100644 --- a/apps/desktop/src/main/services/state/kvDb.ts +++ b/apps/desktop/src/main/services/state/kvDb.ts @@ -776,6 +776,27 @@ 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)"); + 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.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,26 +511,43 @@ 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]); + if (!branchLane || !branchDropdownOpen) return; + let cancelled = false; + setLaneBranchesLoading(true); + window.ade.git.listBranches({ laneId: branchLane.id }) + .then((result) => { if (!cancelled) setLaneBranches(result); }) + .catch(() => {}) + .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); setTimeout(() => branchSearchInputRef.current?.focus(), 0); } }, [branchDropdownOpen]); @@ -844,18 +877,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 +984,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 +1841,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 +2092,7 @@ export function LanesPage() { ) : null}
) : null} - {branchCheckoutError && primaryLane && selectedLaneId === primaryLane.id ? ( + {branchCheckoutError && branchLane && !branchDropdownOpen ? (
{branchCheckoutError}