diff --git a/README.md b/README.md index 360dff52..6e9bfff8 100644 --- a/README.md +++ b/README.md @@ -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" @@ -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) diff --git a/bun.lock b/bun.lock index 47437c4e..653313e7 100644 --- a/bun.lock +++ b/bun.lock @@ -8,6 +8,7 @@ "@inquirer/checkbox": "^5.0.4", "@inquirer/confirm": "^6.0.4", "@inquirer/input": "^5.0.4", + "@inquirer/select": "^5.0.6", "commander": "^13", }, "devDependencies": { @@ -88,6 +89,8 @@ "@inquirer/input": ["@inquirer/input@5.0.4", "", { "dependencies": { "@inquirer/core": "^11.1.1", "@inquirer/type": "^4.0.3" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-4B3s3jvTREDFvXWit92Yc6jF1RJMDy2VpSqKtm4We2oVU65YOh2szY5/G14h4fHlyQdpUmazU5MPCFZPRJ0AOw=="], + "@inquirer/select": ["@inquirer/select@5.0.6", "", { "dependencies": { "@inquirer/ansi": "^2.0.3", "@inquirer/core": "^11.1.3", "@inquirer/figures": "^2.0.3", "@inquirer/type": "^4.0.3" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-9DyVbNCo4q0C3CkGd6zW0SW3NQuuk4Hy0NSbP6zErz2YNWF4EHHJCRzcV34/CDQLraeAQXbHYlMofuUrs6BBZQ=="], + "@inquirer/type": ["@inquirer/type@4.0.3", "", { "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-cKZN7qcXOpj1h+1eTTcGDVLaBIHNMT1Rz9JqJP5MnEJ0JhgVWllx7H/tahUp5YEK1qaByH2Itb8wLG/iScD5kw=="], "@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="], @@ -144,8 +147,14 @@ "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + "fast-string-truncated-width": ["fast-string-truncated-width@3.0.3", "", {}, "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g=="], + + "fast-string-width": ["fast-string-width@3.0.2", "", { "dependencies": { "fast-string-truncated-width": "^3.0.2" } }, "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg=="], + "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="], + "fast-wrap-ansi": ["fast-wrap-ansi@0.2.0", "", { "dependencies": { "fast-string-width": "^3.0.2" } }, "sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w=="], + "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], "get-east-asian-width": ["get-east-asian-width@1.4.0", "", {}, "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q=="], @@ -234,6 +243,8 @@ "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], + "@inquirer/select/@inquirer/core": ["@inquirer/core@11.1.3", "", { "dependencies": { "@inquirer/ansi": "^2.0.3", "@inquirer/figures": "^2.0.3", "@inquirer/type": "^4.0.3", "cli-width": "^4.1.0", "fast-wrap-ansi": "^0.2.0", "mute-stream": "^3.0.0", "signal-exit": "^4.1.0" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-TBAGPDGvpwFSQ4nkawQzq5/X7DhElANjvKeUtcjpVnBIfuH/OEu4M+79R3+bGPtwxST4DOIGRtF933mUH2bRVw=="], + "cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], "conventional-commits-parser/meow": ["meow@13.2.0", "", {}, "sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA=="], diff --git a/package.json b/package.json index 053dca21..a628acc7 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/shell/arb.zsh b/shell/arb.zsh index 776540b5..daa51f66 100644 --- a/shell/arb.zsh +++ b/shell/arb.zsh @@ -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 "$@" } @@ -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' @@ -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:' \ diff --git a/src/commands/cd.ts b/src/commands/cd.ts new file mode 100644 index 00000000..03249f71 --- /dev/null +++ b/src/commands/cd.ts @@ -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 "); + 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'."); + } +} diff --git a/src/index.ts b/src/index.ts index 944b35f0..80374b6f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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"; @@ -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); diff --git a/test/arb.bats b/test/arb.bats index c00241ec..aa80c2c2 100644 --- a/test/arb.bats +++ b/test/arb.bats @@ -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" {