Skip to content

Commit d3a4986

Browse files
authored
Merge branch 'main' into failing-integration-test-276
2 parents 5701790 + daf134a commit d3a4986

15 files changed

Lines changed: 210 additions & 21 deletions

File tree

README.md

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -85,11 +85,11 @@ Here's the full lifecycle of a workspace — from creation to cleanup:
8585
arb create fix-login frontend backend
8686

8787
# Work in individual repos as usual
88-
cd fix-login/frontend
88+
arb cd fix-login/frontend
8989
# hack hack hack
9090
git add -p && git commit -m "Fix the login page"
9191

92-
cd ../backend
92+
arb cd fix-login/backend
9393
# hack hack hack
9494
git add -p && git commit -m "Fix the login endpoint"
9595

@@ -242,7 +242,15 @@ The active workspace (the one you're currently inside) is marked with `*`.
242242

243243
### Navigate
244244

245-
`arb path` prints the absolute path to the arb root, a workspace, or a worktree from anywhere below the arb root:
245+
`arb cd` changes into a workspace or worktree directory. It requires the shell integration installed by `install.sh`:
246+
247+
```bash
248+
arb cd fix-login # cd into workspace
249+
arb cd fix-login/frontend # cd into a specific worktree
250+
arb cd # interactive workspace picker
251+
```
252+
253+
`arb path` prints the absolute path to the arb root, a workspace, or a worktree — useful in scripts and shell pipelines:
246254

247255
```bash
248256
arb path # /home/you/my-project (the arb root)

bun.lock

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
"@inquirer/checkbox": "^5.0.4",
1717
"@inquirer/confirm": "^6.0.4",
1818
"@inquirer/input": "^5.0.4",
19+
"@inquirer/select": "^5.0.6",
1920
"commander": "^13"
2021
},
2122
"devDependencies": {

shell/arb.zsh

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,17 @@ arb() {
99
cd "${_arb_recover:-/}" 2>/dev/null || cd "$HOME"
1010
fi
1111

12+
if [[ "$1" == "cd" ]]; then
13+
# Pass help flags through without capturing
14+
case " ${*:2} " in
15+
*" --help "*|*" -h "*) command arb cd "${@:2}"; return ;;
16+
esac
17+
local _arb_dir
18+
_arb_dir="$(command arb cd "${@:2}")" || return
19+
cd "$_arb_dir"
20+
return
21+
fi
22+
1223
command arb "$@"
1324
}
1425

@@ -52,6 +63,7 @@ _arb() {
5263
'remove:Remove a workspace'
5364
'list:List all workspaces'
5465
'path:Print the path to the arb root or a workspace'
66+
'cd:Change to a workspace directory'
5567
'add:Add worktrees to the workspace'
5668
'drop:Drop worktrees from the workspace'
5769
'status:Show worktree status'
@@ -75,6 +87,22 @@ _arb() {
7587
path)
7688
_arguments '1:workspace:($ws_names)'
7789
;;
90+
cd)
91+
local input="${words[2]:-}"
92+
if [[ "$input" == */* ]]; then
93+
# After slash: complete worktree names within the workspace
94+
local ws_name="${input%%/*}"
95+
local ws_dir="$base_dir/$ws_name"
96+
if [[ -d "$ws_dir" ]]; then
97+
local -a wt_names=(${ws_dir}/*(N/:t))
98+
wt_names=(${wt_names:#.arbws})
99+
compadd -p "$ws_name/" -a wt_names
100+
fi
101+
else
102+
# Before slash: complete workspace names
103+
_arguments '1:workspace:($ws_names)'
104+
fi
105+
;;
78106
create)
79107
_arguments \
80108
'(-b --branch)'{-b,--branch}'[Branch name]:branch:' \

src/commands/cd.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { existsSync } from "node:fs";
2+
import select from "@inquirer/select";
3+
import type { Command } from "commander";
4+
import { error, info } from "../lib/output";
5+
import { listWorkspaces } from "../lib/repos";
6+
import type { ArbContext } from "../lib/types";
7+
8+
export function registerCdCommand(program: Command, getCtx: () => ArbContext): void {
9+
program
10+
.command("cd [name]")
11+
.summary("Navigate to a workspace directory")
12+
.description(
13+
'Change into a workspace or worktree directory. Supports workspace/repo paths (e.g. "fix-login/frontend"). When run without arguments in a TTY, shows an interactive workspace picker.\n\nRequires shell integration (installed by install.sh) to change the shell\'s working directory. Without it, the resolved path is printed to stdout.',
14+
)
15+
.action(async (input?: string) => {
16+
const ctx = getCtx();
17+
18+
if (!input) {
19+
if (!process.stdin.isTTY) {
20+
error("Usage: arb cd <workspace>");
21+
process.exit(1);
22+
}
23+
24+
const workspaces = listWorkspaces(ctx.baseDir);
25+
if (workspaces.length === 0) {
26+
error("No workspaces found.");
27+
process.exit(1);
28+
}
29+
30+
const selected = await select({
31+
message: "Select a workspace",
32+
choices: workspaces.map((name) => ({ name, value: name })),
33+
pageSize: 20,
34+
});
35+
36+
process.stdout.write(`${ctx.baseDir}/${selected}\n`);
37+
printHintIfNeeded();
38+
return;
39+
}
40+
41+
const slashIdx = input.indexOf("/");
42+
const wsName = slashIdx >= 0 ? input.slice(0, slashIdx) : input;
43+
const subpath = slashIdx >= 0 ? input.slice(slashIdx + 1) : "";
44+
45+
const wsDir = `${ctx.baseDir}/${wsName}`;
46+
if (!existsSync(`${wsDir}/.arbws`)) {
47+
error(`Workspace '${wsName}' does not exist`);
48+
process.exit(1);
49+
}
50+
51+
if (subpath) {
52+
const fullPath = `${wsDir}/${subpath}`;
53+
if (!existsSync(fullPath)) {
54+
error(`'${subpath}' not found in workspace '${wsName}'`);
55+
process.exit(1);
56+
}
57+
process.stdout.write(`${fullPath}\n`);
58+
} else {
59+
process.stdout.write(`${wsDir}\n`);
60+
}
61+
62+
printHintIfNeeded();
63+
});
64+
}
65+
66+
function printHintIfNeeded(): void {
67+
if (process.stdout.isTTY && process.stderr.isTTY) {
68+
info("Hint: install shell integration to cd directly. See 'arb cd --help'.");
69+
}
70+
}

src/commands/clone.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,32 +27,35 @@ export function registerCloneCommand(program: Command, getCtx: () => ArbContext)
2727
process.exit(1);
2828
}
2929

30-
const result = await Bun.$`git clone ${url} ${target}`.quiet().nothrow();
30+
const result = await Bun.$`git clone ${url} ${target}`.cwd(ctx.reposDir).quiet().nothrow();
3131
if (result.exitCode !== 0) {
3232
error(`Clone failed: ${result.stderr.toString().trim()}`);
3333
process.exit(1);
3434
}
3535

36-
await Bun.$`git -C ${target} checkout --detach`.quiet().nothrow();
36+
await Bun.$`git -C ${target} checkout --detach`.cwd(target).quiet().nothrow();
3737

3838
if (options.upstream) {
3939
// Add upstream remote
40-
const addResult = await Bun.$`git -C ${target} remote add upstream ${options.upstream}`.quiet().nothrow();
40+
const addResult = await Bun.$`git -C ${target} remote add upstream ${options.upstream}`
41+
.cwd(target)
42+
.quiet()
43+
.nothrow();
4144
if (addResult.exitCode !== 0) {
4245
error(`Failed to add upstream remote: ${addResult.stderr.toString().trim()}`);
4346
process.exit(1);
4447
}
4548

4649
// Set remote.pushDefault so resolveRemotes() detects the fork layout
47-
await Bun.$`git -C ${target} config remote.pushDefault origin`.quiet().nothrow();
50+
await Bun.$`git -C ${target} config remote.pushDefault origin`.cwd(target).quiet().nothrow();
4851

4952
// Fetch upstream and auto-detect HEAD
50-
const fetchResult = await Bun.$`git -C ${target} fetch upstream`.quiet().nothrow();
53+
const fetchResult = await Bun.$`git -C ${target} fetch upstream`.cwd(target).quiet().nothrow();
5154
if (fetchResult.exitCode !== 0) {
5255
error(`Failed to fetch upstream: ${fetchResult.stderr.toString().trim()}`);
5356
process.exit(1);
5457
}
55-
await Bun.$`git -C ${target} remote set-head upstream --auto`.quiet().nothrow();
58+
await Bun.$`git -C ${target} remote set-head upstream --auto`.cwd(target).quiet().nothrow();
5659

5760
info(` publish: origin (${url})`);
5861
info(` upstream: upstream (${options.upstream})`);

src/commands/open.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ export function registerOpenCommand(program: Command, getCtx: () => ArbContext):
2121
const { wsDir } = requireWorkspace(ctx);
2222

2323
// Check if command exists in PATH
24-
const which = Bun.spawnSync(["which", command]);
24+
const which = Bun.spawnSync(["which", command], { cwd: wsDir });
2525
if (which.exitCode !== 0) {
2626
error(`'${command}' not found in PATH`);
2727
process.exit(1);
@@ -48,6 +48,7 @@ export function registerOpenCommand(program: Command, getCtx: () => ArbContext):
4848
}
4949

5050
const proc = Bun.spawn([command, ...extraFlags, ...dirsToOpen], {
51+
cwd: wsDir,
5152
stdout: "inherit",
5253
stderr: "inherit",
5354
stdin: "inherit",

src/commands/pull.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,10 @@ export function registerPullCommand(program: Command, getCtx: () => ArbContext):
116116
inlineStart(a.repo, `pulling (${a.pullMode})`);
117117
const pullRemote = remotesMap.get(a.repo)?.publish ?? "origin";
118118
const pullFlag = a.pullMode === "rebase" ? "--rebase" : "--no-rebase";
119-
const pullResult = await Bun.$`git -C ${a.repoDir} pull ${pullFlag} ${pullRemote} ${branch}`.quiet().nothrow();
119+
const pullResult = await Bun.$`git -C ${a.repoDir} pull ${pullFlag} ${pullRemote} ${branch}`
120+
.cwd(a.repoDir)
121+
.quiet()
122+
.nothrow();
120123
if (pullResult.exitCode === 0) {
121124
inlineResult(a.repo, `pulled ${plural(a.behind, "commit")} (${a.pullMode})`);
122125
pullOk++;
@@ -182,7 +185,7 @@ async function assessPullRepo(
182185
}
183186

184187
if (!(await remoteBranchExists(repoDir, branch, publishRemote))) {
185-
const configRemote = await Bun.$`git -C ${repoDir} config branch.${branch}.remote`.quiet().nothrow();
188+
const configRemote = await Bun.$`git -C ${repoDir} config branch.${branch}.remote`.cwd(repoDir).quiet().nothrow();
186189
if (configRemote.exitCode === 0 && configRemote.text().trim().length > 0) {
187190
return { ...base, skipReason: "remote branch gone" };
188191
}
@@ -208,11 +211,14 @@ async function assessPullRepo(
208211
}
209212

210213
async function detectPullMode(repoDir: string, branch: string): Promise<"rebase" | "merge"> {
211-
const branchRebase = await Bun.$`git -C ${repoDir} config --get branch.${branch}.rebase`.quiet().nothrow();
214+
const branchRebase = await Bun.$`git -C ${repoDir} config --get branch.${branch}.rebase`
215+
.cwd(repoDir)
216+
.quiet()
217+
.nothrow();
212218
if (branchRebase.exitCode === 0) {
213219
return branchRebase.text().trim() !== "false" ? "rebase" : "merge";
214220
}
215-
const pullRebase = await Bun.$`git -C ${repoDir} config --get pull.rebase`.quiet().nothrow();
221+
const pullRebase = await Bun.$`git -C ${repoDir} config --get pull.rebase`.cwd(repoDir).quiet().nothrow();
216222
if (pullRebase.exitCode === 0) {
217223
return pullRebase.text().trim() !== "false" ? "rebase" : "merge";
218224
}

src/commands/push.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ export function registerPushCommand(program: Command, getCtx: () => ArbContext):
110110
a.outcome === "will-force-push"
111111
? ["push", "-u", "--force-with-lease", a.publishRemote, a.branch]
112112
: ["push", "-u", a.publishRemote, a.branch];
113-
const pushResult = await Bun.$`git -C ${a.repoDir} ${pushArgs}`.quiet().nothrow();
113+
const pushResult = await Bun.$`git -C ${a.repoDir} ${pushArgs}`.cwd(a.repoDir).quiet().nothrow();
114114
if (pushResult.exitCode === 0) {
115115
inlineResult(a.repo, `pushed ${plural(a.ahead, "commit")}`);
116116
pushOk++;
@@ -171,7 +171,7 @@ async function assessPushRepo(
171171
if (!(await remoteBranchExists(repoDir, branch, publishRemote))) {
172172
// Tracking config present means the branch was pushed before (set by git push -u).
173173
// If it's gone now, the remote branch was deleted (e.g. merged via PR).
174-
const trackingRemote = await Bun.$`git -C ${repoDir} config branch.${branch}.remote`.quiet().nothrow();
174+
const trackingRemote = await Bun.$`git -C ${repoDir} config branch.${branch}.remote`.cwd(repoDir).quiet().nothrow();
175175
if (trackingRemote.exitCode === 0 && trackingRemote.text().trim()) {
176176
return { ...base, skipReason: "remote branch gone" };
177177
}

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Command, type Help } from "commander";
22
import { registerAddCommand } from "./commands/add";
3+
import { registerCdCommand } from "./commands/cd";
34
import { registerCloneCommand } from "./commands/clone";
45
import { registerCreateCommand } from "./commands/create";
56
import { registerDropCommand } from "./commands/drop";
@@ -178,6 +179,7 @@ registerCreateCommand(program, getCtx);
178179
registerRemoveCommand(program, getCtx);
179180
registerListCommand(program, getCtx);
180181
registerPathCommand(program, getCtx);
182+
registerCdCommand(program, getCtx);
181183
registerAddCommand(program, getCtx);
182184
registerDropCommand(program, getCtx);
183185
registerStatusCommand(program, getCtx);

0 commit comments

Comments
 (0)