Skip to content

Commit 2bf5002

Browse files
authored
Merge branch 'main' into need-for-snapshot
2 parents 6dcb94f + 159ae65 commit 2bf5002

13 files changed

Lines changed: 244 additions & 84 deletions

File tree

GUIDELINES.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,14 @@ All human-facing output (progress lines, prompts, summaries, errors) goes to std
127127

128128
Arborist maintains no state files beyond the `.arb/` marker directory, `.arbws/config` in each workspace, and git's own metadata. Workspaces are discovered by scanning for directories containing `.arbws`. Repos are discovered by scanning `.arb/repos/` for directories containing `.git`. This makes the state inspectable, debuggable, and impossible to corrupt through arb bugs alone.
129129

130+
### Mutating commands fetch by default, read-only commands do not
131+
132+
State-changing commands (`pull`, `push`, `rebase`, `merge`) automatically fetch from all remotes before assessing the workspace. This ensures operations are based on the latest remote state, preventing mistakes like rebasing onto a stale base branch. Use `--no-fetch` to skip when refs are known to be fresh.
133+
134+
Read-only commands (`status`, `list`) do not fetch by default to stay fast for frequent use. Both support `--fetch` to opt in when fresh remote data is needed.
135+
136+
The parallel pre-fetch also serves a performance purpose: `parallelFetch()` fetches all repos concurrently, while the subsequent mutation operations (pull, push, rebase, merge) run sequentially one repo at a time. Batching the network I/O upfront avoids per-repo fetch latency during the sequential phase.
137+
130138
### Repo classification: local vs remote
131139

132140
`classifyRepos()` separates repos into those with remotes and local-only repos. Commands that interact with remotes (fetch, pull, integrate) use this to gracefully skip local repos with a reason. This allows mixed local/remote workspaces.

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,7 @@ arb pull
188188
arb rebase
189189
```
190190

191-
If a rebase hits conflicts, arb continues with the remaining repos and reports all conflicts at the end with per-repo resolution instructions. This way you see the complete state of all repos in one pass instead of re-running for each conflict. If you re-run while a repo is still mid-rebase, it is automatically skipped. Prefer merge commits? Use `arb merge` instead — same workflow, uses `git merge`.
191+
Arb automatically fetches all repos before rebasing, so you always rebase onto the latest remote state. If a rebase hits conflicts, arb continues with the remaining repos and reports all conflicts at the end with per-repo resolution instructions. This way you see the complete state of all repos in one pass instead of re-running for each conflict. If you re-run while a repo is still mid-rebase, it is automatically skipped. Prefer merge commits? Use `arb merge` instead — same workflow, uses `git merge`.
192192

193193
Arb auto-detects each repo's default branch, so repos using `main`, `master`, or `develop` coexist without extra configuration.
194194

@@ -199,6 +199,8 @@ arb push
199199
arb push --force
200200
```
201201

202+
All state-changing commands (`rebase`, `merge`, `push`, `pull`) automatically fetch before operating, ensuring they work with the latest remote state. Use `--no-fetch` to skip when refs are known to be fresh. Read-only commands (`status`, `list`) do not fetch by default — use `--fetch` to opt in. If fetching fails (e.g. offline), the command warns and continues with stale data.
203+
202204
All commands show a plan before proceeding. See `arb help <command>` for options.
203205

204206
### Run commands across repos

src/commands/cd.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ export function registerCdCommand(program: Command, getCtx: () => ArbContext): v
1616
const ctx = getCtx();
1717

1818
if (!input) {
19-
if (!process.stdin.isTTY) {
19+
if (!process.stdin.isTTY || !process.stderr.isTTY) {
2020
error("Usage: arb cd <workspace>");
2121
process.exit(1);
2222
}
@@ -27,11 +27,14 @@ export function registerCdCommand(program: Command, getCtx: () => ArbContext): v
2727
process.exit(1);
2828
}
2929

30-
const selected = await select({
31-
message: "Select a workspace",
32-
choices: workspaces.map((name) => ({ name, value: name })),
33-
pageSize: 20,
34-
});
30+
const selected = await select(
31+
{
32+
message: "Select a workspace",
33+
choices: workspaces.map((name) => ({ name, value: name })),
34+
pageSize: 20,
35+
},
36+
{ output: process.stderr },
37+
);
3538

3639
process.stdout.write(`${ctx.baseDir}/${selected}\n`);
3740
printHintIfNeeded();

src/commands/create.ts

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -39,10 +39,13 @@ export function registerCreateCommand(program: Command, getCtx: () => ArbContext
3939
error("Usage: arb create <name> [repos...]");
4040
process.exit(1);
4141
}
42-
name = await input({
43-
message: "Workspace name:",
44-
validate: (v) => validateWorkspaceName(v) ?? true,
45-
});
42+
name = await input(
43+
{
44+
message: "Workspace name:",
45+
validate: (v) => validateWorkspaceName(v) ?? true,
46+
},
47+
{ output: process.stderr },
48+
);
4649
}
4750

4851
const validationError = validateWorkspaceName(name);
@@ -55,11 +58,14 @@ export function registerCreateCommand(program: Command, getCtx: () => ArbContext
5558
if (!branch) {
5659
const defaultBranch = name.toLowerCase();
5760
if (!nameArg && process.stdin.isTTY) {
58-
branch = await input({
59-
message: "Branch name:",
60-
default: defaultBranch,
61-
validate: (v) => (validateBranchName(v) ? true : "Invalid branch name"),
62-
});
61+
branch = await input(
62+
{
63+
message: "Branch name:",
64+
default: defaultBranch,
65+
validate: (v) => (validateBranchName(v) ? true : "Invalid branch name"),
66+
},
67+
{ output: process.stderr },
68+
);
6369
} else {
6470
branch = defaultBranch;
6571
}
@@ -72,9 +78,12 @@ export function registerCreateCommand(program: Command, getCtx: () => ArbContext
7278

7379
let base = options.base;
7480
if (!base && !nameArg && process.stdin.isTTY) {
75-
const baseInput = await input({
76-
message: "Base branch (leave blank for repo default):",
77-
});
81+
const baseInput = await input(
82+
{
83+
message: "Base branch (leave blank for repo default):",
84+
},
85+
{ output: process.stderr },
86+
);
7887
if (baseInput.trim()) {
7988
base = baseInput.trim();
8089
}

src/commands/list.ts

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
import { existsSync } from "node:fs";
22
import type { Command } from "commander";
33
import { configGet } from "../lib/config";
4-
import { bold, dim, green, info, red, yellow } from "../lib/output";
5-
import { listWorkspaces, workspaceRepoDirs } from "../lib/repos";
4+
import { hasRemote } from "../lib/git";
5+
import { bold, dim, green, info, plural, red, yellow } from "../lib/output";
6+
import { parallelFetch, reportFetchFailures } from "../lib/parallel-fetch";
7+
import { resolveRemotesMap } from "../lib/remotes";
8+
import { listRepos, listWorkspaces, workspaceRepoDirs } from "../lib/repos";
69
import { type WorkspaceSummary, gatherWorkspaceSummary, isUnpushed } from "../lib/status";
710
import { isTTY } from "../lib/tty";
811
import type { ArbContext } from "../lib/types";
@@ -23,11 +26,35 @@ export function registerListCommand(program: Command, getCtx: () => ArbContext):
2326
.command("list")
2427
.summary("List all workspaces")
2528
.description(
26-
"List all workspaces in the arb root with aggregate status. Shows branch, base, repo count, and status for each workspace. The active workspace (the one you're currently inside) is marked with *. Use --quick to skip per-repo status gathering for faster output.",
29+
"List all workspaces in the arb root with aggregate status. Shows branch, base, repo count, and status for each workspace. The active workspace (the one you're currently inside) is marked with *. Use --quick to skip per-repo status gathering for faster output. Use --fetch to fetch all repos before listing for fresh remote data.",
2730
)
31+
.option("-f, --fetch", "Fetch all repos before listing")
2832
.option("-q, --quick", "Skip per-repo status (faster for large setups)")
29-
.action(async (options: { quick?: boolean }) => {
33+
.action(async (options: { fetch?: boolean; quick?: boolean }) => {
3034
const ctx = getCtx();
35+
36+
// Fetch all canonical repos (benefits all workspaces)
37+
if (options.fetch) {
38+
const allRepoNames = listRepos(ctx.reposDir);
39+
const fetchDirs: string[] = [];
40+
const localRepos: string[] = [];
41+
for (const repo of allRepoNames) {
42+
const repoDir = `${ctx.reposDir}/${repo}`;
43+
if (await hasRemote(repoDir)) {
44+
fetchDirs.push(repoDir);
45+
} else {
46+
localRepos.push(repo);
47+
}
48+
}
49+
if (fetchDirs.length > 0) {
50+
const remoteRepoNames = allRepoNames.filter((r) => !localRepos.includes(r));
51+
const remotesMap = await resolveRemotesMap(remoteRepoNames, ctx.reposDir);
52+
process.stderr.write(`Fetching ${plural(fetchDirs.length, "repo")}...\n`);
53+
const fetchResults = await parallelFetch(fetchDirs, undefined, remotesMap);
54+
reportFetchFailures(allRepoNames, localRepos, fetchResults);
55+
}
56+
}
57+
3158
const workspaces = listWorkspaces(ctx.baseDir);
3259

3360
// ── Phase 1: gather lightweight metadata (fast, sequential) ──

src/commands/merge.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,11 @@ import type { ArbContext } from "../lib/types";
55
export function registerMergeCommand(program: Command, getCtx: () => ArbContext): void {
66
program
77
.command("merge [repos...]")
8-
.option("--fetch", "Fetch all repos before merging")
8+
.option("-F, --no-fetch", "Skip fetching before merge")
99
.option("-y, --yes", "Skip confirmation prompt")
1010
.summary("Merge the base branch into feature branches")
1111
.description(
12-
"Merge the base branch (e.g. main) into the feature branch for all repos, or only the named repos. Shows a plan and asks for confirmation before proceeding. Repos with uncommitted changes or that are already up to date are skipped. If any repos conflict, arb continues with the remaining repos and reports all conflicts at the end with per-repo resolution instructions.",
12+
"Fetches all repos, then merges the base branch (e.g. main) into the feature branch for all repos, or only the named repos. Shows a plan and asks for confirmation before proceeding. Repos with uncommitted changes or that are already up to date are skipped. If any repos conflict, arb continues with the remaining repos and reports all conflicts at the end with per-repo resolution instructions. Use --no-fetch to skip fetching when refs are known to be fresh.",
1313
)
1414
.action(async (repoArgs: string[], options: { fetch?: boolean; yes?: boolean }) => {
1515
const ctx = getCtx();

src/commands/pull.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,11 @@ export function registerPullCommand(program: Command, getCtx: () => ArbContext):
4646
const remotesMap = await resolveRemotesMap(selectedRepos, ctx.reposDir);
4747

4848
// Phase 1: parallel fetch (only selected repos)
49+
// Two reasons for a separate pre-fetch before git pull:
50+
// 1. Accurate plan display — updates tracking refs before the assessment phase
51+
// 2. Performance — parallelFetch() fetches all repos concurrently, while the
52+
// subsequent git pull commands run sequentially. Batching network I/O upfront
53+
// avoids per-repo fetch latency.
4954
const { repos: allRepos, fetchDirs: allFetchDirs, localRepos } = await classifyRepos(wsDir, ctx.reposDir);
5055
const repos = allRepos.filter((r) => selectedSet.has(r));
5156
const fetchDirs = allFetchDirs.filter((dir) => selectedSet.has(basename(dir)));
@@ -100,10 +105,13 @@ export function registerPullCommand(program: Command, getCtx: () => ArbContext):
100105
error("Not a terminal. Use --yes to skip confirmation.");
101106
process.exit(1);
102107
}
103-
const ok = await confirm({
104-
message: `Pull ${plural(willPull.length, "repo")}?`,
105-
default: false,
106-
});
108+
const ok = await confirm(
109+
{
110+
message: `Pull ${plural(willPull.length, "repo")}?`,
111+
default: false,
112+
},
113+
{ output: process.stderr },
114+
);
107115
if (!ok) {
108116
process.stderr.write("Aborted.\n");
109117
process.exit(130);

src/commands/push.ts

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@ import type { Command } from "commander";
33
import { configGet } from "../lib/config";
44
import { checkBranchMatch, getDefaultBranch, git, hasRemote, remoteBranchExists } from "../lib/git";
55
import { dim, error, info, inlineResult, inlineStart, plural, red, success, yellow } from "../lib/output";
6+
import { parallelFetch, reportFetchFailures } from "../lib/parallel-fetch";
67
import { type RepoRemotes, resolveRemotesMap } from "../lib/remotes";
7-
import { resolveRepoSelection } from "../lib/repos";
8+
import { classifyRepos, resolveRepoSelection } from "../lib/repos";
89
import { isTTY } from "../lib/tty";
910
import type { ArbContext } from "../lib/types";
1011
import { requireBranch, requireWorkspace } from "../lib/workspace-context";
@@ -26,12 +27,13 @@ export function registerPushCommand(program: Command, getCtx: () => ArbContext):
2627
program
2728
.command("push [repos...]")
2829
.option("-f, --force", "Force push with lease (after rebase or amend)")
30+
.option("--no-fetch", "Skip fetching before push")
2931
.option("-y, --yes", "Skip confirmation prompt")
3032
.summary("Push the feature branch to the publish remote")
3133
.description(
32-
"Push the feature branch for all repos, or only the named repos. Pushes to the publish remote (origin by default, or as configured for fork workflows). Sets up tracking on first push. Shows a plan and asks for confirmation before pushing. Skips repos without a remote and repos where the remote branch has been deleted. Use --force after rebase or amend to force push with lease.",
34+
"Fetches all repos, then pushes the feature branch for all repos, or only the named repos. Pushes to the publish remote (origin by default, or as configured for fork workflows). Sets up tracking on first push. Shows a plan and asks for confirmation before pushing. Skips repos without a remote and repos where the remote branch has been deleted. Use --force after rebase or amend to force push with lease. Use --no-fetch to skip fetching when refs are known to be fresh.",
3335
)
34-
.action(async (repoArgs: string[], options: { force?: boolean; yes?: boolean }) => {
36+
.action(async (repoArgs: string[], options: { force?: boolean; fetch?: boolean; yes?: boolean }) => {
3537
const ctx = getCtx();
3638
const { wsDir, workspace } = requireWorkspace(ctx);
3739
const branch = await requireBranch(wsDir, workspace);
@@ -40,6 +42,16 @@ export function registerPushCommand(program: Command, getCtx: () => ArbContext):
4042
const remotesMap = await resolveRemotesMap(selectedRepos, ctx.reposDir);
4143
const configBase = configGet(`${wsDir}/.arbws/config`, "base");
4244

45+
// Phase 0: fetch (unless --no-fetch)
46+
if (options.fetch !== false) {
47+
const { repos: allRepos, fetchDirs, localRepos } = await classifyRepos(wsDir, ctx.reposDir);
48+
if (fetchDirs.length > 0) {
49+
process.stderr.write(`Fetching ${plural(fetchDirs.length, "repo")}...\n`);
50+
const fetchResults = await parallelFetch(fetchDirs, undefined, remotesMap);
51+
reportFetchFailures(allRepos, localRepos, fetchResults);
52+
}
53+
}
54+
4355
// Phase 1: assess each repo
4456
const assessments: PushAssessment[] = [];
4557
for (const repo of selectedRepos) {
@@ -93,10 +105,13 @@ export function registerPushCommand(program: Command, getCtx: () => ArbContext):
93105
error("Not a terminal. Use --yes to skip confirmation.");
94106
process.exit(1);
95107
}
96-
const ok = await confirm({
97-
message: `Push ${plural(willPush.length, "repo")}?`,
98-
default: false,
99-
});
108+
const ok = await confirm(
109+
{
110+
message: `Push ${plural(willPush.length, "repo")}?`,
111+
default: false,
112+
},
113+
{ output: process.stderr },
114+
);
100115
if (!ok) {
101116
process.stderr.write("Aborted.\n");
102117
process.exit(130);

src/commands/rebase.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,11 @@ import type { ArbContext } from "../lib/types";
55
export function registerRebaseCommand(program: Command, getCtx: () => ArbContext): void {
66
program
77
.command("rebase [repos...]")
8-
.option("--fetch", "Fetch all repos before rebasing")
8+
.option("-F, --no-fetch", "Skip fetching before rebase")
99
.option("-y, --yes", "Skip confirmation prompt")
1010
.summary("Rebase feature branches onto the base branch")
1111
.description(
12-
"Rebase the feature branch onto the updated base branch (e.g. main) for all repos, or only the named repos. Shows a plan and asks for confirmation before proceeding. Repos with uncommitted changes or that are already up to date are skipped. If any repos conflict, arb continues with the remaining repos and reports all conflicts at the end with per-repo resolution instructions.",
12+
"Fetches all repos, then rebases the feature branch onto the updated base branch (e.g. main) for all repos, or only the named repos. Shows a plan and asks for confirmation before proceeding. Repos with uncommitted changes or that are already up to date are skipped. If any repos conflict, arb continues with the remaining repos and reports all conflicts at the end with per-repo resolution instructions. Use --no-fetch to skip fetching when refs are known to be fresh.",
1313
)
1414
.action(async (repoArgs: string[], options: { fetch?: boolean; yes?: boolean }) => {
1515
const ctx = getCtx();

src/commands/remove.ts

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -286,10 +286,13 @@ export function registerRemoveCommand(program: Command, getCtx: () => ArbContext
286286
error("Cannot prompt for confirmation: not a terminal. Use --force to skip prompts.");
287287
process.exit(1);
288288
}
289-
const shouldRemove = await confirm({
290-
message: `Remove ${plural(okEntries.length, "workspace")}?`,
291-
default: false,
292-
});
289+
const shouldRemove = await confirm(
290+
{
291+
message: `Remove ${plural(okEntries.length, "workspace")}?`,
292+
default: false,
293+
},
294+
{ output: process.stderr },
295+
);
293296
if (!shouldRemove) {
294297
process.stderr.write("Aborted.\n");
295298
return;
@@ -368,15 +371,18 @@ export function registerRemoveCommand(program: Command, getCtx: () => ArbContext
368371
const confirmMsg = isSingle
369372
? `Remove workspace ${singleName}?`
370373
: `Remove ${plural(assessments.length, "workspace")}?`;
371-
const shouldRemove = await confirm({ message: confirmMsg, default: false });
374+
const shouldRemove = await confirm({ message: confirmMsg, default: false }, { output: process.stderr });
372375
if (!shouldRemove) {
373376
process.stderr.write("Aborted.\n");
374377
return;
375378
}
376379

377380
const allRemoteRepos = assessments.flatMap((a) => a.remoteRepos);
378381
if (allRemoteRepos.length > 0 && !deleteRemote) {
379-
deleteRemote = await confirm({ message: "Also delete remote branches?", default: false });
382+
deleteRemote = await confirm(
383+
{ message: "Also delete remote branches?", default: false },
384+
{ output: process.stderr },
385+
);
380386
}
381387
}
382388

0 commit comments

Comments
 (0)