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
1 change: 1 addition & 0 deletions src/main/git/read/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,4 @@ export {
} from './merge'
export { getPatchIds } from './patch-ids'
export { getRemoteCloneUrl, getRemoteWebUrl, toWebUrl } from './remotes'
export { getSquashLandings } from './squash-landings'
38 changes: 31 additions & 7 deletions src/main/git/read/patch-ids.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
// Patch-id equivalence for the Graph's backport twins: two commits with the
// same patch-id carry the same change — a cherry-picked backport. Batch API:
// the renderer sends the window's commit hashes and gets hash → patch-id back.
// Patch-id equivalence for the Graph: two commits with the same patch-id carry
// the same change — a cherry-picked backport (the twins), or a branch that
// landed as one squash commit (squash-landings.ts, via getRangePatchIds).
// Batch API: the renderer sends the window's commit hashes and gets
// hash → patch-id back.
//
// Unlike the rest of the read side this can't go through runGit's execFile:
// the patches themselves can be huge, so `git diff-tree --stdin --root -p` is
Expand All @@ -14,11 +16,33 @@
import { spawn } from 'node:child_process'
import { locateGit } from '../bin'

export async function getPatchIds(
export function getPatchIds(repoPath: string, hashes: string[]): Promise<Record<string, string>> {
return streamPatchIds(repoPath, hashes)
}

/**
* Patch-id of each branch's WHOLE change, `base..tip` as one diff — what a
* squash merge carries — keyed by tip. diff-tree's stdin form reads
* "<commit> <parent>…" lines, so naming the base as the tip's only parent
* diffs exactly that range, headed by the tip hash patch-id reports. Both
* must be full hashes: abbreviated ones on a stdin line are silently skipped.
*/
export function getRangePatchIds(
repoPath: string,
ranges: readonly { tip: string; base: string }[]
): Promise<Record<string, string>> {
return streamPatchIds(
repoPath,
ranges.map((range) => `${range.tip} ${range.base}`)
)
}

/** Feed diff-tree's stdin lines through `patch-id --stable`: commit → id. */
async function streamPatchIds(
repoPath: string,
hashes: string[]
stdinLines: readonly string[]
): Promise<Record<string, string>> {
if (hashes.length === 0) return {}
if (stdinLines.length === 0) return {}
const bin = await locateGit()
return new Promise((resolve, reject) => {
const options = { cwd: repoPath, windowsHide: true }
Expand Down Expand Up @@ -74,7 +98,7 @@ export async function getPatchIds(
settle()
})

diff.stdin.write(`${hashes.join('\n')}\n`)
diff.stdin.write(`${stdinLines.join('\n')}\n`)
diff.stdin.end()
})
}
120 changes: 120 additions & 0 deletions src/main/git/read/squash-landings.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { afterAll, beforeAll, describe, expect, it } from 'bun:test'
import { execFileSync } from 'node:child_process'
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type { SquashCandidate } from '@shared/types'
import { getSquashLandings } from './squash-landings'

// Integration tests: drive the real `git` binary against a throwaway repo —
// the pull-request host's merge buttons replayed locally: a two-commit branch
// squash-merged, a branch rebase-merged, and a branch that never landed.

let repo: string
let configHome: string
let squashedTip: string
let squashCommit: string
let rebasedTip: string
let rebaseLanding: string
let openTip: string

function git(args: string[]): string {
return execFileSync('git', args, {
cwd: repo,
encoding: 'utf8',
env: {
...process.env,
GIT_AUTHOR_NAME: 'Test Author',
GIT_AUTHOR_EMAIL: 'author@example.com',
GIT_COMMITTER_NAME: 'Test Author',
GIT_COMMITTER_EMAIL: 'author@example.com'
}
}).trim()
}

function commitFile(name: string, content: string, message: string): string {
writeFileSync(join(repo, name), content)
git(['add', '.'])
git(['commit', '-q', '-m', message])
return git(['rev-parse', 'HEAD'])
}

const mainline = () => git(['rev-list', '--first-parent', 'main']).split('\n')
const candidate = (tip: string): SquashCandidate => ({
tip,
base: git(['merge-base', 'main', tip])
})

beforeAll(() => {
// Hermetic git: point global + system config at an empty file so the
// developer's machine config never leaks in (see read.test.ts).
configHome = mkdtempSync(join(tmpdir(), 'gitgrove-config-'))
const emptyConfig = join(configHome, 'gitconfig')
writeFileSync(emptyConfig, '')
process.env.GIT_CONFIG_GLOBAL = emptyConfig
process.env.GIT_CONFIG_SYSTEM = emptyConfig

repo = mkdtempSync(join(tmpdir(), 'gitgrove-squash-'))
git(['init', '-q', '-b', 'main'])
git(['config', 'commit.gpgsign', 'false'])
commitFile('a.txt', 'one\n', 'initial')

git(['checkout', '-q', '-b', 'feature'])
commitFile('f.txt', 'first\n', 'feature part 1')
squashedTip = commitFile('f.txt', 'first\nsecond\n', 'feature part 2')

git(['checkout', '-q', '-b', 'rebased', 'main'])
rebasedTip = commitFile('r.txt', 'rebased\n', 'rebased change')

git(['checkout', '-q', '-b', 'open', 'main'])
openTip = commitFile('o.txt', 'still open\n', 'open change')

// The mainline moves on before anything lands, then takes both branches.
git(['checkout', '-q', 'main'])
commitFile('a.txt', 'one\ntwo\n', 'unrelated mainline work')
git(['merge', '-q', '--squash', 'feature'])
git(['commit', '-q', '-m', 'Feature (#1)'])
squashCommit = git(['rev-parse', 'HEAD'])
git(['cherry-pick', rebasedTip])
rebaseLanding = git(['rev-parse', 'HEAD'])
})

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

describe('getSquashLandings', () => {
it('finds the squash commit carrying a whole multi-commit branch', async () => {
const landings = await getSquashLandings(repo, mainline(), [candidate(squashedTip)])
expect(landings).toEqual({ [squashedTip]: squashCommit })
})

it('finds a rebase merge by the replayed tip commit', async () => {
const landings = await getSquashLandings(repo, mainline(), [candidate(rebasedTip)])
expect(landings).toEqual({ [rebasedTip]: rebaseLanding })
})

it('leaves a branch that never landed out of the result', async () => {
const landings = await getSquashLandings(repo, mainline(), [
candidate(openTip),
candidate(squashedTip)
])
expect(openTip in landings).toBe(false)
expect(landings[squashedTip]).toBe(squashCommit)
})

it('ignores mainline commits older than the branch base', async () => {
// Only the base itself and older are searched here: nothing can match.
const base = git(['merge-base', 'main', squashedTip])
const landings = await getSquashLandings(repo, mainline().slice(mainline().indexOf(base)), [
{ tip: squashedTip, base }
])
expect(landings).toEqual({})
})

it('returns an empty record for an empty request', async () => {
expect(await getSquashLandings(repo, mainline(), [])).toEqual({})
expect(await getSquashLandings(repo, [], [candidate(squashedTip)])).toEqual({})
})
})
62 changes: 62 additions & 0 deletions src/main/git/read/squash-landings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
// Squash and rebase merges leave no merge commit behind: the branch's changes
// land on the default branch as NEW commits, so its tip never becomes an
// ancestor of it and the Graph would draw a finished pull request as a live,
// unmerged branch. This recovers the landing by content instead of ancestry:
// a branch landed when some mainline commit newer than its merge base carries
// the same patch-id as either
// - the branch's whole `base..tip` diff (a squash merge: one commit), or
// - the tip commit itself (a rebase merge: the tip is replayed last).
// patch-id --stable ignores line numbers and whitespace, so the match
// survives the mainline moving on underneath the branch; a squash that needed
// conflict resolution changes the patch and honestly stays unmatched.
//
// The renderer computes the candidates (tips off the mainline plus their
// merge bases — all within the loaded window, see graph/squash.ts), so this
// costs three streamed patch-id passes, never a spawn per branch.

import type { SquashCandidate } from '@shared/types'
import { getPatchIds, getRangePatchIds } from './patch-ids'

export async function getSquashLandings(
repoPath: string,
mainline: string[],
candidates: SquashCandidate[]
): Promise<Record<string, string>> {
if (mainline.length === 0 || candidates.length === 0) return {}
const positionOf = new Map(mainline.map((hash, i) => [hash, i]))
// Only commits newer than a candidate's base can have landed it; a base
// outside the list (off the first-parent chain) searches the whole list.
const baseIndexOf = (c: SquashCandidate) => positionOf.get(c.base) ?? mainline.length
const searched = mainline.slice(0, Math.max(...candidates.map(baseIndexOf)))
const [mainlineIds, rangeIds, tipIds] = await Promise.all([
getPatchIds(repoPath, searched),
getRangePatchIds(repoPath, candidates),
getPatchIds(
repoPath,
candidates.map((c) => c.tip)
)
])

// patch-id → mainline positions, oldest first: the FIRST landing of a
// change is the one that merged the branch (a later revert-and-reapply
// carries the same patch again).
const positionsById = new Map<string, number[]>()
for (let i = searched.length - 1; i >= 0; i--) {
const id = mainlineIds[searched[i]]
if (!id) continue
const positions = positionsById.get(id) ?? []
positions.push(i)
positionsById.set(id, positions)
}

const landings: Record<string, string> = {}
for (const candidate of candidates) {
const baseIndex = baseIndexOf(candidate)
const ids = [rangeIds[candidate.tip], tipIds[candidate.tip]]
const position = ids
.flatMap((id) => (id ? (positionsById.get(id) ?? []) : []))
.find((i) => i < baseIndex)
if (position !== undefined) landings[candidate.tip] = searched[position]
}
return landings
}
14 changes: 13 additions & 1 deletion src/main/ipc/history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,13 @@
// history, blame, a commit's changed files, and the working/commit diffs.

import { IPC } from '@shared/ipc'
import type { ChangedFile, DiffArea, GraphLogOptions, LogOptions } from '@shared/types'
import type {
ChangedFile,
DiffArea,
GraphLogOptions,
LogOptions,
SquashCandidate
} from '@shared/types'
import { ipcMain } from 'electron'
import {
getBlame,
Expand All @@ -16,6 +22,7 @@ import {
getPatchIds,
getRangeDiff,
getRangeFiles,
getSquashLandings,
getWorkingDiff
} from '../git/read'

Expand All @@ -27,6 +34,11 @@ export function registerHistoryHandlers(): void {
ipcMain.handle(IPC.graphPatchIds, (_e, repoPath: string, hashes: string[]) =>
getPatchIds(repoPath, hashes)
)
ipcMain.handle(
IPC.graphSquashLandings,
(_e, repoPath: string, mainline: string[], candidates: SquashCandidate[]) =>
getSquashLandings(repoPath, mainline, candidates)
)
ipcMain.handle(IPC.commitIndex, (_e, repoPath: string, hash: string) =>
getCommitIndex(repoPath, hash)
)
Expand Down
2 changes: 2 additions & 0 deletions src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ const api: GitGroveApi = {
graphLog: (repoPath, options?: GraphLogOptions) =>
ipcRenderer.invoke(IPC.graphLog, repoPath, options),
graphPatchIds: (repoPath, hashes) => ipcRenderer.invoke(IPC.graphPatchIds, repoPath, hashes),
graphSquashLandings: (repoPath, mainline, candidates) =>
ipcRenderer.invoke(IPC.graphSquashLandings, repoPath, mainline, candidates),
commitIndex: (repoPath, hash) => ipcRenderer.invoke(IPC.commitIndex, repoPath, hash),
fileHistory: (repoPath, path, ref) => ipcRenderer.invoke(IPC.fileHistory, repoPath, path, ref),
blame: (repoPath, path, ref) => ipcRenderer.invoke(IPC.blame, repoPath, path, ref),
Expand Down
39 changes: 29 additions & 10 deletions src/renderer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import { Toast } from './components/common/Toast'
import { TooltipLayer } from './components/common/TooltipLayer'
import { GraphDetailPane } from './components/graph/GraphDetailPane'
import { GraphView } from './components/graph/GraphView'
import type { GraphRow } from './components/graph/layout'
import { useBranchRange } from './components/graph/useBranchRange'
import { CommitSummary } from './components/history/CommitSummary'
import { commitMenuItems } from './components/history/commitMenuItems'
Expand Down Expand Up @@ -400,6 +401,28 @@ export function App() {
[branchRange]
)

/** Open a Graph branch's whole-branch changes view (label click, or the
* detail pane's "Squash of …" link). */
const openGraphBranch = useCallback(
(row: GraphRow) => {
resetDetail()
clearDiff()
openRange({
name: row.name,
base: row.baseHash,
head: row.tipHash,
upstream: row.upstreamHash
})
},
[resetDetail, clearDiff, openRange]
)

// Landing commit → the branches squashed into it (reported by GraphView):
// the Graph draws a squash like a merge, the detail pane names it.
const [squashedBranches, setSquashedBranches] = useState<ReadonlyMap<string, GraphRow[]>>(
() => new Map()
)

/** Select a commit, dismissing any open branch-changes view. */
const selectCommitOnly = useCallback(
(commit: Commit) => {
Expand Down Expand Up @@ -1565,6 +1588,10 @@ export function App() {
repoPath={repo.path}
commit={selectedCommit}
range={branchRange}
squashedBranches={
selectedCommit ? (squashedBranches.get(selectedCommit.hash) ?? []) : []
}
onSelectBranch={openGraphBranch}
files={branchRange ? rangeFiles : commitFiles}
filesLoading={branchRange ? rangeFilesLoading : commitFilesLoading}
selectedFilePath={branchRange ? rangeSelPath : commitSelPath}
Expand Down Expand Up @@ -1618,16 +1645,8 @@ export function App() {
}
}}
selectedBranch={selectedGraphBranch}
onSelectBranch={(row) => {
resetDetail()
clearDiff()
openRange({
name: row.name,
base: row.baseHash,
head: row.tipHash,
upstream: row.upstreamHash
})
}}
onSelectBranch={openGraphBranch}
onSquashedBranchesChange={setSquashedBranches}
commitMenuFor={commitMenuFor}
onCheckoutBranch={checkout}
onBranchAction={onBranchAction}
Expand Down
Loading
Loading