From 90984c4316ab9d3cac694eccae21fc053f953488 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Thu, 27 Aug 2026 12:14:27 -0400 Subject: [PATCH] fix(#3734): backlog sentinels no longer create phase branches in query commit (#3933) * test(#3734): 999.x and 0.x backlog sentinels must not create phase branches * fix(#3734): gate query commit's phase-branch arm on isSentinelPhaseId * chore(#3734): changeset fragment (pr number backfilled after PR creation) * chore(#3734): backfill changeset PR number (3933) --------- Co-authored-by: sim --- .changeset/kind-quails-travel.md | 5 +++ src/commands.cts | 5 ++- tests/commands.test.cjs | 75 ++++++++++++++++++++++++++++++++ 3 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 .changeset/kind-quails-travel.md diff --git a/.changeset/kind-quails-travel.md b/.changeset/kind-quails-travel.md new file mode 100644 index 000000000..bee0e69df --- /dev/null +++ b/.changeset/kind-quails-travel.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 3933 +--- +**`/gsd-capture --backlog` no longer scatters backlog items across per-item branches** — `query commit`'s phase-branching arm now treats `999.x`/`0.x` backlog sentinels as non-phases, so a backlog capture commits on the current branch instead of silently creating and switching to a `gsd/phase-999.*` branch per item. (#3734) diff --git a/src/commands.cts b/src/commands.cts index 7d9223347..ebccd015b 100644 --- a/src/commands.cts +++ b/src/commands.cts @@ -1632,7 +1632,10 @@ function cmdCommit(cwd: string, message: string | undefined, files: string[] | u // — see #2528 for the parallel drift problem in phase-locator/phase), // so this is the canonical path-segment-bound read, not a fourth copy. const phaseNum = detectPhaseNumberFromFiles(files); - if (phaseNum) { + // #3734: a 999.x/0.x backlog sentinel is a parking-lot entry, not a real + // phase — the phase arm must never branch-mutate for it (isSentinelPhaseId + // is the invariant's single owner, src/phase-id.cts). + if (phaseNum && !isSentinelPhaseId(phaseNum)) { const phaseInfo = findPhaseInternal(cwd, phaseNum) as Record | null; if (phaseInfo) { branchName = (config['phase_branch_template'] as string) diff --git a/tests/commands.test.cjs b/tests/commands.test.cjs index 1cb190a81..9db3cf35f 100644 --- a/tests/commands.test.cjs +++ b/tests/commands.test.cjs @@ -1805,6 +1805,81 @@ describe('commit command', () => { `second commit must not re-warn once on the phase branch; got stderr=${secondStderr}` ); }); + + // #3734 — the phase arm of `query commit` must not treat a backlog sentinel + // phase id as a real phase. /gsd-capture --backlog commits the sentinel phase + // directory via add-backlog.md; pre-fix each capture created AND switched to a + // gsd/phase-999.- branch, scattering backlog items across branches + // and leaving the operator's branch at the base commit. + test('#3734: 999.x backlog sentinel commits on the current branch and creates no phase branch', () => { + const startBranch = gitOrThrow(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: tmpDir }).trim(); + fs.writeFileSync( + path.join(tmpDir, '.planning', 'config.json'), + JSON.stringify({ + commit_docs: true, + branching_strategy: 'phase', + phase_branch_template: 'gsd/phase-{phase}-{slug}', + }) + ); + fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '999.42-first-idea'), { recursive: true }); + fs.writeFileSync( + path.join(tmpDir, '.planning', 'phases', '999.42-first-idea', '.gitkeep'), '# backlog marker\n' + ); + + const result = runGsdTools( + 'commit "docs: add backlog item 999.42 — first idea" --files .planning/phases/999.42-first-idea/.gitkeep', + tmpDir + ); + assert.ok(result.success, `Command failed: ${result.error}`); + const output = JSON.parse(result.output); + assert.strictEqual(output.committed, true, 'sentinel capture must still commit'); + + const endBranch = gitOrThrow(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: tmpDir }).trim(); + assert.strictEqual(endBranch, startBranch, '#3734: sentinel capture must not switch branches'); + const sentinelBranches = gitOrThrow( + ['branch', '--list', 'gsd/phase-999*'], { cwd: tmpDir } + ).trim(); + assert.strictEqual(sentinelBranches, '', '#3734: no gsd/phase-999.* branch may be created'); + // The "lost work" mode from the issue: the commit must be reachable on the + // branch the operator was actually on. gitOrThrow throws if the path is + // absent from startBranch, so reaching the content assertion proves it. + const onStartBranch = gitOrThrow( + ['show', `${startBranch}:.planning/phases/999.42-first-idea/.gitkeep`], { cwd: tmpDir } + ); + assert.ok(onStartBranch.includes('# backlog marker'), 'sentinel commit must land on the starting branch'); + }); + + test('#3734: 0.x backlog sentinel also never creates a phase branch', () => { + const startBranch = gitOrThrow(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: tmpDir }).trim(); + fs.writeFileSync( + path.join(tmpDir, '.planning', 'config.json'), + JSON.stringify({ + commit_docs: true, + branching_strategy: 'phase', + phase_branch_template: 'gsd/phase-{phase}-{slug}', + }) + ); + fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '0.3-icebox-idea'), { recursive: true }); + fs.writeFileSync(path.join(tmpDir, '.planning', 'phases', '0.3-icebox-idea', '.gitkeep'), '# icebox marker\n'); + + const result = runGsdTools( + 'commit "docs: add backlog item 0.3 — icebox idea" --files .planning/phases/0.3-icebox-idea/.gitkeep', + tmpDir + ); + assert.ok(result.success, `Command failed: ${result.error}`); + assert.strictEqual(JSON.parse(result.output).committed, true, '0.x capture must still commit'); + + const endBranch = gitOrThrow(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: tmpDir }).trim(); + assert.strictEqual(endBranch, startBranch, '#3734: 0.x sentinel must not switch branches'); + const sentinelBranches = gitOrThrow( + ['branch', '--list', 'gsd/phase-0*'], { cwd: tmpDir } + ).trim(); + assert.strictEqual(sentinelBranches, '', '#3734: no gsd/phase-0.* branch may be created'); + const onStartBranch = gitOrThrow( + ['show', `${startBranch}:.planning/phases/0.3-icebox-idea/.gitkeep`], { cwd: tmpDir } + ); + assert.ok(onStartBranch.includes('# icebox marker'), '0.x sentinel commit must land on the starting branch'); + }); }); // ─────────────────────────────────────────────────────────────────────────────