diff --git a/tests/quick-branching.test.cjs b/tests/quick-branching.test.cjs index 5697e4f13..4068029dd 100644 --- a/tests/quick-branching.test.cjs +++ b/tests/quick-branching.test.cjs @@ -1,52 +1,262 @@ /** * Quick task branching tests * - * Validates that /gsd-quick exposes branch_name from init and that the - * workflow checks out a dedicated quick-task branch when configured. + * Validates that /gsd-quick exposes branch_name from init and that the Step 2.5 + * "Handle quick-task branching" block: + * 1. Reuses an existing branch as-is (no rebase / no reset). + * 2. When the branch does not exist, creates it from origin/HEAD's default + * branch — never off the previous task's HEAD (#2916). + * + * Assertions are behavioral (run the bash block in a fixture git repo and + * inspect git state) and structural (parse the markdown for the step's bash + * block). No `.includes()` / regex grepping of raw markdown content — see + * CONTRIBUTING.md "no-source-grep" testing standard. */ const { test, describe } = require('node:test'); const assert = require('node:assert/strict'); -const fs = require('fs'); -const path = require('path'); +const { execFileSync } = require('node:child_process'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const QUICK_PATH = path.join(__dirname, '..', 'get-shit-done', 'workflows', 'quick.md'); + +const GIT_ENV = Object.freeze({ + ...process.env, + GIT_AUTHOR_NAME: 'Test', + GIT_AUTHOR_EMAIL: 'test@test.com', + GIT_COMMITTER_NAME: 'Test', + GIT_COMMITTER_EMAIL: 'test@test.com', +}); + +function git(cwd, ...args) { + return execFileSync('git', args, { + cwd, + env: GIT_ENV, + stdio: ['pipe', 'pipe', 'pipe'], + }) + .toString() + .trim(); +} + +/** + * Structurally extract the bash code under the "Step 2.5: Handle quick-task + * branching" heading. We: + * 1. Locate the Step 2.5 heading. + * 2. Find the next horizontal rule (`---`) that ends the section. + * 3. Concatenate every fenced ```bash block in between. + * + * No `.includes()` content checks — fenced code blocks are parsed the same way + * a markdown parser would. + */ +function extractStep25Bash() { + const content = fs.readFileSync(QUICK_PATH, 'utf-8'); + const lines = content.split(/\r?\n/); + + let start = -1; + let end = -1; + for (let i = 0; i < lines.length; i += 1) { + if (start === -1 && /^\*\*Step 2\.5:\s*Handle quick-task branching\*\*\s*$/.test(lines[i])) { + start = i + 1; + } else if (start !== -1 && /^---\s*$/.test(lines[i])) { + end = i; + break; + } + } + if (start === -1) { + throw new Error('quick.md does not contain a "Step 2.5: Handle quick-task branching" section'); + } + if (end === -1) end = lines.length; + + const bashBlocks = []; + let inBash = false; + let buffer = []; + for (let i = start; i < end; i += 1) { + const line = lines[i]; + if (!inBash && /^```bash\s*$/.test(line)) { + inBash = true; + buffer = []; + continue; + } + if (inBash && /^```\s*$/.test(line)) { + bashBlocks.push(buffer.join('\n')); + inBash = false; + continue; + } + if (inBash) buffer.push(line); + } + if (bashBlocks.length === 0) { + throw new Error('Step 2.5 contains no ```bash code blocks to execute'); + } + return bashBlocks.join('\n'); +} + +/** + * Build a fixture: a bare "origin" repo with `main` (one commit), a clone with + * `origin/HEAD` pointed at `main`, and a checked-out previous-task branch + * carrying its own unmerged commit. + */ +function setupFixture() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-quick-branching-')); + const seedPath = path.join(root, 'seed'); + const originPath = path.join(root, 'origin.git'); + const clonePath = path.join(root, 'clone'); + + fs.mkdirSync(seedPath); + git(seedPath, 'init', '-b', 'main'); + git(seedPath, 'config', 'commit.gpgsign', 'false'); + fs.writeFileSync(path.join(seedPath, 'README.md'), '# seed\n'); + git(seedPath, 'add', 'README.md'); + git(seedPath, 'commit', '-m', 'initial'); + + git(root, 'clone', '--bare', seedPath, originPath); + git(originPath, 'symbolic-ref', 'HEAD', 'refs/heads/main'); + + git(root, 'clone', originPath, clonePath); + git(clonePath, 'config', 'commit.gpgsign', 'false'); + git(clonePath, 'config', 'user.email', 'test@test.com'); + git(clonePath, 'config', 'user.name', 'Test'); + + // Simulate finishing a previous quick task: branch off main, add a commit, + // and stay on it (this is the failure scenario from #2916). + git(clonePath, 'checkout', '-b', 'quick/01-prev-task'); + fs.writeFileSync(path.join(clonePath, 'prev.txt'), 'prev work\n'); + git(clonePath, 'add', 'prev.txt'); + git(clonePath, 'commit', '-m', 'prev quick task work'); + + return { root, clonePath }; +} + +function runStep(bash, cwd, branchName) { + // Write the script to a sibling tempdir, not inside the repo — putting it in + // `cwd` would create an untracked file that trips `git status --porcelain` + // and steers the step into the dirty-tree path. + const scriptDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-quick-step-')); + const scriptPath = path.join(scriptDir, 'step25.sh'); + const script = `#!/usr/bin/env bash\nset -uo pipefail\nbranch_name="${branchName}"\n${bash}\n`; + fs.writeFileSync(scriptPath, script, { mode: 0o755 }); + try { + return execFileSync('bash', [scriptPath], { + cwd, + env: GIT_ENV, + stdio: ['pipe', 'pipe', 'pipe'], + }).toString(); + } finally { + fs.rmSync(scriptDir, { recursive: true, force: true }); + } +} describe('quick workflow: branching support', () => { - const workflowPath = path.join(__dirname, '..', 'get-shit-done', 'workflows', 'quick.md'); - let content; - test('workflow file exists', () => { - assert.ok(fs.existsSync(workflowPath), 'workflows/quick.md should exist'); + assert.ok(fs.existsSync(QUICK_PATH), 'workflows/quick.md should exist'); }); test('init parse list includes branch_name', () => { - content = fs.readFileSync(workflowPath, 'utf-8'); - assert.ok(content.includes('branch_name'), 'quick workflow should parse branch_name from init JSON'); + // Structural: the workflow's init JSON parsing must mention branch_name as + // a top-level field. We assert this by parsing the init step's bash blocks + // for the `branch_name` JSON path / variable rather than substring-grep. + const content = fs.readFileSync(QUICK_PATH, 'utf-8'); + const lines = content.split(/\r?\n/); + // Walk every fenced bash block in the file; look for an assignment that + // reads a `branch_name` field (jq, awk, or shell parameter). + let found = false; + let inBash = false; + for (const line of lines) { + if (!inBash && /^```bash\s*$/.test(line)) { inBash = true; continue; } + if (inBash && /^```\s*$/.test(line)) { inBash = false; continue; } + if (!inBash) continue; + if (/\bbranch_name\b/.test(line)) { found = true; break; } + } + assert.ok(found, 'quick workflow should expose branch_name inside a bash block'); }); - test('workflow includes quick-task branching step', () => { - content = fs.readFileSync(workflowPath, 'utf-8'); - assert.ok(content.includes('Step 2.5: Handle quick-task branching')); - // Branching block must (a) honour the existing branch if present and - // (b) create new branches off origin/HEAD, not current HEAD (#2916). - assert.ok( - content.includes('git switch "$branch_name"'), - 'should reuse existing branch via git switch' - ); - assert.ok( - content.includes('refs/remotes/origin/HEAD'), - 'should detect default branch from origin/HEAD instead of branching off current HEAD' - ); - assert.ok( - content.includes('git checkout -b "$branch_name"'), - 'should create new branch via git checkout -b after switching to default' - ); + test('Step 2.5 section is present and contains executable bash', () => { + const bash = extractStep25Bash(); + assert.ok(bash.length > 0, 'Step 2.5 should contain at least one bash block'); }); - test('branching step runs before task directory creation', () => { - content = fs.readFileSync(workflowPath, 'utf-8'); + test('Step 2.5 runs before Step 3 (task directory creation)', () => { + const content = fs.readFileSync(QUICK_PATH, 'utf-8'); const branchingIndex = content.indexOf('Step 2.5: Handle quick-task branching'); const createDirIndex = content.indexOf('Step 3: Create task directory'); - assert.ok(branchingIndex !== -1 && createDirIndex !== -1, 'workflow should contain both branching and directory steps'); - assert.ok(branchingIndex < createDirIndex, 'branching should happen before quick task directories and commits'); + assert.ok( + branchingIndex !== -1 && createDirIndex !== -1, + 'workflow should contain both branching and directory steps' + ); + assert.ok( + branchingIndex < createDirIndex, + 'branching should happen before quick task directories and commits' + ); + }); + + test('new quick-task branch contains 0 commits inherited from previous-task HEAD (#2916)', () => { + const bash = extractStep25Bash(); + const { root, clonePath } = setupFixture(); + + try { + // Sanity: we begin sitting on the previous quick-task branch, 1 ahead of origin/main. + assert.equal( + git(clonePath, 'rev-parse', '--abbrev-ref', 'HEAD'), + 'quick/01-prev-task' + ); + assert.equal( + git(clonePath, 'rev-list', '--count', 'origin/main..HEAD'), + '1', + 'fixture should be 1 commit ahead of origin/main' + ); + + runStep(bash, clonePath, 'quick/02-new-task'); + + assert.equal( + git(clonePath, 'rev-parse', '--abbrev-ref', 'HEAD'), + 'quick/02-new-task', + 'Step 2.5 should switch to the new quick-task branch' + ); + + const inherited = git(clonePath, 'rev-list', '--count', 'origin/main..HEAD'); + assert.equal( + inherited, + '0', + `new quick-task branch must branch off origin/main, but inherited ${inherited} commit(s) from previous-task HEAD` + ); + assert.equal( + git(clonePath, 'rev-parse', 'HEAD'), + git(clonePath, 'rev-parse', 'origin/main'), + 'new quick-task branch tip must equal origin/main tip' + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + test('Step 2.5 reuses an existing quick-task branch instead of forking again', () => { + const bash = extractStep25Bash(); + const { root, clonePath } = setupFixture(); + + try { + // Pre-create the target branch off origin/main with its own commit, then + // walk away to a different branch — the step must switch back to it. + git(clonePath, 'checkout', '-B', 'quick/02-new-task', 'origin/main'); + fs.writeFileSync(path.join(clonePath, 'task02.txt'), 'task 2 work\n'); + git(clonePath, 'add', 'task02.txt'); + git(clonePath, 'commit', '-m', 'task 02 wip'); + const task02Sha = git(clonePath, 'rev-parse', 'HEAD'); + git(clonePath, 'checkout', 'quick/01-prev-task'); + + runStep(bash, clonePath, 'quick/02-new-task'); + + assert.equal( + git(clonePath, 'rev-parse', '--abbrev-ref', 'HEAD'), + 'quick/02-new-task' + ); + assert.equal( + git(clonePath, 'rev-parse', 'HEAD'), + task02Sha, + 'existing-branch tip must be preserved (no rebase/reset)' + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } }); });