diff --git a/get-shit-done/bin/lib/commands.cjs b/get-shit-done/bin/lib/commands.cjs index 27baba554..3e83d8b1b 100644 --- a/get-shit-done/bin/lib/commands.cjs +++ b/get-shit-done/bin/lib/commands.cjs @@ -247,6 +247,43 @@ function cmdCommit(cwd, message, files, raw, amend, noVerify) { return; } + // Ensure branching strategy branch exists before first commit (#1278). + // Pre-execution workflows (discuss, plan, research) commit artifacts but the branch + // was previously only created during execute-phase — too late. + if (config.branching_strategy && config.branching_strategy !== 'none') { + let branchName = null; + if (config.branching_strategy === 'phase') { + // Determine which phase we're committing for from the file paths + const phaseMatch = (files || []).join(' ').match(/(\d+)-/); + if (phaseMatch) { + const phaseNum = phaseMatch[1]; + const phaseInfo = findPhaseInternal(cwd, phaseNum); + if (phaseInfo) { + branchName = config.phase_branch_template + .replace('{phase}', phaseInfo.phase_number) + .replace('{slug}', phaseInfo.phase_slug || 'phase'); + } + } + } else if (config.branching_strategy === 'milestone') { + const milestone = getMilestoneInfo(cwd); + if (milestone && milestone.version) { + branchName = config.milestone_branch_template + .replace('{milestone}', milestone.version) + .replace('{slug}', generateSlugInternal(milestone.name) || 'milestone'); + } + } + if (branchName) { + const currentBranch = execGit(cwd, ['rev-parse', '--abbrev-ref', 'HEAD']); + if (currentBranch.exitCode === 0 && currentBranch.stdout.trim() !== branchName) { + // Create branch if it doesn't exist, or switch to it if it does + const create = execGit(cwd, ['checkout', '-b', branchName]); + if (create.exitCode !== 0) { + execGit(cwd, ['checkout', branchName]); + } + } + } + } + // Stage files const filesToStage = files && files.length > 0 ? files : ['.planning/']; for (const file of filesToStage) { diff --git a/tests/commands.test.cjs b/tests/commands.test.cjs index 43fd08eb5..d465b52e3 100644 --- a/tests/commands.test.cjs +++ b/tests/commands.test.cjs @@ -1185,6 +1185,71 @@ describe('commit command', () => { const logCount = execSync('git log --oneline', { cwd: tmpDir, encoding: 'utf-8' }).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', () => { + // Configure milestone branching strategy + fs.writeFileSync( + path.join(tmpDir, '.planning', 'config.json'), + JSON.stringify({ + commit_docs: true, + branching_strategy: 'milestone', + milestone_branch_template: 'gsd/{milestone}-{slug}', + }) + ); + // getMilestoneInfo reads ROADMAP.md for milestone version/name + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + '## v1.0: Initial Release\n\n### Phase 1: Setup\n' + ); + + // Create a file to commit + fs.writeFileSync(path.join(tmpDir, '.planning', 'test-context.md'), '# Context\n'); + + const result = runGsdTools('commit "docs: add context" --files .planning/test-context.md', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.committed, true, 'should have committed'); + + // Verify we're on the strategy branch + const { execFileSync } = require('child_process'); + const branch = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: tmpDir, encoding: 'utf-8' }).trim(); + assert.strictEqual(branch, 'gsd/v1.0-initial-release', 'should be on milestone branch'); + }); + + test('creates strategy branch before first commit when branching_strategy is phase', () => { + // Configure phase branching strategy + fs.writeFileSync( + path.join(tmpDir, '.planning', 'config.json'), + JSON.stringify({ + commit_docs: true, + branching_strategy: 'phase', + phase_branch_template: 'gsd/phase-{phase}-{slug}', + }) + ); + // Create ROADMAP.md with a phase + 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' + ); + + // Create a context file for phase 1 + fs.writeFileSync(path.join(tmpDir, '.planning', 'phases', '01-setup', '01-CONTEXT.md'), '# Context\n'); + + const result = runGsdTools( + 'commit "docs(01): add context" --files .planning/phases/01-setup/01-CONTEXT.md', + tmpDir + ); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.committed, true, 'should have committed'); + + // Verify we're on the strategy branch + const { execFileSync } = require('child_process'); + const branch = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: tmpDir, encoding: 'utf-8' }).trim(); + assert.strictEqual(branch, 'gsd/phase-01-setup', 'should be on phase branch'); + }); }); // ─────────────────────────────────────────────────────────────────────────────