fix(#3679): gate pr-branch verify on planning-tree deletions (#3803)

* 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:
Tom Boucher
2026-08-24 03:54:02 -04:00
committed by GitHub
parent 9d65cd5404
commit 31fcb833ec
5 changed files with 269 additions and 2 deletions

View 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)

View File

@@ -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.

View File

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

View File

@@ -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."
}
}
}

View File

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