mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-03 07:18:03 +01:00
fix: refuse a base that contains HEAD; skip a self-pointing origin/HEAD (GST-774)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
f739756389
commit
b2c644c3f4
16 files changed
+545
-45
No files matched your search
+58
-14
@@ -15,7 +15,11 @@
|
||||
# trips loudly instead of silently disabling reviewers)
|
||||
# exit 2 base ref unresolvable → all false + SCOPE_ERROR=no_base
|
||||
# (shallow CI checkout / missing fetch — a green here would mean
|
||||
# "we could not look")
|
||||
# "we could not look"; also no default base when origin/HEAD
|
||||
# names the checked-out branch and nothing past it resolves)
|
||||
# exit 2 base is HEAD or contains it → all false + SCOPE_ERROR=base_contains_head
|
||||
# (the branch has commits its real base lacks, so it would be
|
||||
# compared with itself, GST-774; the reason as a comment line)
|
||||
# Every line is shell-safe for `source <(...)` consumers: assignments or
|
||||
# `#`-comments only.
|
||||
#
|
||||
@@ -26,14 +30,25 @@
|
||||
set -euo pipefail
|
||||
|
||||
# Detect the repo's default branch when no arg is given (#703-class
|
||||
# platform-agnostic rule): origin/HEAD -> origin/main -> origin/master -> main.
|
||||
_default_base() {
|
||||
git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's|refs/remotes/origin/||' && return
|
||||
git rev-parse --verify -q origin/main >/dev/null 2>&1 && { echo "main"; return; }
|
||||
git rev-parse --verify -q origin/master >/dev/null 2>&1 && { echo "master"; return; }
|
||||
echo "main"
|
||||
}
|
||||
BASE="${1:-$(_default_base)}"
|
||||
# platform-agnostic rule, lib/default-base.ts): origin/HEAD unless it names the
|
||||
# checked-out branch (a PR flow's clone can point it at the PR branch, GST-511,
|
||||
# GST-774) -> the PR's base (gh, 5 s) -> origin/main -> origin/master -> main.
|
||||
# An origin/HEAD skipped that way with nothing past it resolving leaves BASE
|
||||
# empty, and that fails closed; so does a default-base.ts that cannot run.
|
||||
GSTACK_BUN_CONFIG=/dev/null; case "${OSTYPE:-}" in msys*|cygwin*) if command -v cygpath >/dev/null 2>&1; then GSTACK_BUN_CONFIG=NUL; fi ;; esac
|
||||
_default_base_ts() { bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." 2>/dev/null && pwd)/lib/default-base.ts" "$@"; }
|
||||
NO_DEFAULT=""
|
||||
BASE="${1:-}"
|
||||
if [ -z "$BASE" ]; then
|
||||
_rc=0
|
||||
_found=$(_default_base_ts default 2>/dev/null) || _rc=$?
|
||||
case "$_rc" in
|
||||
0) BASE="$_found" ;;
|
||||
1) BASE="main" ;;
|
||||
3) NO_DEFAULT="$_found" ;;
|
||||
*) NO_DEFAULT="lib/default-base.ts could not run (exit $_rc); pass the base explicitly" ;;
|
||||
esac
|
||||
fi
|
||||
HEAD_REF=""
|
||||
if [ "${2:-}" = "--head" ]; then HEAD_REF="${3:-}"; fi
|
||||
|
||||
@@ -65,16 +80,45 @@ _print_flags() {
|
||||
echo "SCOPE_AUTH=$AUTH"
|
||||
}
|
||||
|
||||
# Base reachability (#2526): a shallow CI checkout or an unfetched ref makes
|
||||
# `git diff` return an empty list — all-false with exit 0, a green that means
|
||||
# "we could not look". Distinguish it before diffing.
|
||||
if ! git rev-parse --verify -q "${BASE}^{commit}" >/dev/null 2>&1; then
|
||||
if [ -z "$BASE" ]; then
|
||||
_print_flags
|
||||
echo "SCOPE_ERROR=no_base"
|
||||
echo "# base ref '${BASE}' is not resolvable — shallow checkout or missing fetch. Run: git fetch origin ${BASE}"
|
||||
echo "# ${NO_DEFAULT}"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# Base reachability (#2526): a shallow CI checkout or an unfetched ref makes
|
||||
# `git diff` return an empty list — all-false with exit 0, a green that means
|
||||
# "we could not look". Distinguish it before diffing. A branch name that does
|
||||
# not resolve here reads its origin twin first (a PR checkout has no local main).
|
||||
if ! git rev-parse --verify -q "${BASE}^{commit}" >/dev/null 2>&1; then
|
||||
case "$BASE" in
|
||||
HEAD|refs/*|origin/*|*[~^@:]*) ;;
|
||||
*) if git rev-parse --verify -q "refs/remotes/origin/${BASE}^{commit}" >/dev/null 2>&1; then BASE="origin/$BASE"; fi ;;
|
||||
esac
|
||||
if ! git rev-parse --verify -q "${BASE}^{commit}" >/dev/null 2>&1; then
|
||||
_print_flags
|
||||
echo "SCOPE_ERROR=no_base"
|
||||
echo "# base ref '${BASE}' is not resolvable — shallow checkout or missing fetch. Run: git fetch origin ${BASE}"
|
||||
exit 2
|
||||
fi
|
||||
fi
|
||||
|
||||
# A base that is the head or already contains it reads the branch against
|
||||
# itself, all false with exit 0 (GST-774). lib/default-base.ts decides whether
|
||||
# that is the base branch itself or a fresh branch (today's output) or a
|
||||
# refusal; it is asked only when git says the base contains the head.
|
||||
if git merge-base --is-ancestor "${HEAD_REF:-HEAD}" "${BASE}" >/dev/null 2>&1; then
|
||||
_rc=0
|
||||
_why=$(_default_base_ts check "$BASE" "${HEAD_REF:-HEAD}" gstack-diff-scope 2>/dev/null) || _rc=$?
|
||||
if [ "$_rc" -ne 0 ]; then
|
||||
_print_flags
|
||||
echo "SCOPE_ERROR=base_contains_head"
|
||||
echo "# ${_why:-lib/default-base.ts could not check the base (exit $_rc) (BASE_CONTAINS_HEAD)}"
|
||||
exit 2
|
||||
fi
|
||||
fi
|
||||
|
||||
# Changed files, NUL-delimited (#2526 minor: `git diff --name-only` octal-quotes
|
||||
# non-ASCII paths, and the trailing quote defeats extension globs; -z avoids it).
|
||||
FILES_LIST=()
|
||||
|
||||
@@ -18,7 +18,9 @@
|
||||
* untracked files). It prints a short summary; the JSON file is the record.
|
||||
* --exception names the blocked .gstack/tmp/docs-audit.json an owner accepted
|
||||
* (Step 14.5 Blocked recovery): the record then carries that exception, so
|
||||
* Step 16 can tell an accepted risk from a skipped audit.
|
||||
* Step 16 can tell an accepted risk from a skipped audit. A --base that is HEAD
|
||||
* or already contains it while the branch has commits its real base lacks is
|
||||
* refused (BASE_CONTAINS_HEAD, lib/default-base.ts): it would select nothing.
|
||||
*
|
||||
* compare re-reads the same state and prints one JSON object: whether HEAD or
|
||||
* the index changed, which recorded paths' content changed or disappeared, and
|
||||
@@ -35,6 +37,8 @@
|
||||
import { spawnSync } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { baseContainsHead } from '../lib/default-base';
|
||||
import { resultLine } from '../lib/result-codes';
|
||||
import { parseReceiptDocs, type ReceiptDocs } from '../lib/ship-receipt-docs';
|
||||
|
||||
function git(args: string[]): string {
|
||||
@@ -135,6 +139,8 @@ function main(argv: string[]): void {
|
||||
const mode = one(args, '--mode');
|
||||
if (mode !== 'edit' && mode !== 'read-only') throw new Error('--mode must be edit or read-only');
|
||||
const base = git(['rev-parse', '--verify', one(args, '--base') + '^{commit}']).trim();
|
||||
const self = baseContainsHead(top, base);
|
||||
if (self) throw new Error(resultLine('snapshot', 'BASE_CONTAINS_HEAD', self));
|
||||
const exceptionFile = flag(args, '--exception')[0];
|
||||
const exception = exceptionFile === undefined ? undefined : acceptedException(exceptionFile);
|
||||
process.chdir(top);
|
||||
|
||||
+23
-4
@@ -19,9 +19,13 @@
|
||||
* /review's review_binding and make /ship loop).
|
||||
*
|
||||
* Exit: 0 with or without hunks. 2 only when the base is unresolvable
|
||||
* (`GATE_ERROR=no_base ref=<ref> fix=<git command>` as the only stdout line)
|
||||
* or the tool itself failed (`GATE_ERROR=internal …`). Unmatched paths are
|
||||
* counted, never an exit 2.
|
||||
* (`GATE_ERROR=no_base ref=<ref> fix=<git command>` as the only stdout line;
|
||||
* `ref=<remote>/HEAD target=<remote>/<branch>` when no base is given, that
|
||||
* remote's HEAD names the checked-out branch and no other default resolves),
|
||||
* when the base is HEAD or already contains it while the branch has commits
|
||||
* its real base lacks (`GATE_ERROR=base_contains_head ref=<ref>`, the
|
||||
* BASE_CONTAINS_HEAD line on stderr), or the tool itself failed
|
||||
* (`GATE_ERROR=internal …`). Unmatched paths are counted, never an exit 2.
|
||||
*/
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
@@ -31,6 +35,8 @@ import {
|
||||
commitInfo, commitsInRange, defaultBase, diffCommit, diffWorkingTree, firstParent, headSha, mergeBase, noBaseFix, readClaudeMd, repoRoot,
|
||||
resolveBase, resolvesToCommit, treePaths, untrackedAsAdded,
|
||||
} from '../lib/gate-diff/git';
|
||||
import { baseContainsHead, noDefaultBaseLine } from '../lib/default-base';
|
||||
import { resultLine } from '../lib/result-codes';
|
||||
import { renderDiagnostics, renderJsonl, renderJsonlBody, renderTable } from '../lib/gate-diff/render';
|
||||
import { resolveStateRoot } from '../lib/state-root';
|
||||
import { runScriptSync, wtreeFingerprint } from '../lib/script-invocation';
|
||||
@@ -141,7 +147,14 @@ function main(argv: string[]): number {
|
||||
candidate = sha.slice(0, 12);
|
||||
specIsTestPath = specDirectoryHoldsSpecs(treePaths(root, sha));
|
||||
} else {
|
||||
const baseRef = parsed.base ?? defaultBase(root);
|
||||
const found = parsed.base !== undefined ? { ref: parsed.base } : defaultBase(root);
|
||||
if ('skipped' in found) {
|
||||
const { remote, branch } = found.skipped;
|
||||
console.error(`gstack-gate-diff: ${noDefaultBaseLine(found.skipped)}`);
|
||||
process.stdout.write(`GATE_ERROR=no_base ref=${remote}/HEAD target=${remote}/${branch} fix=git remote set-head ${remote} <base>\n`);
|
||||
return 2;
|
||||
}
|
||||
const baseRef = found.ref;
|
||||
const head = headSha(root);
|
||||
const base = resolveBase(baseRef, root, head ?? undefined);
|
||||
if (!base || !head) {
|
||||
@@ -154,6 +167,12 @@ function main(argv: string[]): number {
|
||||
process.stdout.write(`GATE_ERROR=no_base ref=${baseRef} fix=${noBaseFix(baseRef, root)}\n`);
|
||||
return 2;
|
||||
}
|
||||
const self = mb === head ? baseContainsHead(root, base.ref) : null;
|
||||
if (self) {
|
||||
console.error(resultLine('gstack-gate-diff', 'BASE_CONTAINS_HEAD', self));
|
||||
process.stdout.write(`GATE_ERROR=base_contains_head ref=${baseRef}\n`);
|
||||
return 2;
|
||||
}
|
||||
files = [...diffWorkingTree(mb, root), ...untrackedAsAdded(root, root)];
|
||||
commits = commitsInRange(mb, head, root);
|
||||
const fp = wtreeFingerprint({ cwd: root });
|
||||
|
||||
+5
-1
@@ -26,6 +26,7 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { baseContainsHead } from '../lib/default-base';
|
||||
import { EXIT, exitTableHelp, formatResult, type PregateFile } from '../lib/headless-artifacts';
|
||||
import { PREGATE_CONFIG_PATH, explainConfig, isConfigUnavailable, loadConfigFromBase, loadConfigFromHead } from '../lib/pregate/config';
|
||||
import { describeDraft, draftConfig } from '../lib/pregate/init-draft';
|
||||
@@ -72,7 +73,8 @@ const HELP = `gstack-pregate — cheap checks before anything paid or remote (do
|
||||
|
||||
--stage preflight|tests|all default all
|
||||
--only <check,...> restrict to these checks (${CHECK_IDS.join(', ')}; tier 2 later: ${PREGATE_TIER2.join(', ')})
|
||||
--base <branch> a branch on origin, not a commit (default: origin/HEAD's branch, else main)
|
||||
--base <branch> a branch on origin, not a commit (default: origin/HEAD's branch unless it is the
|
||||
checked-out one, else the PR's base, origin/main, origin/master; lib/default-base.ts)
|
||||
--allow-repo-commands run pin-file commands and the test runner (what /ship passes); without it those checks are incomplete
|
||||
--policy-from base|head base (default, reviewed) or head (unreviewed; needs --allow-repo-commands; the head file may
|
||||
only add test lanes over the base copy, else PREGATE_HEAD_POLICY_LOOSENS)
|
||||
@@ -285,6 +287,8 @@ function cmdSealed(root: string, rest: string[], json: boolean): number {
|
||||
if (!pushedSha) { process.stderr.write(`gstack-pregate sealed: ${end} is not a commit\n`); return EXIT.usage; }
|
||||
const listed = range ? rangeCommits(root, range) : publishedCommits(root, { localSha: pushedSha, remoteName: remote });
|
||||
const baseRef = gitOut(root, ['rev-parse', '--verify', '--quiet', `refs/remotes/${remote}/${base}^{commit}`]) ? `refs/remotes/${remote}/${base}` : null;
|
||||
const self = baseRef && baseContainsHead(root, baseRef);
|
||||
if (self) { process.stderr.write(`${resultLine('gstack-pregate sealed', 'BASE_CONTAINS_HEAD', self)}; the policy would be the branch's own\n`); return EXIT.refused; }
|
||||
const remoteUrl = gitOut(root, ['remote', 'get-url', remote]);
|
||||
const report = 'error' in listed
|
||||
? sealedErrorReport(listed.error, remoteUrl ?? remote)
|
||||
|
||||
@@ -16,12 +16,16 @@
|
||||
* FIX_RANGE: (with --range-base), REVIEW_PLAN:, one DEPTH_ERROR: per input that failed
|
||||
* the depth closed, one DISPATCH: plus DIFF_CMD: (and DIFF_EXCLUDE: none) per
|
||||
* reviewer and one SKIP: per reviewer left out. A plan that fails closed to full
|
||||
* exits 0; a diff git cannot list exits 1. Logic: lib/review-depth.ts.
|
||||
* exits 0; a diff git cannot list exits 1, and so does a base that is HEAD or already
|
||||
* contains it while the branch has commits its real base lacks (BASE_CONTAINS_HEAD,
|
||||
* lib/default-base.ts). Logic: lib/review-depth.ts.
|
||||
*/
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { baseContainsHead } from '../lib/default-base';
|
||||
import { EXIT, exitTableHelp } from '../lib/headless-artifacts';
|
||||
import { resultLine } from '../lib/result-codes';
|
||||
import { runScriptSync } from '../lib/script-invocation';
|
||||
import { collectCommit, collectLive } from '../lib/review-collect';
|
||||
import { classifyDiff, diffCommand, planReview, reviewDepthLine, reviewPlanLine, scanNumbers, SCOPED_RANGE_LINES, type Depth, type History } from '../lib/review-depth';
|
||||
@@ -102,6 +106,8 @@ function main(): number {
|
||||
return EXIT.fail;
|
||||
}
|
||||
const head = git(['rev-parse', 'HEAD'])?.trim() ?? 'unknown';
|
||||
const self = collected.mergeBase === head ? baseContainsHead(root, git(['rev-parse', '--verify', '-q', `origin/${base}^{commit}`]) ? `origin/${base}` : base) : null;
|
||||
if (self) { process.stderr.write(`${resultLine('gstack-review-plan', 'BASE_CONTAINS_HEAD', self)}; no plan is safer than a plan for a diff compared with itself\n`); return EXIT.fail; }
|
||||
const binDir = path.dirname(Bun.fileURLToPath(import.meta.url));
|
||||
const scopeOf = (args: string[]): Record<string, string> => {
|
||||
const run = runScriptSync(path.join(binDir, 'gstack-diff-scope'), args, { encoding: 'utf8', timeout: 60_000 });
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
* gstack-ship-queue train build|status|verdict|bisect|rebuild|watch|prune ... (merge trains; train --help)
|
||||
* gstack-ship-queue wave plan|build|status|settle ... (wave PRs: 3-6 same-area PRs in one train slot; wave --help)
|
||||
*
|
||||
* Every subcommand takes --base <branch> (default origin/HEAD, else main) and
|
||||
* Every subcommand takes --base <branch> (default lib/default-base.ts) and
|
||||
* ends with GSTACK_RESULT: skill=ship-queue status=complete|refused run=<path>.
|
||||
* gstack-restamp stays the primitive. Nothing here pushes, except land, ready
|
||||
* under changelog_fragments (its READY receipt note), and under a
|
||||
@@ -69,7 +69,7 @@ const HELP = `gstack-ship-queue — the shared merge queue's protocol steps (doc
|
||||
wave plan|build|status|settle ... wave PRs under a train policy: 3-6 same-area PRs in one slot and one version
|
||||
(wave --help; docs/ship-policy.md#wave-prs)
|
||||
|
||||
--base <branch>: base branch (default: origin/HEAD, else main). Nothing here pushes, except land (the stamp commit and the
|
||||
--base <branch>: base branch (default: origin/HEAD unless it names the checked-out branch, else the PR's base, origin/main). Nothing here pushes, except land (the stamp commit and the
|
||||
refs/notes/gstack-land lock), ready under changelog_fragments (its READY receipt note) and merge trains.
|
||||
${exitTableHelp()}`;
|
||||
|
||||
|
||||
@@ -5210,6 +5210,26 @@ wait`'s `--timeout`.
|
||||
**Fix.** Run `wait` again (the pause stays valid until it expires), or find who
|
||||
owns the base's red.
|
||||
|
||||
<a id="base-contains-head"></a>
|
||||
### `BASE_CONTAINS_HEAD`
|
||||
|
||||
**Meaning.** A base-relative step (`gstack-ship-queue ready`, `land` and
|
||||
`train` receipt binding, `gstack-review-plan`, `gstack-pregate`,
|
||||
`gstack-gate-diff`, `gstack-diff-scope`, `gstack-docs-candidate snapshot`)
|
||||
was given a base that is the head or already contains it, while the branch
|
||||
has commits its real base lacks. The comparison would read the branch against
|
||||
itself and report no changes, a false green. The usual cause is a checkout
|
||||
made by a PR flow: `refs/remotes/origin/HEAD` names the PR branch and there is
|
||||
no local `main` (GST-774). The detail names the base, the head, and the real
|
||||
base (the PR's base from `gh`, else `origin/main`, else `origin/master`). For
|
||||
a pull request's head the queue refuses whenever the base contains it. On the
|
||||
base branch itself, on a detached HEAD, and on a fresh branch with no commits
|
||||
past its base, the step runs as before.
|
||||
|
||||
**Fix.** Pass the real base (`--base main`, or the PR's base branch). When
|
||||
`origin/HEAD` names the checked-out branch, `git remote set-head origin main`
|
||||
repairs every default at once.
|
||||
|
||||
<a id="queue-receipt-depth-raised"></a>
|
||||
### `QUEUE_RECEIPT_DEPTH_RAISED`
|
||||
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* default-base — the base branch a command uses when none is given (GST-511), and the
|
||||
* refusal every base-relative step shares when its base is the branch itself (GST-774).
|
||||
* A remote's HEAD counts only when it names a branch other than the checked-out one: a
|
||||
* clone made by a PR flow can point origin/HEAD at the PR branch, and that base diffs
|
||||
* the branch against itself, an empty diff every gate passes. Past it: the PR's base
|
||||
* from gh (a short timeout, prompts off, any failure ignored; none for a PR from another
|
||||
* repository), then `<remote>/main`, then `<remote>/master`. A detached HEAD keeps the
|
||||
* remote's HEAD. bin/gstack-diff-scope reads both through this file's command line.
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { resultLine } from './result-codes';
|
||||
|
||||
export interface DefaultBase {
|
||||
/** The base branch name, or null when nothing resolves. */
|
||||
branch: string | null;
|
||||
/** The remote whose HEAD named `branch`; unset for the PR base, main and master. */
|
||||
remote?: string;
|
||||
/** The first remote HEAD skipped because it names the checked-out branch. */
|
||||
skipped?: { remote: string; branch: string };
|
||||
}
|
||||
|
||||
/** gh's jq for the PR's base branch; a PR from another repository names a branch origin may not hold, so it names none. */
|
||||
const PR_BASE = 'if .isCrossRepository then "" else .baseRefName end';
|
||||
|
||||
function read(cmd: string, args: string[], cwd: string, timeout: number): string {
|
||||
const r = spawnSync(cmd, args, {
|
||||
cwd, encoding: 'utf8', timeout, stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: { ...process.env, GIT_TERMINAL_PROMPT: '0', GH_PROMPT_DISABLED: '1' },
|
||||
});
|
||||
return r.status === 0 ? (r.stdout ?? '').trim() : '';
|
||||
}
|
||||
|
||||
const commit = (cwd: string, ref: string) => read('git', ['rev-parse', '--verify', '--quiet', '--end-of-options', `${ref}^{commit}`], cwd, 30_000);
|
||||
const ancestor = (cwd: string, a: string, b: string) => spawnSync('git', ['merge-base', '--is-ancestor', a, b], { cwd, timeout: 30_000, stdio: 'ignore' }).status === 0;
|
||||
|
||||
export function defaultBaseBranch(cwd: string, remotes: readonly string[] = ['origin']): DefaultBase {
|
||||
const current = read('git', ['symbolic-ref', '--quiet', 'HEAD'], cwd, 30_000).replace(/^refs\/heads\//, '');
|
||||
let skipped: DefaultBase['skipped'];
|
||||
for (const remote of remotes) {
|
||||
const prefix = `refs/remotes/${remote}/`;
|
||||
const target = read('git', ['symbolic-ref', '--quiet', `${prefix}HEAD`], cwd, 30_000);
|
||||
if (!target.startsWith(prefix)) continue;
|
||||
const branch = target.slice(prefix.length);
|
||||
if (branch !== current) return { branch, remote };
|
||||
skipped ??= { remote, branch };
|
||||
}
|
||||
const pr = current ? read('gh', ['pr', 'view', '--json', 'baseRefName,isCrossRepository', '-q', PR_BASE], cwd, 5_000).split('\n')[0] : '';
|
||||
if (pr) return { branch: pr, skipped };
|
||||
for (const name of ['main', 'master']) {
|
||||
if (remotes.some((remote) => commit(cwd, `refs/remotes/${remote}/${name}`))) return { branch: name, skipped };
|
||||
}
|
||||
return { branch: null, skipped };
|
||||
}
|
||||
|
||||
/** Why there is no default base: a remote HEAD named the checked-out branch and nothing past it resolved. */
|
||||
export function noDefaultBaseLine({ remote, branch }: { remote: string; branch: string }): string {
|
||||
return `${remote}/HEAD names the checked-out branch ${branch}, and no PR base, ${remote}/main or ${remote}/master resolves; pass the base explicitly or run git remote set-head ${remote} <base>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Why `base` cannot be the base of a comparison with `head` (BASE_CONTAINS_HEAD), or null. A
|
||||
* base that is the head or already contains it compares the branch with itself, so the step
|
||||
* would report no changes. A pull-request head (`head` other than HEAD) is refused whenever the
|
||||
* base contains it. The checked-out branch is refused only when its real base (defaultBaseBranch)
|
||||
* is another branch that lacks HEAD, so the branch has commits this base hides; on the base branch
|
||||
* itself, on a detached HEAD, on a fresh branch with no commits past its base, or with no real base
|
||||
* resolvable, it is null and the step keeps today's result.
|
||||
*/
|
||||
export function baseContainsHead(cwd: string, base: string, head = 'HEAD'): string | null {
|
||||
if (!ancestor(cwd, head, base)) return null;
|
||||
const baseSha = commit(cwd, base);
|
||||
const headSha = commit(cwd, head);
|
||||
if (!baseSha || !headSha) return null;
|
||||
const what = `${base} (${baseSha.slice(0, 12)}) ${baseSha === headSha ? 'is' : 'already contains'}`;
|
||||
if (head !== 'HEAD') return `${what} the head ${headSha.slice(0, 12)}, which then has no changes of its own`;
|
||||
const branch = read('git', ['symbolic-ref', '--quiet', '--short', 'HEAD'], cwd, 30_000);
|
||||
if (!branch) return null;
|
||||
const real = defaultBaseBranch(cwd);
|
||||
if (!real.branch || real.branch === branch) return null;
|
||||
const realRef = `${real.remote ?? 'origin'}/${real.branch}`;
|
||||
const realSha = commit(cwd, `refs/remotes/${realRef}`);
|
||||
if (!realSha || ancestor(cwd, headSha, realSha)) return null;
|
||||
return `${what} HEAD ${headSha.slice(0, 12)} on ${branch}, whose commits ${realRef} lacks; the real base is ${real.branch}`;
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
const [verb, ...args] = process.argv.slice(2);
|
||||
const cwd = process.cwd();
|
||||
if (verb === 'default') {
|
||||
const found = defaultBaseBranch(cwd);
|
||||
if (found.branch) console.log(found.branch);
|
||||
else if (found.skipped) console.log(noDefaultBaseLine(found.skipped));
|
||||
process.exitCode = found.branch ? 0 : found.skipped ? 3 : 1;
|
||||
} else if (verb === 'check' && args[0] && args.length <= 3) {
|
||||
const refused = baseContainsHead(cwd, args[0], args[1] || 'HEAD');
|
||||
if (refused) console.log(resultLine(args[2] || 'gstack', 'BASE_CONTAINS_HEAD', refused));
|
||||
process.exitCode = refused ? 1 : 0;
|
||||
} else {
|
||||
console.error('usage: default-base.ts default | check <base> [<head>] [<context>]');
|
||||
process.exitCode = 2;
|
||||
}
|
||||
}
|
||||
+13
-13
@@ -13,6 +13,7 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { defaultBaseBranch, type DefaultBase } from '../default-base';
|
||||
import type { CommitInfo, FileDiff, FileStatus, Hunk, Unsupported } from './types';
|
||||
|
||||
export const EMPTY_TREE = '4b825dc642cb6eb9a060e54bf8d69288fbee4904';
|
||||
@@ -63,20 +64,19 @@ function remoteNames(cwd: string): string[] {
|
||||
}
|
||||
|
||||
/**
|
||||
* The remote's HEAD target → `main` → `master` (the gstack-diff-scope chain),
|
||||
* as a ref name; the remotes are walked in resolveBase's order, so the default
|
||||
* and the fix line come from the same remote.
|
||||
* The default base (lib/default-base.ts, the gstack-diff-scope chain) as a ref
|
||||
* name: a remote's HEAD target as `<remote>/<branch>` unless it names the
|
||||
* checked-out branch, the PR's base, `main`, `master`; the remotes are walked
|
||||
* in resolveBase's order, so the default and the fix line come from the same
|
||||
* remote. With no remote naming one, a local main or master, else `main`.
|
||||
* `skipped` when a remote HEAD named the checked-out branch and nothing past it
|
||||
* resolved: the caller fails closed instead of diffing the branch against itself.
|
||||
*/
|
||||
export function defaultBase(cwd: string): string {
|
||||
const remotes = remoteNames(cwd);
|
||||
for (const remote of remotes) {
|
||||
const head = gitText(['symbolic-ref', '-q', `refs/remotes/${remote}/HEAD`], cwd);
|
||||
if (head) return head.replace(/^refs\/remotes\//, '');
|
||||
}
|
||||
for (const name of ['main', 'master']) {
|
||||
if (resolvesToCommit(name, cwd) || remotes.some((remote) => resolvesToCommit(`${remote}/${name}`, cwd))) return name;
|
||||
}
|
||||
return 'main';
|
||||
export function defaultBase(cwd: string): { ref: string } | { skipped: NonNullable<DefaultBase['skipped']> } {
|
||||
const found = defaultBaseBranch(cwd, remoteNames(cwd));
|
||||
if (found.branch) return { ref: found.remote ? `${found.remote}/${found.branch}` : found.branch };
|
||||
if (found.skipped) return { skipped: found.skipped };
|
||||
return { ref: ['main', 'master'].find((name) => resolvesToCommit(name, cwd)) ?? 'main' };
|
||||
}
|
||||
|
||||
export function isShallow(cwd: string): boolean {
|
||||
|
||||
@@ -17,6 +17,7 @@ import { isRegistryUnavailable, loadRegistryFromBase, loadRegistryFromHead, type
|
||||
import { resolveStateRoot } from '../state-root';
|
||||
import { RESULT_CODES, TROUBLESHOOTING_DOC, resultLine } from '../result-codes';
|
||||
import { runScriptSync, wtreeFingerprint, type InvocationOptions, type WtreeResult } from '../script-invocation';
|
||||
import { baseContainsHead } from '../default-base';
|
||||
import { policyUnavailableLine, resolveBaseBranch } from '../ship-policy';
|
||||
import { headPolicy } from './head-policy';
|
||||
import { PREGATE_CONFIG_PATH, isConfigUnavailable, loadConfigFromBase, loadConfigFromUser, type ConfigError, type ConfigLoad, type PregateConfig } from './config';
|
||||
@@ -48,7 +49,7 @@ export function readTestBackend(env: NodeJS.ProcessEnv, invocation?: InvocationO
|
||||
return (r.status === 0 && (r.stdout ?? '').trim()) || 'local';
|
||||
}
|
||||
export interface ContextRefusal {
|
||||
code: 'POLICY_SOURCE_UNAVAILABLE' | 'PREGATE_CONFIG_INVALID' | 'PREGATE_HEAD_POLICY_LOOSENS' | 'REGEN_REGISTRY_INVALID' | 'WTREE_UNAVAILABLE';
|
||||
code: 'POLICY_SOURCE_UNAVAILABLE' | 'BASE_CONTAINS_HEAD' | 'PREGATE_CONFIG_INVALID' | 'PREGATE_HEAD_POLICY_LOOSENS' | 'REGEN_REGISTRY_INVALID' | 'WTREE_UNAVAILABLE';
|
||||
message: string;
|
||||
/** A cause-specific fix that replaces the code's own (a gstack-wtree exit names its repair; so does a `--base` that is no origin branch). */
|
||||
fix?: string;
|
||||
@@ -210,6 +211,8 @@ export function buildContext(o: BuildContextOptions): ContextLoad {
|
||||
const baseRef = `origin/${base}`;
|
||||
const baseSha = git(o.repoRoot, ['rev-parse', '--verify', '--quiet', `${baseRef}^{commit}`]);
|
||||
if (!baseSha.ok || !baseSha.out) return { refused: notAnOriginBranch(o.repoRoot, base) ?? { code: 'POLICY_SOURCE_UNAVAILABLE', message: `${baseRef} is not a resolvable commit` } };
|
||||
const self = baseContainsHead(o.repoRoot, baseRef);
|
||||
if (self) return { refused: { code: 'BASE_CONTAINS_HEAD', message: self } };
|
||||
const head = git(o.repoRoot, ['rev-parse', 'HEAD']).out;
|
||||
const mergeBase = git(o.repoRoot, ['merge-base', baseSha.out, 'HEAD']).out || baseSha.out;
|
||||
let configLoad: ConfigLoad = loadConfigFromBase(o.repoRoot, base, o.knownChecks);
|
||||
|
||||
@@ -254,6 +254,11 @@ export const RELEASE_RESULT_CODES = {
|
||||
summary: 'no new base head passed the paused cases before gstack-base-red wait’s timeout',
|
||||
fix: 'run gstack-base-red wait --pause <file> again (the pause stays valid until it expires), or check who owns the base red',
|
||||
},
|
||||
BASE_CONTAINS_HEAD: {
|
||||
anchor: 'base-contains-head',
|
||||
summary: 'the base is the head or already contains it while the branch has commits its real base lacks, so a base-relative step would compare the branch with itself and report no changes',
|
||||
fix: 'compare against the branch’s real base (the PR’s base branch, usually main): pass it as the base, or run git remote set-head origin <base> when origin/HEAD names the checked-out branch',
|
||||
},
|
||||
QUEUE_RECEIPT_DEPTH_RAISED: {
|
||||
anchor: 'queue-receipt-depth-raised',
|
||||
summary: 'the PR body’s /ship receipt records a review depth below the one its head needs (the queue recomputed it from git), or a current receipt records no review_depth at all',
|
||||
|
||||
+10
-4
@@ -15,6 +15,7 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { defaultBaseBranch, noDefaultBaseLine } from './default-base';
|
||||
import { RESULT_CODES, type ResultCodeName } from './result-codes';
|
||||
import { cmpVersion, parseVersion } from './version-source';
|
||||
|
||||
@@ -635,12 +636,17 @@ function git(repoRoot: string, args: string[]): { ok: boolean; out: string; err:
|
||||
return { ok: r.status === 0, out: (r.stdout ?? '').trim(), err: (r.stderr ?? '').trim() };
|
||||
}
|
||||
|
||||
/** `--base` when given, else origin/HEAD's branch, else `main`. */
|
||||
/**
|
||||
* `--base` when given, else the default base (lib/default-base.ts): origin/HEAD's
|
||||
* branch unless it is the checked-out one, the PR's base, origin/main, origin/master.
|
||||
* When none resolves it stays `main`, which origin then lacks, so every reader of
|
||||
* origin/<base> refuses; when origin/HEAD named the checked-out branch, stderr says why.
|
||||
*/
|
||||
export function resolveBaseBranch(repoRoot: string, explicit?: string): string {
|
||||
if (explicit && explicit.trim()) return explicit.trim().replace(/^origin\//, '');
|
||||
const r = git(repoRoot, ['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD']);
|
||||
if (r.ok && r.out) return r.out.replace(/^origin\//, '');
|
||||
return 'main';
|
||||
const found = defaultBaseBranch(repoRoot);
|
||||
if (!found.branch && found.skipped) process.stderr.write(`gstack: ${noDefaultBaseLine(found.skipped)}\n`);
|
||||
return found.branch ?? 'main';
|
||||
}
|
||||
|
||||
function finish(rawText: string | null, source: PolicySource): LoadedPolicy {
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import * as path from 'node:path';
|
||||
import { baseContainsHead } from './default-base';
|
||||
import type { ShipReceipt } from './headless-artifacts';
|
||||
import { collectCommit } from './review-collect';
|
||||
import { depthOfCommitInput, gateScanNeeded, type Depth } from './review-depth';
|
||||
@@ -189,12 +190,14 @@ export function receiptDepthVerdict(root: string, baseRef: string, r: ShipReceip
|
||||
return { ok: false, line, message: `the receipt ${why}, but head ${head.slice(0, 12)} needs ${need.depth} (${need.reasons.join(', ')}${errs ? `; ${errs}` : ''}); the receipt is unchanged; run /ship on ${head.slice(0, 12)}` };
|
||||
}
|
||||
|
||||
/** bindReceipt plus the owner exceptions and the review depth: what READY and train build accept; the targeted line is reported, never refused on. */
|
||||
/** bindReceipt plus the owner exceptions and the review depth: what READY and train build accept; the targeted line is reported, never refused on. A base that already contains the head refuses (BASE_CONTAINS_HEAD, GST-774) before any depth is computed. */
|
||||
export function bindShippedReceipt(root: string, baseRef: string, body: string, pr: string | undefined, accepted: readonly string[]):
|
||||
| { ok: true; receipt: ShipReceipt; head: string; line: string; depthLine: string; targetedLine: string }
|
||||
| { ok: false; code: ReceiptBindingCode | 'QUEUE_RECEIPT_DEPTH_RAISED' | 'QUEUE_RECEIPT_EXCEPTION_INVALID'; message: string; line: string; depthLine?: string; targetedLine?: string } {
|
||||
| { ok: false; code: ReceiptBindingCode | 'BASE_CONTAINS_HEAD' | 'QUEUE_RECEIPT_DEPTH_RAISED' | 'QUEUE_RECEIPT_EXCEPTION_INVALID'; message: string; line: string; depthLine?: string; targetedLine?: string } {
|
||||
const b = bindReceipt(body, pr, accepted);
|
||||
if (!b.ok) return b;
|
||||
const self = baseContainsHead(root, baseRef, b.head);
|
||||
if (self) return { ok: false, code: 'BASE_CONTAINS_HEAD', message: self, line: b.line };
|
||||
const ancestor = (commit: string) => b.receipt.carry?.ancestry === 'rewritten' || git(root, ['merge-base', '--is-ancestor', commit, b.head]).ok;
|
||||
const problems = exceptionProblems(body, b.receipt, ancestor);
|
||||
if (problems.length) return { ok: false, code: 'QUEUE_RECEIPT_EXCEPTION_INVALID', message: `the receipt's owner exception does not stand: ${problems.join('; ')}`, line: b.line };
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* GST-774: a Capy machine checked out at a PR branch has refs/remotes/origin/HEAD naming that branch and
|
||||
* no local main. The default base must not be the checked-out branch (lib/default-base.ts, the GST-511
|
||||
* rule), and every base-relative step refuses a base that is HEAD or already contains it while the
|
||||
* branch has commits its real base lacks (BASE_CONTAINS_HEAD) instead of reporting no changes. The
|
||||
* control is a normal clone on the same feature branch with origin/HEAD naming main and a local main.
|
||||
*/
|
||||
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { baseContainsHead, defaultBaseBranch } from '../lib/default-base';
|
||||
import { buildContext } from '../lib/pregate/context';
|
||||
import { runScriptSync } from '../lib/script-invocation';
|
||||
import { extractReceipt } from '../lib/ship-receipt';
|
||||
import { bindShippedReceipt } from '../lib/ship-receipt-depth';
|
||||
import { resolveBaseBranch } from '../lib/ship-policy';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const BIN = (name: string) => path.join(ROOT, 'bin', name);
|
||||
const big = Array.from({ length: 80 }, (_, i) => `exports.f${i} = () => ${i};`).join('\n') + '\n';
|
||||
|
||||
interface Fx { root: string; origin: string; clone: string; control: string; noMain: string; home: string; failGh: string; releaseGh: string; env: Record<string, string> }
|
||||
let fx: Fx;
|
||||
|
||||
function run(cmd: string, args: string[], cwd: string, env: Record<string, string> = fx.env) {
|
||||
const r = spawnSync(cmd, args, { cwd, encoding: 'utf8', timeout: 60_000, env: { ...process.env, ...env } });
|
||||
if (r.status !== 0) throw new Error(`${cmd} ${args.join(' ')} failed in ${cwd}: ${r.stderr}`);
|
||||
return (r.stdout ?? '').trim();
|
||||
}
|
||||
const git = (cwd: string, ...args: string[]) => run('git', args, cwd);
|
||||
function bin(name: string, args: string[], cwd: string, extra: Record<string, string> = {}) {
|
||||
const r = runScriptSync(BIN(name), args, { cwd, encoding: 'utf8', timeout: 120_000, maxBuffer: 64 * 1024 * 1024, env: { ...process.env, ...fx.env, ...extra } });
|
||||
return { status: r.status, out: r.stdout ?? '', err: r.stderr ?? '' };
|
||||
}
|
||||
function withEnv<T>(env: Record<string, string>, fn: () => T): T {
|
||||
const saved = Object.fromEntries(Object.keys(env).map(k => [k, process.env[k]]));
|
||||
Object.assign(process.env, env);
|
||||
try { return fn(); } finally { for (const [k, v] of Object.entries(saved)) { if (v === undefined) delete process.env[k]; else process.env[k] = v; } }
|
||||
}
|
||||
const stub = (dir: string, body: string) => { fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(path.join(dir, 'gh'), `#!/bin/sh\n${body}\n`, { mode: 0o755 }); return dir; };
|
||||
|
||||
beforeAll(() => {
|
||||
const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gst774-')));
|
||||
expect(spawnSync('git', ['rev-parse', '--show-toplevel'], { cwd: root, encoding: 'utf8', timeout: 10_000 }).status).not.toBe(0);
|
||||
const home = path.join(root, 'home');
|
||||
fs.mkdirSync(home);
|
||||
const gitconfig = path.join(root, 'gitconfig');
|
||||
fs.writeFileSync(gitconfig, '[user]\n\tname = fixture\n\temail = fixture@test\n[commit]\n\tgpgsign = false\n[init]\n\tdefaultBranch = main\n');
|
||||
const failGh = stub(path.join(root, 'gh-fail'), 'exit 1');
|
||||
const releaseGh = stub(path.join(root, 'gh-release'), 'echo release');
|
||||
const env = { HOME: home, GSTACK_STATE_ROOT: home, GSTACK_HOME: home, GIT_CONFIG_GLOBAL: gitconfig, GIT_CONFIG_NOSYSTEM: '1', PATH: `${failGh}${path.delimiter}${process.env.PATH}` };
|
||||
fx = { root, origin: path.join(root, 'origin.git'), clone: path.join(root, 'clone'), control: path.join(root, 'control'), noMain: path.join(root, 'nomain'), home, failGh, releaseGh, env };
|
||||
const seed = path.join(root, 'seed');
|
||||
run('git', ['init', '-q', '--bare', '-b', 'main', fx.origin], root);
|
||||
run('git', ['init', '-q', '-b', 'main', seed], root);
|
||||
fs.mkdirSync(path.join(seed, 'lib'));
|
||||
fs.writeFileSync(path.join(seed, 'README.md'), '# fixture\n');
|
||||
fs.writeFileSync(path.join(seed, 'lib', 'app.js'), 'exports.app = () => 1;\n');
|
||||
git(seed, 'add', '-A');
|
||||
git(seed, 'commit', '-qm', 'seed');
|
||||
expect(git(seed, 'log', '-1', '--format=%an <%ae>')).toBe('fixture <fixture@test>');
|
||||
git(seed, 'remote', 'add', 'origin', fx.origin);
|
||||
git(seed, 'push', '-q', 'origin', 'main', 'main:release');
|
||||
git(seed, 'checkout', '-q', '-b', 'pr-branch');
|
||||
fs.writeFileSync(path.join(seed, 'lib', 'big.js'), big);
|
||||
git(seed, 'add', '-A');
|
||||
git(seed, 'commit', '-qm', 'pr change');
|
||||
git(seed, 'push', '-q', 'origin', 'pr-branch');
|
||||
|
||||
run('git', ['clone', '-q', '--branch', 'pr-branch', fx.origin, fx.clone], root);
|
||||
git(fx.clone, 'symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/pr-branch');
|
||||
run('git', ['clone', '-q', fx.origin, fx.control], root);
|
||||
git(fx.control, 'checkout', '-q', '-b', 'pr-branch', 'origin/pr-branch');
|
||||
|
||||
const nomainOrigin = path.join(root, 'nomain.git');
|
||||
run('git', ['clone', '-q', '--bare', fx.origin, nomainOrigin], root);
|
||||
git(nomainOrigin, 'branch', '-m', 'main', 'trunk');
|
||||
git(nomainOrigin, 'symbolic-ref', 'HEAD', 'refs/heads/trunk');
|
||||
run('git', ['clone', '-q', '--branch', 'pr-branch', nomainOrigin, fx.noMain], root);
|
||||
git(fx.noMain, 'symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/pr-branch');
|
||||
});
|
||||
afterAll(() => { if (fx) fs.rmSync(fx.root, { recursive: true, force: true }); });
|
||||
|
||||
describe('the fixture is the reported checkout', () => {
|
||||
// Value: protects=every case below reads the GST-774 shape, outside any checkout and with its own state root; fails_when=the
|
||||
// clone has a local main or origin/HEAD names main, or state resolves into the operator's root; seam=none
|
||||
test('the clone is on the PR branch, origin/HEAD names it, there is no local main, and state resolves into the fixture', () => {
|
||||
expect(git(fx.clone, 'symbolic-ref', '--short', 'HEAD')).toBe('pr-branch');
|
||||
expect(git(fx.clone, 'symbolic-ref', 'refs/remotes/origin/HEAD')).toBe('refs/remotes/origin/pr-branch');
|
||||
expect(spawnSync('git', ['rev-parse', '--verify', '--quiet', 'refs/heads/main'], { cwd: fx.clone, timeout: 10_000 }).status).not.toBe(0);
|
||||
expect(git(fx.clone, 'rev-parse', 'origin/main')).toBe(git(fx.control, 'rev-parse', 'main'));
|
||||
expect(git(fx.control, 'symbolic-ref', 'refs/remotes/origin/HEAD')).toBe('refs/remotes/origin/main');
|
||||
expect(git(fx.clone, 'rev-parse', '--show-toplevel')).toBe(fx.clone);
|
||||
expect(bin('gstack-paths', ['--get', 'GSTACK_STATE_ROOT'], fx.clone).out.trim()).toBe(fx.home);
|
||||
const slug = bin('gstack-slug', ['--get', 'SLUG'], fx.clone);
|
||||
expect(slug.status).toBe(0);
|
||||
expect(slug.out.trim()).not.toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveBaseBranch skips an origin/HEAD that names the checked-out branch', () => {
|
||||
// Value: protects=ship-queue, pregate and the receipt writer default to the PR's real base, never the branch itself; fails_when=
|
||||
// origin/HEAD naming the PR branch is taken as the base; seam=lib/default-base.ts
|
||||
test('origin/main on the bug checkout, the PR base when gh names one, explicit --base as given; the control is unchanged', () => {
|
||||
withEnv(fx.env, () => {
|
||||
expect(resolveBaseBranch(fx.clone)).toBe('main');
|
||||
expect(resolveBaseBranch(fx.clone, 'pr-branch')).toBe('pr-branch');
|
||||
expect(resolveBaseBranch(fx.control)).toBe('main');
|
||||
git(fx.control, 'checkout', '-q', 'main');
|
||||
try { expect(resolveBaseBranch(fx.control)).toBe('main'); } finally { git(fx.control, 'checkout', '-q', 'pr-branch'); }
|
||||
});
|
||||
withEnv({ ...fx.env, PATH: `${fx.releaseGh}${path.delimiter}${process.env.PATH}` }, () => expect(resolveBaseBranch(fx.clone)).toBe('release'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('the review depth ship-queue ready binds', () => {
|
||||
// Value: protects=a READY on the bug checkout never binds a light depth for an 80-line diff; fails_when=the queue compares the
|
||||
// PR head with itself and reports required=light runtime_lines=0; seam=bindShippedReceipt
|
||||
test('origin/<PR branch> refuses BASE_CONTAINS_HEAD; the resolved default base binds standard; the control binds the same', () => {
|
||||
const body = path.join(fx.root, 'body.md');
|
||||
const w = bin('gstack-ship-receipt', ['write', '--pr', '7', '--base', 'main', '--version', '1.0.0', '--session-kind', 'unattended', '--body', body], fx.clone);
|
||||
expect(w.status, w.err).toBe(0);
|
||||
const text = fs.readFileSync(body, 'utf8');
|
||||
expect(extractReceipt(text).receipt!.review_depth).toMatchObject({ depth: 'standard', runtime_lines: 80 });
|
||||
const head = git(fx.clone, 'rev-parse', 'HEAD');
|
||||
withEnv(fx.env, () => {
|
||||
const self = bindShippedReceipt(fx.clone, 'origin/pr-branch', text, '7', [head]);
|
||||
expect(self.ok).toBe(false);
|
||||
if (self.ok) return;
|
||||
expect(self.code as string).toBe('BASE_CONTAINS_HEAD');
|
||||
expect(self.message).toContain(head.slice(0, 12));
|
||||
const real = bindShippedReceipt(fx.clone, `origin/${resolveBaseBranch(fx.clone)}`, text, '7', [head]);
|
||||
expect(real.ok && real.depthLine).toContain('receipt=standard required=standard runtime_lines=80 class=code');
|
||||
const control = bindShippedReceipt(fx.control, `origin/${resolveBaseBranch(fx.control)}`, text, '7', [head]);
|
||||
expect(control.ok && control.depthLine).toContain('receipt=standard required=standard runtime_lines=80 class=code');
|
||||
});
|
||||
}, 120_000);
|
||||
});
|
||||
|
||||
describe('gstack-review-plan', () => {
|
||||
// Value: protects=/ship's review plan never plans light for a diff it compared with itself; fails_when=--base <PR branch> plans
|
||||
// REVIEW_DEPTH light runtime_lines=0; seam=bin/gstack-review-plan
|
||||
test('--base <PR branch> refuses; --base main plans the same depth on the bug checkout and the control; main itself plans an empty diff', () => {
|
||||
const self = bin('gstack-review-plan', ['--base', 'pr-branch'], fx.clone);
|
||||
expect(self.status).toBe(1);
|
||||
expect(self.err).toContain('(BASE_CONTAINS_HEAD)');
|
||||
const depth = (out: string) => /^REVIEW_DEPTH: .*$/m.exec(out)?.[0];
|
||||
const real = bin('gstack-review-plan', ['--base', 'main'], fx.clone);
|
||||
expect(real.status, real.err).toBe(0);
|
||||
expect(depth(real.out)).toContain('REVIEW_DEPTH: standard runtime_lines=80');
|
||||
const control = bin('gstack-review-plan', ['--base', 'main'], fx.control);
|
||||
expect(depth(control.out)).toBe(depth(real.out));
|
||||
git(fx.control, 'checkout', '-q', 'main');
|
||||
try {
|
||||
const onMain = bin('gstack-review-plan', ['--base', 'main'], fx.control);
|
||||
expect(onMain.status, onMain.err).toBe(0);
|
||||
expect(depth(onMain.out)).toContain('runtime_lines=0');
|
||||
} finally { git(fx.control, 'checkout', '-q', 'pr-branch'); }
|
||||
}, 120_000);
|
||||
});
|
||||
|
||||
describe('the pregate base and the sealed policy base', () => {
|
||||
// Value: protects=the pregate selects the PR's files and reads the base branch's policy, never the PR branch's; fails_when=the
|
||||
// default base is the PR branch, so 0 files are touched and the policy is the PR's own; seam=buildContext, cmdSealed
|
||||
const load = (cwd: string, base: string) => buildContext({ repoRoot: cwd, base, policyFrom: 'base', allowRepoCommands: false, cores: 64, testBackend: 'local', env: { ...process.env, ...fx.env }, progress: () => {} });
|
||||
test('the default base is main with the PR file touched; an explicit PR-branch base refuses; the control and main itself are unchanged', () => {
|
||||
withEnv(fx.env, () => {
|
||||
const bug = load(fx.clone, resolveBaseBranch(fx.clone));
|
||||
expect('ctx' in bug && { base: bug.ctx.base, touched: bug.ctx.touched.map(t => t.path) }).toEqual({ base: 'main', touched: ['lib/big.js'] });
|
||||
const self = load(fx.clone, 'pr-branch');
|
||||
expect('refused' in self && (self.refused.code as string)).toBe('BASE_CONTAINS_HEAD');
|
||||
const control = load(fx.control, resolveBaseBranch(fx.control));
|
||||
expect('ctx' in control && { base: control.ctx.base, touched: control.ctx.touched.map(t => t.path) }).toEqual({ base: 'main', touched: ['lib/big.js'] });
|
||||
git(fx.control, 'checkout', '-q', 'main');
|
||||
try {
|
||||
const onMain = load(fx.control, resolveBaseBranch(fx.control));
|
||||
expect('ctx' in onMain && onMain.ctx.touched).toEqual([]);
|
||||
} finally { git(fx.control, 'checkout', '-q', 'pr-branch'); }
|
||||
});
|
||||
}, 120_000);
|
||||
|
||||
test('sealed reads the policy from origin/main by default and refuses an explicit PR-branch base', () => {
|
||||
const bug = bin('gstack-pregate', ['sealed'], fx.clone);
|
||||
expect(bug.status, bug.err).toBe(0);
|
||||
expect(bug.out).toContain('policy=no sealed key on refs/remotes/origin/main or HEAD');
|
||||
const self = bin('gstack-pregate', ['sealed', '--base', 'pr-branch'], fx.clone);
|
||||
expect(self.status).not.toBe(0);
|
||||
expect(self.err).toContain('(BASE_CONTAINS_HEAD)');
|
||||
const control = bin('gstack-pregate', ['sealed'], fx.control);
|
||||
expect(control.out).toBe(bug.out);
|
||||
}, 120_000);
|
||||
});
|
||||
|
||||
describe('gstack-gate-diff and gstack-diff-scope defaults', () => {
|
||||
// Value: protects=the gate scan and the scope flags read the PR's diff on the bug checkout; fails_when=the default base is the
|
||||
// PR branch, so the scan lists nothing and every scope flag is false with exit 0; seam=lib/gate-diff/git.ts, bin/gstack-diff-scope
|
||||
test('gate-diff: the default diffs against origin/main like the control; the PR branch refuses; no resolvable default fails closed', () => {
|
||||
const summary = (out: string) => /^GATE_SUMMARY: .*$/m.exec(out)?.[0];
|
||||
const bug = bin('gstack-gate-diff', [], fx.clone);
|
||||
expect(bug.status, bug.out + bug.err).toBe(0);
|
||||
const control = bin('gstack-gate-diff', [], fx.control);
|
||||
expect(control.status, control.out + control.err).toBe(0);
|
||||
expect(summary(bug.out)).toBe(summary(control.out));
|
||||
expect(summary(bug.out)).toContain('unmatched=1');
|
||||
const self = bin('gstack-gate-diff', ['pr-branch'], fx.clone);
|
||||
expect(self.status).toBe(2);
|
||||
expect(self.out).toStartWith('GATE_ERROR=base_contains_head ref=pr-branch');
|
||||
const none = bin('gstack-gate-diff', [], fx.noMain);
|
||||
expect(none.status).toBe(2);
|
||||
expect(none.out).toStartWith('GATE_ERROR=no_base ref=origin/HEAD target=origin/pr-branch');
|
||||
}, 120_000);
|
||||
|
||||
test('diff-scope: the default reads the PR file like the control; the PR branch refuses; main and a fresh branch keep today\'s output', () => {
|
||||
const bug = bin('gstack-diff-scope', [], fx.clone);
|
||||
expect(bug.status, bug.out + bug.err).toBe(0);
|
||||
expect(bug.out).toContain('SCOPE_BACKEND=true');
|
||||
const control = bin('gstack-diff-scope', [], fx.control);
|
||||
expect(control.out).toBe(bug.out);
|
||||
const self = bin('gstack-diff-scope', ['pr-branch'], fx.clone);
|
||||
expect(self.status).toBe(2);
|
||||
expect(self.out).toContain('SCOPE_ERROR=base_contains_head');
|
||||
const none = bin('gstack-diff-scope', [], fx.noMain);
|
||||
expect(none.status).toBe(2);
|
||||
expect(none.out).toContain('SCOPE_ERROR=no_base');
|
||||
git(fx.control, 'checkout', '-q', 'main');
|
||||
try {
|
||||
const onMain = bin('gstack-diff-scope', [], fx.control);
|
||||
expect(onMain.status).toBe(0);
|
||||
expect(onMain.out).not.toContain('=true');
|
||||
git(fx.control, 'checkout', '-q', '-b', 'fresh');
|
||||
fs.writeFileSync(path.join(fx.control, 'lib', 'app.js'), 'exports.app = () => 2;\n');
|
||||
const fresh = bin('gstack-diff-scope', ['main'], fx.control);
|
||||
expect(fresh.status, fresh.out).toBe(0);
|
||||
expect(fresh.out).toContain('SCOPE_BACKEND=true');
|
||||
} finally { git(fx.control, 'checkout', '-q', '--', 'lib/app.js'); git(fx.control, 'checkout', '-q', 'pr-branch'); }
|
||||
}, 120_000);
|
||||
});
|
||||
|
||||
describe('the docs-audit base', () => {
|
||||
// Value: protects=/ship's documentation audit selects the PR's files; fails_when=the snapshot's base is the PR head, so it
|
||||
// selects nothing and every later freshness check passes; seam=bin/gstack-docs-candidate snapshot
|
||||
test('snapshot refuses a base that is HEAD; the merge base with origin/main selects the PR file like the control', () => {
|
||||
const snap = (cwd: string, base: string, name: string) => bin('gstack-docs-candidate', ['snapshot', '--out', path.join(fx.root, `${name}.json`), '--audit-id', name, '--mode', 'read-only', '--base', base], cwd);
|
||||
const self = snap(fx.clone, git(fx.clone, 'rev-parse', 'origin/pr-branch'), 'self');
|
||||
expect(self.status).not.toBe(0);
|
||||
expect(self.err).toContain('(BASE_CONTAINS_HEAD)');
|
||||
const real = snap(fx.clone, git(fx.clone, 'merge-base', 'origin/main', 'HEAD'), 'real');
|
||||
expect(real.status, real.err).toBe(0);
|
||||
expect(real.out).toContain('committed: "lib/big.js"');
|
||||
const control = snap(fx.control, git(fx.control, 'merge-base', 'origin/main', 'HEAD'), 'control');
|
||||
expect(control.out.split('\n').slice(2).join('\n')).toBe(real.out.split('\n').slice(2).join('\n'));
|
||||
}, 120_000);
|
||||
});
|
||||
|
||||
describe('baseContainsHead keeps today\'s result where the branch is not compared with itself', () => {
|
||||
// Value: protects=the guard refuses only a self-comparison and never the base branch, a detached checkout or a fresh branch;
|
||||
// fails_when=the refusal fires on main, on a CI-style detached HEAD or on a branch with only uncommitted work; seam=lib/default-base.ts
|
||||
test('null on main, detached at the PR head and on a fresh branch; a PR head the base contains refuses; no default resolves past a skipped origin/HEAD', () => {
|
||||
withEnv(fx.env, () => {
|
||||
const head = git(fx.clone, 'rev-parse', 'HEAD');
|
||||
expect(baseContainsHead(fx.clone, 'origin/pr-branch')).toContain('on pr-branch, whose commits origin/main lacks; the real base is main');
|
||||
expect(baseContainsHead(fx.clone, 'origin/main')).toBeNull();
|
||||
expect(baseContainsHead(fx.clone, 'origin/pr-branch', head)).toContain(`the head ${head.slice(0, 12)}`);
|
||||
expect(baseContainsHead(fx.clone, 'origin/main', head)).toBeNull();
|
||||
git(fx.clone, 'checkout', '-q', '--detach', head);
|
||||
try { expect(baseContainsHead(fx.clone, 'origin/pr-branch')).toBeNull(); } finally { git(fx.clone, 'checkout', '-q', 'pr-branch'); }
|
||||
git(fx.control, 'checkout', '-q', 'main');
|
||||
try {
|
||||
expect(baseContainsHead(fx.control, 'origin/main')).toBeNull();
|
||||
git(fx.control, 'checkout', '-q', '-b', 'fresh-2');
|
||||
expect(baseContainsHead(fx.control, 'origin/main')).toBeNull();
|
||||
expect(bin('gstack-review-plan', ['--base', 'main'], fx.control).status).toBe(0);
|
||||
} finally { git(fx.control, 'checkout', '-q', 'pr-branch'); }
|
||||
expect(defaultBaseBranch(fx.noMain)).toEqual({ branch: null, skipped: { remote: 'origin', branch: 'pr-branch' } });
|
||||
expect(defaultBaseBranch(fx.control)).toEqual({ branch: 'main', remote: 'origin' });
|
||||
});
|
||||
}, 120_000);
|
||||
});
|
||||
@@ -329,6 +329,7 @@ describe('parity with the measured prototype', () => {
|
||||
'lib/bun-isolation.ts': 'GSTA-56: every bun launcher (script-invocation, restamp, qa-deadline, the hooks) imports it, so it reaches every consumer of those launchers',
|
||||
'lib/ship-receipt-docs.ts': 'bin/gstack-ship-receipt imports it (the docs audit record and doc caps), so it reaches every consumer of the receipt binary',
|
||||
'lib/ship-receipt-prose.ts': 'bin/gstack-ship-receipt imports it (read --check-prose), so it reaches every consumer of the receipt binary',
|
||||
'lib/default-base.ts': 'GST-511/GST-774: lib/ship-policy.ts (resolveBaseBranch), lib/gate-diff/git.ts, lib/pregate/context.ts, lib/ship-receipt-depth.ts and the review-plan, gate-diff, pregate, docs-candidate and diff-scope bins import or run it, so it reaches every consumer of those readers',
|
||||
};
|
||||
|
||||
test('the shipped map equals or is a reviewed superset of the prototype map on this checkout', () => {
|
||||
|
||||
@@ -45,7 +45,7 @@ export const E2E_TOUCHFILES: Record<string, string[]> = {
|
||||
'scripts/resolvers/review-scope.ts', 'scripts/resolvers/review-army.ts', 'scripts/resolvers/sections.ts', 'scripts/resolvers/index.ts',
|
||||
'scripts/resolvers/types.ts', 'scripts/gen-skill-docs.ts', 'scripts/host-config.ts', 'scripts/discover-skills.ts', 'hosts/claude.ts', 'hosts/index.ts', 'hosts/define-host.ts',
|
||||
'ship/sections/review-army.md*', 'ship/sections/manifest.json', 'review/checklist.md',
|
||||
'bin/gstack-review-scope', 'lib/review-scope.ts', 'lib/ship-policy.ts', 'lib/result-codes-release.ts',
|
||||
'bin/gstack-review-scope', 'lib/review-scope.ts', 'lib/ship-policy.ts', 'lib/default-base.ts', 'lib/result-codes-release.ts',
|
||||
'bin/gstack-review-log', 'bin/gstack-review-read', 'bin/gstack-wtree', 'bin/gstack-slug', 'bin/gstack-remote-identity.sh',
|
||||
'lib/review-evidence.ts', 'lib/claude-bin.ts', 'lib/eval-model.ts',
|
||||
'test/helpers/ship-frozen-scope-actor.ts', 'test/helpers/scratch-repo.ts',
|
||||
@@ -140,7 +140,7 @@ export const E2E_TOUCHFILES: Record<string, string[]> = {
|
||||
'review-enum-completeness': [ 'review/**', 'test/fixtures/review-eval-enum*.rb', 'test/skill-e2e-review.test.ts',
|
||||
'test/fixtures/fake-impeccable.ts', 'test/helpers/fake-impeccable.ts'],
|
||||
'review-base-branch': [ 'review/**', 'test/skill-e2e-review-attribution.test.ts'],
|
||||
'review-gate-integrity': [ 'review/**', 'lib/gate-diff/**', 'bin/gstack-gate-diff', 'scripts/resolvers/gate-scan.ts',
|
||||
'review-gate-integrity': [ 'review/**', 'lib/gate-diff/**', 'lib/default-base.ts', 'bin/gstack-gate-diff', 'scripts/resolvers/gate-scan.ts',
|
||||
'scripts/gate-diff-calibrate.ts', 'test/fixtures/gate-diff/**', 'test/helpers/gate-diff-repo.ts',
|
||||
'scripts/question-registry.ts', 'test/skill-e2e-gate-integrity.test.ts', 'test/helpers/skill-fixture.ts', 'test/helpers/gate-disposition.ts'],
|
||||
'review-design-lite': ['bin/gstack-state-root.sh', 'lib/state-root.ts', 'review/**', 'test/fixtures/review-eval-design-slop.*', 'test/helpers/fake-impeccable.ts', 'test/fixtures/fake-impeccable.ts', 'test/fixtures/impeccable-detect-sample.json', 'lib/design-catalog.ts', 'lib/design-detect-contract.ts', 'bin/gstack-design-detect.ts', 'scripts/resolvers/design-checklist.ts', 'scripts/resolvers/review-army.ts', 'test/skill-e2e-review.test.ts'
|
||||
|
||||
Reference in new issue
Block a user