diff --git a/cli/__tests__/ship-gate-cache-shadow.test.mts b/cli/__tests__/ship-gate-cache-shadow.test.mts new file mode 100644 index 00000000..73be082b --- /dev/null +++ b/cli/__tests__/ship-gate-cache-shadow.test.mts @@ -0,0 +1,143 @@ +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterAll, describe, expect, it } from 'vitest'; +import { testExecFileSync as execFileSync, testSpawnSync as spawnSync } from './_helpers.mts'; + +// sc-1489: a consumer committed `.qavis/receipt.json`, so the base checkout kept putting a stale copy +// in the ship worktree, the link was skipped as "already present", and the qavis-advisory gate could +// never be cleared by running qavis — GUARD_QAVIS_OK=1 was the only exit. + +const scriptPath = fileURLToPath(new URL('../lib/ship/ship-branch.sh', import.meta.url)); +const GENV = { GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_SYSTEM: '/dev/null' }; +const WT_RE = /worktree kept at (.+?)\. Remove/; +const dirs: string[] = []; +afterAll(() => { + for (const d of dirs) rmSync(d, { recursive: true, force: true }); +}); + +// The hook reports the receipt's CONTENT. Presence is not enough to see this bug: the shadow handed +// the gate a file, just the wrong one, so a `-e` probe passes straight through it. +const HOOK = + '#!/bin/sh\ngrep -q live .qavis/receipt.json 2>/dev/null && echo RECEIPT_LIVE || echo RECEIPT_STALE\nexit 0\n'; + +function seedRepo() { + const dir = mkdtempSync(join(tmpdir(), 'shipcache-')); + dirs.push(dir); + const env = { ...process.env, ...GENV }; + const git = (args: string[], opts = {}) => + execFileSync('git', args, { cwd: dir, env, encoding: 'utf8', ...opts }); + mkdirSync(join(dir, '.husky/_'), { recursive: true }); + writeFileSync(join(dir, '.husky/.keep'), ''); + writeFileSync(join(dir, '.husky/_/pre-commit'), HOOK); // gitignored runner; ship fails closed without it + chmodSync(join(dir, '.husky/_/pre-commit'), 0o755); + for (const a of [ + ['init', '-q', '-b', 'work'], + ['config', 'user.email', 'a@b.c'], + ['config', 'user.name', 'a'], + ['config', 'commit.gpgsign', 'false'], + ['add', '.husky/.keep'], + ['commit', '-q', '-m', 'base'], + ['config', 'core.hooksPath', '.husky/_'], + ['remote', 'add', 'origin', 'git@github.com:acme/app.git'], + ]) + git(a, { stdio: 'ignore' }); + mkdirSync(join(dir, '.qavis'), { recursive: true }); + writeFileSync(join(dir, '.qavis/recipe.json'), '{"from":"acme/qavis"}\n'); + return { dir, env, git }; +} + +function ship(dir: string, env: NodeJS.ProcessEnv, git, branch: string, paths = ['note.txt']) { + const r = spawnSync('/bin/bash', [scriptPath, branch, 't', ...paths], { + cwd: dir, + input: 'b\n', + encoding: 'utf8', + env: { ...env, SHIP_DRY_RUN: '1' }, + }); + const wt = WT_RE.exec(r.stderr)?.[1]; // dry-run keeps it; drop so afterAll's rm isn't blocked + if (wt) { + try { + git(['worktree', 'remove', '--force', wt], { stdio: 'ignore' }); + } catch { + /* best-effort */ + } + } + return { + ...r, + gateLog: () => + readFileSync(join(dir, `.devkit/last-ship-gates-${branch.replace(/\//g, '-')}.log`), 'utf8'), + }; +} + +describe('ship — a committed gate cache must lose to the live one', () => { + it('links the live receipt over the stale committed copy, without touching the shipped commit', () => { + const { dir, env, git } = seedRepo(); + writeFileSync(join(dir, '.qavis/receipt.json'), '{"sha":"stale"}\n'); + git(['add', '.qavis'], { stdio: 'ignore' }); + git(['commit', '-q', '-m', 'accidentally track the receipt'], { stdio: 'ignore' }); + writeFileSync(join(dir, '.qavis/receipt.json'), '{"sha":"live"}\n'); // a real pass, post-commit + writeFileSync(join(dir, 'note.txt'), 'hi\n'); + + const r = ship(dir, env, git, 'feat/committed-receipt'); + + expect(r.status, r.stderr).toBe(0); + expect(r.gateLog()).toMatch(/RECEIPT_LIVE/); // the gate read the pass, not the committed staleness + expect(r.gateLog()).not.toMatch(/RECEIPT_STALE/); + expect(r.stderr).toMatch(/gate cache\(s\) are COMMITTED/); + expect(r.stderr).toContain('git rm .qavis/receipt.json'); // pasteable, and landable by ship + expect(r.stderr).not.toContain('git rm --cached'); // --cached leaves it on disk → ship re-adds it + // It must not ALSO reach the linked-config classifier, which would tell the operator to commit the + // very file the notice above says to untrack (and "untracked" is false — the base tracks it). + expect(r.stderr).not.toMatch(/\.qavis\/receipt\.json \(untracked/); + expect(r.stderr).not.toMatch(/commit it so gates/); + // The override is worktree-only: the shipped commit must not delete or rewrite the tracked receipt. + expect(git(['diff', '--name-only', 'work', 'feat/committed-receipt']).trim()).toBe('note.txt'); + }); + + // The notice is only worth printing if devkit can LAND what it prints, in the sequence an operator + // actually follows: `git rm` deletes their live receipt too, so they re-run the tool and it comes + // back — untracked AND gitignored, exactly what ship's force-add pass sweeps up. Regenerating it here + // is the whole point of the test; without that write the deletion lands trivially and pins nothing. + it('lands the untracking even after the tool regenerates the cache', () => { + const { dir, env, git } = seedRepo(); + writeFileSync(join(dir, '.qavis/receipt.json'), '{"sha":"stale"}\n'); + git(['add', '.qavis'], { stdio: 'ignore' }); + git(['commit', '-q', '-m', 'accidentally track the receipt'], { stdio: 'ignore' }); + git(['rm', '-q', '.qavis/receipt.json'], { stdio: 'ignore' }); // exactly what the notice prints + writeFileSync(join(dir, '.gitignore'), '.qavis/receipt.json\n'); + writeFileSync(join(dir, '.qavis/receipt.json'), '{"sha":"live"}\n'); // `qavis qa` re-run + writeFileSync(join(dir, 'note.txt'), 'hi\n'); + + const r = ship(dir, env, git, 'feat/untrack-receipt', [ + '.gitignore', + '.qavis/receipt.json', + 'note.txt', + ]); + + expect(r.status, r.stderr).toBe(0); + const tree = git(['ls-tree', '-r', '--name-only', 'feat/untrack-receipt']).trim().split('\n'); + expect(tree).not.toContain('.qavis/receipt.json'); // the defect is gone from the base, for good + expect(tree).toContain('.gitignore'); + // Nothing stale reached the worktree, so the notice must stay quiet on the ship that fixes it. + expect(r.stderr).not.toMatch(/gate cache\(s\) are COMMITTED/); + // ...and the regenerated cache still reached the gate, so this ship is QA-clearable like any other. + expect(r.gateLog()).toMatch(/RECEIPT_LIVE/); + }); + + it('leaves an already-gitignored receipt on the normal link path', () => { + const { dir, env, git } = seedRepo(); + writeFileSync(join(dir, '.gitignore'), '.qavis/receipt.json\n'); + git(['add', '.qavis/recipe.json', '.gitignore'], { stdio: 'ignore' }); + git(['commit', '-q', '-m', 'track recipe, ignore receipt'], { stdio: 'ignore' }); + writeFileSync(join(dir, '.qavis/receipt.json'), '{"sha":"live"}\n'); + writeFileSync(join(dir, 'note.txt'), 'hi\n'); + + const r = ship(dir, env, git, 'feat/ignored-receipt'); + + expect(r.status, r.stderr).toBe(0); + expect(r.gateLog()).toMatch(/RECEIPT_LIVE/); + expect(r.stderr).toMatch(/\.qavis\/receipt\.json \(gitignored cache/); + expect(r.stderr).not.toMatch(/gate cache\(s\) are COMMITTED/); // no false positive + }); +}); diff --git a/cli/lib/ship/assert-staged-set.sh b/cli/lib/ship/assert-staged-set.sh index cfa63b08..c8ef17f1 100644 --- a/cli/lib/ship/assert-staged-set.sh +++ b/cli/lib/ship/assert-staged-set.sh @@ -122,7 +122,7 @@ _ship_report_object_environment() { # ship_assert_staged_unchanged # Preflight, run immediately before the commit: nothing between staging and here may touch the index -# (prepare_gate_worktree and link_untracked_gate_configs only create UNTRACKED symlinks), so this is +# (prepare_gate_worktree and link_untracked_gate_configs write only the WORKING TREE), so this is # an exact equality check. Catches a clobber that lands before the gate chain even starts. ship_assert_staged_unchanged() { local wt=$1 state=$2 expected actual diff --git a/cli/lib/ship/link-gate-configs.sh b/cli/lib/ship/link-gate-configs.sh index 77d4e546..e4196a34 100644 --- a/cli/lib/ship/link-gate-configs.sh +++ b/cli/lib/ship/link-gate-configs.sh @@ -37,6 +37,29 @@ GATE_PROJECTION_FIXED_CANDIDATES=( .qavis/receipt.json ) +# Candidates that are a content-addressed CACHE, not source. A copy in the base commit is stale by +# construction — the sha it attests cannot cover the set being shipped — so it must lose to the live +# one, or the gate reading it can never be cleared by running the tool (sc-1489). +GATE_PROJECTION_CACHE_CANDIDATES=( + .qavis/receipt.json +) + +# gate_projection_is_stale_cache +# A cache candidate the BASE COMMIT put in $WT and that is still there: not a symlink (we placed that), +# not a path change-application already removed (a ship that untracks it), and not one absent from HEAD +# (change-application put those there from the invoking checkout — already the live bytes). +gate_projection_is_stale_cache() { + local wt=$1 rel=$2 cache + for cache in "${GATE_PROJECTION_CACHE_CANDIDATES[@]}"; do + [ "$rel" = "$cache" ] || continue + [ -L "$wt/$rel" ] && return 1 + [ -f "$wt/$rel" ] || return 1 + git -C "$wt" cat-file -e "HEAD:$rel" 2>/dev/null && return 0 + return 1 + done + return 1 +} + gate_config_path_emitter() { local self_dir emitter self_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) @@ -93,9 +116,9 @@ gate_projection_source_is_ignored() { # link_untracked_gate_configs [purpose] link_untracked_gate_configs() { local wt=$1 root=$2 purpose=${3:-ship} emitter resolved rel line index_rel='' candidate_manifest='' - local main_root='' candidate_root=$root source='' + local main_root='' candidate_root=$root source='' stale_hit='' local projection_manifest=${DEVKIT_REVIEW_PROJECTION_MANIFEST:-} projection_tool='' - local linked=() linked_sources=() candidates=() + local linked=() linked_sources=() candidates=() stale=() case "$purpose" in ship | review | review-baseline) ;; *) @@ -180,15 +203,39 @@ link_untracked_gate_configs() { # Present in the repo but absent from the committed worktree = the gate would fail open. The # -L guard also skips a pre-existing symlink so `ln` never aborts on it. Empty local projection # dirs are unusable, so a populated main-worktree copy wins; files keep root-first precedence. + # Recorded BEFORE the source lookup so the diagnostic still prints when the operator's checkout + # has no live copy to link. A stale cache never enters `linked`: it is present in the committed + # tree, so that notice's wording and count would both be wrong for it. + stale_hit= + if gate_projection_is_stale_cache "$wt" "$rel"; then + rm -f "$wt/$rel" # worktree only; the shipped commit is asserted unchanged by this file's test + stale+=("$rel") + stale_hit=1 + fi source=$(gate_link_source "$root" "$main_root" "$rel" prefer-populated) || continue - [ ! -e "$wt/$rel" ] && [ ! -L "$wt/$rel" ] || continue + if [ -z "$stale_hit" ]; then + [ ! -e "$wt/$rel" ] && [ ! -L "$wt/$rel" ] || continue + linked+=("$rel") + linked_sources+=("$source") + fi mkdir -p "$wt/$(dirname "$rel")" ln -s "$source" "$wt/$rel" - linked+=("$rel") - linked_sources+=("$source") done fi + if [ "${#stale[@]}" -gt 0 ]; then + { + echo "⚠️ ship: ${#stale[@]} gate cache(s) are COMMITTED, so the base checkout carried a stale copy" + echo " into the gate worktree — the live one was used instead. These are content-addressed: a" + echo " committed copy can never match the set being shipped. Untrack them and LAND it on the" + echo " base, or every ship repeats this. Use \`git rm\`, NOT \`--cached\` — a file left on disk" + echo " stages no deletion. Ship BOTH paths, ideally on their own so no QA gate is in the way:" + for rel in "${stale[@]}"; do + echo " - git rm $rel && printf '%s\\n' '$rel' >> .gitignore" + done + } >&2 + fi + # Guard the empty array BEFORE expanding it (stock-macOS bash 3.2 aborts on "${arr[@]}" when empty # under `set -u`; cf. commit-with-gate-capture.sh). [ "${#linked[@]}" -eq 0 ] && return 0 diff --git a/cli/lib/ship/ship-branch.sh b/cli/lib/ship/ship-branch.sh index 93729116..6d7fa6d0 100644 --- a/cli/lib/ship/ship-branch.sh +++ b/cli/lib/ship/ship-branch.sh @@ -250,7 +250,12 @@ done # vanished, publishing a devkit whose gate supervisor could not resolve its own import. # Every PATHS entry is caller-explicit (positional after --; directories rejected above), so # forcing them is precisely what was asked — same reasoning as reship.sh's `git add -f` (#199). +# A path the patch staged as DELETED is skipped: this pass exists to catch ignored files the diff +# MISSED, never to overrule one it expressed. Without the guard, deleting a tracked file whose +# gitignored copy is back on disk (a regenerable cache — sc-1489's receipt) silently re-adds it and +# the deletion can never land, however many times it is shipped. git -C "$ROOT" ls-files -o -i --exclude-standard -- "${PATHS[@]}" | while IFS= read -r f; do + git -C "$WT" diff --cached --quiet --diff-filter=D -- "$f" || continue mkdir -p "$WT/$(dirname "$f")" cp -Pp "$ROOT/$f" "$WT/$f" git -C "$WT" add -f -- "$f" diff --git a/dist/cli/lib/ship/assert-staged-set.sh b/dist/cli/lib/ship/assert-staged-set.sh index cfa63b08..c8ef17f1 100644 --- a/dist/cli/lib/ship/assert-staged-set.sh +++ b/dist/cli/lib/ship/assert-staged-set.sh @@ -122,7 +122,7 @@ _ship_report_object_environment() { # ship_assert_staged_unchanged # Preflight, run immediately before the commit: nothing between staging and here may touch the index -# (prepare_gate_worktree and link_untracked_gate_configs only create UNTRACKED symlinks), so this is +# (prepare_gate_worktree and link_untracked_gate_configs write only the WORKING TREE), so this is # an exact equality check. Catches a clobber that lands before the gate chain even starts. ship_assert_staged_unchanged() { local wt=$1 state=$2 expected actual diff --git a/dist/cli/lib/ship/link-gate-configs.sh b/dist/cli/lib/ship/link-gate-configs.sh index 77d4e546..e4196a34 100644 --- a/dist/cli/lib/ship/link-gate-configs.sh +++ b/dist/cli/lib/ship/link-gate-configs.sh @@ -37,6 +37,29 @@ GATE_PROJECTION_FIXED_CANDIDATES=( .qavis/receipt.json ) +# Candidates that are a content-addressed CACHE, not source. A copy in the base commit is stale by +# construction — the sha it attests cannot cover the set being shipped — so it must lose to the live +# one, or the gate reading it can never be cleared by running the tool (sc-1489). +GATE_PROJECTION_CACHE_CANDIDATES=( + .qavis/receipt.json +) + +# gate_projection_is_stale_cache +# A cache candidate the BASE COMMIT put in $WT and that is still there: not a symlink (we placed that), +# not a path change-application already removed (a ship that untracks it), and not one absent from HEAD +# (change-application put those there from the invoking checkout — already the live bytes). +gate_projection_is_stale_cache() { + local wt=$1 rel=$2 cache + for cache in "${GATE_PROJECTION_CACHE_CANDIDATES[@]}"; do + [ "$rel" = "$cache" ] || continue + [ -L "$wt/$rel" ] && return 1 + [ -f "$wt/$rel" ] || return 1 + git -C "$wt" cat-file -e "HEAD:$rel" 2>/dev/null && return 0 + return 1 + done + return 1 +} + gate_config_path_emitter() { local self_dir emitter self_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) @@ -93,9 +116,9 @@ gate_projection_source_is_ignored() { # link_untracked_gate_configs [purpose] link_untracked_gate_configs() { local wt=$1 root=$2 purpose=${3:-ship} emitter resolved rel line index_rel='' candidate_manifest='' - local main_root='' candidate_root=$root source='' + local main_root='' candidate_root=$root source='' stale_hit='' local projection_manifest=${DEVKIT_REVIEW_PROJECTION_MANIFEST:-} projection_tool='' - local linked=() linked_sources=() candidates=() + local linked=() linked_sources=() candidates=() stale=() case "$purpose" in ship | review | review-baseline) ;; *) @@ -180,15 +203,39 @@ link_untracked_gate_configs() { # Present in the repo but absent from the committed worktree = the gate would fail open. The # -L guard also skips a pre-existing symlink so `ln` never aborts on it. Empty local projection # dirs are unusable, so a populated main-worktree copy wins; files keep root-first precedence. + # Recorded BEFORE the source lookup so the diagnostic still prints when the operator's checkout + # has no live copy to link. A stale cache never enters `linked`: it is present in the committed + # tree, so that notice's wording and count would both be wrong for it. + stale_hit= + if gate_projection_is_stale_cache "$wt" "$rel"; then + rm -f "$wt/$rel" # worktree only; the shipped commit is asserted unchanged by this file's test + stale+=("$rel") + stale_hit=1 + fi source=$(gate_link_source "$root" "$main_root" "$rel" prefer-populated) || continue - [ ! -e "$wt/$rel" ] && [ ! -L "$wt/$rel" ] || continue + if [ -z "$stale_hit" ]; then + [ ! -e "$wt/$rel" ] && [ ! -L "$wt/$rel" ] || continue + linked+=("$rel") + linked_sources+=("$source") + fi mkdir -p "$wt/$(dirname "$rel")" ln -s "$source" "$wt/$rel" - linked+=("$rel") - linked_sources+=("$source") done fi + if [ "${#stale[@]}" -gt 0 ]; then + { + echo "⚠️ ship: ${#stale[@]} gate cache(s) are COMMITTED, so the base checkout carried a stale copy" + echo " into the gate worktree — the live one was used instead. These are content-addressed: a" + echo " committed copy can never match the set being shipped. Untrack them and LAND it on the" + echo " base, or every ship repeats this. Use \`git rm\`, NOT \`--cached\` — a file left on disk" + echo " stages no deletion. Ship BOTH paths, ideally on their own so no QA gate is in the way:" + for rel in "${stale[@]}"; do + echo " - git rm $rel && printf '%s\\n' '$rel' >> .gitignore" + done + } >&2 + fi + # Guard the empty array BEFORE expanding it (stock-macOS bash 3.2 aborts on "${arr[@]}" when empty # under `set -u`; cf. commit-with-gate-capture.sh). [ "${#linked[@]}" -eq 0 ] && return 0 diff --git a/dist/cli/lib/ship/ship-branch.sh b/dist/cli/lib/ship/ship-branch.sh index 93729116..6d7fa6d0 100644 --- a/dist/cli/lib/ship/ship-branch.sh +++ b/dist/cli/lib/ship/ship-branch.sh @@ -250,7 +250,12 @@ done # vanished, publishing a devkit whose gate supervisor could not resolve its own import. # Every PATHS entry is caller-explicit (positional after --; directories rejected above), so # forcing them is precisely what was asked — same reasoning as reship.sh's `git add -f` (#199). +# A path the patch staged as DELETED is skipped: this pass exists to catch ignored files the diff +# MISSED, never to overrule one it expressed. Without the guard, deleting a tracked file whose +# gitignored copy is back on disk (a regenerable cache — sc-1489's receipt) silently re-adds it and +# the deletion can never land, however many times it is shipped. git -C "$ROOT" ls-files -o -i --exclude-standard -- "${PATHS[@]}" | while IFS= read -r f; do + git -C "$WT" diff --cached --quiet --diff-filter=D -- "$f" || continue mkdir -p "$WT/$(dirname "$f")" cp -Pp "$ROOT/$f" "$WT/$f" git -C "$WT" add -f -- "$f" diff --git a/docs/decisions/qavis-advisory-gate.md b/docs/decisions/qavis-advisory-gate.md index f5023b24..8cdb705d 100644 --- a/docs/decisions/qavis-advisory-gate.md +++ b/docs/decisions/qavis-advisory-gate.md @@ -29,3 +29,4 @@ created: 2026-07-07 - 2026-07-22 — Fail-open stays exit 0 but is now LOUD: when the advisory cannot run, the gate prints one stderr line naming WHY (qavis not on PATH / route failed / unparseable verdict) instead of returning a bare null that read exactly like SILENT. defaultRoute now returns a RouteResult whose null arm carries the reason, so there is one printer and no detail is discarded; ENOENT is discriminated from a qavis that ran and failed. Silent fail-open made a dead gate indistinguishable from 'nothing to QA'. - 2026-07-22 — devkit doctor now reports the advisory gate's liveness outside commit time: recipe present + qavis on PATH, printed in all three doctor modes. Advisory only — never a CheckResult and never a --fix target, since a repo that keeps the guard but skips installing qavis made a choice, not drift. Resolved against the git root, the cwd the husky fragment shells the gate from. Paid for the doctor.mts size ratchet (grandfathered at 894, shrink-only) by extracting the four synced-asset drift checks and the CheckResult primitive into cli/lib/doctor/ — which also let commit-msg-block.mts import the type instead of restating it structurally to dodge an import cycle. - 2026-07-28 — sc-1307: An ADVISE remedy now forces qavis qa --route vision because the gate's diff-aware classifier intentionally catches semantic UI impact in backend-only paths that qa's auto filename router can skip. A skip receipt was rejected: it would waive exactly those backend-to-UI cases. The classifier's deterministic launchability pre-gate guarantees the forced vision route is available. +- 2026-08-12 — sc-1489: A COMMITTED receipt now loses to the live one in the gate worktree. frink committed .qavis/receipt.json by accident; the ship link loop skips any path the base checkout already placed in the gate worktree, so the stale copy shadowed the live cache and qavis route compared the staged sha against staleness — two genuine "qavis qa --staged --route vision" passes still blocked, with GUARD_QAVIS_OK=1 the only exit. That is Rejected (c) reached from the other direction: the receipt mechanism was satisfiable, but a tracked copy silently disabled the clearing path. Ruling: GATE_PROJECTION_CACHE_CANDIDATES names the gate inputs that are content-addressed CACHES rather than source; for those, a base-committed copy is removed from the gate worktree and the live one linked over it, with a loud notice naming the untrack. The removal is worktree-only — the index is untouched and the ship commit is index-only, asserted by the test. REJECTED en route: aborting the ship on a committed cache. It reads as the stricter fix, but the guard reads the ship BASE, so no ship can clear itself — the untracking has to land on the base in a SEPARATE PR round-trip first, while every ship in the repo stays blocked. That is Rejected (c)'s shape (a block whose remedy the blocked path cannot carry), and it would also fire for consumers the advisory fail-opens for anyway. The notice prints "git rm", never "--cached": with "--cached" the file stays on disk, so the base-vs-worktree diff stages no deletion and ship's force-add pass for ignored paths re-commits it. Landing "git rm" needed a second fix, in ship-branch.sh: that force-add pass now SKIPS any path the applied patch staged as deleted. It exists to catch ignored files the diff missed, never to overrule one it expressed — and without the guard the realistic sequence silently defeats the remedy, because "git rm" also deletes the operator's live receipt, so they re-run the tool and the regenerated cache (now untracked AND gitignored) is swept back into the commit. That is general to ship, not cache-specific: it is what "devkit ship cannot express deleting the tracked receipt while the gitignored cache exists on disk at the same path" (the original report) actually was. reship.sh keeps its documented "current content of each path" semantics and is unchanged. Tests pin the resulting tree, with the regeneration step included.