Skip to content

Commit e2e3be1

Browse files
henrikjeclaude
andcommitted
fix: set explicit cwd on all spawn calls to prevent posix_spawn ENOENT
When `arb remove` runs from inside the workspace being removed, rmSync deletes the user's cwd and subsequent Bun.spawn/Bun.$ calls crash with ENOENT because the OS validates the inherited cwd before exec. Adding an explicit cwd to every spawn site ensures a valid directory is always used. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 57b22bf commit e2e3be1

File tree

9 files changed

+45
-18
lines changed

9 files changed

+45
-18
lines changed

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/lib/git.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export async function detectOperation(repoDir: string): Promise<GitOperation> {
1515

1616
export async function git(repoDir: string, ...args: string[]): Promise<{ exitCode: number; stdout: string }> {
1717
const proc = Bun.spawn(["git", "-C", repoDir, ...args], {
18+
cwd: repoDir,
1819
stdin: "ignore",
1920
stdout: "pipe",
2021
stderr: "pipe",

src/lib/parallel-fetch.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ export async function parallelFetch(
5555

5656
for (const remote of remotesToFetch) {
5757
const proc = Bun.spawn(["git", "-C", repoDir, "fetch", "--prune", remote], {
58+
cwd: repoDir,
5859
stdout: "pipe",
5960
stderr: "pipe",
6061
});
@@ -87,7 +88,7 @@ export async function parallelFetch(
8788
results.set(repo, { repo, exitCode: lastExitCode, output: allOutput });
8889

8990
// Auto-detect remote HEAD on the upstream remote
90-
await Bun.$`git -C ${repoDir} remote set-head ${upstreamRemote} --auto`.quiet().nothrow();
91+
await Bun.$`git -C ${repoDir} remote set-head ${upstreamRemote} --auto`.cwd(repoDir).quiet().nothrow();
9192
} catch {
9293
results.set(repo, { repo, exitCode: 1, output: "fetch failed" });
9394
}

src/lib/workspace-branch.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,9 @@ export async function workspaceBranch(wsDir: string): Promise<WorkspaceBranchRes
2121

2222
// Config missing or empty — try to infer from first worktree
2323
const repoDirs = workspaceRepoDirs(wsDir);
24-
if (repoDirs.length > 0) {
25-
const result = await Bun.$`git -C ${repoDirs[0]} branch --show-current`.quiet().nothrow();
24+
const firstRepoDir = repoDirs[0];
25+
if (firstRepoDir) {
26+
const result = await Bun.$`git -C ${firstRepoDir} branch --show-current`.cwd(firstRepoDir).quiet().nothrow();
2627
if (result.exitCode === 0) {
2728
const branch = result.text().trim();
2829
if (branch) {

src/lib/worktrees.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,11 +120,14 @@ export async function addWorktrees(
120120
const branchExists = await branchExistsLocally(repoPath, branch);
121121

122122
// Prune stale worktrees
123-
await Bun.$`git -C ${repoPath} worktree prune`.quiet().nothrow();
123+
await Bun.$`git -C ${repoPath} worktree prune`.cwd(repoPath).quiet().nothrow();
124124

125125
if (branchExists) {
126126
inlineStart(repo, `attaching branch ${branch}`);
127-
const wt = await Bun.$`git -C ${repoPath} worktree add ${wsDir}/${repo} ${branch}`.quiet().nothrow();
127+
const wt = await Bun.$`git -C ${repoPath} worktree add ${wsDir}/${repo} ${branch}`
128+
.cwd(repoPath)
129+
.quiet()
130+
.nothrow();
128131
if (wt.exitCode !== 0) {
129132
inlineResult(repo, "failed");
130133
const errText = wt.stderr.toString().trim();
@@ -142,6 +145,7 @@ export async function addWorktrees(
142145
// (pushed, merged, remote branch deleted) vs never-pushed branches.
143146
const noTrack = repoHasRemote ? ["--no-track"] : [];
144147
const wt = await Bun.$`git -C ${repoPath} worktree add ${noTrack} -b ${branch} ${wsDir}/${repo} ${startPoint}`
148+
.cwd(repoPath)
145149
.quiet()
146150
.nothrow();
147151
if (wt.exitCode !== 0) {

test/arb.bats

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3486,3 +3486,13 @@ push_then_delete_remote() {
34863486
[[ "$output" == *"Removed workspace ws-solo"* ]]
34873487
}
34883488

3489+
@test "arb remove --force succeeds when cwd is inside the workspace being removed" {
3490+
arb create doomed repo-a repo-b
3491+
3492+
cd "$TEST_DIR/project/doomed"
3493+
run arb remove doomed --force
3494+
[ "$status" -eq 0 ]
3495+
[[ "$output" == *"Removed workspace doomed"* ]]
3496+
[ ! -d "$TEST_DIR/project/doomed" ]
3497+
}
3498+

0 commit comments

Comments
 (0)