Skip to content

Commit 3bb4182

Browse files
henrikjeclaude
andcommitted
feat(retarget): walk stack chain to find nearest non-merged ancestor
When `arb retarget` is called without a target and the configured base is merged, the command now walks the workspace config chain to find the nearest non-merged ancestor instead of falling back to the default branch. For main←a←b←c with b merged, workspace c retargets onto a. The chain walk runs at the command layer before per-repo classification, using dependency-injected helpers for testability. Manual fetch before chain walking ensures fresh refs for merge detection; non-stacked workspaces preserve phased render via the normal runPlanFlow path. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent c520a28 commit 3bb4182

File tree

6 files changed

+633
-15
lines changed

6 files changed

+633
-15
lines changed
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# Smart Retarget for Deep Stacks
2+
3+
Date: 2026-03-28
4+
5+
## Context
6+
7+
When workspace C stacks on workspace B which stacks on workspace A (`main <- a <- b <- c`), and B gets merged into `main`, running `arb retarget` in C falls back to the repo's default branch (`main`). In a deep stack, the correct target is A — the next non-merged ancestor in the stack chain. Without chain walking, the user must manually specify `arb retarget feat/a`, which requires knowing the stack topology.
8+
9+
The "local branches as base" feature (DR-0098) added `sourceWorkspace` to the status model, identifying which workspace has the base branch checked out. This enables reading the base workspace's config to discover the chain.
10+
11+
## Options
12+
13+
### A: Per-repo chain walking in the classifier
14+
15+
Each repo independently resolves the chain-walked target during `assessRetargetRepo`. The classifier reads workspace configs and runs merge detection.
16+
17+
- **Pros:** Self-contained per repo; no shared state.
18+
- **Cons:** Workspace configs are a workspace-level concern, not per-repo. The classifier has no access to `arbRootDir` or workspace listing. Duplicates merge detection across repos. Breaks the classifier's design contract (classifiers operate on `RepoStatus`, not workspace configs).
19+
20+
### B: Command-layer chain walking before per-repo classification
21+
22+
The command layer resolves the chain-walked target once, then passes it to the classifier as a pre-resolved `targetBranch`. Uses a single representative repo for merge detection.
23+
24+
- **Pros:** Clean separation — workspace-level concern handled at workspace level. Single merge detection pass. Classifier's existing `targetBranch` parameter handles the result naturally.
25+
- **Cons:** Uses one representative repo for merge detection; if repos have fundamentally different remote structures, the representative may not reflect all repos. Per-repo classifier catches this via `retarget-target-not-found` skip (non-blocking).
26+
27+
### C: Chain walking inside `postAssess` after initial assessment
28+
29+
After the first round of assessment reveals all repos targeting default (merged base), intercept with chain walking and re-assess with the new target.
30+
31+
- **Pros:** No changes to fetch timing; assessment already has fresh refs.
32+
- **Cons:** Double assessment. `postAssess` can't trigger re-assessment easily. Complex control flow.
33+
34+
## Decision
35+
36+
Option B: command-layer chain walking before per-repo classification. Fetch manually before chain walking (`shouldFetch: false` to `runPlanFlow`), ensuring fresh refs for merge detection.
37+
38+
## Reasoning
39+
40+
The chain walk is a workspace-level concern — it reads workspace configs, which are workspace-scoped. Placing it in the command layer matches the existing pattern where workspace-level decisions (config updates, target resolution) live in the command, while per-repo decisions live in the classifier.
41+
42+
Manual fetch before chain walking ensures accurate merge detection. This trades phased render (the optimization showing a pre-fetch plan that updates post-fetch) for correctness. Retarget is an infrequent operation, making the trade-off acceptable.
43+
44+
The dependency-injected `walkRetargetChain` function isolates the algorithm from git and filesystem dependencies, enabling thorough unit testing without spawning processes.
45+
46+
## Consequences
47+
48+
- `arb retarget` without arguments automatically follows the stack chain when the base is merged, finding the nearest non-merged ancestor. Users no longer need to know the exact stack topology.
49+
- Chain walking requires workspace configs to exist for intermediate branches. Deleted workspaces break the chain, falling back to default branch resolution (same as current behavior).
50+
- Layered squash merges (where each level was independently squash-merged) may not be detected by the current merge detection (cumulative patch-id doesn't match). Regular merges and single-level squash merges work correctly. This is an existing merge detection limitation, not introduced by chain walking.
51+
- Retarget loses phased render when chain walking is possible (`targetBranch === null && configBase` path). Non-stacked workspaces and explicit targets are unaffected.

src/commands/retarget.ts

Lines changed: 139 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import { basename } from "node:path";
1+
import { basename, dirname } from "node:path";
22
import { type Command, Option } from "commander";
3-
import { predictMergeConflict } from "../lib/analysis";
3+
import { detectBranchMerged, predictMergeConflict } from "../lib/analysis";
44
import { predictStashPopConflict } from "../lib/analysis/conflict-prediction";
55
import {
66
ArbError,
@@ -27,15 +27,19 @@ import {
2727
VERBOSE_COMMIT_LIMIT,
2828
buildCachedStatusAssess,
2929
confirmOrExit,
30+
getUnchangedRepos,
31+
parallelFetch,
32+
reportFetchFailures,
3033
resolveDefaultFetch,
3134
runPlanFlow,
35+
walkRetargetChain,
3236
} from "../lib/sync";
3337
import { assessRetargetRepo } from "../lib/sync/classify-retarget";
3438
import { runContinueFlow } from "../lib/sync/continue-flow";
3539
import type { RetargetAssessment } from "../lib/sync/types";
3640
import { dryRunNotice, error, info, inlineResult, inlineStart, plural, yellow } from "../lib/terminal";
3741
import { shouldColor } from "../lib/terminal/tty";
38-
import { requireBranch, requireWorkspace, workspaceRepoDirs } from "../lib/workspace";
42+
import { listWorkspaces, requireBranch, requireWorkspace, workspaceRepoDirs } from "../lib/workspace";
3943
import { rejectExplicitBaseRemotePrefix, resolveWorkspaceBaseResolution } from "../lib/workspace/base";
4044
import { workspaceBranch } from "../lib/workspace/branch";
4145

@@ -56,7 +60,7 @@ export function registerRetargetCommand(program: Command): void {
5660
)
5761
.summary("Change the base branch and rebase onto it")
5862
.description(
59-
"Examples:\n\n arb retarget feature1 Retarget onto feature1\n arb retarget Retarget onto the default branch\n arb retarget feature1 --verbose Show commits in the plan\n\nChanges the workspace's base branch and rebases all repos onto the new base. This is the \"I want to change what my workspace is based on\" command.\n\nWith a branch argument, retargets onto that branch — useful for stacking onto a feature branch, switching between base branches, or retargeting after the base branch has been merged. Without a branch argument, retargets onto each repo's default branch (e.g. main) and removes the configured base.\n\nWhen the old base was merged (squash or regular), uses 'git rebase --onto' to replay only your commits. When the old base was not merged, uses the same mechanism to graft your commits onto the new base.\n\nRequires a configured base branch or an explicit branch argument. If no base is configured and no branch is given, this is an error — use 'arb retarget <branch>' to set a base.\n\nAll-or-nothing: if any repo is blocked (dirty, wrong branch, etc.), the entire retarget is refused so the workspace config stays consistent. Use --autostash to stash uncommitted changes before rebasing.\n\nAlways operates on all repos in the workspace — retarget is a structural change to the workspace, not a per-repo operation. To change the base config without rebasing, or to rebase selectively, use 'arb branch base <branch>' then 'arb rebase [repos...]'.\n\nUse --verbose to show the incoming commits for each repo in the plan. Use --graph to show a branch divergence diagram. See 'arb help stacked' for stacked workspace workflows.",
63+
"Examples:\n\n arb retarget feature1 Retarget onto feature1\n arb retarget Retarget onto the default branch\n arb retarget feature1 --verbose Show commits in the plan\n\nChanges the workspace's base branch and rebases all repos onto the new base. This is the \"I want to change what my workspace is based on\" command.\n\nWith a branch argument, retargets onto that branch — useful for stacking onto a feature branch, switching between base branches, or retargeting after the base branch has been merged. Without a branch argument, walks the stack chain to find the nearest non-merged ancestor (e.g. if B is merged in A←B←C, retargets C onto A). If no non-merged ancestor exists, retargets onto each repo's default branch (e.g. main) and removes the configured base.\n\nWhen the old base was merged (squash or regular), uses 'git rebase --onto' to replay only your commits. When the old base was not merged, uses the same mechanism to graft your commits onto the new base.\n\nRequires a configured base branch or an explicit branch argument. If no base is configured and no branch is given, this is an error — use 'arb retarget <branch>' to set a base.\n\nAll-or-nothing: if any repo is blocked (dirty, wrong branch, etc.), the entire retarget is refused so the workspace config stays consistent. Use --autostash to stash uncommitted changes before rebasing.\n\nAlways operates on all repos in the workspace — retarget is a structural change to the workspace, not a per-repo operation. To change the base config without rebasing, or to rebase selectively, use 'arb branch base <branch>' then 'arb rebase [repos...]'.\n\nUse --verbose to show the incoming commits for each repo in the plan. Use --graph to show a branch divergence diagram. See 'arb help stacked' for stacked workspace workflows.",
6064
)
6165
.action(
6266
arbAction(async (ctx, branchArg: string | undefined, options) => {
@@ -153,6 +157,37 @@ export function registerRetargetCommand(program: Command): void {
153157
const allFetchDirs = workspaceRepoDirs(wsDir);
154158
const allRepos = allFetchDirs.map((d) => basename(d));
155159

160+
// Chain walk requires fresh refs for merge detection. When chain walking
161+
// is possible (no explicit target + configured base), fetch manually before
162+
// the chain walk and pass shouldFetch: false to runPlanFlow. Otherwise,
163+
// let runPlanFlow handle fetching normally (preserving phased render).
164+
const chainWalkPossible = targetBranch === null && configBase !== null;
165+
166+
let fetchFailed: string[] = [];
167+
let unchangedRepos = new Set<string>();
168+
let chainWalkPath: string[] = [];
169+
170+
if (chainWalkPossible && shouldFetch && allFetchDirs.length > 0) {
171+
const fetchResults = await parallelFetch(allFetchDirs, undefined, remotesMap);
172+
cache.invalidateAfterFetch();
173+
unchangedRepos = getUnchangedRepos(fetchResults);
174+
fetchFailed = reportFetchFailures(allRepos, fetchResults);
175+
176+
const chainResult = await resolveChainWalkTarget(
177+
configBase,
178+
allFetchDirs,
179+
ctx.reposDir,
180+
ctx.arbRootDir,
181+
remotesMap,
182+
cache,
183+
);
184+
if (chainResult) {
185+
targetBranch = chainResult.targetBranch;
186+
chainWalkPath = chainResult.walkedPath;
187+
info(`base branch ${configBase} was merged; following stack chain to ${targetBranch}`);
188+
}
189+
}
190+
156191
// Phase 2: assess
157192
const autostash = options.autostash === true;
158193
const includeWrongBranch = options.includeWrongBranch === true;
@@ -166,14 +201,14 @@ export function registerRetargetCommand(program: Command): void {
166201
remotesMap,
167202
cache,
168203
analysisCache: ctx.analysisCache,
169-
classify: ({ repo, repoDir, status, fetchFailed }) => {
204+
classify: ({ repo, repoDir, status, fetchFailed: repoFetchFailed }) => {
170205
const repoPath = `${ctx.reposDir}/${basename(repoDir)}`;
171206
return assessRetargetRepo(
172207
status,
173208
repoDir,
174209
branch,
175210
targetBranch,
176-
fetchFailed,
211+
repoFetchFailed,
177212
{
178213
autostash,
179214
includeWrongBranch,
@@ -196,15 +231,19 @@ export function registerRetargetCommand(program: Command): void {
196231
return nextAssessments;
197232
};
198233

234+
// When chain walk fetched manually, skip fetch in runPlanFlow.
235+
// Otherwise, let runPlanFlow handle fetching normally (phased render preserved).
236+
const fetchedManually = chainWalkPossible && shouldFetch && allFetchDirs.length > 0;
199237
const assessments = await runPlanFlow({
200-
shouldFetch,
238+
shouldFetch: fetchedManually ? false : shouldFetch,
201239
fetchDirs: allFetchDirs,
202240
reposForFetchReport: allRepos,
203241
remotesMap,
204-
assess,
242+
assess: fetchedManually ? (_ff, _unch) => assess(fetchFailed, unchangedRepos) : assess,
205243
postAssess,
206-
formatPlan: (nextAssessments) => formatRetargetPlan(nextAssessments, workspace, options.verbose),
207-
onPostFetch: () => cache.invalidateAfterFetch(),
244+
formatPlan: (nextAssessments) =>
245+
formatRetargetPlan(nextAssessments, workspace, options.verbose, chainWalkPath),
246+
...(fetchedManually ? {} : { onPostFetch: () => cache.invalidateAfterFetch() }),
208247
});
209248

210249
// Phase 3: all-or-nothing check
@@ -228,6 +267,12 @@ export function registerRetargetCommand(program: Command): void {
228267

229268
// When all repos are skipped, ensure at least one could retarget
230269
if (willRetarget.length === 0 && upToDate.length === 0 && skipped.length > 0) {
270+
if (chainWalkPath.length > 0) {
271+
error(`Stack chain walk resolved to ${targetBranch}, but that branch was not found on any repo's remote.`);
272+
throw new ArbError(
273+
`Stack chain walk resolved to ${targetBranch}, but that branch was not found on any repo's remote.`,
274+
);
275+
}
231276
error("Cannot retarget: target branch not found on any repo.");
232277
throw new ArbError("Cannot retarget: target branch not found on any repo.");
233278
}
@@ -414,15 +459,25 @@ async function buildRetargetConfigAfter(
414459

415460
// ── Plan rendering ──
416461

417-
function formatRetargetPlan(assessments: RetargetAssessment[], workspace: string, verbose?: boolean): string {
418-
const nodes = buildRetargetPlanNodes(assessments, workspace, verbose);
462+
function formatRetargetPlan(
463+
assessments: RetargetAssessment[],
464+
workspace: string,
465+
verbose?: boolean,
466+
chainWalkPath?: string[],
467+
): string {
468+
const nodes = buildRetargetPlanNodes(assessments, workspace, verbose, chainWalkPath);
419469
const envCols = Number(process.env.COLUMNS);
420470
const termCols = process.stdout.columns ?? (Number.isFinite(envCols) ? envCols : 0);
421471
const ctx: RenderContext = { tty: shouldColor(), terminalWidth: termCols > 0 ? termCols : undefined };
422472
return render(nodes, ctx);
423473
}
424474

425-
function buildRetargetPlanNodes(assessments: RetargetAssessment[], workspace: string, verbose?: boolean): OutputNode[] {
475+
function buildRetargetPlanNodes(
476+
assessments: RetargetAssessment[],
477+
workspace: string,
478+
verbose?: boolean,
479+
chainWalkPath?: string[],
480+
): OutputNode[] {
426481
const nodes: OutputNode[] = [{ kind: "gap" }];
427482

428483
const rows = assessments.map((a) => {
@@ -457,7 +512,7 @@ function buildRetargetPlanNodes(assessments: RetargetAssessment[], workspace: st
457512
});
458513

459514
// Config action hint
460-
const configAction = computeRetargetConfigAction(assessments, workspace);
515+
const configAction = computeRetargetConfigAction(assessments, workspace, chainWalkPath);
461516
if (configAction) {
462517
nodes.push({ kind: "gap" });
463518
nodes.push({
@@ -542,6 +597,7 @@ function retargetActionCell(a: RetargetAssessment): Cell {
542597
function computeRetargetConfigAction(
543598
assessments: RetargetAssessment[],
544599
workspace: string,
600+
chainWalkPath?: string[],
545601
): { workspace: string; description: string } | null {
546602
const retargetable = assessments.filter((a) => a.outcome === "will-retarget" || a.outcome === "up-to-date");
547603
if (retargetable.length === 0) return null;
@@ -551,7 +607,11 @@ function computeRetargetConfigAction(
551607

552608
const from = first.oldBase || "default";
553609
const to = first.targetBranch;
554-
return { workspace, description: `change base branch from ${from} to ${to}` };
610+
let description = `change base branch from ${from} to ${to}`;
611+
if (chainWalkPath && chainWalkPath.length > 0) {
612+
description += ` (via stack: ${chainWalkPath.join(" → ")} merged)`;
613+
}
614+
return { workspace, description };
555615
}
556616

557617
// ── Post-assess helpers ──
@@ -624,3 +684,67 @@ async function maybeWriteRetargetConfig(options: {
624684
}
625685
return true;
626686
}
687+
688+
// ── Chain walk ──
689+
690+
async function resolveChainWalkTarget(
691+
configBase: string,
692+
allFetchDirs: string[],
693+
reposDir: string,
694+
arbRootDir: string,
695+
remotesMap: Map<string, { base?: string }>,
696+
cache: {
697+
findBranchWorktree(repoDir: string, branch: string): Promise<string | null>;
698+
getDefaultBranch(repoDir: string, remote: string): Promise<string | null>;
699+
remoteBranchExists(repoDir: string, branch: string, remote: string): Promise<boolean>;
700+
branchExistsLocally(repoDir: string, branch: string): Promise<boolean>;
701+
},
702+
): Promise<{ targetBranch: string; walkedPath: string[] } | null> {
703+
const firstRepoDir = allFetchDirs[0];
704+
if (!firstRepoDir) return null;
705+
706+
const repo = basename(firstRepoDir);
707+
const repoPath = `${reposDir}/${repo}`;
708+
const baseRemote = remotesMap.get(repo)?.base ?? "";
709+
if (!baseRemote) return null;
710+
711+
// Find sourceWorkspace for configBase
712+
const worktreePath = await cache.findBranchWorktree(repoPath, configBase);
713+
if (!worktreePath) return null;
714+
const sourceWorkspace = basename(dirname(worktreePath));
715+
716+
// Check if configBase is merged into default
717+
const defaultBranch = await cache.getDefaultBranch(repoPath, baseRemote);
718+
if (!defaultBranch) return null;
719+
720+
const defaultRef = `${baseRemote}/${defaultBranch}`;
721+
const remoteExists = await cache.remoteBranchExists(repoPath, configBase, baseRemote);
722+
const configBaseRef = remoteExists ? `${baseRemote}/${configBase}` : configBase;
723+
const merged = await detectBranchMerged(firstRepoDir, defaultRef, 200, configBaseRef);
724+
if (!merged) return null;
725+
726+
// Walk the chain
727+
const result = await walkRetargetChain(configBase, sourceWorkspace, {
728+
readWorkspaceBase: (wsName) => readWorkspaceConfig(`${arbRootDir}/${wsName}/.arbws/config.json`)?.base ?? null,
729+
isBranchMerged: async (branchName) => {
730+
const branchRemoteExists = await cache.remoteBranchExists(repoPath, branchName, baseRemote);
731+
const branchRef = branchRemoteExists ? `${baseRemote}/${branchName}` : branchName;
732+
const localExists = !branchRemoteExists ? await cache.branchExistsLocally(repoPath, branchName) : true;
733+
if (!localExists) return false;
734+
const detection = await detectBranchMerged(firstRepoDir, defaultRef, 200, branchRef);
735+
return detection !== null;
736+
},
737+
findWorkspaceForBranch: (branchName) => {
738+
for (const ws of listWorkspaces(arbRootDir)) {
739+
const wsConfig = readWorkspaceConfig(`${arbRootDir}/${ws}/.arbws/config.json`);
740+
if (wsConfig?.branch === branchName) return ws;
741+
}
742+
return null;
743+
},
744+
});
745+
746+
if (result.didWalk && result.targetBranch) {
747+
return { targetBranch: result.targetBranch, walkedPath: result.walkedPath };
748+
}
749+
return null;
750+
}

src/lib/sync/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,4 @@ export {
2929
} from "./network-errors";
3030
export { VERBOSE_COMMIT_LIMIT } from "./constants";
3131
export { runUndoFlow } from "./undo";
32+
export { type ChainWalkDeps, type ChainWalkResult, walkRetargetChain } from "./retarget-chain";

0 commit comments

Comments
 (0)