mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-06 00:44:07 +01:00
fix(gate-diff): resolve the base to origin/<base> when no local branch exists; the no_base fix line fetches into the tracking ref
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
8d9501373b
commit
e81a46ab59
5 files changed
+253
-17
No files matched your search
@@ -1,5 +1,12 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
**Three /ship frictions the unattended lanes hit (GSTA-102).** Each fix is one commit.
|
||||
|
||||
### Fixed
|
||||
- **`gstack-gate-diff <base>` in a clone with no local `<base>` branch.** Before, `gstack-gate-diff main` in a checkout that cloned only its feature branch printed `GATE_ERROR=no_base ref=main fix=git fetch origin main`, and that fix did not help: the tool only tried the local branch, and in a single-branch clone a bare `git fetch origin main` updates FETCH_HEAD alone. Now `lib/gate-diff/git.ts` resolves the base to its remote-tracking branch (`origin/main`, origin first, then the other remotes) when no local branch exists, says so once on stderr, and the merge-base is the same commit. When the remote-tracking branch is missing too, the fix line fetches into it explicitly: `git fetch origin +main:refs/remotes/origin/main` (with `--unshallow` on a shallow clone), which works in every clone shape. `test/gate-diff-cli.test.ts` adds a single-branch clone fixture with no local `main` and no `origin/main`: it asserts the error, runs the printed fix verbatim, and expects the next run to list the fixture's RH-1 hunk; two more clones cover a second remote that carries the branch when origin does not, and an upstream-only clone of a master-default repository whose default base (no argument) now comes from the same remote list as the fix line. The refspec is forced (`+main:refs/remotes/origin/main`) so the line also repairs a tracking ref that exists but shares no history with HEAD.
|
||||
|
||||
## [1.91.226.0] - 2026-10-26
|
||||
|
||||
**/review can re-review just its own fixes after a fixing pass, under the same rules as /ship (#3339).** Before, every /review fix cycle reran the whole review over the whole branch, so a large branch could hit the three-cycle stop on findings in code the earlier passes had already read. Now the pass after an edited pass starts with `gstack-review-log --start review --fix-range` and its checklist, specialists and QA read only what changed since that pass. The Step 4.8 adversarial review still reads the whole branch on every pass, because its rows carry their own freshness. A narrowed pass is never the final review: when it makes no edits, /review runs one full pass before writing the final record, and a fix-range record is never CURRENT. Garry's "do it" on 2026-10-24 for a fix-range re-review with a final full pass covers it.
|
||||
|
||||
@@ -29,7 +29,7 @@ import * as os from 'node:os';
|
||||
import { scanCandidate, parseGateIntegrityBlock, specDirectoryHoldsSpecs, type FileDiff, type CommitInfo } from '../lib/gate-diff/index';
|
||||
import {
|
||||
commitInfo, commitsInRange, defaultBase, diffCommit, diffWorkingTree, firstParent, headSha, mergeBase, noBaseFix, readClaudeMd, repoRoot,
|
||||
resolvesToCommit, treePaths, untrackedAsAdded,
|
||||
resolveBase, resolvesToCommit, treePaths, untrackedAsAdded,
|
||||
} from '../lib/gate-diff/git';
|
||||
import { renderDiagnostics, renderJsonl, renderJsonlBody, renderTable } from '../lib/gate-diff/render';
|
||||
import { resolveStateRoot } from '../lib/state-root';
|
||||
@@ -142,13 +142,14 @@ function main(argv: string[]): number {
|
||||
specIsTestPath = specDirectoryHoldsSpecs(treePaths(root, sha));
|
||||
} else {
|
||||
const baseRef = parsed.base ?? defaultBase(root);
|
||||
const baseSha = resolvesToCommit(baseRef, root);
|
||||
const head = headSha(root);
|
||||
if (!baseSha || !head) {
|
||||
const base = resolveBase(baseRef, root, head ?? undefined);
|
||||
if (!base || !head) {
|
||||
process.stdout.write(`GATE_ERROR=no_base ref=${baseRef} fix=${noBaseFix(baseRef, root)}\n`);
|
||||
return 2;
|
||||
}
|
||||
const mb = mergeBase(baseSha, head, root);
|
||||
if (base.local) console.error(`gstack-gate-diff: ${base.local === 'unrelated' ? `local ${baseRef} shares no history with HEAD` : `no local branch ${baseRef}`}; 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`);
|
||||
return 2;
|
||||
|
||||
@@ -178,8 +178,11 @@ draft are cut: the artifact path is tool-owned, a child's self-reported
|
||||
path list is both a pathspec-injection surface and a self-report, and the
|
||||
only history need is calibration, which `--commit <sha>` covers by scanning
|
||||
one commit's first-parent delta without the working tree.) Positional
|
||||
base defaults the way `gstack-diff-scope` does (`origin/HEAD` → `main` →
|
||||
`master`); an already-qualified ref is used as given. stdout: one
|
||||
base defaults the way `gstack-diff-scope` does (the remote's `HEAD` target,
|
||||
origin first, then the other remotes → `main` → `master`); an
|
||||
already-qualified ref is used as given, and a branch name with no local
|
||||
branch resolves through its remote-tracking twin (`origin/main` for `main`:
|
||||
the merge-base is the same commit), noted once on stderr. stdout: one
|
||||
`GATE_SUMMARY: listed=N read=N tagged={RH-1:2,RH-15:1} unmatched=N
|
||||
coverage=<patterns> languages_unlisted=<…> candidate=<sha+wtree>` line, then
|
||||
the read-level listing (bounded; the full listing goes to `--out`).
|
||||
|
||||
+68
-9
@@ -46,12 +46,35 @@ export function resolvesToCommit(ref: string, cwd: string): string | null {
|
||||
return gitText(['rev-parse', '--verify', '-q', `${ref}^{commit}`], cwd);
|
||||
}
|
||||
|
||||
/** origin/HEAD's target → `main` → `master` (the gstack-diff-scope chain), as a ref name. */
|
||||
/**
|
||||
* A ref that could be a branch name: not a sha, not relative or symbolic
|
||||
* (`HEAD~1`, `main^2`, `@{u}`, `HEAD`, `FETCH_HEAD`), not a full `refs/` path.
|
||||
* Only such a ref has a remote-tracking twin (`origin/<ref>`) worth trying, and
|
||||
* only such a ref goes into a fix-line refspec; `origin/HEAD~1` would resolve
|
||||
* to a different commit than the `HEAD~1` the caller named.
|
||||
*/
|
||||
function isBranchName(ref: string): boolean {
|
||||
return !/^[0-9a-f]{7,64}$/.test(ref) && !/[~^]|@\{/.test(ref) && !/^(?:[A-Z_]*HEAD)$/.test(ref) && !ref.startsWith('refs/');
|
||||
}
|
||||
|
||||
function remoteNames(cwd: string): string[] {
|
||||
const remotes = (gitText(['remote'], cwd) ?? '').split('\n').filter(Boolean);
|
||||
return remotes.includes('origin') ? ['origin', ...remotes.filter((r) => r !== 'origin')] : remotes;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function defaultBase(cwd: string): string {
|
||||
const head = gitText(['symbolic-ref', '-q', 'refs/remotes/origin/HEAD'], cwd);
|
||||
if (head) return head.replace(/^refs\/remotes\//, '');
|
||||
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) || resolvesToCommit(`origin/${name}`, cwd)) return name;
|
||||
if (resolvesToCommit(name, cwd) || remotes.some((remote) => resolvesToCommit(`${remote}/${name}`, cwd))) return name;
|
||||
}
|
||||
return 'main';
|
||||
}
|
||||
@@ -60,14 +83,50 @@ export function isShallow(cwd: string): boolean {
|
||||
return gitText(['rev-parse', '--is-shallow-repository'], cwd) === 'true';
|
||||
}
|
||||
|
||||
/** The concrete recovery for an unresolvable base: fetch it from the detected remote, unshallowing when needed. */
|
||||
/**
|
||||
* The base as a ref that resolves here and, when `head` is given, shares
|
||||
* history with it: the ref itself, else its remote-tracking branch
|
||||
* (`origin/main` for `main`). A clone that checked out only the feature
|
||||
* branch has no local `main`, and the merge-base against `origin/main` is the
|
||||
* same commit; a stale local `main` that shares no history with HEAD (a
|
||||
* fetched-over clone) yields to the twin too, so the fix line's fetch into the
|
||||
* tracking ref is a fix and not a loop.
|
||||
*/
|
||||
export function resolveBase(ref: string, cwd: string, head?: string): { ref: string; sha: string; local?: 'missing' | 'unrelated' } | null {
|
||||
const related = (sha: string) => !head || mergeBase(sha, head, cwd) !== null;
|
||||
const direct = resolvesToCommit(ref, cwd);
|
||||
if (direct && related(direct)) return { ref, sha: direct };
|
||||
if (!isBranchName(ref)) return direct ? { ref, sha: direct } : null;
|
||||
for (const remote of remoteNames(cwd)) {
|
||||
const tracking = `${remote}/${ref}`;
|
||||
const sha = resolvesToCommit(tracking, cwd);
|
||||
if (sha && related(sha)) return { ref: tracking, sha, local: direct ? 'unrelated' : 'missing' };
|
||||
}
|
||||
return direct ? { ref, sha: direct } : null;
|
||||
}
|
||||
|
||||
/** A word for a printed shell line: as is when it is plain, single-quoted otherwise (ref names may hold `;`, `$`, `(`, `{1,2}`). */
|
||||
function shellWord(s: string): string {
|
||||
return /^[A-Za-z0-9._\/@^~+:-]+$/.test(s) ? s : `'${s.replace(/'/g, "'\\''")}'`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The concrete recovery for an unresolvable base: fetch it from the detected
|
||||
* remote into its remote-tracking ref (which resolveBase then finds; a bare
|
||||
* `git fetch origin main` only updates FETCH_HEAD in a single-branch clone),
|
||||
* unshallowing when needed. The refspec is forced (`+`) so the line also
|
||||
* repairs a tracking ref that exists but shares no history with HEAD. A sha
|
||||
* or a relative ref (`HEAD~1`, `main^2`, `@{u}`) is not a refspec: that gets a
|
||||
* plain fetch (deepened when shallow).
|
||||
*/
|
||||
export function noBaseFix(ref: string, cwd: string): string {
|
||||
const remotes = (gitText(['remote'], cwd) ?? '').split('\n').filter(Boolean);
|
||||
const remotes = remoteNames(cwd);
|
||||
const m = /^([^/]+)\/(.+)$/.exec(ref);
|
||||
const remote = m && remotes.includes(m[1]) ? m[1] : remotes.includes('origin') ? 'origin' : remotes[0] ?? 'origin';
|
||||
const remote = m && remotes.includes(m[1]) ? m[1] : remotes[0] ?? 'origin';
|
||||
const branch = m && remotes.includes(m[1]) ? m[2] : ref;
|
||||
if (/^[0-9a-f]{7,64}\^?$/.test(ref) || ref.endsWith('^')) return isShallow(cwd) ? `git fetch --deepen=50 ${remote}` : `git fetch ${remote}`;
|
||||
return isShallow(cwd) ? `git fetch --unshallow ${remote} ${branch}` : `git fetch ${remote} ${branch}`;
|
||||
if (!isBranchName(ref)) return isShallow(cwd) ? `git fetch --deepen=50 ${shellWord(remote)}` : `git fetch ${shellWord(remote)}`;
|
||||
const refspec = shellWord(`+${branch}:refs/remotes/${remote}/${branch}`);
|
||||
return isShallow(cwd) ? `git fetch --unshallow ${shellWord(remote)} ${refspec}` : `git fetch ${shellWord(remote)} ${refspec}`;
|
||||
}
|
||||
|
||||
export function mergeBase(a: string, b: string, cwd: string): string | null {
|
||||
|
||||
+168
-2
@@ -66,9 +66,175 @@ describe('gstack-gate-diff arguments and exit contract', () => {
|
||||
test('an unresolvable base is exit 2 with GATE_ERROR=no_base and a concrete fetch as the ONLY stdout line', () => {
|
||||
const r = run(repo({ fixture: 'rh1-skip' }), ['nosuchbranch']);
|
||||
expect(r.status).toBe(2);
|
||||
expect(r.stdout.trim().split('\n')).toEqual(['GATE_ERROR=no_base ref=nosuchbranch fix=git fetch origin nosuchbranch']);
|
||||
expect(r.stdout.trim().split('\n')).toEqual(['GATE_ERROR=no_base ref=nosuchbranch fix=git fetch origin +nosuchbranch:refs/remotes/origin/nosuchbranch']);
|
||||
const remote = run(repo({ fixture: 'rh1-skip' }), ['upstream/release']);
|
||||
expect(remote.stdout.trim()).toBe('GATE_ERROR=no_base ref=upstream/release fix=git fetch origin upstream/release');
|
||||
expect(remote.stdout.trim()).toBe('GATE_ERROR=no_base ref=upstream/release fix=git fetch origin +upstream/release:refs/remotes/origin/upstream/release');
|
||||
// A relative, symbolic or full ref is not a refspec: it gets a plain fetch, never `+HEAD~9:refs/remotes/origin/HEAD~9`.
|
||||
for (const ref of ['HEAD~9', 'FETCH_HEAD', 'refs/heads/nosuch']) {
|
||||
expect([ref, run(repo({ fixture: 'rh1-skip' }), [ref]).stdout.trim()]).toEqual([ref, `GATE_ERROR=no_base ref=${ref} fix=git fetch origin`]);
|
||||
}
|
||||
// Nor does a relative ref get a remote-tracking twin: `feature~1` with no local `feature` stays no_base
|
||||
// even though `origin/feature~1` would resolve (to a commit other than the one named).
|
||||
const rooted = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-gate-diff-root-'));
|
||||
repos.push(rooted);
|
||||
gitIn(rooted, ['clone', '-q', '--single-branch', '--branch', 'feature', `file://${repo({ fixture: 'rh1-skip', commit: true })}`, '.']);
|
||||
gitIn(rooted, ['checkout', '-q', '--orphan', 'solo']);
|
||||
gitIn(rooted, ['commit', '-q', '--allow-empty', '-m', 'root']);
|
||||
gitIn(rooted, ['branch', '-q', '-D', 'feature']);
|
||||
expect(gitIn(rooted, ['rev-parse', '--verify', '-q', 'origin/feature~1^{commit}']).trim()).not.toBe('');
|
||||
const rel = run(rooted, ['feature~1']);
|
||||
expect(rel.status).toBe(2);
|
||||
expect(rel.stdout.trim()).toBe('GATE_ERROR=no_base ref=feature~1 fix=git fetch origin');
|
||||
});
|
||||
|
||||
test('a clone with no local main resolves the base to origin/main; the fix line it prints when that is missing too actually works', () => {
|
||||
const src = repo({ fixture: 'rh1-skip', commit: true });
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-gate-diff-clone-'));
|
||||
repos.push(dir);
|
||||
gitIn(dir, ['clone', '-q', '--single-branch', '--branch', 'feature', `file://${src}`, '.']);
|
||||
expect(gitIn(dir, ['branch', '--list', 'main']).trim()).toBe('');
|
||||
expect(gitIn(dir, ['branch', '-r']).trim()).not.toContain('origin/main');
|
||||
|
||||
const missing = run(dir, ['main']);
|
||||
expect(missing.status).toBe(2);
|
||||
expect(missing.stdout.trim()).toBe('GATE_ERROR=no_base ref=main fix=git fetch origin +main:refs/remotes/origin/main');
|
||||
// Value: protects=the fix line for a `<remote>/<branch>` base names the branch, not the tracking ref, in its refspec;
|
||||
// fails_when=noBaseFix builds the refspec from the raw ref (`origin/main:refs/remotes/origin/origin/main`) or resolveBase
|
||||
// reports a direct hit as a fallback; why_new=the only remote-form control uses a remote that does not exist, so it never
|
||||
// reaches the known-remote branch; seam=none; no_claim=nothing about remotes other than origin.
|
||||
expect(run(dir, ['origin/main']).stdout.trim()).toBe('GATE_ERROR=no_base ref=origin/main fix=git fetch origin +main:refs/remotes/origin/main');
|
||||
|
||||
gitIn(dir, missing.stdout.trim().replace(/^.* fix=git /, '').split(' '));
|
||||
expect(gitIn(dir, ['branch', '--list', 'main']).trim()).toBe('');
|
||||
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: no local branch main; base is origin/main');
|
||||
const direct = run(dir, ['origin/main']);
|
||||
expect(direct.status).toBe(0);
|
||||
expect(summary(direct.stdout).tagged).toBe('RH-1:1');
|
||||
expect(direct.stderr).not.toContain('no local branch');
|
||||
});
|
||||
|
||||
// Value: protects=a local base branch that shares no history with HEAD yields to its remote-tracking twin, and when
|
||||
// the twin is missing too the printed fix repairs the next run instead of looping on the stale local branch;
|
||||
// fails_when=resolveBase returns the first ref that resolves without checking it is related to HEAD, so the fix line's
|
||||
// fetch into origin/main never changes what the next run picks; why_new=the other clone tests have no local main at
|
||||
// all; seam=none; no_claim=nothing about a local branch that is related but behind.
|
||||
test('a stale local main unrelated to HEAD yields to origin/main; without the twin the fix line repairs the next run', () => {
|
||||
const src = repo({ fixture: 'rh1-skip', commit: true });
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-gate-diff-stale-'));
|
||||
repos.push(dir);
|
||||
gitIn(dir, ['clone', '-q', '--single-branch', '--branch', 'feature', `file://${src}`, '.']);
|
||||
gitIn(dir, ['checkout', '-q', '--orphan', 'main']);
|
||||
gitIn(dir, ['rm', '-rfq', '.']);
|
||||
fs.writeFileSync(path.join(dir, 'README'), 'unrelated history\n');
|
||||
gitIn(dir, ['add', 'README']);
|
||||
gitIn(dir, ['-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-qm', 'unrelated main']);
|
||||
gitIn(dir, ['checkout', '-q', 'feature']);
|
||||
expect(spawnSync('git', ['merge-base', 'main', 'HEAD'], { cwd: dir, timeout: 30_000 }).status).not.toBe(0);
|
||||
|
||||
const stale = run(dir, ['main']);
|
||||
expect(stale.status).toBe(2);
|
||||
expect(stale.stdout.trim()).toBe('GATE_ERROR=no_base ref=main fix=git fetch origin +main:refs/remotes/origin/main');
|
||||
gitIn(dir, stale.stdout.trim().replace(/^.* fix=git /, '').split(' '));
|
||||
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 shares no history with HEAD; base is origin/main');
|
||||
expect(r.stderr).not.toContain('no local branch');
|
||||
// A ref name with shell metacharacters is quoted in the printed fix line (it is meant to be run verbatim).
|
||||
const odd = run(dir, ['rel;echo pwned']);
|
||||
expect(odd.status).toBe(2);
|
||||
expect(odd.stdout.trim()).toBe("GATE_ERROR=no_base ref=rel;echo pwned fix=git fetch origin '+rel;echo pwned:refs/remotes/origin/rel;echo pwned'");
|
||||
});
|
||||
|
||||
// Value: protects=a clone whose only remote is not called origin resolves the base through that remote and the fix line
|
||||
// fetches from it; fails_when=remoteNames or resolveBase hard-code origin, or noBaseFix falls back to the literal 'origin'
|
||||
// while a real remote exists; why_new=every existing fixture has either an origin remote or none; seam=none;
|
||||
// no_claim=nothing about the order when origin and another remote both carry the branch.
|
||||
test('a clone whose only remote is upstream resolves the base to upstream/main, and the fix line fetches from upstream', () => {
|
||||
const src = repo({ fixture: 'rh1-skip', commit: true });
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-gate-diff-upstream-'));
|
||||
repos.push(dir);
|
||||
gitIn(dir, ['clone', '-q', '-o', 'upstream', '--single-branch', '--branch', 'feature', `file://${src}`, '.']);
|
||||
expect(gitIn(dir, ['remote']).trim()).toBe('upstream');
|
||||
|
||||
const missing = run(dir, ['main']);
|
||||
expect(missing.status).toBe(2);
|
||||
expect(missing.stdout.trim()).toBe('GATE_ERROR=no_base ref=main fix=git fetch upstream +main:refs/remotes/upstream/main');
|
||||
|
||||
gitIn(dir, missing.stdout.trim().replace(/^.* fix=git /, '').split(' '));
|
||||
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: no local branch main; base is upstream/main');
|
||||
});
|
||||
|
||||
// Value: protects=resolveBase walks past origin to the other remotes (origin exists but lacks the branch, upstream has it);
|
||||
// fails_when=the loop stops after origin or only consults origin when origin exists; why_new=the upstream-only test has one
|
||||
// remote and the origin tests have only origin, so the second iteration was never reached; seam=none; no_claim=nothing about
|
||||
// which remote wins when both carry the branch.
|
||||
test('origin without main and upstream with it: the base is upstream/main', () => {
|
||||
const src = repo({ fixture: 'rh1-skip', commit: true });
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-gate-diff-two-remotes-'));
|
||||
repos.push(dir);
|
||||
gitIn(dir, ['clone', '-q', '--single-branch', '--branch', 'feature', `file://${src}`, '.']);
|
||||
gitIn(dir, ['remote', 'add', 'upstream', `file://${src}`]);
|
||||
gitIn(dir, ['fetch', '-q', 'upstream', 'main:refs/remotes/upstream/main']);
|
||||
expect(gitIn(dir, ['branch', '-r']).trim()).not.toContain('origin/main');
|
||||
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: no local branch main; base is upstream/main');
|
||||
});
|
||||
|
||||
// Value: protects=the default base (no argument) is found through the same remote list as resolveBase and the fix line, so an
|
||||
// upstream-only clone of a master-default repository defaults to upstream/master instead of a `main` that exists nowhere;
|
||||
// fails_when=defaultBase probes only `origin/<name>`; why_new=no test ran without a base in a clone whose remote is not
|
||||
// origin; seam=the remote HEAD symref is removed first because single-branch clones set it on some git versions and not
|
||||
// others; no_claim=nothing about a remote HEAD that points at the feature branch.
|
||||
test('no explicit base in an upstream-only clone of a master-default repository: the base is upstream/master', () => {
|
||||
const src = repo({ fixture: 'rh1-skip', commit: true });
|
||||
gitIn(src, ['branch', '-m', 'main', 'master']);
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-gate-diff-default-upstream-'));
|
||||
repos.push(dir);
|
||||
gitIn(dir, ['clone', '-q', '-o', 'upstream', '--single-branch', '--branch', 'feature', `file://${src}`, '.']);
|
||||
gitIn(dir, ['fetch', '-q', 'upstream', 'master:refs/remotes/upstream/master']);
|
||||
gitIn(dir, ['remote', 'set-head', 'upstream', '-d']);
|
||||
const r = run(dir, []);
|
||||
expect(r.status).toBe(0);
|
||||
expect(summary(r.stdout).tagged).toBe('RH-1:1');
|
||||
expect(r.stderr).toContain('gstack-gate-diff: no local branch master; base is upstream/master');
|
||||
});
|
||||
|
||||
// Value: protects=a shallow clone gets a fix line that unshallows into the tracking ref, both when the base is absent and
|
||||
// when it is present but shares no reachable history (merge-base fails), and that line repairs the clone;
|
||||
// fails_when=the shallow branch of noBaseFix keeps the bare `--unshallow origin main` (FETCH_HEAD only), or a resolved base
|
||||
// with no merge-base stops being no_base; why_new=the existing shallow test covers only the `<sha>^` --deepen form, and no
|
||||
// test reaches the merge-base-null branch; seam=none; no_claim=nothing about --deepen.
|
||||
test('a shallow single-branch clone: the fix line unshallows into origin/main, including when origin/main exists but has no merge-base', () => {
|
||||
const src = repo({ fixture: 'rh1-skip', commit: true });
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-gate-diff-shallow-base-'));
|
||||
repos.push(dir);
|
||||
gitIn(dir, ['clone', '-q', '--depth', '1', '--single-branch', '--branch', 'feature', `file://${src}`, '.']);
|
||||
|
||||
const absent = run(dir, ['main']);
|
||||
expect(absent.status).toBe(2);
|
||||
expect(absent.stdout.trim()).toBe('GATE_ERROR=no_base ref=main fix=git fetch --unshallow origin +main:refs/remotes/origin/main');
|
||||
|
||||
gitIn(dir, ['fetch', '-q', '--depth', '1', 'origin', 'main:refs/remotes/origin/main']);
|
||||
expect(gitIn(dir, ['rev-parse', '--verify', '-q', 'origin/main^{commit}']).trim()).not.toBe('');
|
||||
const disjoint = run(dir, ['main']);
|
||||
expect(disjoint.status).toBe(2);
|
||||
expect(disjoint.stdout.trim()).toBe('GATE_ERROR=no_base ref=main fix=git fetch --unshallow origin +main:refs/remotes/origin/main');
|
||||
|
||||
gitIn(dir, disjoint.stdout.trim().replace(/^.* fix=git /, '').split(' '));
|
||||
expect(gitIn(dir, ['rev-parse', '--is-shallow-repository']).trim()).toBe('false');
|
||||
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: no local branch main; base is origin/main');
|
||||
});
|
||||
|
||||
test('an empty candidate is exit 0 with a zero summary, never an error', () => {
|
||||
|
||||
Reference in new issue
Block a user