Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
143 changes: 143 additions & 0 deletions cli/__tests__/ship-gate-cache-shadow.test.mts
Original file line number Diff line number Diff line change
@@ -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 };

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Clear review-mode environment variables.

seedRepo() copies the parent environment. If the parent sets a review-mode key, this test can run the review projection instead of the ship flow. Delete DEVKIT_RUN_MODE, DEVKIT_REVIEW_ASSET_ROOT, and DEVKIT_REVIEW_PROGRESS from env before invoking the script.

Based on learnings, commit/ship mode requires the absence of these three environment keys.

Proposed fix
   const env = { ...process.env, ...GENV };
+  delete env.DEVKIT_RUN_MODE;
+  delete env.DEVKIT_REVIEW_ASSET_ROOT;
+  delete env.DEVKIT_REVIEW_PROGRESS;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const env = { ...process.env, ...GENV };
const env = { ...process.env, ...GENV };
delete env.DEVKIT_RUN_MODE;
delete env.DEVKIT_REVIEW_ASSET_ROOT;
delete env.DEVKIT_REVIEW_PROGRESS;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cli/__tests__/ship-gate-cache-shadow.test.mts` at line 34, Update the test
environment setup around env in the ship-flow test to remove DEVKIT_RUN_MODE,
DEVKIT_REVIEW_ASSET_ROOT, and DEVKIT_REVIEW_PROGRESS after copying process.env
and GENV. Ensure these keys are absent before seedRepo() invokes the script,
preserving commit/ship mode regardless of the parent environment.

Source: Learnings

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
});
});
2 changes: 1 addition & 1 deletion cli/lib/ship/assert-staged-set.sh
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ _ship_report_object_environment() {

# ship_assert_staged_unchanged <worktree> <state-file>
# 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
Expand Down
57 changes: 52 additions & 5 deletions cli/lib/ship/link-gate-configs.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 <worktree> <repo-relative-path>
# 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)
Expand Down Expand Up @@ -93,9 +116,9 @@ gate_projection_source_is_ignored() {
# link_untracked_gate_configs <worktree> <root> [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) ;;
*)
Expand Down Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions cli/lib/ship/ship-branch.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion dist/cli/lib/ship/assert-staged-set.sh
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ _ship_report_object_environment() {

# ship_assert_staged_unchanged <worktree> <state-file>
# 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
Expand Down
57 changes: 52 additions & 5 deletions dist/cli/lib/ship/link-gate-configs.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 <worktree> <repo-relative-path>
# 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)
Expand Down Expand Up @@ -93,9 +116,9 @@ gate_projection_source_is_ignored() {
# link_untracked_gate_configs <worktree> <root> [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) ;;
*)
Expand Down Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions dist/cli/lib/ship/ship-branch.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading