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
14 changes: 11 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,11 +85,11 @@ Here's the full lifecycle of a workspace — from creation to cleanup:
arb create fix-login frontend backend

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

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

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

### Navigate

`arb path` prints the absolute path to the arb root, a workspace, or a worktree from anywhere below the arb root:
`arb cd` changes into a workspace or worktree directory. It requires the shell integration installed by `install.sh`:

```bash
arb cd fix-login # cd into workspace
arb cd fix-login/frontend # cd into a specific worktree
arb cd # interactive workspace picker
```

`arb path` prints the absolute path to the arb root, a workspace, or a worktree — useful in scripts and shell pipelines:

```bash
arb path # /home/you/my-project (the arb root)
Expand Down
11 changes: 11 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"@inquirer/checkbox": "^5.0.4",
"@inquirer/confirm": "^6.0.4",
"@inquirer/input": "^5.0.4",
"@inquirer/select": "^5.0.6",
"commander": "^13"
},
"devDependencies": {
Expand Down
28 changes: 28 additions & 0 deletions shell/arb.zsh
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,17 @@ arb() {
cd "${_arb_recover:-/}" 2>/dev/null || cd "$HOME"
fi

if [[ "$1" == "cd" ]]; then
# Pass help flags through without capturing
case " ${*:2} " in
*" --help "*|*" -h "*) command arb cd "${@:2}"; return ;;
esac
local _arb_dir
_arb_dir="$(command arb cd "${@:2}")" || return
cd "$_arb_dir"
return
fi

command arb "$@"
}

Expand Down Expand Up @@ -52,6 +63,7 @@ _arb() {
'remove:Remove a workspace'
'list:List all workspaces'
'path:Print the path to the arb root or a workspace'
'cd:Change to a workspace directory'
'add:Add worktrees to the workspace'
'drop:Drop worktrees from the workspace'
'status:Show worktree status'
Expand All @@ -75,6 +87,22 @@ _arb() {
path)
_arguments '1:workspace:($ws_names)'
;;
cd)
local input="${words[2]:-}"
if [[ "$input" == */* ]]; then
# After slash: complete worktree names within the workspace
local ws_name="${input%%/*}"
local ws_dir="$base_dir/$ws_name"
if [[ -d "$ws_dir" ]]; then
local -a wt_names=(${ws_dir}/*(N/:t))
wt_names=(${wt_names:#.arbws})
compadd -p "$ws_name/" -a wt_names
fi
else
# Before slash: complete workspace names
_arguments '1:workspace:($ws_names)'
fi
;;
create)
_arguments \
'(-b --branch)'{-b,--branch}'[Branch name]:branch:' \
Expand Down
70 changes: 70 additions & 0 deletions src/commands/cd.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { existsSync } from "node:fs";
import select from "@inquirer/select";
import type { Command } from "commander";
import { error, info } from "../lib/output";
import { listWorkspaces } from "../lib/repos";
import type { ArbContext } from "../lib/types";

export function registerCdCommand(program: Command, getCtx: () => ArbContext): void {
program
.command("cd [name]")
.summary("Navigate to a workspace directory")
.description(
'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.',
)
.action(async (input?: string) => {
const ctx = getCtx();

if (!input) {
if (!process.stdin.isTTY) {
error("Usage: arb cd <workspace>");
process.exit(1);
}

const workspaces = listWorkspaces(ctx.baseDir);
if (workspaces.length === 0) {
error("No workspaces found.");
process.exit(1);
}

const selected = await select({
message: "Select a workspace",
choices: workspaces.map((name) => ({ name, value: name })),
pageSize: 20,
});

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

const slashIdx = input.indexOf("/");
const wsName = slashIdx >= 0 ? input.slice(0, slashIdx) : input;
const subpath = slashIdx >= 0 ? input.slice(slashIdx + 1) : "";

const wsDir = `${ctx.baseDir}/${wsName}`;
if (!existsSync(`${wsDir}/.arbws`)) {
error(`Workspace '${wsName}' does not exist`);
process.exit(1);
}

if (subpath) {
const fullPath = `${wsDir}/${subpath}`;
if (!existsSync(fullPath)) {
error(`'${subpath}' not found in workspace '${wsName}'`);
process.exit(1);
}
process.stdout.write(`${fullPath}\n`);
} else {
process.stdout.write(`${wsDir}\n`);
}

printHintIfNeeded();
});
}

function printHintIfNeeded(): void {
if (process.stdout.isTTY && process.stderr.isTTY) {
info("Hint: install shell integration to cd directly. See 'arb cd --help'.");
}
}
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Command, type Help } from "commander";
import { registerAddCommand } from "./commands/add";
import { registerCdCommand } from "./commands/cd";
import { registerCloneCommand } from "./commands/clone";
import { registerCreateCommand } from "./commands/create";
import { registerDropCommand } from "./commands/drop";
Expand Down Expand Up @@ -178,6 +179,7 @@ registerCreateCommand(program, getCtx);
registerRemoveCommand(program, getCtx);
registerListCommand(program, getCtx);
registerPathCommand(program, getCtx);
registerCdCommand(program, getCtx);
registerAddCommand(program, getCtx);
registerDropCommand(program, getCtx);
registerStatusCommand(program, getCtx);
Expand Down
42 changes: 42 additions & 0 deletions test/arb.bats
Original file line number Diff line number Diff line change
Expand Up @@ -798,6 +798,48 @@ teardown() {
[[ "$output" == *"does not exist"* ]]
}

# ── cd ───────────────────────────────────────────────────────────

@test "arb cd prints correct workspace path" {
arb create my-feature --all-repos
run arb cd my-feature
[ "$status" -eq 0 ]
[ "$output" = "$TEST_DIR/project/my-feature" ]
}

@test "arb cd with subpath prints correct worktree path" {
arb create my-feature repo-a
run arb cd my-feature/repo-a
[ "$status" -eq 0 ]
[ "$output" = "$TEST_DIR/project/my-feature/repo-a" ]
}

@test "arb cd with nonexistent workspace fails" {
run arb cd does-not-exist
[ "$status" -ne 0 ]
[[ "$output" == *"does not exist"* ]]
}

@test "arb cd with nonexistent subpath fails" {
arb create my-feature repo-a
run arb cd my-feature/nonexistent
[ "$status" -ne 0 ]
[[ "$output" == *"not found in workspace"* ]]
}

@test "arb cd with no arg in non-TTY fails" {
run arb cd
[ "$status" -ne 0 ]
[[ "$output" == *"Usage: arb cd"* ]]
}

@test "arb cd rejects non-workspace directory" {
mkdir -p "$TEST_DIR/project/not-a-workspace"
run arb cd not-a-workspace
[ "$status" -ne 0 ]
[[ "$output" == *"does not exist"* ]]
}

# ── status ───────────────────────────────────────────────────────

@test "arb status shows base branch name" {
Expand Down
Loading