Skip to content

Commit e941ca3

Browse files
authored
Merge pull request #59 from hack-dance/fix/review-comments-round2
fix: address post-merge review findings (JSON stdout purity, branch slug collisions, dedupe over-collapse)
2 parents 51f2874 + 11f5788 commit e941ca3

8 files changed

Lines changed: 169 additions & 6 deletions

File tree

src/backends/runtime-backend.ts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@ export interface RuntimeBaseOptions {
2020
readonly profiles?: readonly string[];
2121
readonly cwd: string;
2222
readonly env?: Record<string, string>;
23+
/**
24+
* Route subprocess stdout to stderr — required by `--json` callers whose
25+
* stdout must remain a single parseable envelope.
26+
*/
27+
readonly routeStdoutToStderr?: boolean;
2328
}
2429

2530
export interface RuntimeUpOptions extends RuntimeBaseOptions {
@@ -68,7 +73,11 @@ export const composeRuntimeBackend: RuntimeBackend = {
6873
...(opts.detach ? ["-d"] : []),
6974
];
7075
if (opts.detach) {
71-
return await run(cmd, { cwd: opts.cwd, env: opts.env });
76+
return await run(cmd, {
77+
cwd: opts.cwd,
78+
env: opts.env,
79+
stdout: opts.routeStdoutToStderr ? "stderr" : "inherit",
80+
});
7281
}
7382

7483
const env = mergeSpawnEnv(opts.env);
@@ -80,8 +89,11 @@ export const composeRuntimeBackend: RuntimeBackend = {
8089
stderr: "pipe",
8190
});
8291

92+
const stdoutTarget = opts.routeStdoutToStderr
93+
? process.stderr
94+
: process.stdout;
8395
const stdoutGrouper = createStructuredLogGrouper({
84-
write: (text) => process.stdout.write(text),
96+
write: (text) => stdoutTarget.write(text),
8597
});
8698
const stderrGrouper = createStructuredLogGrouper({
8799
write: (text) => process.stderr.write(text),
@@ -107,7 +119,11 @@ export const composeRuntimeBackend: RuntimeBackend = {
107119
},
108120
async down(opts) {
109121
const cmd = [...buildComposeArgs(opts), "down"];
110-
return await run(cmd, { cwd: opts.cwd, env: opts.env });
122+
return await run(cmd, {
123+
cwd: opts.cwd,
124+
env: opts.env,
125+
stdout: opts.routeStdoutToStderr ? "stderr" : "inherit",
126+
});
111127
},
112128
async psJson(opts) {
113129
const cmd = [...buildComposeArgs(opts), "ps", "--format", "json"];

src/commands/project.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5431,6 +5431,7 @@ async function runUpCommand({
54315431
detach,
54325432
cwd: dirname(project.composeFile),
54335433
env: envOverrides.env,
5434+
routeStdoutToStderr: json,
54345435
});
54355436
if (upCode !== 0) {
54365437
await removeProjectRuntimeStateEntry({
@@ -5673,6 +5674,7 @@ async function runDownCommand({
56735674
composeProject: composeProjectName,
56745675
profiles,
56755676
cwd: dirname(project.composeFile),
5677+
routeStdoutToStderr: json,
56765678
});
56775679

56785680
try {
@@ -5756,6 +5758,7 @@ async function runRestartDownPhase(opts: {
57565758
readonly lifecycleComposeProject: string;
57575759
readonly profiles: readonly string[];
57585760
readonly branch: string | null;
5761+
readonly routeStdoutToStderr?: boolean;
57595762
readonly envForCompose: Readonly<Record<string, string>>;
57605763
}): Promise<number> {
57615764
const downBefore = await runLifecycleCommands({
@@ -5775,6 +5778,7 @@ async function runRestartDownPhase(opts: {
57755778
composeProject: opts.composeProjectName,
57765779
profiles: opts.profiles,
57775780
cwd: dirname(opts.project.composeFile),
5781+
routeStdoutToStderr: opts.routeStdoutToStderr === true,
57785782
});
57795783
try {
57805784
await stopLifecycleProcesses({
@@ -5821,6 +5825,7 @@ async function runRestartUpPhase(opts: {
58215825
readonly branch: string | null;
58225826
readonly envName?: string | null;
58235827
readonly detach?: boolean;
5828+
readonly routeStdoutToStderr?: boolean;
58245829
}): Promise<number> {
58255830
await maybeSyncOauthAliasesInCompose({ project: opts.project });
58265831

@@ -5906,6 +5911,7 @@ async function runRestartUpPhase(opts: {
59065911
detach: opts.detach === true,
59075912
cwd: dirname(opts.project.composeFile),
59085913
env: envOverrides.env,
5914+
routeStdoutToStderr: opts.routeStdoutToStderr === true,
59095915
});
59105916
if (upCode !== 0) {
59115917
await removeProjectRuntimeStateEntry({
@@ -6043,6 +6049,7 @@ async function runRestartCommand({
60436049
profiles,
60446050
branch,
60456051
envForCompose: lifecycleEnv,
6052+
routeStdoutToStderr: json,
60466053
});
60476054
if (downCode !== 0) {
60486055
if (json) {
@@ -6074,6 +6081,7 @@ async function runRestartCommand({
60746081
envName: effectiveEnvName,
60756082
// `--json` implies detach so the command terminates with a result.
60766083
detach: json,
6084+
routeStdoutToStderr: json,
60776085
});
60786086

60796087
if (!json) {

src/init/validation.ts

466 Bytes
Binary file not shown.

src/lib/branches.ts

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
import { ensureDir, readTextFile, writeTextFileIfChanged } from "./fs.ts";
77
import {
88
isLinkedGitWorktree,
9+
listGitWorktrees,
910
resolveGitCurrentBranch,
1011
} from "./git-worktree.ts";
1112
import { getString, isRecord } from "./guards.ts";
@@ -213,7 +214,46 @@ export async function resolveEffectiveBranch(opts: {
213214
return { branch: null, source: "none", gitBranch };
214215
}
215216

216-
return { branch: slug, source: "worktree", gitBranch };
217+
const branch = await resolveCollisionFreeBranchSlug({
218+
slug,
219+
gitBranch,
220+
projectRoot: opts.projectRoot,
221+
});
222+
return { branch, source: "worktree", gitBranch };
223+
}
224+
225+
/**
226+
* Guards the auto-derived branch slug against sanitization collisions:
227+
* `feature/api` and `feature-api` both sanitize to `feature-api`, which
228+
* would silently share one branch instance between two worktrees (and let
229+
* either stop the other's containers). When another linked worktree's
230+
* branch sanitizes to the same slug from a DIFFERENT raw name, both sides
231+
* deterministically get a short raw-name hash suffix, keeping every
232+
* worktree's derived instance distinct and stable across invocations.
233+
*/
234+
async function resolveCollisionFreeBranchSlug(opts: {
235+
readonly slug: string;
236+
readonly gitBranch: string;
237+
readonly projectRoot: string;
238+
}): Promise<string> {
239+
const worktrees = await listGitWorktrees({ repoRoot: opts.projectRoot });
240+
if (worktrees === null) {
241+
return opts.slug;
242+
}
243+
const colliding = worktrees.some(
244+
(entry) =>
245+
entry.branch !== null &&
246+
entry.branch !== opts.gitBranch &&
247+
sanitizeBranchSlug(entry.branch) === opts.slug
248+
);
249+
if (!colliding) {
250+
return opts.slug;
251+
}
252+
const hash = new Bun.CryptoHasher("sha1")
253+
.update(opts.gitBranch)
254+
.digest("hex")
255+
.slice(0, 4);
256+
return `${opts.slug}-${hash}`;
217257
}
218258

219259
function defaultBranchesFile(): BranchesFile {

src/lib/shell.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,12 @@ export interface RunOptions {
5858
readonly cwd?: string;
5959
readonly env?: Record<string, string>;
6060
readonly stdin?: "inherit" | "pipe" | "ignore";
61+
/**
62+
* Route the child's stdout to THIS process's stderr (fd 2). Used by
63+
* `--json` code paths where stdout must stay a single parseable
64+
* envelope while subprocess output remains visible to humans.
65+
*/
66+
readonly stdout?: "inherit" | "stderr";
6167
}
6268

6369
export async function run(
@@ -68,7 +74,7 @@ export async function run(
6874
cwd: opts.cwd,
6975
env: buildSpawnEnv(opts.env),
7076
stdin: opts.stdin ?? "inherit",
71-
stdout: "inherit",
77+
stdout: opts.stdout === "stderr" ? 2 : "inherit",
7278
stderr: "inherit",
7379
});
7480

tests/branches-effective-branch.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,3 +134,46 @@ test("non-git directories resolve to the base instance", async () => {
134134
expect(resolved.source).toBe("none");
135135
expect(resolved.branch).toBeNull();
136136
});
137+
138+
test("colliding sanitized slugs across worktrees get deterministic distinct suffixes", async () => {
139+
const fixture = await createFixture({ branch: "feature/api" });
140+
const secondRoot = resolve(fixture.primaryRoot, "..", "linked-2");
141+
await runGit(
142+
["worktree", "add", "-b", "feature-api", secondRoot],
143+
fixture.primaryRoot
144+
);
145+
146+
const first = await resolveEffectiveBranch({
147+
explicitBranch: null,
148+
projectRoot: fixture.linkedRoot,
149+
autoBranchEnabled: true,
150+
});
151+
const second = await resolveEffectiveBranch({
152+
explicitBranch: null,
153+
projectRoot: secondRoot,
154+
autoBranchEnabled: true,
155+
});
156+
157+
expect(first.branch).not.toBe(second.branch);
158+
expect(first.branch).toStartWith("feature-api-");
159+
expect(second.branch).toStartWith("feature-api-");
160+
161+
const firstAgain = await resolveEffectiveBranch({
162+
explicitBranch: null,
163+
projectRoot: fixture.linkedRoot,
164+
autoBranchEnabled: true,
165+
});
166+
expect(firstAgain.branch).toBe(first.branch);
167+
});
168+
169+
test("non-colliding worktree slugs stay unsuffixed", async () => {
170+
const fixture = await createFixture({ branch: "feature/solo" });
171+
172+
const resolved = await resolveEffectiveBranch({
173+
explicitBranch: null,
174+
projectRoot: fixture.linkedRoot,
175+
autoBranchEnabled: true,
176+
});
177+
178+
expect(resolved.branch).toBe("feature-solo");
179+
});

tests/init-discovery-validation.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,16 @@ test("dedupeCandidatesByPackage keeps the highest-scored script per package", ()
125125
expect(result.dropped).toEqual([{ candidate: start, keptScriptName: "dev" }]);
126126
});
127127

128+
test("dedupeCandidatesByPackage preserves distinct dev:* services in a single package", () => {
129+
const web = candidate({ id: "root:dev:web", scriptName: "dev:web" });
130+
const api = candidate({ id: "root:dev:api", scriptName: "dev:api" });
131+
132+
const result = dedupeCandidatesByPackage({ candidates: [web, api] });
133+
134+
expect(result.selected).toEqual([web, api]);
135+
expect(result.dropped).toEqual([]);
136+
});
137+
128138
test("dedupeAggregatorCandidates drops a root --filter aggregator in favor of the package's own script", () => {
129139
const webDev = candidate({
130140
id: "apps/web/package.json:dev",

tests/runtime-backend.test.ts

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { afterAll, beforeAll, beforeEach, expect, test } from "bun:test";
33
import { registerScopedModuleMock } from "./helpers/scoped-module-mock.ts";
44

55
const runCalls: string[][] = [];
6+
const runOpts: { stdout?: string }[] = [];
67
const execCalls: string[][] = [];
78

89
const shellMock = await registerScopedModuleMock({
@@ -17,8 +18,12 @@ const shellMock = await registerScopedModuleMock({
1718
execCalls.push([...cmd]);
1819
return { exitCode: 0, stdout: "", stderr: "" };
1920
},
20-
run: async (cmd: readonly string[]) => {
21+
run: async (
22+
cmd: readonly string[],
23+
opts: { readonly stdout?: string } = {}
24+
) => {
2125
runCalls.push([...cmd]);
26+
runOpts.push({ stdout: opts.stdout });
2227
return 0;
2328
},
2429
findExecutableInPath: () => "/usr/bin/docker",
@@ -56,6 +61,7 @@ beforeAll(() => {
5661

5762
beforeEach(() => {
5863
runCalls.length = 0;
64+
runOpts.length = 0;
5965
execCalls.length = 0;
6066
});
6167

@@ -287,3 +293,37 @@ test("composeRuntimeBackend.exec disables TTY for non-interactive sessions", asy
287293
"dev",
288294
]);
289295
});
296+
297+
test("detached up routes stdout to stderr when requested (--json purity)", async () => {
298+
const composeRuntimeBackend = await loadComposeRuntimeBackend();
299+
await composeRuntimeBackend.up({
300+
composeFiles: ["/tmp/compose.yml"],
301+
composeProject: "demo",
302+
detach: true,
303+
cwd: "/tmp",
304+
routeStdoutToStderr: true,
305+
});
306+
expect(runOpts[0]?.stdout).toBe("stderr");
307+
});
308+
309+
test("detached up inherits stdout by default", async () => {
310+
const composeRuntimeBackend = await loadComposeRuntimeBackend();
311+
await composeRuntimeBackend.up({
312+
composeFiles: ["/tmp/compose.yml"],
313+
composeProject: "demo",
314+
detach: true,
315+
cwd: "/tmp",
316+
});
317+
expect(runOpts[0]?.stdout).toBe("inherit");
318+
});
319+
320+
test("down routes stdout to stderr when requested (--json purity)", async () => {
321+
const composeRuntimeBackend = await loadComposeRuntimeBackend();
322+
await composeRuntimeBackend.down({
323+
composeFiles: ["/tmp/compose.yml"],
324+
composeProject: "demo",
325+
cwd: "/tmp",
326+
routeStdoutToStderr: true,
327+
});
328+
expect(runOpts[0]?.stdout).toBe("stderr");
329+
});

0 commit comments

Comments
 (0)