Skip to content

Commit d82cf45

Browse files
henrikjeclaude
andcommitted
refactor: use Commander.js .conflicts() for mutually exclusive options
Replace manual if/error/throw validation blocks with declarative .conflicts() declarations on Option objects. This catches conflicts at parse time (before action handlers run) and removes 11 manual checks across 8 command files. Migrated conflicts: - --quiet/--json/--verbose/--schema (status, list, branch show, repo list, log) - --rebase/--merge (pull) - --soft/--mixed/--hard (reset) - --repo/--workspace (template add) Kept as manual checks (custom error messages with actionable hints): - --dirty/--where ("Use --where dirty,... instead") - --no-status/filters ("Status gathering is required") - Option-vs-argument conflicts (Commander can't express these) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 6be535d commit d82cf45

File tree

14 files changed

+84
-130
lines changed

14 files changed

+84
-130
lines changed

src/commands/branch.ts

Lines changed: 11 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { basename } from "node:path";
2-
import type { Command } from "commander";
2+
import { type Command, Option } from "commander";
33
import { ArbError, arbAction, readWorkspaceConfig, writeWorkspaceConfig } from "../lib/core";
44
import type { ArbContext } from "../lib/core";
55
import { GitCache, branchNameError } from "../lib/git";
@@ -173,22 +173,24 @@ export function registerBranchCommand(program: Command): void {
173173

174174
branch
175175
.command("show", { isDefault: true })
176-
.option("-q, --quiet", "Output just the branch name")
177-
.option("-v, --verbose", "Show per-repo branch and remote tracking detail")
176+
.addOption(new Option("-q, --quiet", "Output just the branch name").conflicts(["json", "verbose"]))
177+
.addOption(new Option("-v, --verbose", "Show per-repo branch and remote tracking detail").conflicts("quiet"))
178178
.option("--fetch", "Fetch remotes before displaying (default in verbose mode)")
179179
.option("-N, --no-fetch", "Skip fetching")
180-
.option("--json", "Output structured JSON")
181-
.option("--schema", "Print JSON Schema for this command's --json output and exit")
180+
.addOption(new Option("--json", "Output structured JSON").conflicts("quiet"))
181+
.addOption(
182+
new Option("--schema", "Print JSON Schema for this command's --json output and exit").conflicts([
183+
"json",
184+
"quiet",
185+
"verbose",
186+
]),
187+
)
182188
.summary("Show the workspace branch (default)")
183189
.description(
184190
"Examples:\n\n arb branch show Show branch, base, and share\n arb branch show -v Per-repo tracking detail\n arb branch show -q Just the branch name\n\nShow the workspace branch, base branch, share (remote tracking) branch, and any per-repo deviations. Use --verbose to show a per-repo table with branch and remote tracking info (fetches by default; use -N to skip). Press Ctrl+C during the fetch to cancel and use stale data. Use --quiet to output just the branch name (useful for scripting). Use --json for machine-readable output.\n\nSee 'arb help scripting' for output modes and piping.",
185191
)
186192
.action(async (options, command) => {
187193
if (options.schema) {
188-
if (options.json || options.quiet || options.verbose) {
189-
error("Cannot combine --schema with --json, --quiet, or --verbose.");
190-
throw new ArbError("Cannot combine --schema with --json, --quiet, or --verbose.");
191-
}
192194
printSchema(BranchJsonOutputSchema);
193195
return;
194196
}
@@ -209,16 +211,6 @@ async function runBranch(
209211
const wsDir = `${ctx.arbRootDir}/${ctx.currentWorkspace}`;
210212
const configFile = `${wsDir}/.arbws/config.json`;
211213

212-
if (options.quiet && options.json) {
213-
error("Cannot combine --quiet with --json.");
214-
throw new ArbError("Cannot combine --quiet with --json.");
215-
}
216-
217-
if (options.quiet && options.verbose) {
218-
error("Cannot combine --quiet with --verbose.");
219-
throw new ArbError("Cannot combine --quiet with --verbose.");
220-
}
221-
222214
const wb = await workspaceBranch(wsDir);
223215
const branch = wb?.branch ?? (ctx.currentWorkspace as string);
224216
const base = readWorkspaceConfig(configFile)?.base ?? null;

src/commands/list.ts

Lines changed: 9 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { existsSync } from "node:fs";
22
import { basename } from "node:path";
3-
import type { Command } from "commander";
3+
import { type Command, Option } from "commander";
44
import { z } from "zod";
55
import { ArbError, type RelativeTimeParts, arbAction, formatRelativeTimeParts, readWorkspaceConfig } from "../lib/core";
66
import type { ArbContext } from "../lib/core";
@@ -69,28 +69,23 @@ export function registerListCommand(program: Command): void {
6969
.option("-w, --where <filter>", "Filter workspaces by repo status flags (comma = OR, + = AND, ^ = negate)")
7070
.option("--older-than <duration>", "Only list workspaces not touched in the given duration (e.g. 30d, 2w, 3m, 1y)")
7171
.option("--newer-than <duration>", "Only list workspaces touched within the given duration (e.g. 7d, 2w)")
72-
.option("-q, --quiet", "Output one workspace name per line")
73-
.option("--json", "Output structured JSON")
74-
.option("--schema", "Print JSON Schema for this command's --json output and exit")
72+
.addOption(new Option("-q, --quiet", "Output one workspace name per line").conflicts("json"))
73+
.addOption(new Option("--json", "Output structured JSON").conflicts("quiet"))
74+
.addOption(
75+
new Option("--schema", "Print JSON Schema for this command's --json output and exit").conflicts([
76+
"json",
77+
"quiet",
78+
]),
79+
)
7580
.action(async (options, command) => {
7681
if (options.schema) {
77-
if (options.json || options.quiet) {
78-
error("Cannot combine --schema with --json or --quiet.");
79-
throw new ArbError("Cannot combine --schema with --json or --quiet.");
80-
}
8182
printSchema(z.array(ListJsonEntrySchema));
8283
return;
8384
}
8485
await arbAction(async (ctx, options) => {
8586
const cache = ctx.cache;
8687
const aCache = ctx.analysisCache;
8788
{
88-
// Conflict checks
89-
if (options.quiet && options.json) {
90-
error("Cannot combine --quiet with --json.");
91-
throw new ArbError("Cannot combine --quiet with --json.");
92-
}
93-
9489
const whereFilter = resolveWhereFilter(options);
9590
const ageFilter = resolveAgeFilter(options);
9691
if ((whereFilter || ageFilter) && options.status === false) {

src/commands/log.ts

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { basename } from "node:path";
2-
import type { Command } from "commander";
2+
import { type Command, Option } from "commander";
33
import { ArbError, arbAction } from "../lib/core";
44
import { getCommitsBetweenFull, gitLocal } from "../lib/git";
55
import { printSchema } from "../lib/json";
@@ -56,18 +56,14 @@ export function registerLogCommand(program: Command): void {
5656
.option("-d, --dirty", "Only log dirty repos (shorthand for --where dirty)")
5757
.option("-w, --where <filter>", "Only log repos matching status filter (comma = OR, + = AND, ^ = negate)")
5858
.option("-v, --verbose", "Show commit bodies and changed files")
59-
.option("--json", "Output structured JSON to stdout")
60-
.option("--schema", "Print JSON Schema for this command's --json output and exit")
59+
.addOption(new Option("--json", "Output structured JSON to stdout").conflicts("schema"))
60+
.addOption(new Option("--schema", "Print JSON Schema for this command's --json output and exit").conflicts("json"))
6161
.summary("Show feature branch commits across repos")
6262
.description(
6363
"Examples:\n\n arb log Show feature commits across repos\n arb log api --verbose Include commit bodies and files\n arb log -n 5 --where dirty Limit commits, filter repos\n\nShow commits on the feature branch since diverging from the base branch across all repos in the workspace. Answers 'what have I done in this workspace?' by showing only the commits that belong to the current feature.\n\nShows commits in the range base..HEAD for each repo. Use --fetch to fetch before showing log (default is no fetch). Use -n to limit how many commits are shown per repo. Use -v/--verbose to also show commit bodies and changed files. Use --json for machine-readable output.\n\nRepos are positional arguments — name specific repos to filter, or omit to show all. Reads repo names from stdin when piped (one per line). Use --where to filter by status flags. See 'arb help filtering' for filter syntax. Skipped repos (detached HEAD, wrong branch) are explained in the output, never silently omitted.\n\nSee 'arb help scripting' for output modes and piping.",
6464
)
6565
.action(async (repoArgs: string[], options, command) => {
6666
if (options.schema) {
67-
if (options.json) {
68-
error("Cannot combine --schema with --json.");
69-
throw new ArbError("Cannot combine --schema with --json.");
70-
}
7167
printSchema(LogJsonOutputSchema);
7268
return;
7369
}

src/commands/pull.ts

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,8 @@ export function registerPullCommand(program: Command): void {
5656
.option("--reset", "Reset to remote tip instead of pulling (overrides rebased-locally skip)")
5757
.option("-y, --yes", "Skip confirmation prompt")
5858
.option("--dry-run", "Show what would happen without executing")
59-
.option("--rebase", "Pull with rebase")
60-
.option("--merge", "Pull with merge")
59+
.addOption(new Option("--rebase", "Pull with rebase").conflicts("merge"))
60+
.addOption(new Option("--merge", "Pull with merge").conflicts("rebase"))
6161
.option("--autostash", "Stash uncommitted changes before pull, re-apply after")
6262
.option("--include-wrong-branch", "Include repos on a different branch than the workspace")
6363
.option("-v, --verbose", "Show incoming commits in the plan")
@@ -75,10 +75,6 @@ export function registerPullCommand(program: Command): void {
7575
error(`${flag} does not accept repo arguments`);
7676
throw new ArbError(`${flag} does not accept repo arguments`);
7777
}
78-
if (options.rebase && options.merge) {
79-
error("Cannot use both --rebase and --merge");
80-
throw new ArbError("Cannot use both --rebase and --merge");
81-
}
8278
const { wsDir } = requireWorkspace(ctx);
8379
const repoNames = await resolveReposFromArgsOrStdin(wsDir, repoArgs);
8480
await runPull(ctx, repoNames, options);

src/commands/repo.ts

Lines changed: 11 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { existsSync, rmSync } from "node:fs";
22
import { basename, join } from "node:path";
3-
import type { Command } from "commander";
3+
import { type Command, Option } from "commander";
44
import { z } from "zod";
55
import { ArbError, arbAction, readProjectConfig, writeProjectConfig } from "../lib/core";
66
import { gitLocal, gitNetwork, networkTimeout } from "../lib/git";
@@ -157,37 +157,26 @@ export function registerRepoCommand(program: Command): void {
157157

158158
repo
159159
.command("list", { isDefault: true })
160-
.option("-q, --quiet", "Output one repo name per line")
161-
.option("-v, --verbose", "Show remote URLs alongside names")
162-
.option("--json", "Output structured JSON")
163-
.option("--schema", "Print JSON Schema for this command's --json output and exit")
160+
.addOption(new Option("-q, --quiet", "Output one repo name per line").conflicts(["json", "verbose"]))
161+
.addOption(new Option("-v, --verbose", "Show remote URLs alongside names").conflicts(["quiet", "json"]))
162+
.addOption(new Option("--json", "Output structured JSON").conflicts(["quiet", "verbose"]))
163+
.addOption(
164+
new Option("--schema", "Print JSON Schema for this command's --json output and exit").conflicts([
165+
"json",
166+
"quiet",
167+
"verbose",
168+
]),
169+
)
164170
.summary("List cloned repos (default)")
165171
.description(
166172
"Examples:\n\n arb repo list List repos with remote roles\n arb repo list -v Include remote URLs\n arb repo list -q One name per line\n\nList all repositories that have been cloned into .arb/repos/. Shows resolved SHARE and BASE remote names for each repo. Use --verbose to include remote URLs alongside names. Use --quiet for plain enumeration (one name per line). Use --json for machine-readable output.",
167173
)
168174
.action(async (options, command) => {
169175
if (options.schema) {
170-
if (options.json || options.quiet || options.verbose) {
171-
error("Cannot combine --schema with --json, --quiet, or --verbose.");
172-
throw new ArbError("Cannot combine --schema with --json, --quiet, or --verbose.");
173-
}
174176
printSchema(z.array(RepoListJsonEntrySchema));
175177
return;
176178
}
177179
await arbAction(async (ctx, options) => {
178-
if (options.quiet && options.json) {
179-
error("Cannot combine --quiet with --json.");
180-
throw new ArbError("Cannot combine --quiet with --json.");
181-
}
182-
if (options.quiet && options.verbose) {
183-
error("Cannot combine --quiet with --verbose.");
184-
throw new ArbError("Cannot combine --quiet with --verbose.");
185-
}
186-
if (options.verbose && options.json) {
187-
error("Cannot combine --verbose with --json.");
188-
throw new ArbError("Cannot combine --verbose with --json.");
189-
}
190-
191180
const repos = listRepos(ctx.reposDir);
192181
if (repos.length === 0) return;
193182

src/commands/reset.ts

Lines changed: 12 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { basename } from "node:path";
2-
import type { Command } from "commander";
2+
import { type Command, Option } from "commander";
33
import {
4-
ArbError,
54
type OperationRecord,
65
type RepoOperationState,
76
arbAction,
@@ -33,17 +32,7 @@ import {
3332
runPlanFlow,
3433
} from "../lib/sync";
3534
import type { CommitDisplayEntry } from "../lib/sync/types";
36-
import {
37-
dryRunNotice,
38-
error,
39-
info,
40-
inlineResult,
41-
inlineStart,
42-
plural,
43-
shouldColor,
44-
warn,
45-
yellow,
46-
} from "../lib/terminal";
35+
import { dryRunNotice, info, inlineResult, inlineStart, plural, shouldColor, warn, yellow } from "../lib/terminal";
4736
import { requireBranch, requireWorkspace, resolveReposFromArgsOrStdin, workspaceRepoDirs } from "../lib/workspace";
4837

4938
export type ResetMode = "soft" | "mixed" | "hard";
@@ -375,9 +364,16 @@ export function registerResetCommand(program: Command): void {
375364
.option("--fetch", "Fetch from all remotes before reset (default)")
376365
.option("-N, --no-fetch", "Skip fetching before reset")
377366
.option("--base", "Always reset to the base branch, even when a remote share branch exists")
378-
.option("--soft", "Move HEAD only; commits become staged changes")
379-
.option("--mixed", "Move HEAD and reset index; changes become unstaged (default)")
380-
.option("--hard", "Move HEAD, reset index and working tree; discards all local changes")
367+
.addOption(new Option("--soft", "Move HEAD only; commits become staged changes").conflicts(["mixed", "hard"]))
368+
.addOption(
369+
new Option("--mixed", "Move HEAD and reset index; changes become unstaged (default)").conflicts(["soft", "hard"]),
370+
)
371+
.addOption(
372+
new Option("--hard", "Move HEAD, reset index and working tree; discards all local changes").conflicts([
373+
"soft",
374+
"mixed",
375+
]),
376+
)
381377
.option("-y, --yes", "Skip confirmation prompt")
382378
.option("--dry-run", "Show what would happen without executing")
383379
.option("-v, --verbose", "Show commits to be reset in the plan")
@@ -389,11 +385,6 @@ export function registerResetCommand(program: Command): void {
389385
)
390386
.action(
391387
arbAction(async (ctx, repoArgs: string[], options) => {
392-
const modeFlags = [options.soft, options.mixed, options.hard].filter(Boolean);
393-
if (modeFlags.length > 1) {
394-
error("Cannot combine --soft, --mixed, and --hard");
395-
throw new ArbError("Cannot combine --soft, --mixed, and --hard");
396-
}
397388
const resetMode: ResetMode = options.soft ? "soft" : options.hard ? "hard" : "mixed";
398389

399390
const where = resolveWhereFilter(options);

src/commands/status.ts

Lines changed: 16 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { basename, resolve } from "node:path";
2-
import type { Command } from "commander";
2+
import { type Command, Option } from "commander";
33
import { predictMergeConflict } from "../lib/analysis";
4-
import { ArbError, type CommandContext, arbAction, readOperationRecord, readWorkspaceConfig } from "../lib/core";
4+
import { type CommandContext, arbAction, readOperationRecord, readWorkspaceConfig } from "../lib/core";
55
import { localTimeout } from "../lib/git/git";
66
import { printSchema } from "../lib/json";
77
import { type StatusJsonOutput, StatusJsonOutputSchema } from "../lib/json";
@@ -38,7 +38,6 @@ import {
3838
} from "../lib/sync";
3939
import {
4040
clearScanProgress,
41-
error,
4241
hintsEnabled,
4342
isTTY,
4443
listenForAbortSignal,
@@ -55,20 +54,26 @@ export function registerStatusCommand(program: Command): void {
5554
.option("-w, --where <filter>", "Filter repos by status flags (comma = OR, + = AND, ^ = negate)")
5655
.option("--fetch", "Fetch from all remotes before showing status (default)")
5756
.option("-N, --no-fetch", "Skip fetching")
58-
.option("-v, --verbose", "Show file-level detail for each repo")
59-
.option("-q, --quiet", "Output one repo name per line")
60-
.option("--json", "Output structured JSON (combine with --verbose for commit and file detail)")
61-
.option("--schema", "Print JSON Schema for this command's --json output and exit")
57+
.addOption(new Option("-v, --verbose", "Show file-level detail for each repo").conflicts("quiet"))
58+
.addOption(new Option("-q, --quiet", "Output one repo name per line").conflicts(["json", "verbose"]))
59+
.addOption(
60+
new Option("--json", "Output structured JSON (combine with --verbose for commit and file detail)").conflicts(
61+
"quiet",
62+
),
63+
)
64+
.addOption(
65+
new Option("--schema", "Print JSON Schema for this command's --json output and exit").conflicts([
66+
"json",
67+
"quiet",
68+
"verbose",
69+
]),
70+
)
6271
.summary("Show repo branches, sync status, and local changes")
6372
.description(
6473
"Examples:\n\n arb status Show all repos\n arb status --dirty Only repos with local changes\n arb status api web --verbose File-level detail for specific repos\n\nShow each repo's position relative to the base branch, push status against the share remote, and local changes (staged, modified, untracked). The summary includes the workspace's last commit date (most recent author date across all repos).\n\nRepos are positional arguments — name specific repos to filter, or omit to show all. Reads repo names from stdin when piped (one per line), enabling composition like: arb status -q --where dirty | arb log.\n\nUse --dirty to only show repos with uncommitted changes. Use --where <filter> to filter by status flags. See 'arb help filtering' for filter syntax. Fetches from all remotes by default for fresh data (skip with -N/--no-fetch). Press Ctrl+C during the fetch to cancel and use stale data. Quiet mode (-q) skips fetching by default for scripting speed. Use --verbose for file-level detail. Use --json for machine-readable output. Combine --json --verbose to include commit lists and file-level detail in JSON output.\n\nFor a live dashboard with sync commands, use 'arb watch'.\n\nMerged branches show the detected PR number when available (e.g. 'merged (#123), gone'), extracted from merge or squash commit subjects. JSON output includes detectedPr fields.\n\nSee 'arb help stacked' for stacked workspace status flags. See 'arb help scripting' for output modes and piping.",
6574
)
6675
.action(async (repoArgs: string[], options, command) => {
6776
if (options.schema) {
68-
if (options.json || options.quiet || options.verbose) {
69-
error("Cannot combine --schema with --json, --quiet, or --verbose.");
70-
throw new ArbError("Cannot combine --schema with --json, --quiet, or --verbose.");
71-
}
7277
printSchema(StatusJsonOutputSchema);
7378
return;
7479
}
@@ -96,16 +101,6 @@ async function runStatus(
96101
const aCache = ctx.analysisCache;
97102
const where = resolveWhereFilter(options);
98103

99-
// Conflict checks
100-
if (options.quiet && options.json) {
101-
error("Cannot combine --quiet with --json.");
102-
throw new ArbError("Cannot combine --quiet with --json.");
103-
}
104-
if (options.quiet && options.verbose) {
105-
error("Cannot combine --quiet with --verbose.");
106-
throw new ArbError("Cannot combine --quiet with --verbose.");
107-
}
108-
109104
// Resolve repo selection: positional args > stdin > all
110105
const selectedRepos = await resolveReposFromArgsOrStdin(wsDir, repoArgs);
111106
const selectedSet = new Set(selectedRepos);

0 commit comments

Comments
 (0)