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 <sim@local>
This commit is contained in:
Tom Boucher
2026-08-27 12:14:27 -04:00
committed by GitHub
parent c5f2b94b27
commit 90984c4316
3 changed files with 84 additions and 1 deletions

View File

@@ -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)

View File

@@ -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<string, unknown> | null;
if (phaseInfo) {
branchName = (config['phase_branch_template'] as string)

View File

@@ -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.<n>-<slug> 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');
});
});
// ─────────────────────────────────────────────────────────────────────────────