Lane stacking
July 31, 2026 · View on GitHub
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)
Data model
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.
Base-ref resolution
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 predictionautoRebaseService— 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.
Child-lane guidance for spawned work
The base-ref mechanics above decide what a new lane branches from, but a
caller still has to pick the right creation verb. A fresh lane
(ade lanes create) branches off the remote default branch by default
(git.newLaneBaseSource: "remote", resolved host-side by
resolveDefaultRemoteLaneBase / resolveLaneCreateRemoteBase — see the
Lanes README lifecycle notes), so it does not carry the current lane's
uncommitted-to-main commits. A child lane (ade lanes child) branches off
the parent's HEAD, so it does. When an agent spins up new work that should
build on the commits already sitting in its lane, it wants a child, not a
fresh lane.
Two CLI affordances make this reachable without the desktop UI:
- Unmerged-work nudge.
ade lanes createandade new chat --auto-create-lane(both go throughdetectUnmergedLaneCreateNudgeinapps/ade-cli/src/cli.ts) check whether the current worktree has commits ahead oforigin/<default>. If it does, the command prints a stderr nudge before continuing —⚠ Lane "<current>" has N commit(s) not on <default>— suggestingade lanes child --lane <current> --name <new>to carry them, and noting that it is otherwise continuing off remote main. The nudge is advisory only; the requested create still runs. - Stale-base warning. When the remote-first default base is resolved
(
resolveLaneCreateRemoteBase, wired with anonWarningsink inadeRpcServer.ts), a failed fetch surfaces⚠ Base origin/<default> may be stale — fetch failed; using last-known ref., and a local default branch that is behind its upstream surfaces⚠ local <default> is N behind origin — creating off possibly-stale base.Both are best-effort enrichment; lane creation still falls back to the local base on any failure.
The agent-facing decision table lives in the ade-lanes-git Agent Skill
(apps/desktop/resources/agent-skills/ade-lanes-git/SKILL.md): continue my
unmerged work → ade lanes child --lane <current> --name <n>; fresh
unrelated feature → ade lanes create --name <n> (off remote main);
build on another lane's unlanded work → ade lanes child --lane <that> --name <n>.
The ade-cli-control-plane skill cross-references the same rule from its
spawning-agents guidance.
Stack chain retrieval
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.
Reparenting
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.
Rebase runs
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.
- 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.
Rebase suggestions
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.
Auto-rebase
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 and the 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).
Renderer wiring
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 (see Pull requests).
Gotchas
- 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.