diff --git a/bin/gstack-diff-scope b/bin/gstack-diff-scope
index 0530b084b..275bee55d 100755
--- a/bin/gstack-diff-scope
+++ b/bin/gstack-diff-scope
@@ -38,13 +38,13 @@ HEAD_REF=""
if [ "${2:-}" = "--head" ]; then HEAD_REF="${3:-}"; fi
_twin=""
case "$BASE" in
- HEAD|origin/*|refs/*|*[~^@:]*) ;;
- *) if ! printf '%s' "$BASE" | grep -Eq '^[0-9a-f]{7,64}$' && ! git remote | grep -qxF "${BASE%%/*}"; then _twin="refs/remotes/origin/${BASE}"; fi ;;
+ HEAD|refs/*|*[~^@:]*) ;;
+ origin/*) if git show-ref --verify -q "refs/remotes/$BASE"; then BASE="refs/remotes/$BASE"; fi ;;
+ *) if git remote | grep -qxF "${BASE%%/*}"; then
+ if git show-ref --verify -q "refs/remotes/$BASE"; then BASE="refs/remotes/$BASE"; fi
+ elif ! printf '%s' "$BASE" | grep -Eq '^[0-9a-f]{7,64}$'; then _twin="refs/remotes/origin/${BASE}"; fi ;;
esac
-if [ -n "$_twin" ] && git show-ref --verify -q "$_twin" && git merge-base "$_twin" "${HEAD_REF:-HEAD}" >/dev/null 2>&1; then
- _local=$(git rev-parse --verify -q "refs/heads/${BASE}^{commit}" 2>/dev/null || true)
- if [ -z "$_local" ] || ! git merge-base "$_local" "${HEAD_REF:-HEAD}" >/dev/null 2>&1 || git merge-base --is-ancestor "$_local" "$_twin"; then BASE="$_twin"; fi
-fi
+if [ -n "$_twin" ] && git show-ref --verify -q "$_twin" && git merge-base "$_twin" "${HEAD_REF:-HEAD}" >/dev/null 2>&1; then BASE="$_twin"; fi
FRONTEND=false
BACKEND=false
diff --git a/bin/gstack-gate-diff b/bin/gstack-gate-diff
index 413369d45..3d817a385 100755
--- a/bin/gstack-gate-diff
+++ b/bin/gstack-gate-diff
@@ -148,7 +148,7 @@ function main(argv: string[]): number {
process.stdout.write(`GATE_ERROR=no_base ref=${baseRef} fix=${noBaseFix(baseRef, root)}\n`);
return 2;
}
- if (base.local) console.error(`gstack-gate-diff: ${{ unrelated: `local ${baseRef} shares no history with HEAD`, missing: `no local branch ${baseRef}`, behind: `local ${baseRef} is behind ${base.ref}` }[base.local]}; base is ${base.ref}`);
+ if (base.local) console.error(`gstack-gate-diff: ${{ unrelated: `local ${baseRef} shares no history with HEAD`, missing: `no local branch ${baseRef}`, behind: `local ${baseRef} is behind ${base.ref}`, ahead: `local ${baseRef} is ahead of ${base.ref}`, diverged: `local ${baseRef} has diverged from ${base.ref}` }[base.local]}; base is ${base.ref}`);
const mb = mergeBase(base.sha, head, root);
if (!mb) {
process.stdout.write(`GATE_ERROR=no_base ref=${baseRef} fix=${noBaseFix(baseRef, root)}\n`);
diff --git a/changelog.d/gst-464-receipt-targeted-base.md b/changelog.d/gst-464-receipt-targeted-base.md
index 9ffb69b60..588f9cabf 100644
--- a/changelog.d/gst-464-receipt-targeted-base.md
+++ b/changelog.d/gst-464-receipt-targeted-base.md
@@ -4,10 +4,10 @@
### Fixed
- **Step 11** saves the targeted record to `.gstack/tmp/targeted.json` (`gstack-review-dispatch verify --targeted-out`), and the Step 19 and handoff `gstack-ship-receipt write` commands pass it as `--review-targeted` whenever that file exists. Every Step 9 entry creates `.gstack/tmp`, adds it to `.git/info/exclude` and deletes the file. So a pass without the reader leaves the receipt legacy, an earlier pass's record can't carry over, and the record never counts as an uncommitted edit.
-- **`gstack-ship-preflight`** fetches the base into `origin/` with an explicit refspec, so a single-branch clone also diffs against the current base tip. When a PR is open at HEAD and the diff lists far more files than the PR changes (more than twice as many, plus 50), it stops before any reader runs. The stop names the PR's base, or a fork's lagging base when the two names match.
-- **`gstack-gate-diff` and `gstack-diff-scope`** compare against origin's tracking branch when the local base branch is missing, unrelated to HEAD, or behind it. gate-diff says so (`local main is behind origin/main; base is origin/main`). This applies whether you name the base or let the tool detect the default. A local branch that is ahead of or diverged from origin's stays the base. The tracking branch is read by its full name (`refs/remotes/origin/`), and only for a bare branch name, so a tag or a fetched branch called `origin/main`, an explicit `origin/main` and a pinned sha are never redirected to another commit. `gstack-review-plan` reads both, so its gate scan and scope now match the merge base it already took from `origin/`.
+- **`gstack-ship-preflight`** fetches the base into `origin/` with an explicit refspec, so a single-branch clone also diffs against the current base tip. When a PR is open at HEAD and the diff lists far more files than the PR changes (more than twice as many, plus 50), or fewer, it stops before any reader runs. Fewer files means origin's base is ahead of the PR's, as in a fork, and the gate scan would miss commits the PR lands. The stop names the PR's base, or the fork case when the two names match.
+- **`gstack-gate-diff` and `gstack-diff-scope`** compare against origin's tracking branch whenever it shares history with HEAD, the same base `gstack-review-plan`'s path list reads. A local `main` behind it counted commits the base already had; one ahead of or diverged from it left out commits the branch lands. gate-diff says which (`local main is ahead of origin/main; base is origin/main`). This applies whether you name the base or let the tool detect the default. A local branch stays the base only when origin's is missing or unrelated. Tracking branches are read by their full names (`refs/remotes/origin/main`), so a tag or a fetched branch called `origin/main` can't stand in, whether you pass `main`, `origin/main` or nothing. A pinned sha is never redirected. `gstack-review-plan` reads both, so its gate scan and scope now match the merge base it already took from `origin/`.
### Tests
-- `test/review-targeted.test.ts`: the generated sections' own commands carry the record from Step 11's verify to `gstack-ship-receipt write`, and ready's line reads `verdict=ok`. A run without the reader passes no flag, and Step 9 clears the file.
-- `test/ship-preflight.test.ts`: a single-branch clone's preflight refreshes `origin/main`, and a 60-file diff against a 3-file PR stops at the pushed head. The stop quotes an unsafe base name and names the fork case. Negative controls: another head, a matching size, or a zero count goes on.
-- `test/review-plan-cli.test.ts`, `test/gate-diff-cli.test.ts`, `test/diff-scope.test.ts`: a local `main` behind `origin/main` yields to it. One that is ahead, diverged, or next to an unrelated `origin/main` stays the base. A tag or fetched branch named `origin/main`, an explicit `origin/main`, a pinned sha and `HEAD` are never redirected. The review plan of a docs-only branch that merged the newer `origin/main` stays docs; before the fix, the gate scan counted origin's skipped test.
+- `test/review-targeted.test.ts`: the generated sections' own commands carry the record from Step 11's verify to `gstack-ship-receipt write`, and ready's line reads `verdict=ok`. A run without the reader passes no flag, and Step 9 clears the file, even when `.git/info` is missing or the exclude file has no final newline.
+- `test/ship-preflight.test.ts`: a single-branch clone's preflight refreshes `origin/main`, and a 60-file diff against a 3-file or a 61-file PR stops at the pushed head. The stop quotes an unsafe base name and names the fork case. Negative controls: another head, a matching size, or a zero count goes on.
+- `test/review-plan-cli.test.ts`, `test/gate-diff-cli.test.ts`, `test/diff-scope.test.ts`: a local `main` behind, ahead of or diverged from `origin/main` yields to it, so an unpushed `test.skip` on local `main` is scanned. One next to an unrelated `origin/main` stays the base. A tag or fetched branch named `origin/main` never stands in, and a pinned sha and `HEAD` are never redirected. The review plan of a docs-only branch that merged the newer `origin/main` stays docs; before the fix, the gate scan counted origin's skipped test.
diff --git a/lib/gate-diff/git.ts b/lib/gate-diff/git.ts
index 09f95d755..37910adef 100644
--- a/lib/gate-diff/git.ts
+++ b/lib/gate-diff/git.ts
@@ -85,28 +85,35 @@ export function isShallow(cwd: string): boolean {
/**
* The base as a ref that resolves here and, when `head` is given, shares
- * history with it. An unqualified branch name (`main`, never `origin/main`,
- * a sha or a relative ref) yields to origin's tracking branch, read by its full
- * name so a tag or a fetched branch called `origin/main` cannot stand in, when
- * the local branch is missing, shares no history with HEAD, or is behind it: a
- * local `main` behind origin counts commits the base already has as the
- * branch's change (GST-462). A local branch ahead of or diverged from origin's
- * stays the base. Other remotes stand in only for a missing or unrelated local
- * branch, so the fix line's fetch into the tracking ref is a fix and not a loop.
- * `local` says why a tracking branch won.
+ * history with it. A bare branch name (`main`; never a sha, a relative ref or
+ * `refs/`) yields to its remote-tracking branch whenever that shares history
+ * with HEAD, the base gstack-review-plan's path list reads: a local `main`
+ * behind origin would count commits the base already has (GST-462), and one
+ * ahead of or diverged from it would leave out commits the branch lands. A
+ * missing or unrelated local branch yields to any remote's; otherwise only
+ * origin's counts. A remote-qualified ref (`origin/main`) is read as
+ * `refs/remotes/origin/main` when that exists. Tracking branches are read by
+ * their full names, so a tag or a fetched branch called `origin/main` cannot
+ * stand in. `local` says why a tracking branch won.
*/
-export function resolveBase(ref: string, cwd: string, head?: string): { ref: string; sha: string; local?: 'missing' | 'unrelated' | 'behind' } | null {
+export function resolveBase(ref: string, cwd: string, head?: string): { ref: string; sha: string; local?: 'missing' | 'unrelated' | 'behind' | 'ahead' | 'diverged' } | null {
const related = (sha: string) => !head || mergeBase(sha, head, cwd) !== null;
- const direct = resolvesToCommit(ref, cwd);
const remotes = remoteNames(cwd);
+ const direct = resolvesToCommit(ref, cwd);
+ if (remotes.some((remote) => ref.startsWith(`${remote}/`))) {
+ const sha = resolvesToCommit(`refs/remotes/${ref}`, cwd) ?? direct;
+ return sha ? { ref, sha } : null;
+ }
+ if (!isBranchName(ref)) return direct ? { ref, sha: direct } : null;
const local = !direct ? 'missing' : !related(direct) ? 'unrelated' : null;
- if (!isBranchName(ref) || remotes.some((remote) => ref.startsWith(`${remote}/`))) return direct ? { ref, sha: direct } : null;
for (const remote of remotes) {
if (!local && remote !== 'origin') continue;
const sha = resolvesToCommit(`refs/remotes/${remote}/${ref}`, cwd);
- if (!sha || !related(sha) || sha === direct) continue;
+ if (!sha || !related(sha)) continue;
+ if (sha === direct) return { ref, sha };
if (local) return { ref: `${remote}/${ref}`, sha, local };
- if (mergeBase(direct!, sha, cwd) === direct) return { ref: `${remote}/${ref}`, sha, local: 'behind' };
+ const fork = mergeBase(direct!, sha, cwd);
+ return { ref: `${remote}/${ref}`, sha, local: fork === direct ? 'behind' : fork === sha ? 'ahead' : 'diverged' };
}
return direct ? { ref, sha: direct } : null;
}
diff --git a/lib/ship-preflight.ts b/lib/ship-preflight.ts
index 3b3f8f191..13efa4040 100644
--- a/lib/ship-preflight.ts
+++ b/lib/ship-preflight.ts
@@ -65,16 +65,16 @@ function integrate(cwd: string, base: string, r: Report): boolean {
return merge(cwd, base, r);
}
-/** The diff against origin/ must be about the PR's size: far more files (a stale or wrong base, HARN-15's 17,000-file review of main's history) stops before any reader runs. Checked only when HEAD is the PR's pushed head. */
+/** The diff against origin/ must be about the PR's size: far more files (a stale or wrong base, HARN-15's 17,000-file review of main's history) or fewer (a fork's origin ahead of the PR's base, which would leave commits out of the gate scan) stops before any reader runs. Checked only when HEAD is the PR's pushed head. */
function prSized(cwd: string, base: string, n: string, r: Report): boolean {
const view = gh(cwd, ['pr', 'view', n, '--json', 'changedFiles,headRefOid,baseRefName', '--jq', '"\\(.changedFiles) \\(.headRefOid) \\(.baseRefName)"']);
const [pr, prHead, prBase] = view.status === 0 ? view.stdout.trim().split(' ') : [];
if (!pr || !/^[1-9]\d*$/.test(pr) || prHead !== git(cwd, ['rev-parse', 'HEAD']).stdout.trim()) return true;
const local = git(cwd, ['diff', '--name-only', forkPoint(cwd, base), 'HEAD']).stdout.split('\n').filter(Boolean).length;
- if (local <= 2 * Number(pr) + 50) return true;
+ if (local >= Number(pr) && local <= 2 * Number(pr) + 50) return true;
const word = /^[A-Za-z0-9._\/-]+$/.test(prBase ?? '') ? prBase! : `'${(prBase ?? '').replace(/'/g, "'\\''")}'`;
r.stop(`the diff against origin/${base} lists ${local} files but PR #${n} changes ${pr} (its base is ${word}): ${prBase === base
- ? `origin is not the PR's base repository or its ${base} lags it (a fork); fetch the PR's base repository's ${base} into origin/${base}, then rerun`
+ ? `origin is not the PR's base repository (a fork) or its ${base} has moved; fetch the PR's base repository's ${base} into origin/${base}, then rerun`
: `origin/${base} is not the PR's base; rerun with --base ${word} after git fetch origin +refs/heads/${word}:refs/remotes/origin/${word}`}`);
return false;
}
diff --git a/ship/sections/review-army.md b/ship/sections/review-army.md
index a575f89d7..491c6f756 100644
--- a/ship/sections/review-army.md
+++ b/ship/sections/review-army.md
@@ -5,7 +5,7 @@
Set CYCLES to 0 on first entry only, with PASS set to 1,
`REVIEW_SINCE=$(date -u +%Y-%m-%dT%H:%M:%SZ)` saved and the `ship: review_scope`
policy line (Step 3) kept. Every entry saves `PASS_HEAD=$(git rev-parse HEAD)` and runs
-`mkdir -p .gstack/tmp && rm -f .gstack/tmp/targeted.json && _EX=$(git rev-parse --git-path info/exclude) && { grep -qxF '/.gstack/tmp/' "$_EX" || echo '/.gstack/tmp/' >> "$_EX"; }`
+`mkdir -p .gstack/tmp && rm -f .gstack/tmp/targeted.json && _EX=$(git rev-parse --git-path info/exclude) && mkdir -p "$(dirname "$_EX")" && { grep -qxF '/.gstack/tmp/' "$_EX" 2>/dev/null || printf '\n/.gstack/tmp/\n' >> "$_EX"; }`
(a pass without the targeted reader leaves no record, and the record never reads as an uncommitted edit); from pass 2 on,
`RANGE_BASE` is the previous pass's PASS_HEAD. Keep existing approvals; changed
finding scope needs a new decision. Order: checklist/design, specialists (9.1),
diff --git a/ship/sections/review-army.md.tmpl b/ship/sections/review-army.md.tmpl
index 10a84d149..6db54bf16 100644
--- a/ship/sections/review-army.md.tmpl
+++ b/ship/sections/review-army.md.tmpl
@@ -3,7 +3,7 @@
Set CYCLES to 0 on first entry only, with PASS set to 1,
`REVIEW_SINCE=$(date -u +%Y-%m-%dT%H:%M:%SZ)` saved and the `ship: review_scope`
policy line (Step 3) kept. Every entry saves `PASS_HEAD=$(git rev-parse HEAD)` and runs
-`mkdir -p .gstack/tmp && rm -f .gstack/tmp/targeted.json && _EX=$(git rev-parse --git-path info/exclude) && { grep -qxF '/.gstack/tmp/' "$_EX" || echo '/.gstack/tmp/' >> "$_EX"; }`
+`mkdir -p .gstack/tmp && rm -f .gstack/tmp/targeted.json && _EX=$(git rev-parse --git-path info/exclude) && mkdir -p "$(dirname "$_EX")" && { grep -qxF '/.gstack/tmp/' "$_EX" 2>/dev/null || printf '\n/.gstack/tmp/\n' >> "$_EX"; }`
(a pass without the targeted reader leaves no record, and the record never reads as an uncommitted edit); from pass 2 on,
`RANGE_BASE` is the previous pass's PASS_HEAD. Keep existing approvals; changed
finding scope needs a new decision. Order: checklist/design, specialists (9.1),
diff --git a/test/diff-scope.test.ts b/test/diff-scope.test.ts
index 3689a39ab..fd7dd3aa4 100644
--- a/test/diff-scope.test.ts
+++ b/test/diff-scope.test.ts
@@ -94,6 +94,9 @@ describe('gstack-diff-scope', () => {
git('tag', '-d', 'origin/main');
git('update-ref', 'refs/remotes/origin/origin/main', 'HEAD');
expect(scopeOf(['origin/main'])).toContain('SCOPE_DOCS=true');
+ git('tag', 'origin/main', 'HEAD');
+ expect(scopeOf(['origin/main'])).toContain('SCOPE_DOCS=true');
+ git('tag', '-d', 'origin/main');
const pinned = git('rev-parse', 'origin/main').stdout.toString().trim();
git('update-ref', `refs/remotes/origin/${pinned}`, 'HEAD');
expect(scopeOf([pinned])).toContain('SCOPE_DOCS=true');
@@ -104,6 +107,25 @@ describe('gstack-diff-scope', () => {
expect(unrelated).toContain('SCOPE_BACKEND=true');
});
+ // Value: protects=diff-scope reads the base gstack-review-plan's path list reads (origin/), so a test file a local
+ // main carries but origin lacks counts when the branch includes it; fails_when=a related local main ahead of origin/main
+ // stays the base (fix-check, pass 2: SCOPE_TESTS=false beside a path list naming the test); seam=a tracking ref set with
+ // update-ref; no_claim=remotes other than origin
+ test('a local main ahead of origin/main yields to it, so its unpushed test file is in scope', () => {
+ const dir = createRepo([]);
+ const git = (...a: string[]) => spawnSync('git', a, { cwd: dir, stdio: 'pipe', timeout: 5000 });
+ git('checkout', '-q', 'main');
+ git('update-ref', 'refs/remotes/origin/main', 'HEAD');
+ mkdirSync(join(dir, 'test'), { recursive: true });
+ writeFileSync(join(dir, 'test', 'a.test.ts'), "test.skip('b', () => {});\n");
+ git('add', '.'); git('commit', '-qm', 'unpushed test');
+ git('checkout', '-q', '-b', 'topic', 'main');
+ mkdirSync(join(dir, 'docs'), { recursive: true });
+ writeFileSync(join(dir, 'docs', 'x.md'), '# x\n');
+ git('add', '.'); git('commit', '-qm', 'docs');
+ expect(spawnSync(SCRIPT, ['main'], { cwd: dir, stdio: 'pipe', timeout: 5000 }).stdout.toString()).toContain('SCOPE_TESTS=true');
+ });
+
test('detects frontend files', () => {
const dir = createRepo(['styles.css', 'component.tsx']);
const scope = runScope(dir);
diff --git a/test/gate-diff-cli.test.ts b/test/gate-diff-cli.test.ts
index 74b20e56a..6d5632b2b 100644
--- a/test/gate-diff-cli.test.ts
+++ b/test/gate-diff-cli.test.ts
@@ -153,7 +153,7 @@ describe('gstack-gate-diff arguments and exit contract', () => {
// commits the base already has (merged into the branch from origin) do not count as the branch's change (GST-462,
// GBRA-218); fails_when=resolveBase returns the local ref whenever it resolves and shares history with HEAD; seam=a
// tracking ref moved with update-ref, as a fetch would; no_claim=nothing about remotes other than origin.
- test('a local main behind origin/main yields to it and says so; ahead, diverged or beside an unrelated origin/main it stays the base', () => {
+ test('a local main behind, ahead of or diverged from origin/main yields to it and says so; beside an unrelated origin/main it stays the base', () => {
const src = repo({ fixture: 'rh1-skip', commit: true });
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-gate-diff-behind-'));
repos.push(dir);
@@ -180,12 +180,39 @@ describe('gstack-gate-diff arguments and exit contract', () => {
gitIn(dir, ['update-ref', ref, sha]);
};
commitOn('refs/heads/main', 'unpushed');
- expect(run(dir, ['main']).stderr).not.toContain('base is origin/main');
+ const ahead = run(dir, ['main']);
+ expect(ahead.status).toBe(0);
+ expect(ahead.stderr).toContain('gstack-gate-diff: local main is ahead of origin/main; base is origin/main');
commitOn('refs/remotes/origin/main', 'upstream moved');
- expect(run(dir, ['main']).stderr).not.toContain('base is origin/main');
+ const diverged = run(dir, ['main']);
+ expect(diverged.status).toBe(0);
+ expect(diverged.stderr).toContain('gstack-gate-diff: local main has diverged from origin/main; base is origin/main');
const orphan = gitIn(dir, ['-c', 'user.name=t', '-c', 'user.email=t@t', 'commit-tree', gitIn(dir, ['rev-parse', 'HEAD^{tree}']).trim(), '-m', 'unrelated']).trim();
gitIn(dir, ['update-ref', 'refs/remotes/origin/main', orphan]);
- expect(run(dir, ['main']).stderr).not.toContain('base is origin/main');
+ const unrelated = run(dir, ['main']);
+ expect(unrelated.status).toBe(0);
+ expect(summary(unrelated.stdout).tagged).toBe('');
+ expect(unrelated.stderr).not.toContain('base is origin/main');
+ });
+
+ // Value: protects=the gate scan reads the same base as gstack-review-plan's path list (origin/), so commits a local
+ // main carries but origin lacks are scanned when the branch includes them; fails_when=a related local main that is ahead
+ // of origin/main stays the base (fix-check, pass 2: an unpushed test.skip listed in paths, gate_tagged=0); seam=a local
+ // branch moved with update-ref; no_claim=a fork whose origin is not the PR's base repository (ship preflight's prSized)
+ test('a local main ahead of origin/main yields to it, so its unpushed test.skip is scanned', () => {
+ const src = repo({ fixture: 'rh1-skip', commit: true });
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-gate-diff-ahead-'));
+ repos.push(dir);
+ gitIn(dir, ['clone', '-q', `file://${src}`, '.']);
+ gitIn(dir, ['branch', '-q', '--no-track', 'main', 'origin/feature']);
+ gitIn(dir, ['checkout', '-q', '-b', 'topic', 'main']);
+ fs.writeFileSync(path.join(dir, 'NOTES.md'), 'topic note\n');
+ gitIn(dir, ['add', 'NOTES.md']);
+ gitIn(dir, ['-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-qm', 'topic']);
+ const r = run(dir, ['main']);
+ expect(r.status).toBe(0);
+ expect(summary(r.stdout).tagged).toBe('RH-1:1');
+ expect(r.stderr).toContain('gstack-gate-diff: local main is ahead of origin/main; base is origin/main');
});
// Value: protects=the base cannot be redirected to the candidate by a ref the contributor controls: a tag named
@@ -193,7 +220,7 @@ describe('gstack-gate-diff arguments and exit contract', () => {
// branch, and an explicit origin/main is never re-qualified; fails_when=resolveBase resolves the twin by its short name or
// tries a twin for a remote-qualified ref (outside voice, pass 2: both emptied the gate scan of a test.skip); seam=refs a
// fetch would create; no_claim=a tag shadowing a local branch name, which predates this change
- test('a tag or a fetched branch named origin/main cannot empty the gate scan', () => {
+ test('a tag or a fetched branch named origin/main cannot empty the gate scan, named bare, qualified or defaulted', () => {
const src = repo({ fixture: 'rh1-skip', commit: true });
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-gate-diff-shadow-'));
repos.push(dir);
@@ -202,6 +229,9 @@ describe('gstack-gate-diff arguments and exit contract', () => {
expect(gitIn(dir, ['branch', '--list', 'main']).trim()).toBe('');
gitIn(dir, ['tag', 'origin/main', 'HEAD']);
expect(summary(run(dir, ['main']).stdout).tagged).toBe('RH-1:1');
+ expect(summary(run(dir, ['origin/main']).stdout).tagged).toBe('RH-1:1');
+ gitIn(dir, ['symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/main']);
+ expect(summary(run(dir, []).stdout).tagged).toBe('RH-1:1');
gitIn(dir, ['tag', '-d', 'origin/main']);
gitIn(dir, ['update-ref', 'refs/remotes/origin/origin/main', 'HEAD']);
expect(summary(run(dir, ['origin/main']).stdout).tagged).toBe('RH-1:1');
diff --git a/test/review-targeted.test.ts b/test/review-targeted.test.ts
index 48a29ace5..1a1cd126a 100644
--- a/test/review-targeted.test.ts
+++ b/test/review-targeted.test.ts
@@ -351,6 +351,12 @@ describe('the /ship commands carry the targeted record to the receipt (GST-464)'
expect(fs.existsSync(path.join(repo.work, recordPath!))).toBe(false);
fs.rmSync(path.join(repo.work, '.gstack'), { recursive: true });
expect(spawnSync('bash', ['-c', entry!], { cwd: repo.work, encoding: 'utf8', timeout: 10_000 }).status).toBe(0);
+ const exclude = path.join(repo.work, '.git', 'info', 'exclude');
+ fs.rmSync(path.dirname(exclude), { recursive: true, force: true });
+ expect(spawnSync('bash', ['-c', entry!], { cwd: repo.work, encoding: 'utf8', timeout: 10_000 }).status).toBe(0);
+ fs.writeFileSync(exclude, '*.log');
+ expect(spawnSync('bash', ['-c', entry!], { cwd: repo.work, encoding: 'utf8', timeout: 10_000 }).status).toBe(0);
+ expect(fs.readFileSync(exclude, 'utf8').split('\n')).toEqual(expect.arrayContaining(['*.log', '/.gstack/tmp/']));
fs.writeFileSync(path.join(repo.work, recordPath!), JSON.stringify({ classes: ['money'], rules: TARGETED_RULES, brief_sha: targetedBriefSha(), status: 'done', findings: 0, raised_by: 'targeted' }) + '\n');
const args = expand();
expect(args).toEqual(['--review-targeted', `@${recordPath}`]);
diff --git a/test/ship-preflight.test.ts b/test/ship-preflight.test.ts
index beb726af0..b0002b6c4 100644
--- a/test/ship-preflight.test.ts
+++ b/test/ship-preflight.test.ts
@@ -104,7 +104,7 @@ describe('gstack-ship-preflight', () => {
// Value: protects=a diff far larger than the PR's own (a stale or wrong base: HARN-15 reviewed 17,000 files of main's history)
// stops before any reader runs, naming the PR's base; fails_when=preflight never compares the diff with the PR's changed
// files; seam=gh pr view (changedFiles, headRefOid, baseRefName); no_claim=GitHub's own count
- test('a diff far beyond the PR\'s changed files stops at the pushed head; another head or a matching size goes on', () => {
+ test('a diff far beyond or short of the PR\'s changed files stops at the pushed head; another head or a matching size goes on', () => {
const files = Object.fromEntries(Array.from({ length: 60 }, (_, i) => [`src/f${i}.ts`, `export const f${i} = ${i};\n`]));
const f = fx({}, files);
const head = f.git('rev-parse', 'HEAD').stdout.trim();
@@ -115,11 +115,15 @@ describe('gstack-ship-preflight', () => {
expect(r.stdout).toContain('STOP: the diff against origin/main lists 60 files but PR #7 changes 3 (its base is release): origin/main is not the PR\'s base; rerun with --base release after git fetch origin +refs/heads/release:refs/remotes/origin/release');
stub(3, head, 'main');
const fork = f.run(PREFLIGHT, ['--base', 'main']);
- expect(fork.stdout).toContain('(its base is main): origin is not the PR\'s base repository or its main lags it (a fork)');
+ expect(fork.stdout).toContain('(its base is main): origin is not the PR\'s base repository (a fork) or its main has moved');
expect(fork.stdout).not.toContain('rerun with --base');
stub(3, head, 'x;rm');
expect(f.run(PREFLIGHT, ['--base', 'main']).stdout).toContain("rerun with --base 'x;rm' after git fetch origin +refs/heads/'x;rm':refs/remotes/origin/'x;rm'");
- for (const [changed, prHead] of [[3, 'f'.repeat(40)], [60, head], [0, head]] as const) {
+ stub(61, head, 'main');
+ const fewer = f.run(PREFLIGHT, ['--base', 'main']);
+ expect(fewer.status).toBe(3);
+ expect(fewer.stdout).toContain('STOP: the diff against origin/main lists 60 files but PR #7 changes 61 (its base is main): origin is not the PR\'s base repository (a fork) or its main has moved');
+ for (const [changed, prHead] of [[3, 'f'.repeat(40)], [61, 'f'.repeat(40)], [60, head], [0, head]] as const) {
stub(changed, prHead);
const go = f.run(PREFLIGHT, ['--base', 'main']);
expect(go.stdout).not.toContain('lists 60 files');
diff --git a/test/size-ledger/ship/gst-464-receipt-targeted-base.json b/test/size-ledger/ship/gst-464-receipt-targeted-base.json
index fa63e754c..5040cf235 100644
--- a/test/size-ledger/ship/gst-464-receipt-targeted-base.json
+++ b/test/size-ledger/ship/gst-464-receipt-targeted-base.json
@@ -1,4 +1,4 @@
{
- "bytes": 527,
+ "bytes": 577,
"why": "GST-464: Step 9 creates .gstack/tmp, excludes it and clears the targeted record on every entry"
}