Stacked lanes are parent-child lane pairs where the child's branch is
based on the parent's branch rather than on main. This enables:
- stacked PRs (each lane in the stack opens its own PR, merged in order)
- incremental development (build feature B on top of feature A before A lands)
- rebase propagation (when the parent moves, propagate changes down)
Each lane row has parent_lane_id (nullable, FK to lanes.id). A
stack is simply a chain of lanes linked through this column. The
lanes table does not materialize "stack roots" — they're implicit
(rows with parent_lane_id IS NULL).
LaneSummary.stackDepth and .childCount are computed per-list via
computeStackDepth and a per-list memoization map.
The central helper is shouldLaneTrackParent in
src/shared/laneBaseResolution.ts. A child lane tracks its parent's
branch as its comparison ref only when:
- The parent exists.
- The parent is a non-primary lane (primary is excluded because its branch is the project default — tracking it would always produce zero behind-counts).
- The parent has a valid, non-empty
branch_ref.
Otherwise the child falls back to its own base_ref (the project
default branch, e.g., main).
branchNameFromLaneRef strips refs/heads/, refs/remotes/, and
origin/ prefixes so comparisons work uniformly.
Consumers:
laneService.computeLaneStatus— ahead/behind mathlaneService.rebaseStart— target ref for rebaseconflictService.resolveLaneRebaseTarget— comparison ref for conflict prediction (combined withresolveQueueRebaseOverride)autoRebaseService— head-change handlingrebaseSuggestionService— deciding when a suggestion appliesrebaseNeedUtils.tsrenderer helpers — route-to-lane mapping
If a new consumer needs a lane's "upstream reference," it must use
these helpers rather than reading parent_lane_id or base_ref
directly.
laneService.getStackChain(laneId):
- Walks up via
parent_lane_idto find the root ancestor. - Runs a recursive CTE (
with recursive stack as …) in SQLite to collect every descendant from that root that is not archived and shares the same project id. - Sorts children by
created_atso the tree has a stable display order. - Returns an ordered array of
StackChainItem:
type StackChainItem = {
laneId: string;
laneName: string;
branchRef: string;
depth: number; // 0 = root
parentLaneId: string | null;
status: LaneStatus; // ahead/behind/dirty computed per item
};Status is computed with the correct base (parent branch for tracked
children, base_ref otherwise) and memoized per-call.
laneService.reparent({ laneId, newParentLaneId, stackBaseBranchRef? }):
- Refuses to reparent the primary lane (
lane_type === "primary"). - Refuses to reparent a lane under one of its own descendants
(detected by walking up from
newParentLaneId). - Refuses to reparent a lane to itself.
- Resolves the new base ref: when
stackBaseBranchRefis provided it is resolved in the project repo throughresolveBranchRebaseTargetwithpreferRemote: trueso a name likedeveloppicksorigin/developwhen it exists; otherwise the new parent's currentbranch_refis used (with the primary-lane upstream fallback handled byresolveParentRebaseTarget). - No-op fast path: when the persisted parent link and the resolved base
ref both match the lane's current state,
reparentreturns the current head sha for both pre/post and does not run git. This keeps redundant "Apply" clicks from the Manage Lane dialog cheap. - Otherwise updates
parent_lane_id, persists the newbase_ref, records alane_reparentoperation in the history timeline with the reasonreparent, and rebases the lane's worktree onto the resolved base commit. - Triggers downstream refresh events (rebase suggestion service re-evaluates, stack graph re-renders).
ReparentLaneResult carries the before/after parent ids and base refs
plus pre/post head shas so the UI can update state without a full list
refresh.
syncRemoteCommandService (ade-cli) parses stackBaseBranchRef off the
lanes.reparent payload as an optional trimmed string, so headless
controllers driving stack edits over sync see the same surface as the
desktop renderer.
laneService.rebaseStart() orchestrates multi-lane rebases:
scope: "lane_only" | "lane_and_descendants"— default islane_and_descendants. The resolver builds an order list viaresolveRebaseOrderthat walks the stack in parent→child order so children rebase onto freshly rebased parents.pushMode: "none" | "push" | "force-with-lease"— whether to push each lane after its rebase completes.baseBranchOverride— persists a new base branch on the root lane (rejected if the root is a tracked child).actor,reason— audit metadata.
Each rebase run has a unique runId and lives in an in-memory
rebaseRuns map. Only one run per root stack can be running at a
time:
if (another run with root-ancestor == this root is already running)
throw "A rebase run is already active for this lane stack"
Per-lane rebase:
- Capture
preHeadSha. - Run
git rebase <target-ref>where the target is:- the parent's branch when tracked, or
origin/<base>with fallback to local<base>when the parent is primary or absent, or- a queue override when a PR queue has supplied one.
- On success: capture
postHeadSha, optionally push. - On conflict: mark
status = 'conflict', collect conflicting files, pause the run until user resolves viaade.git.rebaseContinue/.rebaseAbort. - Emit
rebase-run-eventIPC events throughout.
rebaseAbort reverts each lane that was rebased in the run by
resetting back to its preHeadSha. rebaseRollback does the same
after a run has finished.
rebaseSuggestionService monitors stacked lanes for a parent head
advance. When detected, it emits a RebaseSuggestion:
type RebaseSuggestion = {
laneId: string;
parentLaneId: string;
parentHeadSha: string;
behindBy: number;
lastSuggestedAt: string;
deferredUntil: string | null;
dismissedAt: string | null;
};State is persisted in the KV store under rebase:suggestion:<laneId>.
Suggestions are suppressed when:
- the lane has been dismissed for the current parent head sha, or
- the lane has been deferred and
deferredUntilhas not yet passed.
When the parent head sha changes, dismiss state is reset so a fresh suggestion can re-appear.
The renderer subscribes via ade.lanes.rebaseSuggestions.event and
surfaces a banner on lane rows plus a LaneRebaseBanner inline with
Rebase Now / Snooze banner / Hide banner actions. Those controls only
change suggestion visibility; PR workflow rebase needs come from
conflictService.scanRebaseNeeds() and remain actionable while the lane
is still behind.
autoRebaseService is the opt-in background worker that rebases
children when a parent advances. Enable via Settings → Lane Behavior
→ Auto-rebase child lanes.
State storage is auto_rebase:status:<laneId> in the KV store. The
AutoRebaseLaneStatus record tracks:
state:"autoRebased" | "rebasePending" | "rebaseConflict" | "rebaseFailed"parentHeadShaat the point of rebaseconflictCount,messagesource:"auto"or"manual"(for attention items surfaced in the PRs > Rebase tab)
Key behaviors:
- Head-change events from
laneService(preHeadSha→postHeadSha) triggeronHeadChanged, which enumerates direct children and queues rebases. - The service debounces via
RUN_DEBOUNCE_MS(1.2 s) to batch bursts of head changes, andSWEEP_DEBOUNCE_MS(30 s) for scheduled sweeps. recordAttentionStatuslets other subsystems (queue landing, manual rebase UI) annotate a lane so it appears in the Rebase tab's attention section.- Statuses expire from the "auto-rebased" banner after
AUTO_REBASED_TTL_MS(15 min).
When a lane belongs to an active PR merge queue, its rebase target is
the queue's tracking branch, not the lane's static base branch.
resolveQueueRebaseOverride in
src/main/services/shared/queueRebase.ts returns a
QueueRebaseOverride that conflictService.resolveLaneRebaseTarget
and laneService.rebaseStart both respect.
LaneStackPanerenders the stack graph in the left pane of the Lanes tab. Nodes show runtime dot (running/awaiting-input/ended) and integration-source chips for integration lanes.LanesPagepassesintegrationSourcesByLaneIdbuilt viabuildIntegrationSourcesByLaneIdfromrenderer/lib/integrationLanes.ts.LaneRebaseBanneris conditionally rendered above the lane detail whenlistRebaseSuggestionsreturns a suggestion that is neither dismissed nor deferred. PR workflow banners use rebase-need drift and route hide/snooze actions back torebaseSuggestionService, so hiding a notification does not move a still-behind lane out of the active Rebase/Merge action list.rebaseNeedUtils.tson the renderer side providesbuildUpstreamRebaseChainfor surfacing the full upstream rebase chain in the PRs Rebase tab (seepull-requests/stacking.md).
- Primary-parented children are repaired on startup by
repairPrimaryParentedRootLanes. If you create a non-primary lane with the primary as its parent (bypassingcreateChild), it will be detached on the next app launch. - Cycles are impossible via IPC thanks to the reparent guard, but
a SQL hotfix could introduce one.
getStackChainuses avisitedset when walking up and the recursive CTE naturally terminates at rows with no children. parent_lane_idcan reference an archived lane. The stack chain recursive CTE filters archived lanes explicitly (where l.project_id = ? and l.status != 'archived'), so archived ancestors truncate the chain.- Base-ref drift is only repaired on startup. Editing a lane's
base_ref via direct SQL without going through
laneServiceand without re-running the repair routine will leave a mismatch that only manifests in ahead/behind counts.