Skip to content

Commit f50f408

Browse files
committed
fix(cli): preserve usage code for lifecycle json
1 parent 966b863 commit f50f408

2 files changed

Lines changed: 83 additions & 4 deletions

File tree

src/commands/project.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -711,6 +711,13 @@ function emitLifecycleResult(opts: {
711711
return opts.exitCode;
712712
}
713713

714+
function buildLifecycleJsonErrorResult(opts: { readonly error: unknown }) {
715+
if (opts.error instanceof CliUsageError) {
716+
return errorResult({ code: "E_USAGE", message: opts.error.message });
717+
}
718+
return errorResultFromUnknown({ error: opts.error });
719+
}
720+
714721
/**
715722
* Envelope for lifecycle actions that were routed to a remote node — the
716723
* remote runner owns per-service detail, so the payload reports the routing
@@ -5303,7 +5310,7 @@ async function handleUp({
53035310
return await runUpCommand({ ctx, args, json: true, startedAtMs });
53045311
} catch (error: unknown) {
53055312
return emitLifecycleResult({
5306-
result: errorResultFromUnknown({ error }),
5313+
result: buildLifecycleJsonErrorResult({ error }),
53075314
exitCode: 1,
53085315
});
53095316
}
@@ -5628,7 +5635,7 @@ async function handleDown({
56285635
});
56295636
}
56305637
return emitLifecycleResult({
5631-
result: errorResultFromUnknown({ error }),
5638+
result: buildLifecycleJsonErrorResult({ error }),
56325639
exitCode: 1,
56335640
});
56345641
}
@@ -6026,7 +6033,7 @@ async function handleRestart({
60266033
});
60276034
}
60286035
return emitLifecycleResult({
6029-
result: errorResultFromUnknown({ error }),
6036+
result: buildLifecycleJsonErrorResult({ error }),
60306037
exitCode: 1,
60316038
});
60326039
}

tests/lifecycle-json.test.ts

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { afterEach, beforeEach, expect, test } from "bun:test";
2-
import { mkdtemp, rm } from "node:fs/promises";
2+
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
33
import { tmpdir } from "node:os";
44
import { join } from "node:path";
55

@@ -116,6 +116,78 @@ test("usage errors under --json emit an E_USAGE envelope instead of help text",
116116
expect(result.stdout).not.toContain("Usage:");
117117
});
118118

119+
for (const action of ["up", "down", "restart"] as const) {
120+
test(`${action} --json preserves E_USAGE for a detached linked worktree`, async () => {
121+
const worktreeRoot = await createDetachedWorktreeFixture();
122+
const result = await runCliWithCapturedOutput([
123+
action,
124+
"--json",
125+
"--path",
126+
worktreeRoot,
127+
]);
128+
129+
expect(result.exitCode).toBe(1);
130+
const parsed = JSON.parse(result.stdout) as {
131+
ok: boolean;
132+
error?: { code: string; message: string };
133+
};
134+
expect(parsed.ok).toBe(false);
135+
expect(parsed.error?.code).toBe("E_USAGE");
136+
expect(parsed.error?.message).toContain("Detached linked worktree");
137+
expect(parsed.error?.message).toContain("--branch <name>");
138+
});
139+
}
140+
141+
async function createDetachedWorktreeFixture(): Promise<string> {
142+
if (!tempDir) {
143+
throw new Error("Missing temp directory");
144+
}
145+
146+
const primaryRoot = join(tempDir, "repo-primary");
147+
const hackDir = join(primaryRoot, ".hack");
148+
await mkdir(hackDir, { recursive: true });
149+
await writeFile(
150+
join(hackDir, "hack.config.json"),
151+
`${JSON.stringify({ name: "json-worktree", dev_host: "json-worktree.hack" }, null, 2)}\n`
152+
);
153+
await writeFile(
154+
join(hackDir, "docker-compose.yml"),
155+
["services:", " api:", " image: alpine:3.20", ""].join("\n")
156+
);
157+
await writeFile(join(primaryRoot, "README.md"), "# fixture\n");
158+
159+
runGit({ cwd: primaryRoot, args: ["init", "-b", "main"] });
160+
runGit({
161+
cwd: primaryRoot,
162+
args: ["config", "user.email", "test@example.com"],
163+
});
164+
runGit({ cwd: primaryRoot, args: ["config", "user.name", "Test User"] });
165+
runGit({ cwd: primaryRoot, args: ["add", "."] });
166+
runGit({ cwd: primaryRoot, args: ["commit", "-m", "init"] });
167+
168+
const worktreeRoot = join(tempDir, "repo-worktree");
169+
runGit({
170+
cwd: primaryRoot,
171+
args: ["worktree", "add", "-b", "feature/json", worktreeRoot],
172+
});
173+
runGit({ cwd: worktreeRoot, args: ["checkout", "--detach"] });
174+
return worktreeRoot;
175+
}
176+
177+
function runGit(opts: {
178+
readonly cwd: string;
179+
readonly args: readonly string[];
180+
}): void {
181+
const result = Bun.spawnSync(["git", "-C", opts.cwd, ...opts.args], {
182+
stdin: "ignore",
183+
stdout: "pipe",
184+
stderr: "pipe",
185+
});
186+
if (result.exitCode !== 0) {
187+
throw new Error(Buffer.from(result.stderr).toString("utf8"));
188+
}
189+
}
190+
119191
async function runCliWithCapturedOutput(
120192
args: readonly string[]
121193
): Promise<CapturedRunResult> {

0 commit comments

Comments
 (0)