mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-11 21:57:14 +03:00
Merge pull request #419 from pbakaus/diff-base-detection
Detect the diff base in context-signals instead of assuming main/master
This commit is contained in:
@@ -86,15 +86,109 @@ function gitSignals(cwd) {
|
||||
return { isRepo: false, branch: null, base: null, changedFiles: [], changedCount: 0 };
|
||||
}
|
||||
const branch = run(['rev-parse', '--abbrev-ref', 'HEAD']);
|
||||
// The merge target is detected, not assumed. A hardcoded main/master list
|
||||
// diffed develop-based repos against the wrong base, so git.changedFiles
|
||||
// carried the whole develop/main divergence into scan.targets (issue
|
||||
// #302). Signals, most specific first: the branch's configured upstream
|
||||
// (@{u}; a branch pushed with -u tracks itself and is skipped by the
|
||||
// self-check), then the remote's default-branch symref (origin/HEAD),
|
||||
// then the conventional integration names. The conventional fallbacks
|
||||
// are withheld when the current branch IS one of them: sitting on main
|
||||
// in a repo that also has develop must not diff the two integration
|
||||
// branches against each other.
|
||||
// Candidates carry a display name (what git.base reports) and the revs to
|
||||
// try, in order. A remote ref like `upstream/release` (fork workflows) or
|
||||
// an origin/HEAD target with no local checkout is a perfectly good diff
|
||||
// base, so revs are not limited to local branch names.
|
||||
const remotes = (run(['remote']) || '').split('\n').filter(Boolean);
|
||||
// Read @{u} as a FULL symbolic ref: refs/heads/... is a local upstream
|
||||
// (branch.<x>.remote = "."), refs/remotes/<r>/... is remote-tracking. No
|
||||
// string guessing on the abbreviated form survives contact with reality:
|
||||
// a local upstream named release/2.0 is one branch name, and a local
|
||||
// feature/foo beside a remote actually named "feature" is only told apart
|
||||
// from feature's remote-tracking refs by the full ref namespace.
|
||||
const resolveUpstream = () => {
|
||||
const full = run(['rev-parse', '--symbolic-full-name', '@{u}']);
|
||||
if (!full) return null;
|
||||
if (full.startsWith('refs/heads/')) {
|
||||
const name = full.slice('refs/heads/'.length);
|
||||
return { name, rev: name };
|
||||
}
|
||||
if (full.startsWith('refs/remotes/')) {
|
||||
const rest = full.slice('refs/remotes/'.length);
|
||||
const i = rest.indexOf('/');
|
||||
if (i > 0) return { name: rest.slice(i + 1), rev: rest };
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const conventional = ['develop', 'main', 'master'];
|
||||
// On an integration branch itself the scope hint is the working tree. No
|
||||
// signal may override that: an origin/HEAD or upstream naming a DIFFERENT
|
||||
// integration branch (sitting on develop while the remote default is
|
||||
// main) would produce exactly the integration-vs-integration divergence
|
||||
// this detection exists to prevent. "Integration branch" means a
|
||||
// conventional name OR any remote's default branch (origin first, but a
|
||||
// fork-parent layout may only have an `upstream` remote), so a
|
||||
// non-standard default like trunk is guarded the same way. A detached
|
||||
// checkout (branch reads as the literal `HEAD`) has no branch identity to
|
||||
// diff for and keeps the working-tree scope too.
|
||||
const remoteHeads = [];
|
||||
for (const r of [...new Set(['origin', ...remotes])]) {
|
||||
// The symref's own prefix is the remote just queried, so it is stripped
|
||||
// directly; the remote need not be in `git remote` output (tests and
|
||||
// partial clones fabricate refs/remotes/origin/* without a remote).
|
||||
const ref = run(['symbolic-ref', '--short', `refs/remotes/${r}/HEAD`]);
|
||||
if (ref && ref.startsWith(`${r}/`)) remoteHeads.push({ name: ref.slice(r.length + 1), rev: ref });
|
||||
}
|
||||
const onIntegrationBranch = branch === 'HEAD'
|
||||
|| conventional.includes(branch)
|
||||
|| remoteHeads.some((head) => head.name === branch);
|
||||
let base = null;
|
||||
for (const b of ['main', 'master']) {
|
||||
if (run(['rev-parse', '--verify', '--quiet', b]) !== null) {
|
||||
base = b;
|
||||
break;
|
||||
let baseRev = null;
|
||||
if (!onIntegrationBranch) {
|
||||
const upstream = resolveUpstream();
|
||||
// Every named candidate tries the local branch first, then that name on
|
||||
// every remote (origin first). Covering all remotes up front is what
|
||||
// makes the name-level dedup below safe: a develop or main that exists
|
||||
// only as upstream/<name> still resolves even though origin's candidate
|
||||
// claimed the name first.
|
||||
const remoteOrder = ['origin', ...remotes.filter((name) => name !== 'origin')];
|
||||
const revsFor = (name) => [name, ...remoteOrder.map((r) => `${r}/${name}`)];
|
||||
const candidates = [];
|
||||
const seen = new Set();
|
||||
const addCandidate = (name, revs) => {
|
||||
if (!name || name === branch || seen.has(name)) return;
|
||||
seen.add(name);
|
||||
candidates.push({ name, revs });
|
||||
};
|
||||
// The upstream tracks the actual merge target, so its own rev wins over
|
||||
// a possibly stale local branch of the same name.
|
||||
if (upstream) addCandidate(upstream.name, [upstream.rev]);
|
||||
// A develop branch marks a git-flow repo where features merge to develop
|
||||
// even when the platform default (origin/HEAD) was never flipped off
|
||||
// main; an existing develop therefore outranks the remote default. This
|
||||
// is #302's own repro shape, and repos without develop are unaffected.
|
||||
// A remote's advertised default prefers its own remote-tracking rev over
|
||||
// a possibly stale local checkout of the same name, for the same reason
|
||||
// the upstream candidate leads with its rev. That applies to the develop
|
||||
// candidate too when the remote default IS develop: it sits before the
|
||||
// remote-default entries in the order, so it must lead with their rev
|
||||
// itself or a stale local develop would win.
|
||||
const advertisedRevs = (name) => remoteHeads.filter((head) => head.name === name).map((head) => head.rev);
|
||||
addCandidate('develop', [...new Set([...advertisedRevs('develop'), ...revsFor('develop')])]);
|
||||
for (const head of remoteHeads) addCandidate(head.name, [...new Set([head.rev, ...revsFor(head.name)])]);
|
||||
for (const name of ['main', 'master']) addCandidate(name, revsFor(name));
|
||||
for (const c of candidates) {
|
||||
const rev = c.revs.find((r) => run(['rev-parse', '--verify', '--quiet', r]) !== null);
|
||||
if (rev) {
|
||||
base = c.name;
|
||||
baseRev = rev;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
const diffBase = base && branch && branch !== base ? base : null;
|
||||
const fromDiff = diffBase ? run(['diff', '--name-only', `${diffBase}...HEAD`]) : null;
|
||||
const fromDiff = diffBase ? run(['diff', '--name-only', `${baseRev}...HEAD`]) : null;
|
||||
// porcelain lines are `XY PATH`: a 2-char status + a space, then the path.
|
||||
// Don't trim the combined output — an unstaged-modified line starts with a
|
||||
// leading space (` M path`), and a global trim would eat the first line's
|
||||
|
||||
@@ -185,6 +185,440 @@ describe('gatherSignals', () => {
|
||||
assert.deepEqual(s.scan.targets, ['src']);
|
||||
});
|
||||
|
||||
it('diffs a feature branch against a develop integration branch (#302)', async () => {
|
||||
const { execFileSync } = await import('node:child_process');
|
||||
const git = (...args) => execFileSync('git', args, { cwd: scratch, stdio: 'ignore' });
|
||||
git('init', '-q', '-b', 'develop');
|
||||
git('config', 'user.email', 't@example.com');
|
||||
git('config', 'user.name', 'Test');
|
||||
write('src/App.tsx', 'export default 1;\n');
|
||||
git('add', '.');
|
||||
git('commit', '-qm', 'init');
|
||||
git('checkout', '-q', '-b', 'feature/x');
|
||||
write('src/Hero.tsx', 'export const Hero = () => null;\n');
|
||||
git('add', '.');
|
||||
git('commit', '-qm', 'feature work');
|
||||
const s = await gatherSignals(scratch);
|
||||
// The hardcoded main/master candidate list found no base here, so the
|
||||
// committed feature work was invisible to the scan targets.
|
||||
assert.equal(s.git.base, 'develop');
|
||||
assert.deepEqual(s.git.changedFiles, ['src/Hero.tsx']);
|
||||
assert.deepEqual(s.scan.targets, ['src/Hero.tsx']);
|
||||
});
|
||||
|
||||
it('prefers the remote default branch (origin/HEAD) as the diff base (#302)', async () => {
|
||||
const { execFileSync } = await import('node:child_process');
|
||||
const git = (...args) => execFileSync('git', args, { cwd: scratch, stdio: 'ignore' });
|
||||
git('init', '-q', '-b', 'trunk');
|
||||
git('config', 'user.email', 't@example.com');
|
||||
git('config', 'user.name', 'Test');
|
||||
write('src/App.tsx', 'export default 1;\n');
|
||||
git('add', '.');
|
||||
git('commit', '-qm', 'init');
|
||||
// Fabricate the remote's default-branch symref without a network remote.
|
||||
git('update-ref', 'refs/remotes/origin/trunk', 'HEAD');
|
||||
git('symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/trunk');
|
||||
git('checkout', '-q', '-b', 'feature/y');
|
||||
write('src/Hero.tsx', 'export const Hero = () => null;\n');
|
||||
git('add', '.');
|
||||
git('commit', '-qm', 'feature work');
|
||||
const s = await gatherSignals(scratch);
|
||||
assert.equal(s.git.base, 'trunk');
|
||||
assert.deepEqual(s.git.changedFiles, ['src/Hero.tsx']);
|
||||
});
|
||||
|
||||
it('a branch tracking the integration branch diffs against its upstream (#302)', async () => {
|
||||
const { execFileSync } = await import('node:child_process');
|
||||
const git = (...args) => execFileSync('git', args, { cwd: scratch, stdio: 'ignore' });
|
||||
git('init', '-q', '-b', 'release');
|
||||
git('config', 'user.email', 't@example.com');
|
||||
git('config', 'user.name', 'Test');
|
||||
write('src/App.tsx', 'export default 1;\n');
|
||||
git('add', '.');
|
||||
git('commit', '-qm', 'init');
|
||||
// A self-pointing remote gives git the fetch refspec it needs to map
|
||||
// refs/heads/release -> refs/remotes/origin/release; no network involved.
|
||||
git('remote', 'add', 'origin', '.');
|
||||
git('update-ref', 'refs/remotes/origin/release', 'HEAD');
|
||||
git('checkout', '-q', '-b', 'feature/z');
|
||||
git('branch', '-q', '--set-upstream-to=origin/release');
|
||||
write('src/Hero.tsx', 'export const Hero = () => null;\n');
|
||||
git('add', '.');
|
||||
git('commit', '-qm', 'feature work');
|
||||
const s = await gatherSignals(scratch);
|
||||
assert.equal(s.git.base, 'release');
|
||||
assert.deepEqual(s.git.changedFiles, ['src/Hero.tsx']);
|
||||
});
|
||||
|
||||
it('sitting on the integration branch itself falls back to the working tree', async () => {
|
||||
const { execFileSync } = await import('node:child_process');
|
||||
const git = (...args) => execFileSync('git', args, { cwd: scratch, stdio: 'ignore' });
|
||||
git('init', '-q', '-b', 'develop');
|
||||
git('config', 'user.email', 't@example.com');
|
||||
git('config', 'user.name', 'Test');
|
||||
write('src/App.tsx', 'export default 1;\n');
|
||||
git('add', '.');
|
||||
git('commit', '-qm', 'init');
|
||||
write('src/App.tsx', 'export default 2;\n'); // dirty, uncommitted
|
||||
const s = await gatherSignals(scratch);
|
||||
// No self-diff: base must be null and the dirty working tree is the scope.
|
||||
assert.equal(s.git.base, null);
|
||||
assert.deepEqual(s.git.changedFiles, ['src/App.tsx']);
|
||||
});
|
||||
|
||||
it('uses the remote-tracking ref when the base has no local branch (#302)', async () => {
|
||||
const { execFileSync } = await import('node:child_process');
|
||||
const git = (...args) => execFileSync('git', args, { cwd: scratch, stdio: 'ignore' });
|
||||
git('init', '-q', '-b', 'develop');
|
||||
git('config', 'user.email', 't@example.com');
|
||||
git('config', 'user.name', 'Test');
|
||||
write('src/App.tsx', 'export default 1;\n');
|
||||
git('add', '.');
|
||||
git('commit', '-qm', 'init');
|
||||
git('update-ref', 'refs/remotes/origin/develop', 'HEAD');
|
||||
git('symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/develop');
|
||||
git('checkout', '-q', '-b', 'feature/w');
|
||||
git('branch', '-q', '-D', 'develop'); // remote default exists, local doesn't
|
||||
write('src/Hero.tsx', 'export const Hero = () => null;\n');
|
||||
git('add', '.');
|
||||
git('commit', '-qm', 'feature work');
|
||||
const s = await gatherSignals(scratch);
|
||||
assert.equal(s.git.base, 'develop');
|
||||
assert.deepEqual(s.git.changedFiles, ['src/Hero.tsx']);
|
||||
});
|
||||
|
||||
it('honors an upstream on a non-origin remote (fork workflow) (#302)', async () => {
|
||||
const { execFileSync } = await import('node:child_process');
|
||||
const git = (...args) => execFileSync('git', args, { cwd: scratch, stdio: 'ignore' });
|
||||
git('init', '-q', '-b', 'release');
|
||||
git('config', 'user.email', 't@example.com');
|
||||
git('config', 'user.name', 'Test');
|
||||
write('src/App.tsx', 'export default 1;\n');
|
||||
git('add', '.');
|
||||
git('commit', '-qm', 'init');
|
||||
git('remote', 'add', 'upstream', '.');
|
||||
git('update-ref', 'refs/remotes/upstream/release', 'HEAD');
|
||||
git('checkout', '-q', '-b', 'feature/v');
|
||||
git('branch', '-q', '--set-upstream-to=upstream/release');
|
||||
git('branch', '-q', '-D', 'release'); // the tracked base lives only on the fork parent
|
||||
write('src/Hero.tsx', 'export const Hero = () => null;\n');
|
||||
git('add', '.');
|
||||
git('commit', '-qm', 'feature work');
|
||||
const s = await gatherSignals(scratch);
|
||||
assert.equal(s.git.base, 'release');
|
||||
assert.deepEqual(s.git.changedFiles, ['src/Hero.tsx']);
|
||||
});
|
||||
|
||||
it('an existing develop outranks a main-pointing origin/HEAD (#302)', async () => {
|
||||
const { execFileSync } = await import('node:child_process');
|
||||
const git = (...args) => execFileSync('git', args, { cwd: scratch, stdio: 'ignore' });
|
||||
git('init', '-q', '-b', 'main');
|
||||
git('config', 'user.email', 't@example.com');
|
||||
git('config', 'user.name', 'Test');
|
||||
write('src/App.tsx', 'export default 1;\n');
|
||||
git('add', '.');
|
||||
git('commit', '-qm', 'init');
|
||||
git('branch', '-q', 'develop');
|
||||
// Classic git-flow with the platform default never flipped off main.
|
||||
git('update-ref', 'refs/remotes/origin/main', 'main');
|
||||
git('symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/main');
|
||||
git('checkout', '-q', 'develop');
|
||||
git('checkout', '-q', '-b', 'feature/g');
|
||||
write('src/Hero.tsx', 'export const Hero = () => null;\n');
|
||||
git('add', '.');
|
||||
git('commit', '-qm', 'feature work');
|
||||
const s = await gatherSignals(scratch);
|
||||
// Features merge to develop here; picking origin/HEAD's main would drag
|
||||
// the develop-vs-main divergence into scan targets.
|
||||
assert.equal(s.git.base, 'develop');
|
||||
assert.deepEqual(s.git.changedFiles, ['src/Hero.tsx']);
|
||||
});
|
||||
|
||||
it('remote signals cannot bypass the integration-branch guard (#302)', async () => {
|
||||
const { execFileSync } = await import('node:child_process');
|
||||
const git = (...args) => execFileSync('git', args, { cwd: scratch, stdio: 'ignore' });
|
||||
git('init', '-q', '-b', 'main');
|
||||
git('config', 'user.email', 't@example.com');
|
||||
git('config', 'user.name', 'Test');
|
||||
write('src/App.tsx', 'export default 1;\n');
|
||||
git('add', '.');
|
||||
git('commit', '-qm', 'init');
|
||||
git('branch', '-q', 'develop');
|
||||
git('checkout', '-q', 'develop');
|
||||
// The remote default is main; sitting on develop must still not produce
|
||||
// a develop-vs-main integration diff via the origin/HEAD signal.
|
||||
git('update-ref', 'refs/remotes/origin/main', 'main');
|
||||
git('symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/main');
|
||||
write('src/App.tsx', 'export default 2;\n'); // dirty on develop
|
||||
const s = await gatherSignals(scratch);
|
||||
assert.equal(s.git.base, null);
|
||||
assert.deepEqual(s.git.changedFiles, ['src/App.tsx']);
|
||||
});
|
||||
|
||||
it('honors a local (slashless) upstream branch (#302)', async () => {
|
||||
const { execFileSync } = await import('node:child_process');
|
||||
const git = (...args) => execFileSync('git', args, { cwd: scratch, stdio: 'ignore' });
|
||||
git('init', '-q', '-b', 'canary');
|
||||
git('config', 'user.email', 't@example.com');
|
||||
git('config', 'user.name', 'Test');
|
||||
write('src/App.tsx', 'export default 1;\n');
|
||||
git('add', '.');
|
||||
git('commit', '-qm', 'init');
|
||||
git('checkout', '-q', '-b', 'feature/u');
|
||||
git('branch', '-q', '--set-upstream-to=canary'); // local upstream, no remote
|
||||
write('src/Hero.tsx', 'export const Hero = () => null;\n');
|
||||
git('add', '.');
|
||||
git('commit', '-qm', 'feature work');
|
||||
const s = await gatherSignals(scratch);
|
||||
// canary is neither conventional nor remote, but the configured
|
||||
// upstream names it as the merge target.
|
||||
assert.equal(s.git.base, 'canary');
|
||||
assert.deepEqual(s.git.changedFiles, ['src/Hero.tsx']);
|
||||
});
|
||||
|
||||
it('sitting on a non-standard default branch keeps the working-tree scope (#302)', async () => {
|
||||
const { execFileSync } = await import('node:child_process');
|
||||
const git = (...args) => execFileSync('git', args, { cwd: scratch, stdio: 'ignore' });
|
||||
git('init', '-q', '-b', 'trunk');
|
||||
git('config', 'user.email', 't@example.com');
|
||||
git('config', 'user.name', 'Test');
|
||||
write('src/App.tsx', 'export default 1;\n');
|
||||
git('add', '.');
|
||||
git('commit', '-qm', 'init');
|
||||
git('branch', '-q', 'develop'); // a conventional name also exists
|
||||
git('update-ref', 'refs/remotes/origin/trunk', 'HEAD');
|
||||
git('symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/trunk');
|
||||
write('src/App.tsx', 'export default 2;\n'); // dirty on trunk
|
||||
const s = await gatherSignals(scratch);
|
||||
// trunk IS the integration branch (origin/HEAD says so); develop must
|
||||
// not win the candidate scan and produce a trunk-vs-develop diff.
|
||||
assert.equal(s.git.base, null);
|
||||
assert.deepEqual(s.git.changedFiles, ['src/App.tsx']);
|
||||
});
|
||||
|
||||
it('a detached HEAD keeps the working-tree scope (#302)', async () => {
|
||||
const { execFileSync } = await import('node:child_process');
|
||||
const git = (...args) => execFileSync('git', args, { cwd: scratch, stdio: 'ignore' });
|
||||
git('init', '-q', '-b', 'main');
|
||||
git('config', 'user.email', 't@example.com');
|
||||
git('config', 'user.name', 'Test');
|
||||
write('src/App.tsx', 'export default 1;\n');
|
||||
git('add', '.');
|
||||
git('commit', '-qm', 'init');
|
||||
git('branch', '-q', 'develop');
|
||||
git('checkout', '-q', '--detach');
|
||||
write('src/App.tsx', 'export default 2;\n'); // dirty on a detached tip
|
||||
const s = await gatherSignals(scratch);
|
||||
// A detached checkout has no branch identity to diff for; picking
|
||||
// develop here would refill changedFiles with integration divergence.
|
||||
assert.equal(s.git.base, null);
|
||||
assert.deepEqual(s.git.changedFiles, ['src/App.tsx']);
|
||||
});
|
||||
|
||||
it('the integration guard sees non-origin remote defaults (#302)', async () => {
|
||||
const { execFileSync } = await import('node:child_process');
|
||||
const git = (...args) => execFileSync('git', args, { cwd: scratch, stdio: 'ignore' });
|
||||
git('init', '-q', '-b', 'trunk');
|
||||
git('config', 'user.email', 't@example.com');
|
||||
git('config', 'user.name', 'Test');
|
||||
write('src/App.tsx', 'export default 1;\n');
|
||||
git('add', '.');
|
||||
git('commit', '-qm', 'init');
|
||||
git('branch', '-q', 'develop');
|
||||
// The only remote is upstream (fork-parent layout, no origin at all);
|
||||
// its default branch is trunk, which is exactly where we're sitting.
|
||||
git('remote', 'add', 'upstream', '.');
|
||||
git('update-ref', 'refs/remotes/upstream/trunk', 'HEAD');
|
||||
git('symbolic-ref', 'refs/remotes/upstream/HEAD', 'refs/remotes/upstream/trunk');
|
||||
write('src/App.tsx', 'export default 2;\n'); // dirty on trunk
|
||||
const s = await gatherSignals(scratch);
|
||||
assert.equal(s.git.base, null);
|
||||
assert.deepEqual(s.git.changedFiles, ['src/App.tsx']);
|
||||
});
|
||||
|
||||
it('finds a develop that exists only on a non-origin remote (#302)', async () => {
|
||||
const { execFileSync } = await import('node:child_process');
|
||||
const git = (...args) => execFileSync('git', args, { cwd: scratch, stdio: 'ignore' });
|
||||
git('init', '-q', '-b', 'feature/f');
|
||||
git('config', 'user.email', 't@example.com');
|
||||
git('config', 'user.name', 'Test');
|
||||
write('src/App.tsx', 'export default 1;\n');
|
||||
git('add', '.');
|
||||
git('commit', '-qm', 'init');
|
||||
// Fork-parent layout: develop lives only as upstream/develop, no local
|
||||
// copy, no origin remote, and the feature branch has no upstream.
|
||||
git('remote', 'add', 'upstream', '.');
|
||||
git('update-ref', 'refs/remotes/upstream/develop', 'HEAD');
|
||||
write('src/Hero.tsx', 'export const Hero = () => null;\n');
|
||||
git('add', '.');
|
||||
git('commit', '-qm', 'feature work');
|
||||
const s = await gatherSignals(scratch);
|
||||
assert.equal(s.git.base, 'develop');
|
||||
assert.deepEqual(s.git.changedFiles, ['src/Hero.tsx']);
|
||||
});
|
||||
|
||||
it('a same-name default on a second remote still resolves (#302)', async () => {
|
||||
const { execFileSync } = await import('node:child_process');
|
||||
const git = (...args) => execFileSync('git', args, { cwd: scratch, stdio: 'ignore' });
|
||||
git('init', '-q', '-b', 'feature/h');
|
||||
git('config', 'user.email', 't@example.com');
|
||||
git('config', 'user.name', 'Test');
|
||||
write('src/App.tsx', 'export default 1;\n');
|
||||
git('add', '.');
|
||||
git('commit', '-qm', 'init');
|
||||
git('remote', 'add', 'origin', '.');
|
||||
git('remote', 'add', 'upstream', '.');
|
||||
// origin advertises main but its tracking ref is gone (pruned); the
|
||||
// real main lives only as upstream/main. Name-level dedup must not
|
||||
// discard the upstream rev.
|
||||
git('symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/main');
|
||||
git('update-ref', 'refs/remotes/upstream/main', 'HEAD');
|
||||
git('symbolic-ref', 'refs/remotes/upstream/HEAD', 'refs/remotes/upstream/main');
|
||||
write('src/Hero.tsx', 'export const Hero = () => null;\n');
|
||||
git('add', '.');
|
||||
git('commit', '-qm', 'feature work');
|
||||
const s = await gatherSignals(scratch);
|
||||
assert.equal(s.git.base, 'main');
|
||||
assert.deepEqual(s.git.changedFiles, ['src/Hero.tsx']);
|
||||
});
|
||||
|
||||
it('a local upstream with a slash in its name is not misparsed (#302)', async () => {
|
||||
const { execFileSync } = await import('node:child_process');
|
||||
const git = (...args) => execFileSync('git', args, { cwd: scratch, stdio: 'ignore' });
|
||||
git('init', '-q', '-b', 'release/2.0');
|
||||
git('config', 'user.email', 't@example.com');
|
||||
git('config', 'user.name', 'Test');
|
||||
write('src/App.tsx', 'export default 1;\n');
|
||||
git('add', '.');
|
||||
git('commit', '-qm', 'init');
|
||||
git('checkout', '-q', '-b', 'hotfix/x');
|
||||
git('branch', '-q', '--set-upstream-to=release/2.0');
|
||||
write('src/Hero.tsx', 'export const Hero = () => null;\n');
|
||||
git('add', '.');
|
||||
git('commit', '-qm', 'hotfix work');
|
||||
const s = await gatherSignals(scratch);
|
||||
// "release" is not a remote here; the whole ref is the local base name.
|
||||
assert.equal(s.git.base, 'release/2.0');
|
||||
assert.deepEqual(s.git.changedFiles, ['src/Hero.tsx']);
|
||||
});
|
||||
|
||||
it('a local upstream sharing the branch leaf name is not self-skipped (#302)', async () => {
|
||||
const { execFileSync } = await import('node:child_process');
|
||||
const git = (...args) => execFileSync('git', args, { cwd: scratch, stdio: 'ignore' });
|
||||
git('init', '-q', '-b', 'feature/foo');
|
||||
git('config', 'user.email', 't@example.com');
|
||||
git('config', 'user.name', 'Test');
|
||||
write('src/App.tsx', 'export default 1;\n');
|
||||
git('add', '.');
|
||||
git('commit', '-qm', 'init');
|
||||
git('checkout', '-q', '-b', 'foo');
|
||||
git('branch', '-q', '--set-upstream-to=feature/foo');
|
||||
// Adversarial twist: a remote literally named "feature" exists, so any
|
||||
// prefix-based guess would still misread the LOCAL feature/foo upstream
|
||||
// as remote-tracking. Only the full symbolic ref disambiguates.
|
||||
git('remote', 'add', 'feature', '.');
|
||||
write('src/Hero.tsx', 'export const Hero = () => null;\n');
|
||||
git('add', '.');
|
||||
git('commit', '-qm', 'work');
|
||||
const s = await gatherSignals(scratch);
|
||||
// Truncating feature/foo to "foo" made it look like the current branch
|
||||
// and the valid upstream was discarded.
|
||||
assert.equal(s.git.base, 'feature/foo');
|
||||
assert.deepEqual(s.git.changedFiles, ['src/Hero.tsx']);
|
||||
});
|
||||
|
||||
it('a pruned upstream tracking ref falls back to other remotes (#302)', async () => {
|
||||
const { execFileSync } = await import('node:child_process');
|
||||
const git = (...args) => execFileSync('git', args, { cwd: scratch, stdio: 'ignore' });
|
||||
git('init', '-q', '-b', 'feature/p');
|
||||
git('config', 'user.email', 't@example.com');
|
||||
git('config', 'user.name', 'Test');
|
||||
write('src/App.tsx', 'export default 1;\n');
|
||||
git('add', '.');
|
||||
git('commit', '-qm', 'init');
|
||||
git('remote', 'add', 'origin', '.');
|
||||
git('remote', 'add', 'upstream', '.');
|
||||
// The branch tracks origin/main, but that tracking ref was pruned; the
|
||||
// live main exists only on the upstream remote.
|
||||
git('config', 'branch.feature/p.remote', 'origin');
|
||||
git('config', 'branch.feature/p.merge', 'refs/heads/main');
|
||||
git('update-ref', 'refs/remotes/upstream/main', 'HEAD');
|
||||
write('src/Hero.tsx', 'export const Hero = () => null;\n');
|
||||
git('add', '.');
|
||||
git('commit', '-qm', 'feature work');
|
||||
const s = await gatherSignals(scratch);
|
||||
assert.equal(s.git.base, 'main');
|
||||
assert.deepEqual(s.git.changedFiles, ['src/Hero.tsx']);
|
||||
});
|
||||
|
||||
it('a remote-advertised default outranks a stale local checkout (#302)', async () => {
|
||||
const { execFileSync } = await import('node:child_process');
|
||||
const git = (...args) => execFileSync('git', args, { cwd: scratch, stdio: 'ignore' });
|
||||
git('init', '-q', '-b', 'main');
|
||||
git('config', 'user.email', 't@example.com');
|
||||
git('config', 'user.name', 'Test');
|
||||
write('src/base.css', 'a{}\n');
|
||||
git('add', '.');
|
||||
git('commit', '-qm', 'A');
|
||||
write('src/extra.css', 'b{}\n');
|
||||
git('add', '.');
|
||||
git('commit', '-qm', 'A2');
|
||||
git('update-ref', 'refs/remotes/origin/main', 'HEAD');
|
||||
git('symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/main');
|
||||
git('checkout', '-q', '-b', 'feature/s');
|
||||
write('src/Hero.tsx', 'export const Hero = () => null;\n');
|
||||
git('add', '.');
|
||||
git('commit', '-qm', 'feature work');
|
||||
// The local main checkout is stale (still at A); the remote default is
|
||||
// at A2. Diffing against the stale local would drag src/extra.css in.
|
||||
git('branch', '-f', 'main', 'HEAD~2');
|
||||
const s = await gatherSignals(scratch);
|
||||
assert.equal(s.git.base, 'main');
|
||||
assert.deepEqual(s.git.changedFiles, ['src/Hero.tsx']);
|
||||
});
|
||||
|
||||
it('a develop-pointing remote default outranks a stale local develop (#302)', async () => {
|
||||
const { execFileSync } = await import('node:child_process');
|
||||
const git = (...args) => execFileSync('git', args, { cwd: scratch, stdio: 'ignore' });
|
||||
git('init', '-q', '-b', 'develop');
|
||||
git('config', 'user.email', 't@example.com');
|
||||
git('config', 'user.name', 'Test');
|
||||
write('src/base.css', 'a{}\n');
|
||||
git('add', '.');
|
||||
git('commit', '-qm', 'A');
|
||||
write('src/extra.css', 'b{}\n');
|
||||
git('add', '.');
|
||||
git('commit', '-qm', 'A2');
|
||||
git('update-ref', 'refs/remotes/origin/develop', 'HEAD');
|
||||
git('symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/develop');
|
||||
git('checkout', '-q', '-b', 'feature/t');
|
||||
write('src/Hero.tsx', 'export const Hero = () => null;\n');
|
||||
git('add', '.');
|
||||
git('commit', '-qm', 'feature work');
|
||||
git('branch', '-f', 'develop', 'HEAD~2'); // local develop is stale at A
|
||||
const s = await gatherSignals(scratch);
|
||||
assert.equal(s.git.base, 'develop');
|
||||
assert.deepEqual(s.git.changedFiles, ['src/Hero.tsx']);
|
||||
});
|
||||
|
||||
it('never diffs one integration branch against another (#302)', async () => {
|
||||
const { execFileSync } = await import('node:child_process');
|
||||
const git = (...args) => execFileSync('git', args, { cwd: scratch, stdio: 'ignore' });
|
||||
git('init', '-q', '-b', 'main');
|
||||
git('config', 'user.email', 't@example.com');
|
||||
git('config', 'user.name', 'Test');
|
||||
write('src/App.tsx', 'export default 1;\n');
|
||||
git('add', '.');
|
||||
git('commit', '-qm', 'init');
|
||||
git('branch', '-q', 'develop'); // both integration branches exist
|
||||
write('src/App.tsx', 'export default 2;\n'); // dirty on main
|
||||
const s = await gatherSignals(scratch);
|
||||
// Sitting on main must not pick develop as a base; the dirty working
|
||||
// tree is the scope, exactly as before this change.
|
||||
assert.equal(s.git.base, null);
|
||||
assert.deepEqual(s.git.changedFiles, ['src/App.tsx']);
|
||||
});
|
||||
|
||||
it('has empty scan.targets only when there is no code at all', async () => {
|
||||
const s = await gatherSignals(scratch);
|
||||
assert.deepEqual(s.scan.targets, []);
|
||||
|
||||
Reference in New Issue
Block a user