fix(#3207): create-and-switch on the first phase/milestone commit (#3363)

* test(#3207): invert fresh-create branching tests to expect create+switch

The four fresh-create branching tests (#3079) locked in create-without-
switch behavior that #3207 identifies as the regression: a fresh phase/
milestone branch is created but HEAD never moves onto it, so the first
phase/milestone-scoped commit lands on the base branch. Invert those
assertions to expect create+switch, and add two new tests:
- fresh-create is non-silent (logs the create+switch) (#3207 AC3)
- a second phase commit does not re-warn once HEAD is on the phase branch (#3207 AC5)

The existing-branch path (#2539) is deliberately byte-for-byte unchanged.

RED — fails on next; the fix follows in a separate fix: commit.

* fix(#3207): create-and-switch on the first phase/milestone commit

The #3079 fix (PR #3141) replaced git checkout -b with git branch
(create-only) unconditionally, including the case where the strategy
branch does not yet exist. That regressed #1278: the first phase- or
milestone-scoped commit no longer landed on the strategy branch — it
stayed on the base branch, and the strategy branch was left as an empty
marker pointing at the pre-phase tip. Every subsequent commit then
warned 'already exists; committing on the current branch instead of
switching', wording that misleads because the tool itself created the
branch moments before.

Re-separate the two cases #3079 collapsed:
- branch does NOT exist -> create AND switch (git checkout -b). The
  #3079 resurrection hazard cannot apply: the branch was just verified
  absent, so there is no merged-and-deleted ref to resurrect and no
  silent move onto an existing unrelated branch (#2539 AC2 is honored
  by the existing-branch arm, which is unchanged). The create is logged
  so the first phase-scoped commit is not silent (#3207 AC3).
- branch ALREADY exists -> unchanged: no switch, commit on current
  branch, non-silent warning (#2539/#3079).

Once the first commit switches HEAD onto the strategy branch, the
currentBranch !== branchName guard skips the block on subsequent
commits, so the misleading 'already exists' warning no longer recurs.

* chore(#3207): add changeset fragment

* chore(#3207): backfill changeset PR number (#3363)

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-08-11 18:08:39 -04:00
committed by GitHub
parent 0396d9cab1
commit e7993d77bf
3 changed files with 145 additions and 33 deletions

View File

@@ -1447,7 +1447,7 @@ describe('commit command', () => {
const logCount = gitOrThrow(['log', '--oneline'], { cwd: tmpDir }).trim().split('\n').length;
assert.strictEqual(logCount, 2, 'should have 2 commits (initial + amended)');
});
test('creates strategy branch before first commit when branching_strategy is milestone (#3079: no switch)', () => {
test('#3207: creates AND switches to the milestone branch before first commit', () => {
// Configure milestone branching strategy
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
@@ -1472,15 +1472,17 @@ describe('commit command', () => {
const output = JSON.parse(result.output);
assert.strictEqual(output.committed, true, 'should have committed');
// #3079: the branch should be CREATED but NOT switched to.
// #3207: the branch should be CREATED and HEAD switched to it, so the first
// milestone-scoped commit lands on the milestone branch (#1278 intent). The
// prior #3079 no-switch behavior regressed this for fresh creates.
const branch = gitOrThrow(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: tmpDir }).trim();
assert.notStrictEqual(branch, 'gsd/v1.0-initial-release', '#3079: must NOT switch to the milestone branch');
// Verify the branch WAS created (exists as a ref)
const branchExists = gitOrThrow(['rev-parse', '--verify', 'gsd/v1.0-initial-release'], { cwd: tmpDir });
assert.ok(branchExists.trim(), 'milestone branch should be created even without switching');
assert.strictEqual(branch, 'gsd/v1.0-initial-release', '#3207: must switch to the milestone branch');
// The commit must be reachable on the milestone branch (HEAD is on it).
const committedFile = gitOrThrow(['show', 'HEAD:.planning/test-context.md'], { cwd: tmpDir });
assert.ok(committedFile.includes('# Context'), 'milestone commit must land on the milestone branch');
});
test('creates strategy branch before first commit when branching_strategy is phase (#3079: no switch)', () => {
test('#3207: creates AND switches to the phase branch before first commit', () => {
// Configure phase branching strategy
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
@@ -1509,14 +1511,16 @@ describe('commit command', () => {
const output = JSON.parse(result.output);
assert.strictEqual(output.committed, true, 'should have committed');
// #3079: the branch should be CREATED but NOT switched to. The commit
// lands on the current branch (master/main), and the phase branch exists
// as a ref but HEAD did not move.
// #3207: the branch should be CREATED and HEAD switched to it, so the first
// phase-scoped commit lands on the phase branch (#1278 intent). The prior
// #3079 no-switch behavior regressed this for fresh creates.
const branch = gitOrThrow(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: tmpDir }).trim();
assert.notStrictEqual(branch, 'gsd/phase-01-setup', '#3079: must NOT switch to the phase branch');
// Verify the branch WAS created (exists as a ref)
const branchExists = gitOrThrow(['rev-parse', '--verify', 'gsd/phase-01-setup'], { cwd: tmpDir });
assert.ok(branchExists.trim(), 'phase branch should be created even without switching');
assert.strictEqual(branch, 'gsd/phase-01-setup', '#3207: must switch to the phase branch');
// The commit must be reachable on the phase branch (HEAD is on it).
const committedFile = gitOrThrow(
['show', 'HEAD:.planning/phases/01-setup/01-CONTEXT.md'], { cwd: tmpDir }
);
assert.ok(committedFile.includes('# Context'), 'phase commit must land on the phase branch');
});
test('decimal phase numbers are captured correctly in branching strategy', () => {
@@ -1548,9 +1552,9 @@ describe('commit command', () => {
const output = JSON.parse(result.output);
assert.strictEqual(output.committed, true, 'should have committed');
// #3079: verify branch is created but NOT switched to (decimal phase)
// #3207: the branch should be CREATED and HEAD switched to it (decimal phase).
const branch = gitOrThrow(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: tmpDir }).trim();
assert.notStrictEqual(branch, 'gsd/phase-45.14-golden-capture', '#3079: must NOT switch to the phase branch');
assert.strictEqual(branch, 'gsd/phase-45.14-golden-capture', '#3207: must switch to the decimal phase branch');
// Verify the correct branch name was resolved (not integer-only)
const branchExists = gitOrThrow(['rev-parse', '--verify', 'gsd/phase-45.14-golden-capture'], { cwd: tmpDir });
assert.ok(branchExists.trim(), 'decimal phase branch should be created (45.14, not 14)');
@@ -1610,15 +1614,21 @@ describe('commit command', () => {
const output = JSON.parse(result.output);
assert.strictEqual(output.committed, true, 'should have committed');
// #3079: the commit no longer switches to the phase branch. The phase-07
// branch should be CREATED (resolving correctly to 07, not the archived 02),
// but the commit lands on the current branch.
// #3207: the commit now CREATES and SWITCHES to the phase branch. The
// resolved branch is phase-07 (correct, not the archived 02), and HEAD
// moves onto it so the phase's work accumulates there.
const branch = gitOrThrow(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: tmpDir }).trim();
assert.notStrictEqual(
branch,
'gsd/phase-02-archived-phase',
`must NOT be on the archived phase-02 branch (got ${branch})`
);
// #3207: HEAD must land on the CORRECT freshly-created phase-07 branch.
assert.strictEqual(
branch,
'gsd/phase-07-active-phase',
`must switch onto the correct phase-07 branch (got ${branch})`
);
// Verify the correct phase-07 branch was created (not the archived 02)
const phase07Exists = gitOrThrow(
['rev-parse', '--verify', 'gsd/phase-07-active-phase'],
@@ -1702,6 +1712,96 @@ describe('commit command', () => {
`expected a non-silent warning on stderr when the resolved branch already exists; got stderr=${stderr}`
);
});
// #3207 AC3: the fresh-create path must NOT be silent. Pre-fix the first
// phase-scoped commit produced no output at all, so the divergence between
// "phase branch exists" and "phase work is on it" started invisibly. The fix
// logs the create+switch on stderr.
test('#3207: fresh phase-branch create is non-silent (logs create+switch)', () => {
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', '01-setup'), { recursive: true });
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
'# Roadmap\n\n## Phase 1: Setup\nGoal: Initial setup\n'
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'phases', '01-setup', '01-CONTEXT.md'), '# Context\n'
);
// Observe stderr on the success path via the process seam (execFileSync
// discards stderr on success — same reason the #2539 test uses runNode).
const { TOOLS_PATH } = require('./helpers.cjs');
const proc = runNode([
TOOLS_PATH, 'commit', 'docs(01): add context',
'--files', '.planning/phases/01-setup/01-CONTEXT.md',
], { cwd: tmpDir });
throwIfFailed(proc, 'gsd-tools commit (#3207 non-silent fixture)');
const stderr = proc.stderr || '';
// The fresh create must announce itself — not the "already exists" wording
// (that belongs to the existing-branch path) but a create+switch notice.
assert.ok(
/created.*switched|switched.*created/i.test(stderr) ||
/phase-01-setup/i.test(stderr),
`expected a non-silent create+switch notice on stderr; got stderr=${stderr}`
);
});
// #3207 AC5: once the first commit has switched HEAD onto the phase branch,
// a second phase-scoped commit must NOT emit the misleading "already exists"
// warning — the currentBranch === branchName guard skips the block entirely.
test('#3207: second phase commit does not re-warn once HEAD is on the phase branch', () => {
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', '01-setup'), { recursive: true });
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
'# Roadmap\n\n## Phase 1: Setup\nGoal: Initial setup\n'
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'phases', '01-setup', '01-CONTEXT.md'), '# Context\n'
);
const { TOOLS_PATH } = require('./helpers.cjs');
// First commit — fresh create, switches onto the phase branch.
const first = runNode([
TOOLS_PATH, 'commit', 'docs(01): first',
'--files', '.planning/phases/01-setup/01-CONTEXT.md',
], { cwd: tmpDir });
throwIfFailed(first, 'gsd-tools commit (#3207 first)');
const branchAfterFirst = gitOrThrow(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: tmpDir }).trim();
assert.strictEqual(branchAfterFirst, 'gsd/phase-01-setup', 'first commit must switch onto the phase branch');
// Second commit — HEAD is already on the phase branch, so the block is
// skipped and NO "already exists" warning should appear.
fs.writeFileSync(
path.join(tmpDir, '.planning', 'phases', '01-setup', '02-NOTES.md'), '# Notes\n'
);
const second = runNode([
TOOLS_PATH, 'commit', 'docs(01): second',
'--files', '.planning/phases/01-setup/02-NOTES.md',
], { cwd: tmpDir });
throwIfFailed(second, 'gsd-tools commit (#3207 second)');
const secondStderr = second.stderr || '';
assert.ok(
!/already exists/i.test(secondStderr),
`second commit must not re-warn once on the phase branch; got stderr=${secondStderr}`
);
});
});
// ─────────────────────────────────────────────────────────────────────────────