Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/stacked-branches.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,8 @@ arb extract prereq --ending-with api:HEAD~3 # Explicit repo prefix

Repos without a specified split point have zero commits extracted — they're included in both workspaces but just track the base.

Use `--verbose` to see the individual commits that will be extracted and those that will stay in the original workspace.

### Undoing an extract

```bash
Expand Down
2 changes: 1 addition & 1 deletion shell/arb.bash
Original file line number Diff line number Diff line change
Expand Up @@ -536,7 +536,7 @@ __arb_complete_merge() {
__arb_complete_extract() {
local base_dir="$1" cur="$2"
if [[ "$cur" == -* ]]; then
COMPREPLY=($(compgen -W "--ending-with --starting-with --after-merge -b --branch --fetch -N --no-fetch -y --yes --dry-run --autostash --include-wrong-branch --continue --abort" -- "$cur"))
COMPREPLY=($(compgen -W "--ending-with --starting-with --after-merge -b --branch --fetch -N --no-fetch -y --yes --dry-run -v --verbose --autostash --include-wrong-branch --continue --abort" -- "$cur"))
return
fi
}
Expand Down
1 change: 1 addition & 0 deletions shell/arb.zsh
Original file line number Diff line number Diff line change
Expand Up @@ -525,6 +525,7 @@ _arb() {
'(-N --fetch --no-fetch)'{-N,--no-fetch}'[Skip fetching before extract]' \
'(-y --yes)'{-y,--yes}'[Skip confirmation prompt]' \
'--dry-run[Show what would happen without executing]' \
'(-v --verbose)'{-v,--verbose}'[Show per-commit details in the plan]' \
'--autostash[Stash uncommitted changes before operation]' \
'--include-wrong-branch[Include repos on a different branch than the workspace]' \
'--continue[Resume after resolving conflicts]' \
Expand Down
91 changes: 83 additions & 8 deletions src/commands/extract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,20 @@ import {
writeOperationRecord,
writeWorkspaceConfig,
} from "../lib/core";
import { branchExistsLocally, gitLocal } from "../lib/git";
import { finishSummary, render } from "../lib/render";
import { branchExistsLocally, getCommitsBetweenFull, gitLocal } from "../lib/git";
import { finishSummary, render, verboseCommitsToNodes } from "../lib/render";
import type { RenderContext } from "../lib/render";
import type { Cell, OutputNode } from "../lib/render";
import { cell, skipCell } from "../lib/render";
import { buildConflictReport } from "../lib/render/conflict-report";
import { EXTRACT_EXEMPT_SKIPS } from "../lib/status";
import { buildCachedStatusAssess, confirmOrExit, resolveDefaultFetch, runPlanFlow } from "../lib/sync";
import {
VERBOSE_COMMIT_LIMIT,
buildCachedStatusAssess,
confirmOrExit,
resolveDefaultFetch,
runPlanFlow,
} from "../lib/sync";
import { assessExtractRepo } from "../lib/sync/classify-extract";
import { runContinueFlow } from "../lib/sync/continue-flow";
import { parseSplitPoints, resolveSplitPoints } from "../lib/sync/parse-split-points";
Expand Down Expand Up @@ -53,8 +59,7 @@ export function registerExtractCommand(program: Command): void {
.option("-N, --no-fetch", "Skip fetching before extract")
.option("-y, --yes", "Skip confirmation prompt")
.option("--dry-run", "Show what would happen without executing")
// TODO: implement verbose plan with per-commit listing
// .option("-v, --verbose", "Show per-commit details in the plan")
.option("-v, --verbose", "Show per-commit details in the plan")
.option("--autostash", "Stash uncommitted changes before operation")
.option("--include-wrong-branch", "Include repos on a different branch than the workspace")
.addOption(new Option("--continue", "Resume after resolving conflicts").conflicts("abort"))
Expand Down Expand Up @@ -346,6 +351,9 @@ export function registerExtractCommand(program: Command): void {
// If counting fails, leave as 0
}
}
if (options.verbose) {
await gatherExtractVerboseCommits(nextAssessments, direction);
}
return nextAssessments;
};

Expand All @@ -365,6 +373,7 @@ export function registerExtractCommand(program: Command): void {
direction,
configBase,
planEndpoints,
options.verbose,
),
onPostFetch: () => cache.invalidateAfterFetch(),
});
Expand Down Expand Up @@ -615,6 +624,7 @@ export function formatExtractPlan(
direction: "prefix" | "suffix",
configBase: string | null,
endpoints?: Map<string, { extractEnd: string; remainEnd: string }>,
verbose?: boolean,
): string {
const nodes = buildExtractPlanNodes(
assessments,
Expand All @@ -624,6 +634,7 @@ export function formatExtractPlan(
direction,
configBase,
endpoints,
verbose,
);
const envCols = Number(process.env.COLUMNS);
const termCols = process.stdout.columns ?? (Number.isFinite(envCols) ? envCols : 0);
Expand All @@ -639,8 +650,8 @@ function buildExtractPlanNodes(
direction: "prefix" | "suffix",
configBase: string | null,
endpoints?: Map<string, { extractEnd: string; remainEnd: string }>,
verbose?: boolean,
): OutputNode[] {
// TODO: add verbose plan with per-commit listing when --verbose is implemented
const nodes: OutputNode[] = [{ kind: "gap" }];

// Header
Expand Down Expand Up @@ -704,10 +715,41 @@ function buildExtractPlanNodes(
origCell = cell("");
}

let afterRow: OutputNode[] | undefined;
if (verbose && a.outcome === "will-extract" && a.verbose) {
const verboseNodes: OutputNode[] = [];
const extractedLabel = `Extracted to ${targetWorkspace}:`;
const staysLabel = `Stays in ${workspace}:`;

const extractedSection =
a.verbose.extractedCommits && a.verbose.extractedCommits.length > 0
? verboseCommitsToNodes(
a.verbose.extractedCommits,
a.verbose.totalExtracted ?? a.verbose.extractedCommits.length,
extractedLabel,
)
: [];
const staysSection =
a.verbose.remainingCommits && a.verbose.remainingCommits.length > 0
? verboseCommitsToNodes(
a.verbose.remainingCommits,
a.verbose.totalRemaining ?? a.verbose.remainingCommits.length,
staysLabel,
)
: [];

if (direction === "prefix") {
verboseNodes.push(...extractedSection, ...staysSection);
} else {
verboseNodes.push(...staysSection, ...extractedSection);
}
if (verboseNodes.length > 0) afterRow = verboseNodes;
}

if (direction === "prefix") {
return { cells: { repo: cell(a.repo), new: newCell, orig: origCell } };
return { cells: { repo: cell(a.repo), new: newCell, orig: origCell }, afterRow };
}
return { cells: { repo: cell(a.repo), orig: origCell, new: newCell } };
return { cells: { repo: cell(a.repo), orig: origCell, new: newCell }, afterRow };
});

const columns =
Expand Down Expand Up @@ -751,6 +793,39 @@ function buildExtractPlanNodes(

// ── Helpers ──

/** Gather per-commit details for the extract plan's verbose mode. */
async function gatherExtractVerboseCommits(
assessments: ExtractAssessment[],
direction: "prefix" | "suffix",
): Promise<void> {
await Promise.all(
assessments
.filter((a): a is ExtractAssessment & { boundary: string } => a.outcome === "will-extract" && a.boundary != null)
.map(async (a) => {
try {
const boundary = a.boundary;
let extracted: { shortHash: string; fullHash: string; subject: string }[];
let remaining: { shortHash: string; fullHash: string; subject: string }[];
if (direction === "prefix") {
extracted = await getCommitsBetweenFull(a.repoDir, a.mergeBase, boundary);
remaining = await getCommitsBetweenFull(a.repoDir, boundary, "HEAD");
} else {
remaining = await getCommitsBetweenFull(a.repoDir, a.mergeBase, `${boundary}^`);
extracted = await getCommitsBetweenFull(a.repoDir, `${boundary}^`, "HEAD");
}
a.verbose = {
extractedCommits: extracted.slice(0, VERBOSE_COMMIT_LIMIT),
totalExtracted: extracted.length,
remainingCommits: remaining.slice(0, VERBOSE_COMMIT_LIMIT),
totalRemaining: remaining.length,
};
} catch {
// If commit gathering fails, leave verbose empty
}
}),
);
}

/** Delete branches created in canonical repos during extract (for abort/undo). */
async function cleanupExtractBranches(
reposDir: string,
Expand Down
6 changes: 6 additions & 0 deletions src/lib/sync/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,12 @@ interface ExtractAssessmentBase {
wrongBranch?: boolean;
baseRemote: string;
baseResolvedLocally?: boolean;
verbose?: {
extractedCommits?: CommitDisplayEntry[];
totalExtracted?: number;
remainingCommits?: CommitDisplayEntry[];
totalRemaining?: number;
};
}

export interface ExtractSkipAssessment extends ExtractAssessmentBase {
Expand Down
86 changes: 86 additions & 0 deletions test/integration/extract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -432,3 +432,89 @@ describe("arb undo (extract)", () => {
expect(branchList.trim()).toBe("");
}));
});

// ── Verbose mode ──

describe("extract --verbose", () => {
test("prefix verbose shows extracted and stays commit subjects", () =>
withEnv(async (env) => {
const shas = await setupWithCommits(env, "ws", 5);
const boundary = shas[2] ?? "";
const result = await arb(env, ["extract", "prereq", "--ending-with", boundary, "--verbose", "--dry-run", "-N"], {
cwd: join(env.projectDir, "ws"),
});
expect(result.exitCode).toBe(0);
// Extracted commits (1..3)
expect(result.output).toContain("Extracted to prereq:");
expect(result.output).toContain("commit 1");
expect(result.output).toContain("commit 2");
expect(result.output).toContain("commit 3");
// Stays commits (4..5)
expect(result.output).toContain("Stays in ws:");
expect(result.output).toContain("commit 4");
expect(result.output).toContain("commit 5");
}));

test("suffix verbose shows stays and extracted commit subjects", () =>
withEnv(async (env) => {
const shas = await setupWithCommits(env, "ws", 5);
const boundary = shas[2] ?? "";
const result = await arb(env, ["extract", "cont", "--starting-with", boundary, "--verbose", "--dry-run", "-N"], {
cwd: join(env.projectDir, "ws"),
});
expect(result.exitCode).toBe(0);
// Stays commits (1..2)
expect(result.output).toContain("Stays in ws:");
expect(result.output).toContain("commit 1");
expect(result.output).toContain("commit 2");
// Extracted commits (3..5)
expect(result.output).toContain("Extracted to cont:");
expect(result.output).toContain("commit 3");
expect(result.output).toContain("commit 4");
expect(result.output).toContain("commit 5");
}));

test("verbose does not show commits for no-op repos", () =>
withEnv(async (env) => {
// Create workspace with two repos
await arb(env, ["create", "ws", "-b", "ws", "repo-a", "repo-b"]);
const wt = join(env.projectDir, "ws", "repo-a");
const shas: string[] = [];
for (let i = 1; i <= 3; i++) {
await write(join(wt, `file${i}.txt`), `content ${i}`);
await git(wt, ["add", `file${i}.txt`]);
await git(wt, ["commit", "-m", `commit ${i}`]);
shas.push((await git(wt, ["rev-parse", "HEAD"])).trim());
}
// repo-b has no commits — will be no-op
const boundary = shas[1] ?? "";
const result = await arb(
env,
["extract", "prereq", "--ending-with", `repo-a:${boundary}`, "--verbose", "--dry-run", "-N"],
{
cwd: join(env.projectDir, "ws"),
},
);
expect(result.exitCode).toBe(0);
// repo-a should have verbose sections
expect(result.output).toContain("Extracted to prereq:");
// repo-b is no-op — output should show "no commits" but no verbose section for it
expect(result.output).toContain("repo-b");
// Only one "Extracted to prereq:" label (for repo-a, not repo-b)
const extractedCount = result.output.split("Extracted to prereq:").length - 1;
expect(extractedCount).toBe(1);
}));

test("verbose with --dry-run still shows verbose output", () =>
withEnv(async (env) => {
const shas = await setupWithCommits(env, "ws", 3);
const boundary = shas[1] ?? "";
const result = await arb(env, ["extract", "prereq", "--ending-with", boundary, "--verbose", "--dry-run", "-N"], {
cwd: join(env.projectDir, "ws"),
});
expect(result.exitCode).toBe(0);
expect(result.output).toContain("Extracted to prereq:");
expect(result.output).toContain("Stays in ws:");
expect(result.output).toContain("Dry run");
}));
});
Loading