Fold 51 issue-named CLI black-box + scripts-tooling regression files into their canonical module suites (runtime-launcher-parity, worktree-safety, install-*, managed-hooks, read-guard, capability-registry, etc.), plus a NEW slash-command-namespace.test.cjs grouping the 4 slash/colon-namespace-leak invariant suites that had no canonical owner. Verbatim block-scoped describe wrappers; 427 subtests conserved 1:1. Host-env pre-check (per B2): no CLI-receiving host sets a redirecting GSD_WORKSTREAM/GSD_PROJECT value. One folded suite (bug-3668 runtime resolver) creates an extension-less PATH gsd-tools stub + bash -c; co-locating it with the host's chmodSync tripped local/no-unguarded-nonportable-exec, so it's now Windows-guarded (skip on win32) matching the host suite's own bash -c guard. Regenerates regression-name allowlist (222->182), ratchets file-count allowlist (graphify 7->6, docs entry removed), makes 26 relocated allow-test-rule exemptions issue-ref-compliant (ADR-456; prunes stale ids). Repoints 13 tests/ references across CONTEXT.md, COMMANDS.md/FEATURES.md (EN + ja/ko/pt/zh) and ADR-0002. lint:ci green. Part of epic #1969. Closes #1975. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
696 lines
31 KiB
JavaScript
696 lines
31 KiB
JavaScript
'use strict';
|
|
/**
|
|
* #1146: git.base-branch resolver — single source of truth for default-branch detection.
|
|
*
|
|
* Tests:
|
|
* A. Config override wins (git.base_branch set → returned as-is, no git calls needed)
|
|
* B. origin/HEAD symref resolves → used
|
|
* C. origin/HEAD unset but git remote show origin knows HEAD → AUTHORITATIVE fallback
|
|
* (key regression: master repo with no origin/HEAD → must return "master", NOT "main")
|
|
* D. No origin/HEAD, no remote show, local branch "master" present → returns "master"
|
|
* E. No origin/HEAD, no remote show, local branch "main" present → returns "main"
|
|
* F. No origin/HEAD, no remote show, no local branches → returns "main" (last resort)
|
|
* G. Anti-regression guard: five affected workflows must NOT contain the
|
|
* duplicated bare `:-main` / `:-master` fallback pattern that was the root cause.
|
|
* They must call `gsd_run query git.base-branch` instead.
|
|
* (allow-test-rule: runtime-contract-is-the-product — the workflow .md content IS
|
|
* the runtime surface; the absence of the bad pattern is what ships to agents.)
|
|
*/
|
|
|
|
// allow-test-rule: runtime-contract-is-the-product
|
|
// Justification: the workflow .md files ARE the product surface — agents read and
|
|
// execute them directly. Guard G asserts that the resolved command appears in all five
|
|
// workflows, which requires reading those workflow files. Per TESTING-STANDARDS.md §6.
|
|
|
|
const { describe, test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const os = require('node:os');
|
|
const path = require('node:path');
|
|
const { execSync } = require('node:child_process');
|
|
|
|
const { runGsdTools, cleanup } = require('./helpers.cjs');
|
|
|
|
// ─── helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Create a minimal git repo in a temp dir, optionally setting up a remote
|
|
* and local branches.
|
|
*/
|
|
function createGitRepo(opts = {}) {
|
|
const { prefix = 'gsd-1146-', defaultBranch = 'master' } = opts;
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
execSync(`git init -b ${defaultBranch}`, { cwd: dir, stdio: 'pipe' });
|
|
execSync('git config user.email "test@test.com"', { cwd: dir, stdio: 'pipe' });
|
|
execSync('git config user.name "Test"', { cwd: dir, stdio: 'pipe' });
|
|
execSync('git config commit.gpgsign false', { cwd: dir, stdio: 'pipe' });
|
|
// Need at least one commit so branches exist
|
|
fs.writeFileSync(path.join(dir, 'README.md'), '# test\n');
|
|
execSync('git add README.md', { cwd: dir, stdio: 'pipe' });
|
|
execSync('git commit -m "init"', { cwd: dir, stdio: 'pipe' });
|
|
return dir;
|
|
}
|
|
|
|
/**
|
|
* Create a .planning dir so gsd-tools resolveProjectRoot doesn't bail.
|
|
*/
|
|
function addPlanning(dir) {
|
|
fs.mkdirSync(path.join(dir, '.planning', 'phases'), { recursive: true });
|
|
}
|
|
|
|
/**
|
|
* Write a gsd config.json with git.base_branch set.
|
|
*/
|
|
function setGsdConfig(dir, key, value) {
|
|
const cfgDir = path.join(dir, '.planning');
|
|
fs.mkdirSync(cfgDir, { recursive: true });
|
|
const cfgPath = path.join(cfgDir, 'config.json');
|
|
let cfg = {};
|
|
try { cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8')); } catch (_) { /* new file */ }
|
|
// Set nested key (dot notation). Guard every segment against prototype
|
|
// pollution with inline literal checks at each write site — mirrors the
|
|
// production guard in src/config.cts. A Set/pre-loop guard is NOT recognised
|
|
// by CodeQL's js/prototype-pollution-utility query (see PR #752 / alert #40).
|
|
const parts = key.split('.');
|
|
let obj = cfg;
|
|
for (let i = 0; i < parts.length - 1; i++) {
|
|
const k = parts[i];
|
|
if (k === '__proto__' || k === 'prototype' || k === 'constructor') {
|
|
throw new Error(`setGsdConfig: unsafe config key segment '${k}'`);
|
|
}
|
|
if (typeof obj[k] !== 'object' || obj[k] === null) obj[k] = {};
|
|
obj = obj[k];
|
|
}
|
|
const lastKey = parts[parts.length - 1];
|
|
if (lastKey === '__proto__' || lastKey === 'prototype' || lastKey === 'constructor') {
|
|
throw new Error(`setGsdConfig: unsafe config key segment '${lastKey}'`);
|
|
}
|
|
obj[lastKey] = value;
|
|
fs.writeFileSync(cfgPath, JSON.stringify(cfg, null, 2) + '\n');
|
|
}
|
|
|
|
// Paths to the five affected workflow files
|
|
const WORKFLOW_DIR = path.join(__dirname, '..', 'gsd-core', 'workflows');
|
|
const AFFECTED_WORKFLOWS = [
|
|
path.join(WORKFLOW_DIR, 'execute-phase.md'),
|
|
path.join(WORKFLOW_DIR, 'quick.md'),
|
|
path.join(WORKFLOW_DIR, 'ship.md'),
|
|
path.join(WORKFLOW_DIR, 'complete-milestone.md'),
|
|
path.join(WORKFLOW_DIR, 'pr-branch.md'),
|
|
];
|
|
|
|
// ─── Test suite ───────────────────────────────────────────────────────────────
|
|
|
|
describe('#1146: git.base-branch resolver', () => {
|
|
|
|
test('A. config override git.base_branch → returned immediately', (t) => {
|
|
const dir = createGitRepo({ prefix: 'gsd-1146-a-', defaultBranch: 'master' });
|
|
t.after(() => cleanup(dir));
|
|
addPlanning(dir);
|
|
setGsdConfig(dir, 'git.base_branch', 'develop');
|
|
|
|
const result = runGsdTools(['query', 'git.base-branch'], dir);
|
|
assert.ok(result.success, `git.base-branch with config override failed:\n${result.error}`);
|
|
const branch = result.output.trim();
|
|
assert.strictEqual(branch, 'develop',
|
|
`Expected config override 'develop', got: '${branch}'`);
|
|
});
|
|
|
|
test('B. origin/HEAD symref resolves → returned', (t) => {
|
|
// Create an "origin" bare repo with main branch
|
|
const originDir = createGitRepo({ prefix: 'gsd-1146-b-origin-', defaultBranch: 'main' });
|
|
const worktreeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1146-b-wt-'));
|
|
t.after(() => { cleanup(originDir); cleanup(worktreeDir); });
|
|
|
|
// Clone from origin — this sets origin/HEAD
|
|
execSync(`git clone "${originDir}" "${worktreeDir}"`, { stdio: 'pipe' });
|
|
execSync('git config user.email "test@test.com"', { cwd: worktreeDir, stdio: 'pipe' });
|
|
execSync('git config user.name "Test"', { cwd: worktreeDir, stdio: 'pipe' });
|
|
addPlanning(worktreeDir);
|
|
|
|
// Verify origin/HEAD is set (it should be after clone)
|
|
const symref = execSync('git symbolic-ref refs/remotes/origin/HEAD', { cwd: worktreeDir, encoding: 'utf8' }).trim();
|
|
assert.ok(symref.includes('origin/main'), `Expected origin/HEAD→origin/main, got: ${symref}`);
|
|
|
|
const result = runGsdTools(['query', 'git.base-branch'], worktreeDir);
|
|
assert.ok(result.success, `git.base-branch symref test failed:\n${result.error}`);
|
|
const branch = result.output.trim();
|
|
assert.strictEqual(branch, 'main',
|
|
`Expected 'main' from origin/HEAD, got: '${branch}'`);
|
|
});
|
|
|
|
test('C. KEY REGRESSION — master repo, origin/HEAD unset → returns "master" not "main"', (t) => {
|
|
// This is the bug: git init + remote add without git remote set-head → no origin/HEAD
|
|
// Current code falls back to :-main → wrong. Fixed code uses `git remote show origin`.
|
|
const originDir = createGitRepo({ prefix: 'gsd-1146-c-origin-', defaultBranch: 'master' });
|
|
const cloneDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1146-c-clone-'));
|
|
t.after(() => { cleanup(originDir); cleanup(cloneDir); });
|
|
|
|
// Manually add remote WITHOUT cloning (so origin/HEAD is never set)
|
|
execSync('git init', { cwd: cloneDir, stdio: 'pipe' });
|
|
execSync('git config user.email "test@test.com"', { cwd: cloneDir, stdio: 'pipe' });
|
|
execSync('git config user.name "Test"', { cwd: cloneDir, stdio: 'pipe' });
|
|
execSync('git config commit.gpgsign false', { cwd: cloneDir, stdio: 'pipe' });
|
|
execSync(`git remote add origin "${originDir}"`, { cwd: cloneDir, stdio: 'pipe' });
|
|
execSync('git fetch origin', { cwd: cloneDir, stdio: 'pipe' });
|
|
// Explicitly delete origin/HEAD in case git fetch auto-set it (newer git versions may do this)
|
|
try {
|
|
execSync('git remote set-head origin --delete', { cwd: cloneDir, stdio: 'pipe' });
|
|
} catch (_) { /* ignore — may not exist */ }
|
|
addPlanning(cloneDir);
|
|
|
|
// Confirm origin/HEAD is unset
|
|
let hasSymref = true;
|
|
try {
|
|
execSync('git symbolic-ref refs/remotes/origin/HEAD', { cwd: cloneDir, stdio: 'pipe' });
|
|
} catch (_) {
|
|
hasSymref = false;
|
|
}
|
|
assert.strictEqual(hasSymref, false, 'Test setup: origin/HEAD must be unset for this test case');
|
|
|
|
const result = runGsdTools(['query', 'git.base-branch'], cloneDir);
|
|
assert.ok(result.success, `git.base-branch regression test failed:\n${result.error}`);
|
|
const branch = result.output.trim();
|
|
assert.strictEqual(branch, 'master',
|
|
`BUG REGRESSION: master repo with origin/HEAD unset must return 'master', got: '${branch}'`);
|
|
});
|
|
|
|
test('D. No remote, local branch "master" present, "main" absent → returns "master"', (t) => {
|
|
const dir = createGitRepo({ prefix: 'gsd-1146-d-', defaultBranch: 'master' });
|
|
t.after(() => cleanup(dir));
|
|
addPlanning(dir);
|
|
// No remote configured — falls through to local branch detection
|
|
|
|
const result = runGsdTools(['query', 'git.base-branch'], dir);
|
|
assert.ok(result.success, `git.base-branch local branch test failed:\n${result.error}`);
|
|
const branch = result.output.trim();
|
|
assert.strictEqual(branch, 'master',
|
|
`Expected 'master' from local branch detection, got: '${branch}'`);
|
|
});
|
|
|
|
test('E. No remote, local branch "main" present → returns "main"', (t) => {
|
|
const dir = createGitRepo({ prefix: 'gsd-1146-e-', defaultBranch: 'main' });
|
|
t.after(() => cleanup(dir));
|
|
addPlanning(dir);
|
|
|
|
const result = runGsdTools(['query', 'git.base-branch'], dir);
|
|
assert.ok(result.success, `git.base-branch main branch test failed:\n${result.error}`);
|
|
const branch = result.output.trim();
|
|
assert.strictEqual(branch, 'main',
|
|
`Expected 'main' from local branch detection, got: '${branch}'`);
|
|
});
|
|
|
|
test('F. No remote, no main/master local branch → returns "main" (last resort default)', (t) => {
|
|
const dir = createGitRepo({ prefix: 'gsd-1146-f-', defaultBranch: 'develop' });
|
|
t.after(() => cleanup(dir));
|
|
addPlanning(dir);
|
|
// Branch named "develop" — neither main nor master
|
|
|
|
const result = runGsdTools(['query', 'git.base-branch'], dir);
|
|
assert.ok(result.success, `git.base-branch default fallback test failed:\n${result.error}`);
|
|
const branch = result.output.trim();
|
|
assert.strictEqual(branch, 'main',
|
|
`Expected 'main' as last resort default, got: '${branch}'`);
|
|
});
|
|
|
|
test('A2. config override with flat base_branch key (legacy form) → returned immediately', (t) => {
|
|
const dir = createGitRepo({ prefix: 'gsd-1146-a2-', defaultBranch: 'master' });
|
|
t.after(() => cleanup(dir));
|
|
addPlanning(dir);
|
|
// Write flat base_branch directly to config root (legacy form, not nested under "git")
|
|
const cfgPath = require('node:path').join(dir, '.planning', 'config.json');
|
|
require('node:fs').writeFileSync(cfgPath, JSON.stringify({ base_branch: 'release' }, null, 2) + '\n');
|
|
|
|
const result = runGsdTools(['query', 'git.base-branch'], dir);
|
|
assert.ok(result.success, `git.base-branch with flat config key failed:\n${result.error}`);
|
|
const branch = result.output.trim();
|
|
assert.strictEqual(branch, 'release',
|
|
`Expected flat config override 'release', got: '${branch}'`);
|
|
});
|
|
|
|
test('H. No remote, both "main" and "master" local branches exist → returns "main" (main wins tie-break)', (t) => {
|
|
// Tier-4 tie-break: when both main and master exist locally and no remote info is available,
|
|
// "main" wins (documented in tryLocalBranch JSDoc — modern default).
|
|
const dir = createGitRepo({ prefix: 'gsd-1146-h-', defaultBranch: 'master' });
|
|
t.after(() => cleanup(dir));
|
|
addPlanning(dir);
|
|
// Create a "main" branch alongside the existing "master"
|
|
const { execSync: exec } = require('node:child_process');
|
|
exec('git branch main', { cwd: dir, stdio: 'pipe' });
|
|
// No remote configured — falls to tier-4 (local branch existence)
|
|
|
|
const result = runGsdTools(['query', 'git.base-branch'], dir);
|
|
assert.ok(result.success, `git.base-branch both-branches test failed:\n${result.error}`);
|
|
const branch = result.output.trim();
|
|
assert.strictEqual(branch, 'main',
|
|
`Expected 'main' to win when both main and master exist locally, got: '${branch}'`);
|
|
});
|
|
|
|
test('G. Anti-regression: all five affected workflows use gsd_run query git.base-branch, not bare :-main / :-master', () => {
|
|
// The root-cause pattern: DEFAULT_BRANCH=${DEFAULT_BRANCH:-main} or BASE_BRANCH="${BASE_BRANCH:-main}"
|
|
// After fix: workflows call gsd_run query git.base-branch and remove the bare fallback.
|
|
const BAD_PATTERN = /\$\{(?:DEFAULT_BRANCH|BASE_BRANCH):-(?:main|master)\}/;
|
|
const RESOLVER_CALL = /gsd_run query git\.base-branch/;
|
|
|
|
for (const wfPath of AFFECTED_WORKFLOWS) {
|
|
const name = path.basename(wfPath);
|
|
const content = fs.readFileSync(wfPath, 'utf8');
|
|
|
|
assert.ok(
|
|
!BAD_PATTERN.test(content),
|
|
`${name} still contains the bare :-main/:-master fallback pattern. ` +
|
|
'Must be replaced with gsd_run query git.base-branch (Issue #1146).',
|
|
);
|
|
|
|
assert.ok(
|
|
RESOLVER_CALL.test(content),
|
|
`${name} does not call \`gsd_run query git.base-branch\`. ` +
|
|
'All five affected workflows must delegate to the single resolver (Issue #1146).',
|
|
);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─── gitWorktreeInfoInternal: behaviour (#1268 T0, T1 #1277) ─────────────────
|
|
|
|
const gitBaseBranch = require(path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'git-base-branch.cjs'));
|
|
const { createTempGitProject, createTempDir } = require('./helpers.cjs');
|
|
|
|
describe('#1268 gitWorktreeInfoInternal: relocation to git-base-branch', () => {
|
|
test('gitWorktreeInfoInternal(createTempGitProject()) returns {inside:true, worktreeRoot:<non-empty string>}', (t) => {
|
|
const dir = createTempGitProject('gsd-wt-info-');
|
|
t.after(() => cleanup(dir));
|
|
const result = gitBaseBranch.gitWorktreeInfoInternal(dir);
|
|
assert.strictEqual(result.inside, true, 'inside must be true for a git project dir');
|
|
assert.ok(typeof result.worktreeRoot === 'string' && result.worktreeRoot.length > 0,
|
|
`worktreeRoot must be a non-empty string, got: ${JSON.stringify(result.worktreeRoot)}`);
|
|
});
|
|
|
|
test('gitWorktreeInfoInternal(createTempDir()) returns {inside:false, worktreeRoot:null} for a non-git dir', (t) => {
|
|
const dir = createTempDir('gsd-wt-info-nongit-');
|
|
t.after(() => cleanup(dir));
|
|
const result = gitBaseBranch.gitWorktreeInfoInternal(dir);
|
|
assert.strictEqual(result.inside, false, 'inside must be false for a non-git dir');
|
|
assert.strictEqual(result.worktreeRoot, null, 'worktreeRoot must be null for a non-git dir');
|
|
});
|
|
|
|
test('gitWorktreeInfoInternal never throws (non-git dir)', (t) => {
|
|
const dir = createTempDir('gsd-wt-info-nothrow-');
|
|
t.after(() => cleanup(dir));
|
|
assert.doesNotThrow(() => gitBaseBranch.gitWorktreeInfoInternal(dir));
|
|
});
|
|
});
|
|
|
|
// ─── setGsdConfig prototype-pollution guard (#1406) ───────────────────────────
|
|
|
|
describe('#1406: setGsdConfig prototype-pollution guard', () => {
|
|
test('rejects __proto__ as a key segment', (t) => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1406-'));
|
|
t.after(() => cleanup(dir));
|
|
assert.throws(() => setGsdConfig(dir, '__proto__', 'x'), /unsafe config key segment/);
|
|
assert.throws(() => setGsdConfig(dir, '__proto__.polluted', true), /unsafe config key segment/);
|
|
});
|
|
|
|
test('rejects constructor / prototype chain segments', (t) => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1406-'));
|
|
t.after(() => cleanup(dir));
|
|
assert.throws(() => setGsdConfig(dir, 'constructor.prototype.polluted', true), /unsafe config key segment/);
|
|
assert.throws(() => setGsdConfig(dir, 'safe.__proto__', true), /unsafe config key segment/);
|
|
assert.throws(() => setGsdConfig(dir, 'a.prototype.b', true), /unsafe config key segment/);
|
|
});
|
|
|
|
test('does not pollute Object.prototype after rejected attempts', (t) => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1406-'));
|
|
t.after(() => cleanup(dir));
|
|
try { setGsdConfig(dir, '__proto__.polluted', true); } catch (_) { /* expected */ }
|
|
try { setGsdConfig(dir, 'constructor.prototype.polluted', true); } catch (_) { /* expected */ }
|
|
try { setGsdConfig(dir, 'a.__proto__.polluted', true); } catch (_) { /* expected */ }
|
|
assert.strictEqual(({}).polluted, undefined);
|
|
assert.strictEqual(Object.prototype.polluted, undefined);
|
|
});
|
|
|
|
test('still writes a normal nested key', (t) => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1406-'));
|
|
t.after(() => cleanup(dir));
|
|
setGsdConfig(dir, 'git.base_branch', 'develop');
|
|
const cfgPath = path.join(dir, '.planning', 'config.json');
|
|
const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
|
|
assert.strictEqual(cfg.git.base_branch, 'develop');
|
|
});
|
|
});
|
|
|
|
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
// Folded from tests/bug-2004-pr-branch-milestone.test.cjs — consolidation epic #1969 (B4 #1973)
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
{
|
|
const { describe: __foldDescribe } = require('node:test');
|
|
__foldDescribe("folded:bug-2004-pr-branch-milestone (consolidation epic #1969 B4 #1973)", () => {
|
|
/**
|
|
* Regression tests for bug #2004
|
|
*
|
|
* /gsd-pr-branch must not exclude milestone archive and structural planning
|
|
* commits. The previous implementation filtered ALL .planning/-only commits,
|
|
* including STATE.md, ROADMAP.md, MILESTONES.md, and milestones/** updates
|
|
* that are needed to preserve repository planning state after a merge.
|
|
*
|
|
* Fixed: pr-branch.md now distinguishes:
|
|
* - Transient planning commits (phase plans, summaries, research, context) → EXCLUDE
|
|
* - Structural planning commits (STATE.md, ROADMAP.md, MILESTONES.md,
|
|
* PROJECT.md, milestones/**) → INCLUDE
|
|
* - Code commits (any non-.planning/ file) → INCLUDE
|
|
* - Mixed commits (code + planning) → INCLUDE
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
const { describe, test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
|
|
const workflowPath = path.resolve(
|
|
__dirname, '..', 'gsd-core', 'workflows', 'pr-branch.md'
|
|
);
|
|
|
|
describe('bug #2004: pr-branch preserves structural planning commits', () => {
|
|
let content;
|
|
|
|
test('setup: pr-branch workflow is readable', () => {
|
|
content = fs.readFileSync(workflowPath, 'utf-8');
|
|
assert.ok(content.length > 0, 'pr-branch.md must not be empty');
|
|
});
|
|
|
|
test('workflow distinguishes structural vs transient planning commits', () => {
|
|
content = content || fs.readFileSync(workflowPath, 'utf-8');
|
|
// Must contain language distinguishing structural from transient/phase planning files
|
|
assert.ok(
|
|
/structural|milestone.*archive|STATE\.md.*INCLUDE|preserve.*milestone|milestone.*preserve/i.test(content),
|
|
'pr-branch.md must distinguish structural planning commits from transient ones'
|
|
);
|
|
});
|
|
|
|
test('workflow lists STATE.md and ROADMAP.md as structural files to preserve', () => {
|
|
content = content || fs.readFileSync(workflowPath, 'utf-8');
|
|
assert.ok(
|
|
content.includes('STATE.md'),
|
|
'pr-branch.md must reference STATE.md as a structural file to preserve'
|
|
);
|
|
assert.ok(
|
|
content.includes('ROADMAP.md'),
|
|
'pr-branch.md must reference ROADMAP.md as a structural file to preserve'
|
|
);
|
|
});
|
|
|
|
test('workflow lists MILESTONES.md or milestones/ as structural files to preserve', () => {
|
|
content = content || fs.readFileSync(workflowPath, 'utf-8');
|
|
assert.ok(
|
|
content.includes('MILESTONES.md') || content.includes('milestones/'),
|
|
'pr-branch.md must reference MILESTONES.md or milestones/ as structural files to preserve'
|
|
);
|
|
});
|
|
|
|
test('workflow has four commit categories (code, planning-only, mixed, structural)', () => {
|
|
content = content || fs.readFileSync(workflowPath, 'utf-8');
|
|
// Must have at least a "structural" or "milestone" category beyond the original three
|
|
assert.ok(
|
|
/structural.*commit|milestone.*commit|commit.*structural|commit.*milestone/i.test(content) ||
|
|
/INCLUDE.*STATE\.md|STATE\.md.*INCLUDE/i.test(content),
|
|
'pr-branch.md must classify structural planning commits as INCLUDE'
|
|
);
|
|
});
|
|
|
|
test('create_pr_branch step does not rm -r --cached all of .planning/', () => {
|
|
content = content || fs.readFileSync(workflowPath, 'utf-8');
|
|
// The original bug: `git rm -r --cached .planning/` nuked structural files.
|
|
// The fix must either remove this wholesale rm or scope it to transient dirs.
|
|
// Acceptable: narrowed rm targeting only phase/, quick/, research/, etc.
|
|
// Not acceptable: `git rm -r --cached .planning/` with no scoping.
|
|
const hasUnscoped = /git rm -r --cached \.planning\/(?!\*)?(?!phases|quick|research|threads|todos|debug|seeds|ui-reviews|codebase)/
|
|
.test(content);
|
|
assert.ok(
|
|
!hasUnscoped,
|
|
'create_pr_branch must not use unscoped "git rm -r --cached .planning/" — scope to transient subdirectories only'
|
|
);
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
// Folded from tests/bug-2916-handle-branching-default-base.test.cjs — consolidation epic #1969 (B6 #1975)
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
{
|
|
const { describe: __foldDescribe } = require('node:test');
|
|
__foldDescribe("folded:bug-2916-handle-branching-default-base (consolidation epic #1969 B6 #1975)", () => {
|
|
/**
|
|
* Regression test for #2916: execute-phase `handle_branching` step creates the
|
|
* per-phase branch off whatever HEAD is currently checked out (typically the
|
|
* previous phase's unmerged branch) instead of off `origin/HEAD`.
|
|
*
|
|
* The bug compounded phases on top of each other and stranded them unpushed
|
|
* for weeks. The fix:
|
|
* 1. Detect the default branch via `git symbolic-ref refs/remotes/origin/HEAD`.
|
|
* 2. If $BRANCH_NAME exists, switch to it (preserve existing behavior).
|
|
* 3. Otherwise, ff-update the default branch from origin and create the new
|
|
* phase branch off the default-branch tip.
|
|
* 4. Refuse-or-warn on dirty working tree.
|
|
* 5. Post-creation, assert `git rev-list --count $DEFAULT_BRANCH..HEAD == 0`.
|
|
*
|
|
* This test extracts the bash payload from the <step name="handle_branching">
|
|
* block in execute-phase.md (parsed structurally — no regex on prose), executes
|
|
* it inside a fixture git repo where HEAD sits on a previous-phase branch with
|
|
* extra commits, and asserts that the new phase branch's tip equals
|
|
* `origin/main` (no commits inherited from the previous phase).
|
|
*/
|
|
|
|
const { describe, test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const { execFileSync } = require('node:child_process');
|
|
const fs = require('node:fs');
|
|
const os = require('node:os');
|
|
const path = require('node:path');
|
|
|
|
const { cleanup } = require('./helpers.cjs');
|
|
|
|
const EXECUTE_PHASE_PATH = path.join(
|
|
__dirname,
|
|
'..',
|
|
'gsd-core',
|
|
'workflows',
|
|
'execute-phase.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 that the handle_branching step instructs
|
|
* the agent to run. We:
|
|
* 1. Locate the <step name="handle_branching"> ... </step> block.
|
|
* 2. Walk its body looking for fenced ```bash blocks.
|
|
* 3. Concatenate every bash block in the step (the fix may use more than one).
|
|
*
|
|
* No `.includes()` content checks — we parse fence-delimited code blocks the
|
|
* same way a markdown parser would.
|
|
*/
|
|
function extractHandleBranchingBash() {
|
|
const content = fs.readFileSync(EXECUTE_PHASE_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\s+name="handle_branching">\s*$/.test(lines[i])) {
|
|
start = i + 1;
|
|
} else if (start !== -1 && /^<\/step>\s*$/.test(lines[i])) {
|
|
end = i;
|
|
break;
|
|
}
|
|
}
|
|
if (start === -1 || end === -1) {
|
|
throw new Error(
|
|
'execute-phase.md does not contain a <step name="handle_branching"> ... </step> block'
|
|
);
|
|
}
|
|
|
|
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(
|
|
'handle_branching step contains no ```bash code blocks to execute'
|
|
);
|
|
}
|
|
return bashBlocks.join('\n');
|
|
}
|
|
|
|
/**
|
|
* Build a fixture: a bare "origin" repo with the named default branch (one
|
|
* commit), a clone with `origin/HEAD` pointed at it, and a checked-out
|
|
* previous-phase branch carrying its own unmerged commit.
|
|
*
|
|
* `defaultBranch` is parameterized so callers can lock in that the workflow
|
|
* honors `git symbolic-ref refs/remotes/origin/HEAD` rather than silently
|
|
* defaulting to `main` (#2921 CR feedback — quick-branching.test.cjs got the
|
|
* same treatment in 80f14cac; this test deserves the same coverage).
|
|
*/
|
|
function setupFixture(defaultBranch = 'main') {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2916-'));
|
|
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', defaultBranch);
|
|
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/${defaultBranch}`);
|
|
|
|
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 phase: branch off the default branch, add
|
|
// a commit, and *stay* on it (the failure scenario described in the bug).
|
|
git(clonePath, 'checkout', '-b', 'feature/phase-01-foundation');
|
|
fs.writeFileSync(path.join(clonePath, 'phase01.txt'), 'phase 1 work\n');
|
|
git(clonePath, 'add', 'phase01.txt');
|
|
git(clonePath, 'commit', '-m', 'phase 01 work');
|
|
|
|
return { root, clonePath, defaultBranch };
|
|
}
|
|
|
|
function runHandleBranchingStep(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 its dirty-tree fallback path.
|
|
const scriptDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2916-step-'));
|
|
const scriptPath = path.join(scriptDir, 'handle-branching.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 {
|
|
cleanup(scriptDir);
|
|
}
|
|
}
|
|
|
|
describe('handle_branching branches off origin/HEAD, not current HEAD (#2916)', () => {
|
|
// Run against `main` (conventional default) and `trunk` (non-main default
|
|
// exercising the symbolic-ref code path) so a regression that hard-codes
|
|
// `main` instead of consulting origin/HEAD will fail the trunk variant.
|
|
for (const defaultBranch of ['main', 'trunk']) {
|
|
test(`new phase branch branches off origin/${defaultBranch} with 0 inherited commits`, () => {
|
|
const bash = extractHandleBranchingBash();
|
|
const { root, clonePath } = setupFixture(defaultBranch);
|
|
|
|
try {
|
|
const upstream = `origin/${defaultBranch}`;
|
|
|
|
assert.equal(
|
|
git(clonePath, 'rev-parse', '--abbrev-ref', 'HEAD'),
|
|
'feature/phase-01-foundation'
|
|
);
|
|
assert.equal(
|
|
git(clonePath, 'rev-list', '--count', `${upstream}..HEAD`),
|
|
'1',
|
|
`fixture should be 1 commit ahead of ${upstream}`
|
|
);
|
|
|
|
runHandleBranchingStep(bash, clonePath, 'feature/phase-02-content-sync');
|
|
|
|
assert.equal(
|
|
git(clonePath, 'rev-parse', '--abbrev-ref', 'HEAD'),
|
|
'feature/phase-02-content-sync',
|
|
'handle_branching should switch to the new phase branch'
|
|
);
|
|
|
|
const inherited = git(clonePath, 'rev-list', '--count', `${upstream}..HEAD`);
|
|
assert.equal(
|
|
inherited,
|
|
'0',
|
|
`new phase branch must branch off ${upstream}, but inherited ${inherited} commit(s) from previous-phase HEAD`
|
|
);
|
|
assert.equal(
|
|
git(clonePath, 'rev-parse', 'HEAD'),
|
|
git(clonePath, 'rev-parse', upstream),
|
|
`new phase branch tip must equal ${upstream} tip`
|
|
);
|
|
} finally {
|
|
cleanup(root);
|
|
}
|
|
});
|
|
}
|
|
|
|
test('handle_branching reuses an existing branch instead of forking again', () => {
|
|
const bash = extractHandleBranchingBash();
|
|
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', 'feature/phase-02-content-sync', 'origin/main');
|
|
fs.writeFileSync(path.join(clonePath, 'phase02.txt'), 'phase 2 work\n');
|
|
git(clonePath, 'add', 'phase02.txt');
|
|
git(clonePath, 'commit', '-m', 'phase 02 wip');
|
|
const phase02Sha = git(clonePath, 'rev-parse', 'HEAD');
|
|
git(clonePath, 'checkout', 'feature/phase-01-foundation');
|
|
|
|
runHandleBranchingStep(bash, clonePath, 'feature/phase-02-content-sync');
|
|
|
|
assert.equal(
|
|
git(clonePath, 'rev-parse', '--abbrev-ref', 'HEAD'),
|
|
'feature/phase-02-content-sync'
|
|
);
|
|
assert.equal(
|
|
git(clonePath, 'rev-parse', 'HEAD'),
|
|
phase02Sha,
|
|
'existing-branch tip must be preserved (no rebase/reset)'
|
|
);
|
|
} finally {
|
|
cleanup(root);
|
|
}
|
|
});
|
|
});
|
|
});
|
|
}
|