Skip to content

Commit 2ef4b2c

Browse files
henrikjeclaude
andcommitted
feat(extract): add interactive split-point selection
When `arb extract <name>` is invoked without --ending-with, --starting-with, or --after-merge, launch a two-level interactive selector: a direction prompt (older/newer commits), then a hub-and-spoke repo overview where the user drills into per-repo commit boundary selectors. The selector produces the same resolved split-point map as the explicit CLI flags, so the rest of the pipeline is unchanged. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 2347a60 commit 2ef4b2c

File tree

5 files changed

+476
-33
lines changed

5 files changed

+476
-33
lines changed

src/commands/extract.ts

Lines changed: 73 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -24,14 +24,17 @@ import {
2424
VERBOSE_COMMIT_LIMIT,
2525
buildCachedStatusAssess,
2626
confirmOrExit,
27+
parallelFetch,
28+
reportFetchFailures,
2729
resolveDefaultFetch,
2830
runPlanFlow,
31+
selectExtractBoundaries,
2932
} from "../lib/sync";
3033
import { assessExtractRepo } from "../lib/sync/classify-extract";
3134
import { runContinueFlow } from "../lib/sync/continue-flow";
3235
import { parseSplitPoints, resolveSplitPoints } from "../lib/sync/parse-split-points";
3336
import type { ExtractAssessment } from "../lib/sync/types";
34-
import { dryRunNotice, error, inlineResult, inlineStart, plural, yellow } from "../lib/terminal";
37+
import { dryRunNotice, error, inlineResult, inlineStart, isTTY, plural, yellow } from "../lib/terminal";
3538
import { shouldColor } from "../lib/terminal/tty";
3639
import { addWorktrees, requireBranch, requireWorkspace, workspaceRepoDirs } from "../lib/workspace";
3740
import { validateWorkspaceName } from "../lib/workspace/validation";
@@ -63,7 +66,7 @@ export function registerExtractCommand(program: Command): void {
6366
)
6467
.summary("Extract commits into a new workspace")
6568
.description(
66-
"Examples:\n\n arb extract prereq --ending-with abc123 Extract prefix into 'prereq'\n arb extract cont --starting-with abc123 Extract suffix into 'cont'\n arb extract prereq --ending-with abc123,def456 Multiple repos (auto-detect)\n arb extract prereq --ending-with api:HEAD~3 Per-repo with explicit prefix\n\nSplits the current workspace's branch at a boundary commit, creating a new stacked workspace.\n\nWith --ending-with, extracts the prefix (base through boundary, inclusive) into a new lower workspace. The original workspace is rebased to stack on top.\n\nWith --starting-with, extracts the suffix (boundary through tip, inclusive) into a new upper workspace. The original workspace is reset to before the boundary.\n\nSplit points are specified as commit SHAs (auto-detect repo), <repo>:<commit-ish> (explicit), or tags. Multiple values can be comma-separated.\n\nRepos without an explicit split point have zero commits extracted — they are included in both workspaces but just track the base.\n\nIn base-merged workspaces, split points must be at or after the merge point — pre-merge commits are already on the default branch.",
69+
"Examples:\n\n arb extract prereq Interactive split-point selection\n arb extract prereq --ending-with abc123 Extract prefix into 'prereq'\n arb extract cont --starting-with abc123 Extract suffix into 'cont'\n arb extract prereq --ending-with abc123,def456 Multiple repos (auto-detect)\n arb extract prereq --ending-with api:HEAD~3 Per-repo with explicit prefix\n\nSplits the current workspace's branch at a boundary commit, creating a new stacked workspace.\n\nWith no flags, launches an interactive selector to choose the extraction direction and per-repo split points.\n\nWith --ending-with, extracts the prefix (base through boundary, inclusive) into a new lower workspace. The original workspace is rebased to stack on top.\n\nWith --starting-with, extracts the suffix (boundary through tip, inclusive) into a new upper workspace. The original workspace is reset to before the boundary.\n\nSplit points are specified as commit SHAs (auto-detect repo), <repo>:<commit-ish> (explicit), or tags. Multiple values can be comma-separated.\n\nRepos without an explicit split point have zero commits extracted — they are included in both workspaces but just track the base.\n\nIn base-merged workspaces, split points must be at or after the merge point — pre-merge commits are already on the default branch.",
6770
)
6871
.action(
6972
arbAction(async (ctx, workspaceName: string, options) => {
@@ -176,14 +179,6 @@ export function registerExtractCommand(program: Command): void {
176179
throw new ArbError(msg);
177180
}
178181

179-
// Direction from flags
180-
if (!options.endingWith && !options.startingWith) {
181-
const msg = "Specify --ending-with (prefix extraction) or --starting-with (suffix extraction)";
182-
error(msg);
183-
throw new ArbError(msg);
184-
}
185-
const direction: "prefix" | "suffix" = options.endingWith ? "prefix" : "suffix";
186-
187182
const targetBranch = options.branch ?? workspaceName;
188183

189184
// ── Current workspace context ──
@@ -213,35 +208,76 @@ export function registerExtractCommand(program: Command): void {
213208
}
214209
}
215210

216-
// ── Parse split points ──
211+
// ── Mode dispatch: interactive vs explicit ──
217212

218-
const rawSpecs = options.endingWith ?? options.startingWith ?? [];
219-
const specs = parseSplitPoints(Array.isArray(rawSpecs) ? rawSpecs : [rawSpecs]);
213+
const isInteractive = !options.endingWith && !options.startingWith;
214+
const shouldFetch = resolveDefaultFetch(options.fetch);
220215

221-
// Compute merge-base per repo (needed for split point resolution and classifier)
216+
let direction: "prefix" | "suffix";
217+
let resolvedSplitPoints: Map<string, { repo: string; commitSha: string }>;
218+
let earlyFetchFailed: string[] = [];
222219
const mergeBaseMap = new Map<string, string>();
223-
for (const repo of allRepos) {
224-
const repoDir = `${wsDir}/${repo}`;
225-
try {
226-
const baseRef = configBase
227-
? `origin/${configBase}`
228-
: `origin/${(await cache.getDefaultBranch(`${ctx.reposDir}/${repo}`, "origin")) ?? "main"}`;
229-
const { stdout } = await gitLocal(repoDir, "merge-base", "HEAD", baseRef);
230-
mergeBaseMap.set(repo, stdout.trim());
231-
} catch {
232-
// No merge-base available — skip (repo may have no base)
220+
221+
const computeMergeBases = async () => {
222+
for (const repo of allRepos) {
223+
const repoDir = `${wsDir}/${repo}`;
224+
try {
225+
const baseRef = configBase
226+
? `origin/${configBase}`
227+
: `origin/${(await cache.getDefaultBranch(`${ctx.reposDir}/${repo}`, "origin")) ?? "main"}`;
228+
const { stdout } = await gitLocal(repoDir, "merge-base", "HEAD", baseRef);
229+
mergeBaseMap.set(repo, stdout.trim());
230+
} catch {
231+
// No merge-base available — skip (repo may have no base)
232+
}
233233
}
234-
}
234+
};
235235

236-
// Resolve split points to per-repo SHAs
237-
const resolvedSplitPoints =
238-
specs.length > 0
239-
? await resolveSplitPoints(specs, allRepos, wsDir, mergeBaseMap)
240-
: new Map<string, { repo: string; commitSha: string }>();
236+
if (isInteractive) {
237+
// Interactive mode: TTY required
238+
if (!isTTY() || !process.stdin.isTTY) {
239+
const msg = "Specify --ending-with (prefix extraction) or --starting-with (suffix extraction)";
240+
error(msg);
241+
throw new ArbError(msg);
242+
}
243+
244+
// Fetch first so the selector has accurate commit data
245+
let interactiveFetchFailed: string[] = [];
246+
if (shouldFetch) {
247+
const fetchResults = await parallelFetch(allFetchDirs, undefined, remotesMap);
248+
interactiveFetchFailed = reportFetchFailures(allRepos, fetchResults);
249+
cache.invalidateAfterFetch();
250+
}
251+
252+
await computeMergeBases();
253+
254+
const result = await selectExtractBoundaries({
255+
allRepos,
256+
wsDir,
257+
mergeBaseMap,
258+
newWorkspace: workspaceName,
259+
});
260+
direction = result.direction;
261+
resolvedSplitPoints = result.resolvedSplitPoints;
262+
earlyFetchFailed = interactiveFetchFailed;
263+
} else {
264+
// Explicit mode (flags provided)
265+
direction = options.endingWith ? "prefix" : "suffix";
266+
267+
const rawSpecs = options.endingWith ?? options.startingWith ?? [];
268+
const specs = parseSplitPoints(Array.isArray(rawSpecs) ? rawSpecs : [rawSpecs]);
269+
270+
await computeMergeBases();
271+
272+
resolvedSplitPoints =
273+
specs.length > 0
274+
? await resolveSplitPoints(specs, allRepos, wsDir, mergeBaseMap)
275+
: new Map<string, { repo: string; commitSha: string }>();
276+
}
241277

242278
// ── Assessment ──
243279

244-
const shouldFetch = resolveDefaultFetch(options.fetch);
280+
const assessmentShouldFetch = isInteractive ? false : shouldFetch;
245281
const autostash = options.autostash === true;
246282
const includeWrongBranch = options.includeWrongBranch === true;
247283

@@ -255,6 +291,10 @@ export function registerExtractCommand(program: Command): void {
255291
cache,
256292
analysisCache: ctx.analysisCache,
257293
classify: async ({ repo, repoDir, status, fetchFailed }) => {
294+
// Merge fetch failures from early interactive fetch with any from the plan flow
295+
const allFetchFailed =
296+
earlyFetchFailed.length > 0 ? [...new Set([...fetchFailed, ...earlyFetchFailed])] : fetchFailed;
297+
258298
const boundary = resolvedSplitPoints.get(repo)?.commitSha ?? null;
259299

260300
// Merge-point floor: in merged repos, reject split points below the merge point
@@ -291,7 +331,7 @@ export function registerExtractCommand(program: Command): void {
291331
}
292332

293333
const mb = mergeBaseMap.get(repo) ?? "";
294-
return assessExtractRepo(status, repoDir, branch, direction, targetBranch, boundary, mb, fetchFailed, {
334+
return assessExtractRepo(status, repoDir, branch, direction, targetBranch, boundary, mb, allFetchFailed, {
295335
autostash,
296336
includeWrongBranch,
297337
});
@@ -370,7 +410,7 @@ export function registerExtractCommand(program: Command): void {
370410
};
371411

372412
const assessments = await runPlanFlow({
373-
shouldFetch,
413+
shouldFetch: assessmentShouldFetch,
374414
fetchDirs: allFetchDirs,
375415
reposForFetchReport: allRepos,
376416
remotesMap,

src/lib/sync/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,4 @@ export {
3030
export { VERBOSE_COMMIT_LIMIT } from "./constants";
3131
export { runUndoFlow } from "./undo";
3232
export { type ChainWalkDeps, type ChainWalkResult, walkRetargetChain } from "./retarget-chain";
33+
export { selectExtractBoundaries } from "./interactive-extract";
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
import select, { Separator } from "@inquirer/select";
2+
import { getCommitsBetweenFull } from "../git/git";
3+
import { dim } from "../terminal/output";
4+
import { splitPointSelector } from "../terminal/split-point-selector";
5+
import type { ResolvedSplitPoint } from "./parse-split-points";
6+
7+
interface SelectExtractBoundariesOptions {
8+
allRepos: string[];
9+
wsDir: string;
10+
mergeBaseMap: Map<string, string>;
11+
newWorkspace: string;
12+
}
13+
14+
interface SelectExtractBoundariesResult {
15+
direction: "prefix" | "suffix";
16+
resolvedSplitPoints: Map<string, ResolvedSplitPoint>;
17+
}
18+
19+
interface RepoCommitData {
20+
commits: { shortHash: string; fullHash: string; subject: string }[];
21+
}
22+
23+
const CONFIRM_SENTINEL = "__confirm__";
24+
const NO_COMMITS_SENTINEL = "__no_commits__";
25+
26+
export async function selectExtractBoundaries(
27+
options: SelectExtractBoundariesOptions,
28+
): Promise<SelectExtractBoundariesResult> {
29+
const { allRepos, wsDir, mergeBaseMap, newWorkspace } = options;
30+
31+
// 1. Direction prompt
32+
const direction = await select<"prefix" | "suffix">(
33+
{
34+
message: `Extract into '${newWorkspace}':`,
35+
choices: [
36+
{
37+
name: "Older commits (base \u2192 boundary)",
38+
value: "prefix",
39+
},
40+
{
41+
name: "Newer commits (boundary \u2192 HEAD)",
42+
value: "suffix",
43+
},
44+
],
45+
loop: false,
46+
},
47+
{ output: process.stderr },
48+
);
49+
50+
// 2. Gather commits per repo
51+
const repoData = new Map<string, RepoCommitData>();
52+
for (const repo of allRepos) {
53+
const mergeBase = mergeBaseMap.get(repo);
54+
if (!mergeBase) {
55+
repoData.set(repo, { commits: [] });
56+
continue;
57+
}
58+
const repoDir = `${wsDir}/${repo}`;
59+
const commits = await getCommitsBetweenFull(repoDir, mergeBase, "HEAD");
60+
repoData.set(repo, { commits });
61+
}
62+
63+
// 3. Switch-menu overview loop
64+
const splitPoints = new Map<string, ResolvedSplitPoint>();
65+
const maxRepoLen = Math.max(0, ...allRepos.map((r) => r.length));
66+
67+
while (true) {
68+
const choices = buildOverviewChoices(allRepos, repoData, splitPoints, direction, maxRepoLen);
69+
const selected = await select<string>(
70+
{
71+
message: `Select split points for '${newWorkspace}' (${direction} extraction):`,
72+
choices,
73+
loop: false,
74+
pageSize: allRepos.length + 3, // repos + separator + confirm + some margin
75+
},
76+
{ output: process.stderr, clearPromptOnDone: true },
77+
);
78+
79+
if (selected === CONFIRM_SENTINEL) break;
80+
if (selected === NO_COMMITS_SENTINEL) continue;
81+
82+
// 4. Per-repo drill-in
83+
const repo = selected;
84+
const data = repoData.get(repo);
85+
if (!data || data.commits.length === 0) continue;
86+
87+
const currentBoundary = splitPoints.get(repo)?.commitSha ?? null;
88+
const result = await splitPointSelector(
89+
{
90+
repo,
91+
direction,
92+
commits: data.commits,
93+
currentBoundary,
94+
},
95+
{ output: process.stderr, clearPromptOnDone: true },
96+
);
97+
98+
if (result === null) {
99+
splitPoints.delete(repo);
100+
} else {
101+
splitPoints.set(repo, { repo, commitSha: result });
102+
}
103+
}
104+
105+
return { direction, resolvedSplitPoints: splitPoints };
106+
}
107+
108+
function buildOverviewChoices(
109+
allRepos: string[],
110+
repoData: Map<string, RepoCommitData>,
111+
splitPoints: Map<string, ResolvedSplitPoint>,
112+
direction: "prefix" | "suffix",
113+
maxRepoLen: number,
114+
): ({ name: string; value: string } | Separator)[] {
115+
const repoChoices = allRepos.map((repo) => {
116+
const data = repoData.get(repo);
117+
const totalCommits = data?.commits.length ?? 0;
118+
const paddedName = repo.padEnd(maxRepoLen);
119+
120+
if (totalCommits === 0) {
121+
return {
122+
name: dim(`${paddedName} (no commits)`),
123+
value: NO_COMMITS_SENTINEL,
124+
};
125+
}
126+
127+
const sp = splitPoints.get(repo);
128+
if (!sp) {
129+
return {
130+
name: `${paddedName} not set`,
131+
value: repo,
132+
};
133+
}
134+
135+
const shortSha = sp.commitSha.slice(0, 7);
136+
const commits = data?.commits ?? [];
137+
const extracted = countExtracted(commits, sp.commitSha, direction);
138+
const dirLabel = direction === "prefix" ? `ending with ${shortSha}` : `starting with ${shortSha}`;
139+
const unit = totalCommits === 1 ? "commit" : "commits";
140+
return {
141+
name: `${paddedName} ${dirLabel} (${extracted} of ${totalCommits} ${unit})`,
142+
value: repo,
143+
};
144+
});
145+
146+
return [...repoChoices, new Separator(), { name: "Confirm selection", value: CONFIRM_SENTINEL }];
147+
}
148+
149+
/**
150+
* Count how many commits are extracted given a boundary SHA.
151+
* Commits are ordered newest-first.
152+
*/
153+
function countExtracted(commits: { fullHash: string }[], boundarySha: string, direction: "prefix" | "suffix"): number {
154+
const idx = commits.findIndex((c) => c.fullHash === boundarySha);
155+
if (idx === -1) return 0;
156+
157+
if (direction === "prefix") {
158+
// Boundary and everything older (higher index) is extracted
159+
return commits.length - idx;
160+
}
161+
// Suffix: boundary and everything newer (lower index) is extracted
162+
return idx + 1;
163+
}

src/lib/terminal/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ export {
3636
} from "./output";
3737
export { readNamesFromStdin } from "./stdin";
3838
export { selectWithStatus } from "./select-with-status";
39+
export { splitPointSelector } from "./split-point-selector";
3940
export type { EchoSuppression } from "./suppress-echo";
4041
export { suppressEcho } from "./suppress-echo";
4142
export type { StdinSuppression } from "./suppress-stdin";

0 commit comments

Comments
 (0)