test(#2916): replace string-grep assertions with behavioral fixture test

CodeRabbit nitpick (per project policy `feedback_no_source_grep_tests`):
the prior `tests/quick-branching.test.cjs` asserted branching correctness
by `.includes()`-grepping the raw markdown content for literal command
substrings. Those assertions stayed green even when the underlying
behavior regressed (e.g. when `git checkout -b` was unconditionally run
from the wrong HEAD).

Replace with the same pattern as `bug-2916-handle-branching-default-base
.test.cjs`:
  - Structurally extract the Step 2.5 bash block from quick.md by
    walking the markdown for fenced ```bash blocks under the heading
    (no regex on prose).
  - Spin up a fixture git repo with a bare origin, a clone whose
    `origin/HEAD` points at `main`, and a checked-out previous-task
    branch carrying its own unmerged commit.
  - Execute the extracted bash block via `bash -c` and assert that
    the new branch's tip equals `origin/main` (0 commits inherited
    from the previous-task HEAD).
  - Add a reuse test that pre-creates the target branch with its own
    commit and verifies the script switches back to it without a
    rebase or reset.

The two informational tests (workflow file exists, branching runs
before task-directory creation) are retained, plus the `branch_name`
parsing assertion is rewritten to walk fenced bash blocks rather than
substring-grep arbitrary content.
This commit is contained in:
Tom Boucher
2026-04-30 21:22:56 -04:00
parent 4e2f1105d9
commit 140d334dab

View File

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