* test(#3679): failing-first rows pinning planning preservation and the verify deletion gate * fix(#3679): gate pr-branch verify on planning-tree deletions * test(#3679): extract hashes via rev-parse and de-vacuate the pure-code pin * fix(#3679): close review findings — merged ack, pinned prose gate * fix(#3679): close two-axis review findings — no-renames gate, structural pin * chore(#3679): backfill changeset pr number --------- Co-authored-by: sim <sim@local>
This commit is contained in:
5
.changeset/clever-jaguars-hum.md
Normal file
5
.changeset/clever-jaguars-hum.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Fixed
|
||||
pr: 3803
|
||||
---
|
||||
**/gsd-pr-branch now refuses to verify a PR branch that would delete planning files the target branch tracks** — the verification step counts planning-tree deletions via git diff --name-status and fails on any non-zero count, instead of reporting clean while pre-existing planning content was stripped. The underlying deletion class in the cherry-pick filter was already fixed by the strict-mode rewrite; this makes the workflow able to detect it. (#3679)
|
||||
@@ -234,7 +234,7 @@ Module owning agent-presence resolution and verification, extracted from the Cor
|
||||
Module owning project configuration loading: reads `.planning/config.json`, merges built-in defaults (`CONFIG_DEFAULTS`/`CANONICAL_CONFIG_DEFAULTS`), normalizes legacy keys, applies the active-workstream overlay, validates against the config schema, and warns on unknown keys/profile overrides. Primary interface: `loadConfigResolved(cwd, options) → ConfigResolution { config, source, degraded }` (provenance-aware, ADR-1411 P2 / #1415) — `source` ∈ `'workstream' | 'root' | 'builtin-defaults' | 'global-defaults'`; `degraded:true` when a workstream was requested but its config.json was absent (fell back to root config). `loadConfig(cwd, options) → Record<string,unknown>` is the back-compat thin wrapper over `loadConfigResolved` (byte-identical result). Resolution is **caller-anchored, not loader-anchored**: `loadConfigResolved` resolves `cwd` as-is (no walk-up), so `loadConfig` stays byte-identical for its callers; callers that need cwd-drift tolerance (e.g. `cmdAgentSkills`) anchor to the project root via `findProjectRoot` (Project-Root Resolution Module) *before* calling `loadConfigResolved`. Helper exports: `_deepMergeConfig`, `isGitIgnored`, `_warnUnknownProfileOverrides`. Depends only on leaf modules (`configuration`, `config-schema`, `planning-workspace`, `shell-command-projection`, `core-utils`, `model-catalog`) — no other core dependency. Extracted from the Core module per ADR-857 rollout phase 2e (#885) as the prerequisite for the model-resolver extraction (the resolvers call `loadConfig`); the `core.cjs` re-export spine was retired in epic #1267, so callers import this leaf directly. Source of truth: `gsd-core/bin/lib/config-loader.cjs` (generated from `src/config-loader.cts`).
|
||||
|
||||
### Planning Publication Gate (`planning.pr_strict`)
|
||||
The seam deciding whether `.planning/` artifacts reach the REMOTE — distinct from the Planning Commit Gate below, which decides whether they reach git at all. `planning.pr_strict` (boolean, manifest default `false`) resolves through the same `loadConfigResolved` chain as its `planning.*` siblings (explicit top-level `pr_strict`, then the `planning.pr_strict` alias, then the manifest default), and is additionally registered in `SCHEMA_DEFAULTS` (`src/config.cts`) so `query config-get planning.pr_strict` answers `false` for an absent key rather than `Key not found` — `gsd-core/workflows/pr-branch.md` reads it with a plain `config-get` and must not special-case a missing key. It selects between two filter modes in that workflow: default preserves the five structural planning files plus `milestones/**` and drops nine transient subdirectories; strict drops every `.planning/` path and includes a commit only when it touches at least one file outside `.planning/`. The two path lists are declared ONCE in the workflow (`TRANSIENT_DIRS`, `STRUCTURAL_RE`) and both the un-stage step and the verification assertion are derived from them, because the prior shape declared them twice and the two steps disagreed by construction — `verify` asserted zero `.planning/` paths while `create_pr_branch` was specified to preserve five, so a correct run reported itself as failed on every phase (#2971). The two gates are independent but not orthogonal in effect: `pr_strict` is inert when `commit_docs` is `false`, since nothing is committed for the PR-branch filter to remove. Source of truth: `gsd-core/workflows/pr-branch.md`; key registered in `gsd-core/bin/shared/config-{defaults,schema}.manifest.json`.
|
||||
The seam deciding whether `.planning/` artifacts reach the REMOTE — distinct from the Planning Commit Gate below, which decides whether they reach git at all. `planning.pr_strict` (boolean, manifest default `false`) resolves through the same `loadConfigResolved` chain as its `planning.*` siblings (explicit top-level `pr_strict`, then the `planning.pr_strict` alias, then the manifest default), and is additionally registered in `SCHEMA_DEFAULTS` (`src/config.cts`) so `query config-get planning.pr_strict` answers `false` for an absent key rather than `Key not found` — `gsd-core/workflows/pr-branch.md` reads it with a plain `config-get` and must not special-case a missing key. It selects between two filter modes in that workflow: default preserves the five structural planning files plus `milestones/**` and drops nine transient subdirectories; strict drops every `.planning/` path and includes a commit only when it touches at least one file outside `.planning/`. The two path lists are declared ONCE in the workflow (`TRANSIENT_DIRS`, `STRUCTURAL_RE`) and both the un-stage step and the verification assertion are derived from them, because the prior shape declared them twice and the two steps disagreed by construction — `verify` asserted zero `.planning/` paths while `create_pr_branch` was specified to preserve five, so a correct run reported itself as failed on every phase (#2971). A third gate is deliberately NOT derived from those declarations: `PLANNING_DELETIONS` (#3679) counts DELETED `.planning/` paths via `git diff --name-status --no-renames` and must be `0` in every mode — a deleted planning path is data loss, not filtering, and name-only counting cannot see status. The two gates are independent but not orthogonal in effect: `pr_strict` is inert when `commit_docs` is `false`, since nothing is committed for the PR-branch filter to remove. Source of truth: `gsd-core/workflows/pr-branch.md`; key registered in `gsd-core/bin/shared/config-{defaults,schema}.manifest.json`.
|
||||
|
||||
### Planning Commit Gate (`commit_docs`)
|
||||
The seam deciding whether `.planning/` artifacts reach git. `commit_docs` resolves in the Config Loader Module (`loadConfigResolved`) through an ordered chain — an explicit value (top-level `commit_docs` or its `planning.commit_docs` alias) wins; absent that, `isGitIgnored(cwd, '.planning/')` auto-resolves it to `false`; otherwise the manifest default (`true`) applies. `cmdCommit` (Command Module) is the ONLY sanctioned writer: it returns the typed skip envelope `{ committed: false, skipped: true, hash: null, reason }` with `reason ∈ { 'skipped_commit_docs_false', 'skipped_gitignored', 'skipped_commit_docs_phase_false' }` rather than erroring, and `skipped: true` is explicit so agent prompts match a first-class success signal instead of inferring a skip from a missing `committed` and improvising a raw-git fallback (#3678). Those `reason` strings are a de facto public contract — `agents/gsd-executor.md` pattern-matches on them — so they are additive-only.
|
||||
|
||||
@@ -389,6 +389,13 @@ ALLOWED=$((PLANNING_TOTAL - FORBIDDEN))
|
||||
TOTAL_FILES=$(echo "$DIFF_PATHS" | grep -c . || true)
|
||||
PR_COMMITS=$(git rev-list --count "$TARGET".."$PR_BRANCH")
|
||||
|
||||
# #3679: a DELETED planning path is never legitimate — this workflow only ever
|
||||
# excludes content a cherry-picked commit ADDED; pre-existing target-tracked
|
||||
# planning files must survive byte-identical. Name-only counting cannot see
|
||||
# status (a deleted structural/allowed path verifies clean there), so gate on
|
||||
# deletions explicitly, across every planning category.
|
||||
PLANNING_DELETIONS=$(git diff --name-status --no-renames "$TARGET".."$PR_BRANCH" | grep "^D" | grep -c "\.planning/" || true)
|
||||
|
||||
# Default mode preserves anything under .planning/ that is neither transient nor
|
||||
# structural — config.json, intel/, workstreams/. That is deliberate and unchanged, but it
|
||||
# must not be silent: report it so the user can choose strict mode knowingly.
|
||||
@@ -398,6 +405,12 @@ OTHER=$(echo "$DIFF_PATHS" | grep "^\.planning/" | grep -Ev "$FORBIDDEN_RE" | gr
|
||||
`$FORBIDDEN` is the pass/fail number — it must be `0`. A non-zero value means the filter
|
||||
did not do what this mode promised; report it and do not tell the user to push.
|
||||
|
||||
`$PLANNING_DELETIONS` is a second hard gate (#3679) — it must also be `0`. A non-zero
|
||||
value means the PR branch would DELETE planning files the target branch tracks
|
||||
(`git diff --name-status --no-renames "$TARGET".."$PR_BRANCH" | grep "^D" | grep "\.planning/"` lists
|
||||
them). That is data loss, not filtering — report it, do not tell the user to push, and
|
||||
rebuild the branch.
|
||||
|
||||
Display results:
|
||||
```
|
||||
✅ PR branch created: {PR_BRANCH}
|
||||
@@ -406,6 +419,7 @@ Original: {AHEAD} commits, {ORIGINAL_FILES} files
|
||||
PR branch: {PR_COMMITS} commits, {TOTAL_FILES} files
|
||||
Mode: {PR_MODE}
|
||||
Planning paths in diff: {PLANNING_TOTAL} (allowed {ALLOWED}, forbidden {FORBIDDEN} — must be 0)
|
||||
Planning deletions: {PLANNING_DELETIONS} (must be 0 — #3679)
|
||||
|
||||
Next steps:
|
||||
git push origin {PR_BRANCH}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"version": 1,
|
||||
"paths": {
|
||||
"pr-branch.md": {
|
||||
"reason": "#2971: +5729 bytes vs next. Adds the planning.pr_strict filter mode and repairs two verified defects in the same cherry-pick loop. The growth is four things, none of them prose padding: (1) the canonical TRANSIENT_DIRS/STRUCTURAL_RE declarations plus their per-mode FILTER_PATHS/FORBIDDEN_RE projections, which replace two duplicated hardcoded lists so create_pr_branch and verify can no longer disagree about what the filter promised; (2) a rewritten create_pr_branch loop — the old `git rm -r --cached` staged a DELETION of any .planning/ path the target branch already tracked, and left the picked file untracked on disk so a later commit touching that path aborted with \"untracked working tree files would be overwritten\" and every remaining commit was dropped, both reproduced against real git before the fix; the replacement forces filtered paths back to HEAD in index and worktree, halts on a conflict outside the filter instead of improvising, and skips a commit left empty by filtering; (3) a mode-derived verify step plus the advisory line naming the .planning/ paths default mode deliberately keeps, so correcting the assertion does not trade a permanently-wrong signal for silence; (4) a clean-working-tree precondition, required because the corrected filter now removes files from the working tree. Comments carry the why for each, because every one of them is a place a future edit would otherwise reintroduce the defect."
|
||||
"reason": "#3679 re-arm: +14 lines vs the #2971-acknowledged size. The #2971 rewrite already fixed the deletion class this issue reported (rm -f --ignore-unmatch + checkout HEAD restore — pre-existing target-tracked planning files survive); #3679 adds the piece the brief still required of verify: a PLANNING_DELETIONS count over git diff --name-status with a second must-be-0 gate beside $FORBIDDEN (name-only counting cannot see status, so a deleted allowed/structural planning path verified clean), plus its display line. Prior reason kept below for the audit trail. — #2971: +5729 bytes vs next. Adds the planning.pr_strict filter mode and repairs two verified defects in the same cherry-pick loop. The growth is four things, none of them prose padding: (1) the canonical TRANSIENT_DIRS/STRUCTURAL_RE declarations plus their per-mode FILTER_PATHS/FORBIDDEN_RE projections, which replace two duplicated hardcoded lists so create_pr_branch and verify can no longer disagree about what the filter promised; (2) a rewritten create_pr_branch loop — the old `git rm -r --cached` staged a DELETION of any .planning/ path the target branch already tracked, and left the picked file untracked on disk so a later commit touching that path aborted with \"untracked working tree files would be overwritten\" and every remaining commit was dropped, both reproduced against real git before the fix; the replacement forces filtered paths back to HEAD in index and worktree, halts on a conflict outside the filter instead of improvising, and skips a commit left empty by filtering; (3) a mode-derived verify step plus the advisory line naming the .planning/ paths default mode deliberately keeps, so correcting the assertion does not trade a permanently-wrong signal for silence; (4) a clean-working-tree precondition, required because the corrected filter now removes files from the working tree. Comments carry the why for each, because every one of them is a place a future edit would otherwise reintroduce the defect."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1263,3 +1263,251 @@ describe('handle_branching branches off origin/HEAD, not current HEAD (#2916)',
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ─── #3679 — pr-branch: pre-existing planning content + verify deletion gate ──
|
||||
//
|
||||
// The original deletion class (blanket `git rm -r --cached` of transient dirs
|
||||
// stripping pre-existing base-branch files) was fixed as a side effect of the
|
||||
// #2971 strict-mode rewrite (rm -f --ignore-unmatch + `git checkout HEAD --`
|
||||
// restore). These rows PIN that guarantee behaviorally so it cannot silently
|
||||
// regress, and add the remaining piece from the #3679 brief: the verify step
|
||||
// must FAIL when the PR-branch diff deletes planning files the target tracks
|
||||
// (a deleted allowed/structural path verifies clean today — name-only
|
||||
// counting cannot see status).
|
||||
|
||||
describe('#3679 — pr-branch pre-existing planning content + verify deletion gate', () => {
|
||||
const PR_BRANCH_MD = path.join(WORKFLOW_DIR, 'pr-branch.md');
|
||||
|
||||
function extractStepBash(stepName) {
|
||||
const content = readFileNormalized(PR_BRANCH_MD);
|
||||
const lines = content.split('\n');
|
||||
let start = -1;
|
||||
let end = -1;
|
||||
for (let i = 0; i < lines.length; i += 1) {
|
||||
if (start === -1 && new RegExp(`^<step\\s+name="${stepName}">\\s*$`).test(lines[i])) {
|
||||
start = i + 1;
|
||||
} else if (start !== -1 && /^<\/step>\s*$/.test(lines[i])) {
|
||||
end = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert.ok(start !== -1 && end !== -1, `pr-branch.md must contain the ${stepName} step`);
|
||||
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);
|
||||
}
|
||||
assert.ok(bashBlocks.length > 0, `${stepName} step contains bash blocks`);
|
||||
return bashBlocks.join('\n');
|
||||
}
|
||||
|
||||
test('verify step gates on planning-tree deletions', () => {
|
||||
const bash = extractStepBash('verify');
|
||||
assert.ok(
|
||||
/diff-filter=D|--name-status/.test(bash),
|
||||
'verify must distinguish deletions (diff-filter=D or --name-status)',
|
||||
);
|
||||
assert.ok(
|
||||
/PLANNING_DELETIONS/.test(bash),
|
||||
'verify must compute a planning-deletions count',
|
||||
);
|
||||
// The must-be-0 gate lives in the step PROSE and the display template,
|
||||
// outside every ```bash block — assert it against the full file text so
|
||||
// the enforcement half of criterion 4 is pinned, not just the computation.
|
||||
const fullText = readFileNormalized(PR_BRANCH_MD);
|
||||
assert.ok(
|
||||
/PLANNING_DELETIONS[^\n]*must be .?0/.test(fullText),
|
||||
'verify prose must gate on a zero PLANNING_DELETIONS count',
|
||||
);
|
||||
assert.ok(
|
||||
/Planning deletions: \{PLANNING_DELETIONS\}/.test(fullText),
|
||||
'verify display must surface the deletion count',
|
||||
);
|
||||
});
|
||||
|
||||
test('verify fails when the PR diff deletes target-tracked planning files', (t) => {
|
||||
const repo = createTempDir('gsd-3679-verify-fail-');
|
||||
t.after(() => cleanup(repo));
|
||||
const g = (args) => gitOrThrow(args, { cwd: repo });
|
||||
g(['init', '-q', '-b', 'main']);
|
||||
g(['config', 'user.email', 't@t']);
|
||||
g(['config', 'user.name', 't']);
|
||||
fs.mkdirSync(path.join(repo, '.planning'), { recursive: true });
|
||||
fs.writeFileSync(path.join(repo, '.planning', 'STATE.md'), 'state\n');
|
||||
fs.writeFileSync(path.join(repo, 'code.sh'), 'code\n');
|
||||
g(['add', '-A']);
|
||||
g(['commit', '-qm', 'base']);
|
||||
g(['checkout', '-qb', 'pr']);
|
||||
fs.writeFileSync(path.join(repo, 'code.sh'), 'code2\n');
|
||||
// The failure class under test: the PR branch deletes a planning file the
|
||||
// target tracks (here structural — an allowed category, so the existing
|
||||
// name-only forbidden count cannot catch it).
|
||||
g(['rm', '-q', '.planning/STATE.md']);
|
||||
g(['add', '-A']);
|
||||
g(['commit', '-qm', 'changes + planning deletion']);
|
||||
|
||||
const verifyBash = extractStepBash('verify');
|
||||
const script = [
|
||||
'set -u',
|
||||
'TARGET=main',
|
||||
'PR_BRANCH=pr',
|
||||
'FORBIDDEN_RE="^\\.planning/(phases|quick|research|threads|todos|debug|seeds|codebase|ui-reviews)/"',
|
||||
'STRUCTURAL_RE="^\\.planning/(STATE|ROADMAP|MILESTONES|PROJECT|REQUIREMENTS)\\.md$|^\\.planning/milestones/"',
|
||||
verifyBash,
|
||||
'echo "GATE_FORBIDDEN=$FORBIDDEN"',
|
||||
'echo "GATE_PLANNING_DELETIONS=${PLANNING_DELETIONS:-unset}"',
|
||||
].join('\n');
|
||||
fs.writeFileSync(path.join(repo, 'verify.sh'), script + '\n');
|
||||
const r = runHook(path.join(repo, 'verify.sh'), [], {
|
||||
interpreter: 'bash',
|
||||
cwd: repo,
|
||||
timeoutMs: HOOK_FANOUT_TIMEOUT_MS,
|
||||
});
|
||||
const out = r.stdout + r.stderr;
|
||||
assert.match(out, /GATE_PLANNING_DELETIONS=1/, `deletion count must be non-zero: ${out.slice(0, 400)}`);
|
||||
});
|
||||
|
||||
test('verify passes a clean diff with zero deletions', (t) => {
|
||||
const repo = createTempDir('gsd-3679-verify-clean-');
|
||||
t.after(() => cleanup(repo));
|
||||
const g = (args) => gitOrThrow(args, { cwd: repo });
|
||||
g(['init', '-q', '-b', 'main']);
|
||||
g(['config', 'user.email', 't@t']);
|
||||
g(['config', 'user.name', 't']);
|
||||
fs.mkdirSync(path.join(repo, '.planning'), { recursive: true });
|
||||
fs.writeFileSync(path.join(repo, '.planning', 'STATE.md'), 'state\n');
|
||||
fs.writeFileSync(path.join(repo, 'code.sh'), 'code\n');
|
||||
g(['add', '-A']);
|
||||
g(['commit', '-qm', 'base']);
|
||||
g(['checkout', '-qb', 'pr']);
|
||||
fs.writeFileSync(path.join(repo, 'code.sh'), 'code2\n');
|
||||
g(['add', '-A']);
|
||||
g(['commit', '-qm', 'code only']);
|
||||
|
||||
const verifyBash = extractStepBash('verify');
|
||||
const script = [
|
||||
'set -u',
|
||||
'TARGET=main',
|
||||
'PR_BRANCH=pr',
|
||||
'FORBIDDEN_RE="^\\.planning/(phases|quick|research|threads|todos|debug|seeds|codebase|ui-reviews)/"',
|
||||
'STRUCTURAL_RE="^\\.planning/(STATE|ROADMAP|MILESTONES|PROJECT|REQUIREMENTS)\\.md$|^\\.planning/milestones/"',
|
||||
verifyBash,
|
||||
'echo "GATE_FORBIDDEN=$FORBIDDEN"',
|
||||
'echo "GATE_PLANNING_DELETIONS=${PLANNING_DELETIONS:-unset}"',
|
||||
].join('\n');
|
||||
fs.writeFileSync(path.join(repo, 'verify.sh'), script + '\n');
|
||||
const r = runHook(path.join(repo, 'verify.sh'), [], {
|
||||
interpreter: 'bash',
|
||||
cwd: repo,
|
||||
timeoutMs: HOOK_FANOUT_TIMEOUT_MS,
|
||||
});
|
||||
const out = r.stdout + r.stderr;
|
||||
assert.match(out, /GATE_PLANNING_DELETIONS=0/, `clean diff must count zero deletions: ${out.slice(0, 400)}`);
|
||||
});
|
||||
|
||||
test('create loop preserves pre-existing transient content (#3720 guarantee pinned)', (t) => {
|
||||
const repo = createTempDir('gsd-3679-create-mixed-');
|
||||
t.after(() => cleanup(repo));
|
||||
const g = (args) => gitOrThrow(args, { cwd: repo });
|
||||
g(['init', '-q', '-b', 'main']);
|
||||
g(['config', 'user.email', 't@t']);
|
||||
g(['config', 'user.name', 't']);
|
||||
fs.mkdirSync(path.join(repo, '.planning', 'phases'), { recursive: true });
|
||||
fs.mkdirSync(path.join(repo, '.planning', 'research'), { recursive: true });
|
||||
fs.mkdirSync(path.join(repo, 'infra'), { recursive: true });
|
||||
fs.writeFileSync(path.join(repo, '.planning', 'phases', '1.0-PLAN.md'), 'plan\n');
|
||||
fs.writeFileSync(path.join(repo, '.planning', 'research', 'context.md'), 'ctx\n');
|
||||
// Structural planning state on the TARGET (criterion 3): must survive the
|
||||
// create loop byte-identical alongside the transient content.
|
||||
fs.writeFileSync(path.join(repo, '.planning', 'STATE.md'), 'state\n');
|
||||
fs.writeFileSync(path.join(repo, '.planning', 'ROADMAP.md'), 'roadmap\n');
|
||||
fs.writeFileSync(path.join(repo, 'infra', 'script.sh'), 'code\n');
|
||||
g(['add', '-A']);
|
||||
g(['commit', '-qm', 'base: code + pre-existing planning']);
|
||||
g(['checkout', '-qb', 'feature']);
|
||||
fs.writeFileSync(path.join(repo, 'infra', 'script2.sh'), 'more\n');
|
||||
fs.writeFileSync(path.join(repo, '.planning', 'phases', '2.0-SUMMARY.md'), 'summary\n');
|
||||
g(['add', '-A']);
|
||||
g(['commit', '-qm', 'mixed: code + new transient']);
|
||||
const hash = g(['rev-parse', 'HEAD']).trim();
|
||||
|
||||
// Execute the shipped create_pr_branch loop verbatim (default-mode paths).
|
||||
const createBash = extractStepBash('create_pr_branch');
|
||||
const script = [
|
||||
'set -u',
|
||||
`CURRENT_BRANCH=feature`,
|
||||
'TARGET=main',
|
||||
`INCLUDED_COMMITS="${hash}"`,
|
||||
'FILTER_PATHS=".planning/phases/ .planning/quick/ .planning/research/ .planning/threads/ .planning/todos/ .planning/debug/ .planning/seeds/ .planning/codebase/ .planning/ui-reviews/ "',
|
||||
createBash,
|
||||
].join('\n');
|
||||
fs.writeFileSync(path.join(repo, 'create.sh'), script + '\n');
|
||||
const r = runHook(path.join(repo, 'create.sh'), [], {
|
||||
interpreter: 'bash',
|
||||
cwd: repo,
|
||||
timeoutMs: HOOK_FANOUT_TIMEOUT_MS,
|
||||
});
|
||||
assert.equal(r.exitCode, 0, `create loop must succeed: ${r.stderr.slice(0, 400)}`);
|
||||
|
||||
const status = g(['diff', '--name-status', 'main..feature-pr']);
|
||||
assert.equal((status.match(/^D/gm) || []).length, 0, `no deletions allowed: ${status}`);
|
||||
const diffPaths = g(['diff', '--name-only', 'main..feature-pr']);
|
||||
assert.ok(!diffPaths.includes('2.0-SUMMARY.md'), "commit's own transient file must be excluded");
|
||||
assert.ok(diffPaths.includes('script2.sh'), 'code change must be present');
|
||||
assert.ok(!diffPaths.includes('1.0-PLAN.md'), 'pre-existing plan must be untouched');
|
||||
assert.ok(!diffPaths.includes('STATE.md'), 'structural STATE.md must survive (criterion 3)');
|
||||
assert.ok(!diffPaths.includes('ROADMAP.md'), 'structural ROADMAP.md must survive (criterion 3)');
|
||||
});
|
||||
|
||||
test('create loop preserves planning content on pure-code commits', (t) => {
|
||||
const repo = createTempDir('gsd-3679-create-pure-');
|
||||
t.after(() => cleanup(repo));
|
||||
const g = (args) => gitOrThrow(args, { cwd: repo });
|
||||
g(['init', '-q', '-b', 'main']);
|
||||
g(['config', 'user.email', 't@t']);
|
||||
g(['config', 'user.name', 't']);
|
||||
fs.mkdirSync(path.join(repo, '.planning', 'phases'), { recursive: true });
|
||||
fs.mkdirSync(path.join(repo, 'infra'), { recursive: true });
|
||||
fs.writeFileSync(path.join(repo, '.planning', 'phases', '1.0-PLAN.md'), 'plan\n');
|
||||
fs.writeFileSync(path.join(repo, 'infra', 'script.sh'), 'code\n');
|
||||
g(['add', '-A']);
|
||||
g(['commit', '-qm', 'base']);
|
||||
g(['checkout', '-qb', 'feature']);
|
||||
fs.writeFileSync(path.join(repo, 'infra', 'script2.sh'), 'more\n');
|
||||
g(['add', '-A']);
|
||||
g(['commit', '-qm', 'pure code']);
|
||||
const hash = g(['rev-parse', 'HEAD']).trim();
|
||||
|
||||
const createBash = extractStepBash('create_pr_branch');
|
||||
const script = [
|
||||
'set -u',
|
||||
'CURRENT_BRANCH=feature',
|
||||
'TARGET=main',
|
||||
`INCLUDED_COMMITS="${hash}"`,
|
||||
'FILTER_PATHS=".planning/phases/ .planning/quick/ .planning/research/ .planning/threads/ .planning/todos/ .planning/debug/ .planning/seeds/ .planning/codebase/ .planning/ui-reviews/ "',
|
||||
createBash,
|
||||
].join('\n');
|
||||
fs.writeFileSync(path.join(repo, 'create.sh'), script + '\n');
|
||||
const r = runHook(path.join(repo, 'create.sh'), [], {
|
||||
interpreter: 'bash',
|
||||
cwd: repo,
|
||||
timeoutMs: HOOK_FANOUT_TIMEOUT_MS,
|
||||
});
|
||||
assert.equal(r.exitCode, 0, `create loop must succeed: ${r.stderr.slice(0, 400)}`);
|
||||
const status = g(['diff', '--name-status', 'main..feature-pr']);
|
||||
assert.equal((status.match(/^D/gm) || []).length, 0, `no deletions allowed: ${status}`);
|
||||
assert.ok(status.includes('script2.sh'), 'code change must be present');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user