diff --git a/packages/git/src/diff.ts b/packages/git/src/diff.ts index 52177e1..da1fce6 100644 --- a/packages/git/src/diff.ts +++ b/packages/git/src/diff.ts @@ -1,4 +1,4 @@ -import { exec, execLarge, execLines, execWithStdin } from './exec.js'; +import { exec, execLarge, execLines, execWithStdin, git } from './exec.js'; /** * Flags that neutralize user git config which would otherwise alter the diff @@ -120,6 +120,48 @@ export function getMergeBase(a: string, b: string): string { return exec(`git merge-base ${a} ${b}`); } +function isLocalBranch(ref: string): boolean { + try { + git(['show-ref', '--verify', '--quiet', `refs/heads/${ref}`]); + return true; + } catch { + return false; + } +} + +function getUpstream(ref: string): string | null { + try { + return git(['rev-parse', '--abbrev-ref', '--symbolic-full-name', `${ref}@{upstream}`]) || null; + } catch { + return null; + } +} + +function isBehind(ref: string, other: string): boolean { + return Number(git(['rev-list', '--count', `${ref}..${other}`])) > 0; +} + +/** + * "What changed since master" means since the master everyone else has, not since the copy + * in this checkout — which is usually behind, and every commit it is missing would otherwise + * show up as part of the change set. + * + * Only bare local branch names are redirected: a range, a tag or a commit is taken as pinned + * on purpose. + */ +export function resolveThroughUpstream(ref: string): string { + if (!isLocalBranch(ref)) { + return ref; + } + + const upstream = getUpstream(ref); + if (!upstream) { + return ref; + } + + return isBehind(ref, upstream) ? upstream : ref; +} + export function normalizeRef(ref: string): string { if (ref.includes('...')) { return ref; @@ -131,7 +173,7 @@ export function normalizeRef(ref: string): string { const base = getMergeBase(left, right); return `${base}..${right}`; } - return getMergeBase(ref, 'HEAD'); + return getMergeBase(resolveThroughUpstream(ref), 'HEAD'); } export const WORKING_TREE_REFS = new Set(['work', '.', 'staged', 'unstaged']); @@ -155,7 +197,7 @@ export function resolveBaseRef(ref: string): string { return getMergeBase(left, right); } - return getMergeBase(ref, 'HEAD'); + return getMergeBase(resolveThroughUpstream(ref), 'HEAD'); } export function getFileContent(path: string, ref = 'HEAD'): string { diff --git a/packages/git/src/exec.ts b/packages/git/src/exec.ts index 68927ad..29109ef 100644 --- a/packages/git/src/exec.ts +++ b/packages/git/src/exec.ts @@ -50,6 +50,22 @@ export function execFileLarge(command: string, args: string[]): string { }).trim(); } +/** + * Runs git without a shell. Preferred over the string forms for anything whose arguments + * come from a ref, a path or a search term. + */ +export function git(args: string[]): string { + return execFileLarge('git', args); +} + +export function gitLines(args: string[]): string[] { + const output = git(args); + if (!output) { + return []; + } + return output.split('\n'); +} + export function execLines(cmd: string): string[] { const output = exec(cmd); if (!output) { diff --git a/packages/git/src/index.ts b/packages/git/src/index.ts index e88d4e7..040f157 100644 --- a/packages/git/src/index.ts +++ b/packages/git/src/index.ts @@ -1,7 +1,7 @@ export type { Commit, RepoInfo } from './types.js'; export type { RefCapabilities } from './repo.js'; export { isGitRepo, getRepoRoot, getRepoName, getCurrentBranch, getRepoInfo, getHeadHash, getDiffityDir, getDiffityDirPath, getRefCapabilities, isValidGitRef } from './repo.js'; -export { getDiff, getDiffFiles, getDiffStat, getDiffStatForRef, getUntrackedFiles, getUntrackedDiff, getFileContent, getFileLineCount, getMergeBase, normalizeRef, resolveBaseRef, resolveDiffArgs, resolveRef, revertFile, revertHunk, WORKING_TREE_REFS } from './diff.js'; +export { getDiff, getDiffFiles, getDiffStat, getDiffStatForRef, getUntrackedFiles, getUntrackedDiff, getFileContent, getFileLineCount, getMergeBase, normalizeRef, resolveBaseRef, resolveThroughUpstream, resolveDiffArgs, resolveRef, revertFile, revertHunk, WORKING_TREE_REFS } from './diff.js'; export type { RefDiffArgs } from './diff.js'; export { getStagedFiles, getUnstagedFiles, isDirty } from './status.js'; export { getRecentCommits } from './commits.js'; diff --git a/packages/git/tests/resolve-upstream.test.ts b/packages/git/tests/resolve-upstream.test.ts new file mode 100644 index 0000000..1f2c152 --- /dev/null +++ b/packages/git/tests/resolve-upstream.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +let root: string; +let repoDir: string; +let origCwd: string; + +function git(args: string[], cwd = repoDir): string { + return execFileSync('git', args, { cwd, encoding: 'utf-8', stdio: 'pipe' }).trim(); +} + +function commit(name: string, content: string): void { + writeFileSync(join(repoDir, name), content); + git(['add', '.']); + git(['commit', '-m', `add ${content}`]); +} + +beforeAll(() => { + origCwd = process.cwd(); + root = mkdtempSync(join(tmpdir(), 'diffity-upstream-')); + repoDir = join(root, 'repo'); + const remoteDir = join(root, 'remote.git'); + + execFileSync('git', ['init', '--bare', remoteDir], { stdio: 'pipe' }); + execFileSync('git', ['init', '-b', 'main', repoDir], { stdio: 'pipe' }); + git(['config', 'user.email', 'test@test.com']); + git(['config', 'user.name', 'Test']); + + commit('a.txt', 'a'); + git(['remote', 'add', 'origin', remoteDir]); + git(['push', '-u', 'origin', 'main']); + + // The remote moves on, and a feature branch is cut from that newer main... + commit('b.txt', 'b'); + git(['push', 'origin', 'main']); + git(['checkout', '-b', 'feature']); + commit('c.txt', 'c'); + + // ...while the local main is left where it was, two commits behind. + git(['checkout', 'main']); + git(['reset', '--hard', 'HEAD~1']); + git(['checkout', 'feature']); + + process.chdir(repoDir); +}); + +afterAll(() => { + process.chdir(origCwd); + rmSync(root, { recursive: true, force: true }); +}); + +describe('resolveThroughUpstream', () => { + it('redirects a local branch that is behind its upstream', async () => { + const { resolveThroughUpstream } = await import('../src/diff.js'); + + expect(resolveThroughUpstream('main')).toBe('origin/main'); + }); + + it('leaves a branch that is level with its upstream alone', async () => { + const { resolveThroughUpstream } = await import('../src/diff.js'); + git(['checkout', 'main']); + git(['pull', '--ff-only']); + git(['checkout', 'feature']); + + expect(resolveThroughUpstream('main')).toBe('main'); + }); + + it('leaves a branch with no upstream alone', async () => { + const { resolveThroughUpstream } = await import('../src/diff.js'); + + expect(resolveThroughUpstream('feature')).toBe('feature'); + }); + + it('leaves a commit alone', async () => { + const { resolveThroughUpstream } = await import('../src/diff.js'); + const sha = git(['rev-parse', 'HEAD']); + + expect(resolveThroughUpstream(sha)).toBe(sha); + }); +}); + +describe('normalizeRef', () => { + it('bases the diff on the upstream when the local branch is stale', async () => { + const { normalizeRef } = await import('../src/diff.js'); + git(['checkout', 'main']); + git(['reset', '--hard', 'HEAD~1']); + git(['checkout', 'feature']); + + const expected = git(['merge-base', 'origin/main', 'HEAD']); + + expect(normalizeRef('main')).toBe(expected); + expect(normalizeRef('main')).not.toBe(git(['merge-base', 'main', 'HEAD'])); + }); +});