Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions src/main/git/read.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,10 @@ import {
getConflictSides,
getFileHistory,
getLog,
getMergeBase,
getMergePreview,
getMergeToolName,
getRangeFiles,
getRemoteWebUrl,
getUnpushedCommits,
parseBlamePorcelain,
Expand Down Expand Up @@ -322,6 +324,62 @@ describe('getCommitFiles', () => {
})
})

describe('getMergeBase', () => {
let branchRepo: string
let forkPoint: string
let mainTip: string

beforeAll(() => {
// A feature branch that merged its upstream back in — the update-merge a
// long-lived branch does to stay current. The fork point no longer tells
// the truth about "what the branch changed"; the merge base does.
branchRepo = mkdtempSync(join(tmpdir(), 'gitgrove-mergebase-'))
git(['init', '-q', '-b', 'main'], branchRepo)
git(['config', 'commit.gpgsign', 'false'], branchRepo)
writeFileSync(join(branchRepo, 'base.txt'), 'base\n')
git(['add', '.'], branchRepo)
git(['commit', '-q', '-m', 'base'], branchRepo)
forkPoint = git(['rev-parse', 'HEAD'], branchRepo)
git(['checkout', '-q', '-b', 'feature'], branchRepo)
writeFileSync(join(branchRepo, 'feature.txt'), 'feature\n')
git(['add', '.'], branchRepo)
git(['commit', '-q', '-m', 'feature work'], branchRepo)
git(['checkout', '-q', 'main'], branchRepo)
writeFileSync(join(branchRepo, 'mainline.txt'), 'mainline\n')
git(['add', '.'], branchRepo)
git(['commit', '-q', '-m', 'mainline work'], branchRepo)
mainTip = git(['rev-parse', 'HEAD'], branchRepo)
git(['checkout', '-q', 'feature'], branchRepo)
git(['merge', '-q', '--no-edit', 'main'], branchRepo)
})

afterAll(() => {
rmSync(branchRepo, { recursive: true, force: true })
})

it('finds the last commit two branches agreed on', async () => {
expect(await getMergeBase(branchRepo, 'main', 'feature')).toBe(mainTip)
})

it("diffs only the branch's own work from the merge base", async () => {
// From the fork point the range over-counts: it includes the mainline
// work the branch merged back in…
const fromFork = await getRangeFiles(branchRepo, forkPoint, 'feature')
expect(fromFork.map((f) => f.path)).toEqual(['feature.txt', 'mainline.txt'])
// …from the merge base it is exactly the branch's own changes — what the
// branch's pull request shows.
const base = await getMergeBase(branchRepo, 'main', 'feature')
const own = await getRangeFiles(branchRepo, base, 'feature')
expect(own.map((f) => f.path)).toEqual(['feature.txt'])
})

it('returns null when the commits share no history', async () => {
git(['checkout', '-q', '--orphan', 'unrelated'], branchRepo)
git(['commit', '-q', '-m', 'unrelated root'], branchRepo)
expect(await getMergeBase(branchRepo, 'main', 'unrelated')).toBeNull()
})
})

describe('parseRawNumstat', () => {
it('marks gitlink (mode 160000) entries as submodules', () => {
const raw = [
Expand Down
15 changes: 15 additions & 0 deletions src/main/git/read/files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,21 @@ export async function getRangeFiles(
return parseRawNumstat(out)
}

/**
* The merge base of two commits — the newest commit reachable from both — or
* null when they share no history. The branch-changes view diffs a branch
* from merge-base(upstream, tip) rather than its fork point: after the branch
* merges its upstream back in, the fork-point diff would count everything the
* upstream did in between as the branch's own changes, while the merge base
* is the last point both sides agreed — so `mergeBase..tip` is exactly the
* branch's own work, matching what its pull request shows.
*/
export async function getMergeBase(repoPath: string, a: string, b: string): Promise<string | null> {
// Exit code 1 = no common ancestor (unrelated histories), not an error.
const out = await runGit(repoPath, ['merge-base', a, b], [1])
return out.trim() || null
}

export async function getCommitFiles(repoPath: string, hash: string): Promise<ChangedFile[]> {
// Diff against the first parent so merge commits report only what the merge
// introduced on top of the mainline rather than the union of every parent.
Expand Down
2 changes: 1 addition & 1 deletion src/main/git/read/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export {
runGit
} from './core'
export { getCommitDiff, getRangeDiff, getWorkingDiff } from './diff'
export { getCommitFiles, getRangeFiles, parseRawNumstat } from './files'
export { getCommitFiles, getMergeBase, getRangeFiles, parseRawNumstat } from './files'
export { getGraphLog } from './graph'
export { getCommitIndex, getFileHistory, getLog, getUnpushedCommits } from './log'
export {
Expand Down
4 changes: 4 additions & 0 deletions src/main/ipc/history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
getFileHistory,
getGraphLog,
getLog,
getMergeBase,
getPatchIds,
getRangeDiff,
getRangeFiles,
Expand Down Expand Up @@ -41,6 +42,9 @@ export function registerHistoryHandlers(): void {
ipcMain.handle(IPC.rangeFiles, (_e, repoPath: string, base: string | null, head: string) =>
getRangeFiles(repoPath, base, head)
)
ipcMain.handle(IPC.mergeBase, (_e, repoPath: string, a: string, b: string) =>
getMergeBase(repoPath, a, b)
)
ipcMain.handle(
IPC.rangeDiff,
(_e, repoPath: string, base: string | null, head: string, file: ChangedFile) =>
Expand Down
1 change: 1 addition & 0 deletions src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ const api: GitGroveApi = {
blame: (repoPath, path, ref) => ipcRenderer.invoke(IPC.blame, repoPath, path, ref),
commitFiles: (repoPath, hash) => ipcRenderer.invoke(IPC.commitFiles, repoPath, hash),
rangeFiles: (repoPath, base, head) => ipcRenderer.invoke(IPC.rangeFiles, repoPath, base, head),
mergeBase: (repoPath, a, b) => ipcRenderer.invoke(IPC.mergeBase, repoPath, a, b),
workingDiff: (repoPath, file: ChangedFile, area?: DiffArea) =>
ipcRenderer.invoke(IPC.workingDiff, repoPath, file, area),
commitDiff: (repoPath, hash, file: ChangedFile) =>
Expand Down
7 changes: 6 additions & 1 deletion src/renderer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1621,7 +1621,12 @@ export function App() {
onSelectBranch={(row) => {
resetDetail()
clearDiff()
openRange({ name: row.name, base: row.baseHash, head: row.tipHash })
openRange({
name: row.name,
base: row.baseHash,
head: row.tipHash,
upstream: row.upstreamHash
})
}}
commitMenuFor={commitMenuFor}
onCheckoutBranch={checkout}
Expand Down
48 changes: 48 additions & 0 deletions src/renderer/src/components/graph/layout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,54 @@ describe('layoutGraph', () => {
expect(rowNamed(layout, 'main').baseHash).toBeNull()
})

test('an unmerged branch takes its fork parent tip as upstream', () => {
// main: a ── b feature: f1 ── f2, forked at a, not merged back.
const layout = layoutGraph(
input([
commit('f2', ['f1'], 'feature'),
commit('b', ['a'], 'HEAD -> main'),
commit('f1', ['a']),
commit('a', [])
])
)
// The branch-changes view will diff from merge-base(b, f2), so an
// update-merge from main never counts as the branch's own changes.
expect(rowNamed(layout, 'feature').upstreamHash).toBe('b')
expect(rowNamed(layout, 'main').upstreamHash).toBeNull()
})

test('a merged branch takes its target just before the landing merge as upstream', () => {
// main: a ── b ── m (merges f2) feature: f1 ── f2
const layout = layoutGraph(
input([
commit('m', ['b', 'f2'], 'HEAD -> main'),
commit('f2', ['f1'], 'feature'),
commit('f1', ['a']),
commit('b', ['a']),
commit('a', [])
])
)
// Pre-merge main ('b'), the way a pull request keeps comparing after it
// lands — never 'm', whose diff against the tip would be empty.
expect(rowNamed(layout, 'feature').upstreamHash).toBe('b')
})

test('a sync merge pulling main into a child never becomes main upstream', () => {
// feature stays current by merging main's tip b (s = merge of b): b is a
// merge SOURCE, but main was not merged away — its upstream must not
// become the child branch.
const layout = layoutGraph(
input([
commit('s', ['f1', 'b'], 'feature'),
commit('b', ['a'], 'HEAD -> main'),
commit('f1', ['a']),
commit('a', [])
])
)
expect(rowNamed(layout, 'main').upstreamHash).toBeNull()
expect(rowNamed(layout, 'feature').upstreamHash).toBe('b')
})

test('mainline keeps color slot 0; other branches get stable non-zero slots', () => {
const build = () =>
layoutGraph(
Expand Down
43 changes: 37 additions & 6 deletions src/renderer/src/components/graph/layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,14 @@ export interface GraphRow {
* or null for a chain that starts at a root commit. Feeds the
* branch-changes view: everything in `base..tip` is what the branch did. */
baseHash: string | null
/** Tip of the branch this one grew from — or, when this branch's tip was
* already merged away, that branch as it stood just before the merge (the
* merge commit's first parent), the way a pull request keeps comparing
* after it lands. The branch-changes view diffs from
* merge-base(upstream, tip) instead of baseHash, so upstream work the
* branch merged back in doesn't count as its own changes. Null when the
* window shows no upstream (the mainline, root chains, empty branches). */
upstreamHash: string | null
/** True for a zero-commit branch: a ref pointing at another chain's commit
* (freshly created, nothing committed yet). Its lane is one reserved slot
* right of that anchor commit; tipHash and baseHash both name the anchor,
Expand Down Expand Up @@ -316,13 +324,20 @@ export function layoutGraph(input: GraphInput): GraphLayout {

// Every non-first parent: the commits merges pulled in. Drives hideMerged
// (a tip that is a merge source has been merged), structureOnly (merge
// sources are structure), unnamed-chain naming, and the merge lead-out each
// chain's packing interval reserves.
// sources are structure), unnamed-chain naming, the merge lead-out each
// chain's packing interval reserves, and the upstream a merged branch
// compares against. Children are listed newest-first (commits arrive in
// date order).
const mergeSources = collectMergeSourceHashes(commits)
const mergeChildOf = new Map<string, Commit>()
const mergeChildrenOf = new Map<string, Commit[]>()
for (const c of commits) {
for (const parent of c.parents.slice(1)) {
if (!mergeChildOf.has(parent)) mergeChildOf.set(parent, c)
let children = mergeChildrenOf.get(parent)
if (!children) {
children = []
mergeChildrenOf.set(parent, children)
}
children.push(c)
}
}

Expand Down Expand Up @@ -397,7 +412,7 @@ export function layoutGraph(input: GraphInput): GraphLayout {
if (!visibleBranches && !input.hideMerged) {
for (const c of commits) {
if (chainOf.has(c.hash)) continue
const mergeChild = mergeChildOf.get(c.hash)
const mergeChild = mergeChildrenOf.get(c.hash)?.[0]
const name = (mergeChild && branchNameFromMergeSubject(mergeChild.subject)) ?? c.shortHash
claim(c.hash, { name, kind: 'unnamed', tipHash: c.hash })
}
Expand Down Expand Up @@ -497,6 +512,21 @@ export function layoutGraph(input: GraphInput): GraphLayout {
const base = baseHashOf(id)
return base === null ? undefined : chainOf.get(base)
}
// The ref the branch-changes view resolves its diff base against (see
// GraphRow.upstreamHash). A tip merged into its fork parent compares to
// the parent just before that landing merge; everything else compares to
// the fork parent's tip. Only a merge ON the parent chain counts as
// landing — a sync merge that pulled this tip into a child branch must
// not turn the child into this branch's upstream.
const upstreamHashOf = (id: number): string | null => {
if (chains[id].empty) return null
const parent = parentChainOf(id)
if (parent === undefined) return null
const landing = mergeChildrenOf
.get(chains[id].tipHash)
?.find((child) => chainOf.get(child.hash) === parent)
return landing ? (landing.parents[0] ?? null) : chains[parent].tipHash
}
// Release lines stack directly under the mainline, newest version first —
// maintenance branches always live in the same rows, whatever else is on.
const releaseRank = new Map<number, number>()
Expand Down Expand Up @@ -536,7 +566,7 @@ export function layoutGraph(input: GraphInput): GraphLayout {
const forkColumn = columnOf.get(baseHashOf(id) ?? '')
// An empty chain's tipHash names another chain's commit — a merge of that
// commit is the OWNER's lead-out to reserve, not the empty lane's.
const mergeChild = chains[id].empty ? undefined : mergeChildOf.get(chains[id].tipHash)
const mergeChild = chains[id].empty ? undefined : mergeChildrenOf.get(chains[id].tipHash)?.[0]
const mergeColumn = mergeChild ? columnOf.get(mergeChild.hash) : undefined
packChains.push({
id,
Expand Down Expand Up @@ -564,6 +594,7 @@ export function layoutGraph(input: GraphInput): GraphLayout {
isHead: false,
tipHash: chain.tipHash,
baseHash: baseHashOf(id),
upstreamHash: upstreamHashOf(id),
color: id === mainChain ? 0 : colorForName(chain.name),
startColumn: span[id].start,
endColumn: span[id].end,
Expand Down
27 changes: 23 additions & 4 deletions src/renderer/src/components/graph/useBranchRange.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,13 @@ export interface BranchRange {
head: string
}

/** A branch to open the changes view for. `upstream` (GraphRow.upstreamHash)
* refines `base` to merge-base(upstream, head) before diffing, so work the
* branch merged in FROM its upstream doesn't count as its own changes. */
export interface BranchRangeRequest extends BranchRange {
upstream: string | null
}

interface Params {
getRepoPath: () => string | undefined
fail: (e: unknown) => void
Expand Down Expand Up @@ -51,21 +58,33 @@ export function useBranchRange({ getRepoPath, fail, loadRangeDiff, clearDiff }:

/** Open the branch-changes view for `next` (or close it with null). */
const openRange = useCallback(
async (next: BranchRange | null) => {
async (next: BranchRangeRequest | null) => {
const repoPath = getRepoPath()
const id = ++req.current
setRange(next)
setRange(next && { name: next.name, base: next.base, head: next.head })
setRangeFiles([])
setRangeSelPath(null)
if (!next || !repoPath) return
setRangeFilesLoading(true)
try {
const files = await window.gitgrove.rangeFiles(repoPath, next.base, next.head)
// The fork point over-counts once the branch merged its upstream back
// in — the merge base is the last commit both sides agreed on, so
// base..head is exactly the branch's own work (what its PR shows).
let base = next.base
if (next.upstream) {
const mergeBase = await window.gitgrove.mergeBase(repoPath, next.upstream, next.head)
if (id !== req.current) return
if (mergeBase && mergeBase !== base) {
base = mergeBase
setRange({ name: next.name, base, head: next.head })
}
}
const files = await window.gitgrove.rangeFiles(repoPath, base, next.head)
if (id !== req.current) return
setRangeFiles(files)
if (files.length > 0) {
setRangeSelPath(files[0].path)
loadRangeDiff(next.base, next.head, files[0])
loadRangeDiff(base, next.head, files[0])
} else {
clearDiff()
}
Expand Down
8 changes: 8 additions & 0 deletions src/shared/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ export const IPC = {
blame: 'repo:blame',
commitFiles: 'repo:commit:files',
rangeFiles: 'repo:range:files',
mergeBase: 'repo:merge-base',
workingDiff: 'repo:diff:working',
commitDiff: 'repo:diff:commit',
rangeDiff: 'repo:diff:range',
Expand Down Expand Up @@ -319,6 +320,13 @@ export interface GitGroveApi {
* branch starts at a root commit; the diff runs against the empty tree.
*/
rangeFiles(repoPath: string, base: string | null, head: string): Promise<ChangedFile[]>
/**
* The merge base of two commits — the newest commit reachable from both —
* or null when they share no history. The branch-changes view diffs from
* merge-base(upstream, tip) instead of the fork point, so a branch that
* merged its upstream back in doesn't count the upstream's files as its own.
*/
mergeBase(repoPath: string, a: string, b: string): Promise<string | null>
workingDiff(repoPath: string, file: ChangedFile, area?: DiffArea): Promise<DiffPayload>
commitDiff(repoPath: string, hash: string, file: ChangedFile): Promise<DiffPayload>
/** One file's diff across `base..head` — see rangeFiles. */
Expand Down
Loading