SLOPSHOPPER

git-diff-timeline

Git Diff timeline above the prompt: click a commit to see it, click two to compare them, with their commit messages; a Branches tab compares two branches; a…

newpanebandguardcommandprompt
v0.4.0no licenseupdated 2026-10-03liawzishen/git-diff-timeline
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · git-diff-timeline
│ ┃ Git Diff ✕ › fix the failing auth test and add an audit log call │ ┃ Files changed ↻ │ ┃ ⏺ Read(src/auth.ts) │ ┃ Not a git repository: /work/app ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /gitdiff │ ⎿ git-diff-timeline: Git Diff pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Git Diff
Files changed ↻ Not a git repository: /work/app
README

git-diff-timeline

A Claude Code mod that puts a Git Diff timeline above the prompt.

  • History: a card of the branch's commits (first parent, so a merged pull request is one commit). Click a commit to see its changes; click a second commit to compare the two. The commit messages being compared show under the timeline, with merges named by their pull or merge request title.
  • Branches: compare two branches the way a pull request would (base...compare): commits ahead and behind, where they split, and what the compare branch adds.
  • Files changed: a side panel listing the compared commits and each changed file, with its diff.

It reads local git only, so it works with GitHub, GitLab or any other remote.

Install

Load the folder as a plugin directory, for every session, through the env block of ~/.claude/settings.json:

{
  "env": {
    "CLAUDE_CODE_PLUGIN_DIRS": "/path/to/git-diff-timeline",
    "CLAUDE_CODE_PLUGIN_DIR_WATCH": "1"
  }
}

Or for one session: claude --plugin-dir /path/to/git-diff-timeline.

/gitdiff opens the files view. The position option (band or pane) chooses between the strip above the prompt and a side pane.

Test

claude plugin test .
claude plugin validate .
Source 6 files
hooks/register.tsx 848 lines
1import { atom, read, update } from 'claude-code'
2import type { ElementTable, EngineInterface, Register, RenderElement, Timer } from 'claude-code'
3
4import type { GitDiffCommit, GitDiffFile, GitDiffPatch, GitDiffStat, GitDiffTimeline } from '../types'
5import { branchCard, historyCard } from './card'
6import type { CardNode } from './card'
7import { EMPTY_TREE, LOG_LIMIT, loadBranchCompare, loadPatch, loadStat, loadTimeline } from './git'
8import type { Loaded, Run } from './git'
9import { ARROW_W, SLOT_W, dayLabels, formatTime, resolveStart, shortDate, stepStart, visibleCount } from './layout'
10import {
11  INITIAL,
12  WORKING,
13  buttonLabel,
14  clickCommit,
15  commitTitle,
16  describeStat,
17  diffSpec,
18  merge,
19  nodeIds,
20  rangeCommits,
21  refOf,
22  selectionKey,
23} from './model'
24
25type Kit = Pick<ElementTable<'terminal'>, 'Box' | 'Text' | 'Button' | 'Code'> & {
26  /** Absent on the phone. */
27  Select: ElementTable<'terminal'>['Select'] | null
28  /** The desktop's; the terminal draws the timeline in text. */
29  Svg: ElementTable<'desktop'>['Svg'] | null
30}
31
32type Actions = {
33  refresh: () => void
34  details: () => void
35  mode: (mode: GitDiffTimeline['mode']) => void
36  pickCommit: (index: number) => void
37  pickBranch: (side: 'base' | 'compare', name: string) => void
38  swap: () => void
39  step: (delta: number, count: number) => void
40  toggleFile: (path: string) => void
41}
42
43const PANE = 'git-diff'
44const PANE_ARGS = { id: PANE, title: 'Git Diff', rows: 24, columns: 96 } as const
45
46/** Tools whose calls can change what git sees. */
47const WATCHED = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'Bash'])
48const REFRESH_DELAY_MS = 1200
49/** Files the files view lists, and diffs it holds open at once. */
50const MAX_FILES = 100
51const MAX_OPEN = 4
52/** Commits the files view lists above the files. */
53const MAX_COMMITS = 20
54const BLOCKS = 5
55
56const GRAY = '#9aa3b2'
57const GREEN = '#2f9e5b'
58const RED = '#d9534f'
59const ACCENT = '#ff4d2e'
60
61const timeline = atom({ plugin: 'git-diff-timeline', key: 'timeline' } as const, INITIAL)
62
63/** `band`: the strip above the prompt. `pane`: the side pane, opened on its own. Set in register. */
64let position: 'band' | 'pane' = 'band'
65
66const errorText = (error: unknown) => (error instanceof Error ? error.message : String(error))
67
68const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`
69
70const kitOf = (ui: ElementTable): Kit => ({
71  Box: ui.Box,
72  Text: ui.Text,
73  Button: ui.Button,
74  Code: ui.Code,
75  Select: 'Select' in ui ? ui.Select : null,
76  Svg: 'Svg' in ui ? ui.Svg : null,
77})
78
79const runnerFor = async ($: EngineInterface): Promise<Run> => {
80  const cwd = await $.session.cwd()
81
82  return argv => $.process.run(argv, { cwd, timeoutMs: 15_000 })
83}
84
85/** The files open after a fresh read: the first file for a new pick, else those still changed. */
86const openAfter = (cur: GitDiffTimeline, key: string, stat: GitDiffStat | null): string[] => {
87  if (cur.statKey !== key) {
88    const first = stat?.files[0]
89
90    return first === undefined ? [] : [first.path]
91  }
92
93  const paths = new Set(stat?.files.map(file => file.path) ?? [])
94
95  return cur.open.filter(path => paths.has(path))
96}
97
98/** Reads the diff of each open file still unread (`force`: every open file). */
99const loadPatches = async ($: EngineInterface, force: boolean) => {
100  const t = await read($, timeline)
101
102  if (t.status !== 'ready' || t.stat === null) {
103    return
104  }
105
106  const key = t.statKey
107  const wanted = t.open.filter(path => force || !t.patches.some(patch => patch.path === path))
108
109  if (wanted.length === 0) {
110    return
111  }
112
113  const run = await runnerFor($)
114  const spec = diffSpec(t)
115  const loaded = await Promise.all(wanted.map(path => loadPatch(run, spec, path)))
116
117  await update($, timeline, cur =>
118    cur.statKey !== key
119      ? cur
120      : {
121          ...cur,
122          patches: [...cur.patches.filter(patch => !wanted.includes(patch.path)), ...loaded].filter(patch =>
123            cur.open.includes(patch.path),
124          ),
125        },
126  )
127}
128
129/** Reads what the pick changed, unless it is on screen already (`force` rereads), then its open files. */
130const loadSelection = async ($: EngineInterface, force: boolean) => {
131  const t = await read($, timeline)
132
133  if (t.status !== 'ready') {
134    return
135  }
136
137  const key = selectionKey(t)
138
139  if (!force && t.statKey === key && t.stat !== null) {
140    await loadPatches($, false)
141
142    return
143  }
144
145  const run = await runnerFor($)
146  let stat: GitDiffStat | null = null
147  let statError = ''
148  let branchCompare = t.branchCompare
149
150  try {
151    const [loadedStat, compared] = await Promise.all([
152      loadStat(run, diffSpec(t)),
153      t.mode === 'branches' ? loadBranchCompare(run, t.base, t.compare) : Promise.resolve(null),
154    ])
155
156    stat = loadedStat
157    branchCompare = compared ?? branchCompare
158  } catch (error) {
159    statError = errorText(error)
160  }
161
162  await update($, timeline, cur =>
163    selectionKey(cur) !== key
164      ? cur
165      : {
166          ...cur,
167          stat,
168          statKey: key,
169          statError,
170          branchCompare: cur.mode === 'branches' ? branchCompare : cur.branchCompare,
171          open: openAfter(cur, key, stat),
172          patches: cur.statKey === key ? cur.patches : [],
173        },
174  )
175  await loadPatches($, force)
176}
177
178const refresh = async ($: EngineInterface) => {
179  const cwd = await $.session.cwd()
180  const run = await runnerFor($)
181  const failed = (error: unknown): Loaded => ({
182    status: 'error',
183    message: errorText(error),
184    branch: '',
185    commits: [],
186    hasWorking: false,
187    untracked: 0,
188    baseOfOldest: '',
189    branches: [],
190    defaultBase: '',
191  })
192  const loaded = await loadTimeline(run).catch(failed)
193
194  await update($, timeline, prev => merge(prev, loaded, cwd))
195  await loadSelection($, true)
196}
197
198let pending: Timer | undefined
199
200/** Whether the pane has been seated this session; a pane opened unasked can wait undrawn. */
201let wasPlaced = false
202let hasOffered = false
203
204const openDetails = async ($: EngineInterface) => {
205  try {
206    wasPlaced = (await $.ui.open(PANE_ARGS)).isPlaced
207  } catch {
208    // /gitdiff says why when the pane cannot open.
209  }
210}
211
212/** Shows what the person picked: the files view opened by their press, then a fresh read. */
213const show = async ($: EngineInterface, change: (t: GitDiffTimeline) => GitDiffTimeline) => {
214  await openDetails($)
215  await update($, timeline, t => {
216    const next = change(t)
217
218    return selectionKey(next) === selectionKey(t)
219      ? next
220      : { ...next, stat: null, statKey: '', statError: '', open: [], patches: [] }
221  })
222  await loadSelection($, false)
223}
224
225const pickCommit = ($: EngineInterface, index: number) =>
226  show($, t => ({ ...t, ...clickCommit(t, index), isPinned: true }))
227
228const pickBranch = ($: EngineInterface, side: 'base' | 'compare', name: string) =>
229  show($, t => (side === 'base' ? { ...t, base: name } : { ...t, compare: name }))
230
231const toggleFile = async ($: EngineInterface, path: string) => {
232  await update($, timeline, t => {
233    if (t.open.includes(path)) {
234      return { ...t, open: t.open.filter(p => p !== path), patches: t.patches.filter(patch => patch.path !== path) }
235    }
236
237    const open = [...t.open, path].slice(-MAX_OPEN)
238
239    return { ...t, open, patches: t.patches.filter(patch => open.includes(patch.path)) }
240  })
241  await loadPatches($, false)
242}
243
244const step = ($: EngineInterface, delta: number, count: number) =>
245  update($, timeline, t => ({ ...t, start: stepStart(t.start, delta, count, nodeIds(t).length) }))
246
247const actionsFor = ($: EngineInterface): Actions => ({
248  refresh: () => void refresh($).catch(() => undefined),
249  details: () => void openDetails($),
250  mode: mode => void show($, t => ({ ...t, mode })),
251  pickCommit: index => void pickCommit($, index),
252  pickBranch: (side, name) => void pickBranch($, side, name),
253  swap: () => void show($, t => ({ ...t, base: t.compare, compare: t.base })),
254  step: (delta, count) => void step($, delta, count),
255  toggleFile: path => void toggleFile($, path),
256})
257
258/** A node in words: its pull request or message, `Uncommitted changes`, or what came before. */
259const nodeName = (t: GitDiffTimeline, index: number) => {
260  if (index < 0) {
261    const oldest = t.commits[0]
262
263    return t.baseOfOldest === EMPTY_TREE.sha1 || t.baseOfOldest === EMPTY_TREE.sha256 || oldest === undefined
264      ? 'nothing (before the first commit)'
265      : `before ${commitTitle(oldest)}`
266  }
267
268  const commit = t.commits[index]
269
270  return commit === undefined ? 'Uncommitted changes' : commitTitle(commit)
271}
272
273const when = (seconds: number) => `${shortDate(seconds)}, ${formatTime(seconds)}`
274
275/** What a click does next: wait for the second commit, or start a comparison. */
276const clickHint = (t: GitDiffTimeline) => {
277  const waiting = nodeIds(t).indexOf(t.anchor)
278
279  return waiting < 0
280    ? 'Click a commit to see its changes · click two to compare them'
281    : `Click another commit to compare it with ${nodeName(t, waiting)}`
282}
283
284const rangeTitle = (t: GitDiffTimeline) =>
285  t.mode === 'branches' ? `${t.base} ← ${t.compare}` : `${nodeName(t, t.from)} → ${nodeName(t, t.to)}`
286
287const roleOf = (t: GitDiffTimeline, index: number): CardNode['role'] =>
288  index === t.to ? 'compare' : index === t.from ? 'base' : index > t.from && index < t.to ? 'between' : 'outside'
289
290/** The window of commits the strip shows: where it starts and how many. */
291const windowOf = (t: GitDiffTimeline, columns: number) => {
292  const total = nodeIds(t).length
293  const count = visibleCount(columns, total)
294  const start = resolveStart(t.start, count, total)
295
296  return { total, count, start, indexes: Array.from({ length: Math.min(count, total) }, (_, i) => start + i) }
297}
298
299const branchOptions = (t: GitDiffTimeline) =>
300  t.branches.map(branch => ({
301    value: branch.name,
302    label: branch.name === t.branch ? `${branch.name} (current)` : branch.name,
303  }))
304
305/** The strip's first row: History or Branches, how to pick, the files view and a refresh. */
306const controls = ({ Box, Text, Button, Select }: Kit, t: GitDiffTimeline, on: Actions): RenderElement => {
307  const isHistory = t.mode === 'history'
308  const pickers = isHistory ? (
309    <Text dimColor>{clickHint(t)}</Text>
310  ) : Select === null ? null : t.branches.length === 0 ? (
311      <Text dimColor>No branches to compare.</Text>
312    ) : (
313      <Box flexDirection="row" columnGap={1} alignItems="center" flexWrap="wrap">
314        <Select
315          key="branch-base"
316          label="Base"
317          options={branchOptions(t)}
318          value={t.base}
319          onSelect={name => on.pickBranch('base', name)}
320        />
321        <Text dimColor>←</Text>
322        <Select
323          key="branch-compare"
324          label="Compare"
325          options={branchOptions(t)}
326          value={t.compare}
327          onSelect={name => on.pickBranch('compare', name)}
328        />
329        <Button key="swap" plain label="⇄" onPress={on.swap} />
330      </Box>
331    )
332
333  return (
334    <Box flexDirection="row" flexWrap="wrap" columnGap={1} alignItems="center" justifyContent="space-between">
335      <Box flexDirection="row" columnGap={1} alignItems="center" flexWrap="wrap">
336        <Button
337          key="mode:history"
338          label="History"
339          {...(isHistory ? { variant: 'primary' as const } : {})}
340          onPress={() => on.mode('history')}
341        />
342        <Button
343          key="mode:branches"
344          label="Branches"
345          {...(isHistory ? {} : { variant: 'primary' as const })}
346          onPress={() => on.mode('branches')}
347        />
348        {pickers}
349      </Box>
350      <Box flexDirection="row" columnGap={1}>
351        <Button key="details" label="Files changed" onPress={on.details} />
352        <Button key="refresh" plain label="↻" onPress={on.refresh} />
353      </Box>
354    </Box>
355  )
356}
357
358/** The totals as text: the terminal's strip, and the files view's first line. */
359const totals = ({ Box, Text }: Kit, t: GitDiffTimeline): RenderElement => {
360  const stat = describeStat(t.stat)
361
362  return (
363    <Box flexDirection="row" columnGap={1}>
364      {t.statError !== '' && <Text color={RED}>{t.statError}</Text>}
365      {t.statError === '' && stat === null && <Text dimColor>Reading the diff…</Text>}
366      {stat !== null && <Text>{stat.files}</Text>}
367      {stat !== null && <Text color={GREEN}>{stat.added}</Text>}
368      {stat !== null && <Text color={RED}>{stat.deleted}</Text>}
369    </Box>
370  )
371}
372
373const dotColor = (role: CardNode['role']) =>
374  role === 'base' || role === 'compare' ? { color: ACCENT } : role === 'between' ? {} : { color: GRAY }
375
376/** History: the card (a line of dots in text on the terminal), and under it a button per commit. */
377const historyStrip = (kit: Kit, t: GitDiffTimeline, columns: number, on: Actions): RenderElement => {
378  const { Box, Text, Button, Svg } = kit
379  const { total, count, start, indexes } = windowOf(t, columns)
380  const labels = dayLabels(indexes.map(i => t.commits[i]?.time ?? null))
381  const nodes: CardNode[] = indexes.map((i, k) => {
382    const commit = t.commits[i]
383
384    return commit === undefined
385      ? { label: 'Now', churn: 0, role: roleOf(t, i), isWorking: true, tip: 'Uncommitted changes' }
386      : {
387          label: labels[k] ?? '',
388          churn: commit.added + commit.deleted,
389          role: roleOf(t, i),
390          isWorking: false,
391          tip: `${commit.short} · ${commitTitle(commit)} · +${commit.added} −${commit.deleted}`,
392        }
393  })
394  const title = `${t.branch} · ${plural(t.commits.length, 'commit')}${t.commits.length >= LOG_LIMIT ? ' shown' : ''}`
395  const picture =
396    Svg === null ? (
397      <Box flexDirection="column">
398        {totals(kit, t)}
399        <Box flexDirection="row">
400          <Box width={ARROW_W} />
401          {nodes.map(node => (
402            <Box width={SLOT_W} justifyContent="center">
403              <Text {...dotColor(node.role)} bold={node.role === 'compare'}>
404                {node.isWorking ? '○' : node.role === 'compare' ? '◉' : '●'}
405              </Text>
406            </Box>
407          ))}
408        </Box>
409      </Box>
410    ) : (
411      <Svg
412        source={historyCard({
413          columns,
414          nodes,
415          title,
416          stats: describeStat(t.stat),
417          older: start,
418          newer: total - start - indexes.length,
419        })}
420        alt={`${title}: ${rangeTitle(t)}`}
421      />
422    )
423
424  return (
425    <Box flexDirection="column">
426      {picture}
427      <Box flexDirection="row">
428        <Box width={ARROW_W}>
429          <Button key="prev" plain dimColor={start === 0} label="‹" onPress={() => on.step(-1, count)} />
430        </Box>
431        {indexes.map(i => {
432          const commit = t.commits[i]
433          const role = roleOf(t, i)
434          const look =
435            role === 'compare'
436              ? { variant: 'primary' as const }
437              : role === 'base'
438                ? { variant: 'secondary' as const }
439                : { plain: true as const, dimColor: role === 'outside' }
440
441          return (
442            <Box width={SLOT_W} justifyContent="center">
443              <Button
444                key={`node:${i}`}
445                label={commit === undefined ? 'Now' : buttonLabel(commit)}
446                {...look}
447                onPress={() => on.pickCommit(i)}
448              />
449            </Box>
450          )
451        })}
452        <Box width={ARROW_W} justifyContent="flex-end">
453          <Button key="next" plain dimColor={start + count >= total} label="›" onPress={() => on.step(1, count)} />
454        </Box>
455      </Box>
456      {messageRow(kit, t, t.from, '○')}
457      {messageRow(kit, t, t.to, '●')}
458    </Box>
459  )
460}
461
462/** One end of the comparison: its mark, short sha, message and when. */
463const messageRow = ({ Box, Text }: Kit, t: GitDiffTimeline, index: number, mark: string): RenderElement => {
464  const commit = t.commits[index]
465  const short = index < 0 ? t.baseOfOldest.slice(0, 7) : (commit?.short ?? '')
466
467  return (
468    <Box flexDirection="row" columnGap={1}>
469      <Text color={ACCENT}>{mark}</Text>
470      {short !== '' && <Text dimColor>{short}</Text>}
471      <Box flexShrink={1}>
472        <Text wrap="truncate-end">{nodeName(t, index)}</Text>
473      </Box>
474      {commit !== undefined && <Text dimColor>{when(commit.time)}</Text>}
475    </Box>
476  )
477}
478
479/** Branches: the fork card, or a line of words on the terminal. */
480const branchStrip = (kit: Kit, t: GitDiffTimeline, columns: number): RenderElement => {
481  const { Box, Text, Svg } = kit
482  const compared = t.branchCompare
483  const ahead = compared?.ahead ?? 0
484  const behind = compared?.behind ?? 0
485  const words = `${t.compare} is ${plural(ahead, 'commit')} ahead, ${behind} behind ${t.base}`
486
487  if (Svg === null) {
488    return (
489      <Box flexDirection="column">
490        <Text>{words}</Text>
491        {totals(kit, t)}
492      </Box>
493    )
494  }
495
496  const mergeBase = compared?.mergeBase ?? null
497
498  return (
499    <Svg
500      source={branchCard({
501        columns,
502        base: t.base,
503        compare: t.compare,
504        ahead,
505        behind,
506        aheadTips: compared?.aheadCommits.map(commitTitle) ?? [],
507        behindTips: compared?.behindCommits.map(commitTitle) ?? [],
508        mergeBase: mergeBase === null ? '' : `merge base ${mergeBase.short} · ${shortDate(mergeBase.time)}`,
509        stats: describeStat(t.stat),
510      })}
511      alt={words}
512    />
513  )
514}
515
516const strip = (kit: Kit, t: GitDiffTimeline, columns: number, on: Actions): RenderElement => {
517  const { Box } = kit
518
519  return (
520    <Box flexDirection="column">
521      {controls(kit, t, on)}
522      {t.mode === 'history' ? historyStrip(kit, t, columns, on) : branchStrip(kit, t, columns)}
523    </Box>
524  )
525}
526
527/** GitHub's five squares: a file's share of lines added and deleted. */
528const blocks = (file: GitDiffFile) => {
529  const total = (file.added ?? 0) + (file.deleted ?? 0)
530
531  if (file.added === null || total === 0) {
532    return { added: 0, deleted: 0 }
533  }
534
535  const added = Math.round((file.added / total) * BLOCKS)
536
537  return { added, deleted: BLOCKS - added }
538}
539
540const patchView = ({ Box, Text, Code }: Kit, path: string, patch: GitDiffPatch | undefined): RenderElement => {
541  if (patch === undefined) {
542    return <Text dimColor>Loading the diff…</Text>
543  }
544
545  if (patch.note === 'binary') {
546    return <Text dimColor>Binary file not shown.</Text>
547  }
548
549  if (patch.note === 'empty') {
550    return <Text dimColor>No line changes: the file's mode or name changed.</Text>
551  }
552
553  if (patch.note !== '') {
554    return <Text color={RED}>{patch.note}</Text>
555  }
556
557  return (
558    <Box flexDirection="column">
559      <Code source={patch.hunks} format="diff" path={path} />
560      {patch.isCut && <Text dimColor>Diff cut to fit here; open the file to see the rest.</Text>}
561    </Box>
562  )
563}
564
565const fileRow = (kit: Kit, t: GitDiffTimeline, file: GitDiffFile, on: Actions): RenderElement => {
566  const { Box, Text, Button } = kit
567  const isOpen = t.open.includes(file.path)
568  const share = blocks(file)
569  const rest = BLOCKS - share.added - share.deleted
570
571  return (
572    <Box flexDirection="column">
573      <Box flexDirection="row" columnGap={1}>
574        <Button
575          key={`file:${file.path}`}
576          plain
577          label={`${isOpen ? '▾' : '▸'} ${file.path}`}
578          onPress={() => on.toggleFile(file.path)}
579        />
580        <Text color={GREEN}>{file.added === null ? 'binary' : `+${file.added}`}</Text>
581        {file.deleted !== null && <Text color={RED}>{`−${file.deleted}`}</Text>}
582        <Box flexDirection="row">
583          {share.added > 0 && <Text color={GREEN}>{'■'.repeat(share.added)}</Text>}
584          {share.deleted > 0 && <Text color={RED}>{'■'.repeat(share.deleted)}</Text>}
585          {rest > 0 && <Text dimColor>{'■'.repeat(rest)}</Text>}
586        </Box>
587      </Box>
588      {isOpen && patchView(kit, file.path, t.patches.find(patch => patch.path === file.path))}
589    </Box>
590  )
591}
592
593const notice = ({ Text }: Kit, t: GitDiffTimeline): RenderElement => {
594  switch (t.status) {
595    case 'loading':
596      return <Text dimColor>Reading git history…</Text>
597    case 'no-repo':
598      return <Text dimColor>{`Not a git repository: ${t.cwd}`}</Text>
599    case 'error':
600      return <Text color={RED}>{`git failed: ${t.message}`}</Text>
601    default:
602      return <Text dimColor>{t.message}</Text>
603  }
604}
605
606/** One commit of a comparison: short sha, message and when, and the body's first line under it. */
607const commitRow = ({ Box, Text }: Kit, commit: GitDiffCommit): RenderElement => {
608  const title = commitTitle(commit)
609  const detail = commit.body
610    .split('\n')
611    .map(line => line.trim())
612    .find(line => line !== '' && !title.includes(line) && !line.startsWith('See merge request'))
613
614  return (
615    <Box flexDirection="column">
616      <Box flexDirection="row" columnGap={1}>
617        <Text dimColor>{commit.short}</Text>
618        <Box flexShrink={1}>
619          <Text wrap="truncate-end">{title}</Text>
620        </Box>
621        <Text dimColor>{when(commit.time)}</Text>
622      </Box>
623      {detail !== undefined && (
624        <Box paddingLeft={8}>
625          <Text dimColor wrap="truncate-end">
626            {detail}
627          </Text>
628        </Box>
629      )}
630    </Box>
631  )
632}
633
634/** The commits the comparison takes in, newest first, as GitHub's compare page lists them. */
635const commitList = (kit: Kit, t: GitDiffTimeline): RenderElement | null => {
636  const { Box, Text } = kit
637  const commits =
638    t.mode === 'history' ? rangeCommits(t) : [...(t.branchCompare?.aheadCommits ?? [])].reverse()
639  const total = t.mode === 'history' ? commits.length : (t.branchCompare?.ahead ?? commits.length)
640
641  if (commits.length === 0) {
642    return null
643  }
644
645  return (
646    <Box flexDirection="column">
647      <Text bold>{t.mode === 'history' ? plural(total, 'commit') : `${plural(total, 'commit')} on ${t.compare}`}</Text>
648      {commits.slice(0, MAX_COMMITS).map(commit => commitRow(kit, commit))}
649      {total > MAX_COMMITS && <Text dimColor>{`… ${total - MAX_COMMITS} older not listed`}</Text>}
650    </Box>
651  )
652}
653
654/** The files view: the commits compared, then what changed, file by file, as GitHub shows it. */
655const filesView = (kit: Kit, t: GitDiffTimeline, on: Actions): RenderElement => {
656  const { Box, Text } = kit
657  const files = t.stat?.files ?? []
658  const more = files.length - MAX_FILES
659  const compared = t.branchCompare
660
661  return (
662    <Box flexDirection="column" rowGap={1}>
663      {t.mode === 'branches' && compared !== null && (
664        <Text dimColor>
665          {`${t.compare} is ${plural(compared.ahead, 'commit')} ahead, ${compared.behind} behind ${t.base}` +
666            (compared.mergeBase === null ? ' · no shared history' : ` · they split at ${compared.mergeBase.short}`)}
667        </Text>
668      )}
669      {commitList(kit, t)}
670      {totals(kit, t)}
671      {files.slice(0, MAX_FILES).map(file => fileRow(kit, t, file, on))}
672      {more > 0 && <Text dimColor>{`… ${plural(more, 'more file')} not listed`}</Text>}
673      {t.mode === 'history' && refOf(t, t.to) === WORKING && t.untracked > 0 && (
674        <Text dimColor>{`${plural(t.untracked, 'untracked file')} not in this diff`}</Text>
675      )}
676    </Box>
677  )
678}
679
680const paneView = (kit: Kit, t: GitDiffTimeline, bodyColumns: number, on: Actions): RenderElement => {
681  const { Box, Text, Button } = kit
682  const isReady = t.status === 'ready'
683
684  return (
685    <Box flexDirection="column" paddingX={1} rowGap={1}>
686      <Box flexDirection="row" justifyContent="space-between">
687        <Box flexDirection="row" columnGap={1} flexShrink={1}>
688          <Text bold>Files changed</Text>
689          {isReady && (
690            <Box flexShrink={1}>
691              <Text dimColor wrap="truncate-end">
692                {rangeTitle(t)}
693              </Text>
694            </Box>
695          )}
696        </Box>
697        {position === 'band' && <Button key="refresh" plain label="↻" onPress={on.refresh} />}
698      </Box>
699      {isReady && position === 'pane' && strip(kit, t, bodyColumns - 2, on)}
700      {isReady ? filesView(kit, t, on) : notice(kit, t)}
701    </Box>
702  )
703}
704
705/** The strip is always on screen in a repository; the pane only counts while it is open. */
706const refreshIfShown = async ($: EngineInterface) => {
707  try {
708    if (position === 'band' || (await $.ui.panes()).some(pane => pane.id === PANE)) {
709      await refresh($)
710    }
711  } catch {
712    // The view keeps its last good state; the next trigger tries again.
713  }
714}
715
716/** One refresh per burst of tool calls, after the burst settles. */
717const scheduleRefresh = ($: EngineInterface) => {
718  if (pending !== undefined) {
719    return
720  }
721
722  pending = $.clock.after(REFRESH_DELAY_MS, () => {
723    pending = undefined
724    void refreshIfShown($)
725  })
726}
727
728const begin = async ($: EngineInterface) => {
729  try {
730    await refresh($)
731
732    if (position === 'band') {
733      // The strip replaces the side pane: close one an earlier load or setting left open.
734      await $.ui.close({ id: PANE })
735
736      return
737    }
738
739    const { status } = await read($, timeline)
740
741    if (status !== 'no-repo' && status !== 'error') {
742      wasPlaced = (await $.ui.open(PANE_ARGS)).isPlaced
743    }
744  } catch {
745    // Opening the pane by hand (/gitdiff) shows the error.
746  }
747}
748
749/**
750 * A pane opened at session start is unasked, and a desktop that has not attached yet
751 * seats it late or never. The first prompt of a repository session asks again: opened
752 * with the person's prompt behind it, it is seated at any width.
753 */
754const offerOnce = async ($: EngineInterface) => {
755  if (position !== 'pane' || hasOffered) {
756    return
757  }
758
759  try {
760    const { status } = await read($, timeline)
761
762    if (status === 'loading') {
763      return
764    }
765
766    hasOffered = true
767
768    if (!wasPlaced && status !== 'no-repo' && status !== 'error') {
769      wasPlaced = (await $.ui.open(PANE_ARGS)).isPlaced
770    }
771  } catch {
772    // The pane stays as it was; /gitdiff opens it by hand.
773  }
774}
775
776export const register: Register = (on, options) => {
777  position = options.position === 'pane' ? 'pane' : 'band'
778
779  on('session.start', async ($, e, next) => {
780    await $.command.register({ name: 'gitdiff', description: 'Open the Git Diff files view (what changed, file by file)' })
781    void begin($)
782
783    return next(e)
784  })
785
786  on('command.run', { command: 'gitdiff' }, async $ => {
787    await refresh($).catch(() => undefined)
788
789    const opened = await $.ui.open(PANE_ARGS)
790
791    wasPlaced = opened.isPlaced
792
793    return { text: opened.isPlaced ? 'Git Diff pane opened.' : `Git Diff pane is waiting: ${opened.reason}` }
794  })
795
796  on('prompt.submit', async ($, e, next) => {
797    await offerOnce($)
798
799    return next(e)
800  })
801
802  on('turn.complete', async ($, e, next) => {
803    const done = await next(e)
804
805    scheduleRefresh($)
806
807    return done
808  })
809
810  on('tool.call', async ($, e, next) => {
811    const ran = await next(e)
812
813    if (WATCHED.has(e.tool)) {
814      scheduleRefresh($)
815    }
816
817    return ran
818  })
819
820  // The strip above the prompt: drawn in a repository, left alone elsewhere.
821  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
822    if (position !== 'band' || e.props.hasSurvey) {
823      return next(e)
824    }
825
826    const t = await read($, timeline)
827
828    if (t.status !== 'ready' && t.status !== 'error') {
829      return next(e)
830    }
831
832    const kit = kitOf($.ui.resolve(e))
833    const { Text } = kit
834
835    return t.status === 'ready' ? (
836      strip(kit, t, e.props.bodyColumns, actionsFor($))
837    ) : (
838      <Text color={RED}>{`Git Diff: git failed: ${t.message}`}</Text>
839    )
840  })
841
842  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
843    const t = await read($, timeline)
844
845    return paneView(kitOf($.ui.resolve(e)), t, e.props.bodyColumns, actionsFor($))
846  })
847}
848
hooks/card.ts 263 lines
1import { slotCenter } from './layout'
2
3/**
4 * The cards the desktop draws as one Svg each: a deep navy card, the compared span
5 * aglow in orange-red. Units are tenths of a cell, so a dot drawn at a slot's centre
6 * sits over the commit button in that slot below the card.
7 */
8
9/** viewBox units per cell: a card is `columns * CELL` wide. */
10export const CELL = 10
11
12const H = 170
13const BY = 116
14const WAVE = 50
15const MAX_DOTS = 12
16
17const CARD = '#0f1729'
18const INK = '#eef1f7'
19const INK2 = '#a3adc2'
20const MUTED = '#8a94ab'
21const LINE = '#2a3550'
22const DOT = '#56627f'
23const IN_RANGE = '#c9d1e3'
24const ADDED = '#5fd38d'
25const DELETED = '#ff7b8a'
26const A1 = '#ffa25c'
27const A2 = '#ff4d2e'
28const FONT = 'Segoe UI, system-ui, -apple-system, sans-serif'
29
30export type CardNode = {
31  /** Under the dot: `Jul 4`, `Now`. */
32  label: string
33  /** Lines changed: the wave's height over the dot. */
34  churn: number
35  role: 'outside' | 'base' | 'between' | 'compare'
36  isWorking: boolean
37  /** What the dot is, for a reader that hovers or cannot see it. */
38  tip: string
39}
40
41export type CardStats = { files: string; added: string; deleted: string } | null
42
43export type HistoryCardInput = {
44  /** The band's width in cells: the card spans it. */
45  columns: number
46  /** The commits in view, oldest first, one per slot. */
47  nodes: CardNode[]
48  title: string
49  stats: CardStats
50  /** Commits beyond the view on each side. */
51  older: number
52  newer: number
53}
54
55export type BranchCardInput = {
56  columns: number
57  base: string
58  compare: string
59  ahead: number
60  behind: number
61  /** Titles of each side's commits, oldest first. */
62  aheadTips: string[]
63  behindTips: string[]
64  /** Under the fork: `merge base · Jul 13`; '' when the branches share no history. */
65  mergeBase: string
66  stats: CardStats
67}
68
69const r = (n: number) => Math.round(n * 10) / 10
70
71const esc = (text: string) =>
72  text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
73
74/** A smooth curve through the points (Catmull-Rom as cubic Béziers). */
75const smooth = (points: ReadonlyArray<readonly [number, number]>) => {
76  const [x0 = 0, y0 = 0] = points[0] ?? []
77  let d = `M${r(x0)},${r(y0)}`
78
79  for (let i = 0; i < points.length - 1; i += 1) {
80    const a = points[i - 1] ?? points[i]!
81    const b = points[i]!
82    const c = points[i + 1]!
83    const e = points[i + 2] ?? c
84
85    d += ` C${r(b[0] + (c[0] - a[0]) / 7)},${r(b[1] + (c[1] - a[1]) / 7)} ${r(c[0] - (e[0] - b[0]) / 7)},${r(c[1] - (e[1] - b[1]) / 7)} ${r(c[0])},${r(c[1])}`
86  }
87
88  return d
89}
90
91const open = (width: number, label: string, glowX1: number, glowX2: number) =>
92  [
93    `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${H}" width="${width * 4}" height="${H * 4}" font-family="${FONT}" role="img" aria-label="${esc(label)}">`,
94    '<defs>',
95    '<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#151f37"/><stop offset="1" stop-color="#0b1220"/></linearGradient>',
96    `<linearGradient id="accent" gradientUnits="userSpaceOnUse" x1="${r(glowX1)}" y1="0" x2="${r(Math.max(glowX2, glowX1 + 1))}" y2="0"><stop offset="0" stop-color="${A1}"/><stop offset="1" stop-color="${A2}"/></linearGradient>`,
97    `<linearGradient id="fill" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${A2}" stop-opacity="0.5"/><stop offset="1" stop-color="${A2}" stop-opacity="0"/></linearGradient>`,
98    `<radialGradient id="halo"><stop offset="0" stop-color="${A2}" stop-opacity="0.22"/><stop offset="1" stop-color="${A2}" stop-opacity="0"/></radialGradient>`,
99    '<filter id="glow" x="-50%" y="-50%" width="200%" height="200%"><feGaussianBlur stdDeviation="4" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge></filter>',
100    '</defs>',
101    `<rect width="${width}" height="${H}" rx="22" fill="url(#bg)"/>`,
102  ].join('')
103
104/** The header's right side: the totals, or that they are on their way. */
105const statsText = (x: number, stats: CardStats) =>
106  stats === null
107    ? `<text x="${r(x)}" y="30" font-size="15" fill="${MUTED}" text-anchor="end">reading the diff…</text>`
108    : `<text x="${r(x)}" y="30" font-size="15" text-anchor="end"><tspan fill="${INK}">${esc(stats.files)}</tspan><tspan dx="14" fill="${ADDED}">${esc(stats.added)}</tspan><tspan dx="10" fill="${DELETED}">${esc(stats.deleted)}</tspan></text>`
109
110const glowDot = (x: number, y: number, extra = '') =>
111  `<circle cx="${r(x)}" cy="${y}" r="17" fill="${A2}" opacity="0.35" filter="url(#glow)"/><circle${extra} cx="${r(x)}" cy="${y}" r="8" fill="${A2}" stroke="${CARD}" stroke-width="3"/>`
112
113const nodeMark = (node: CardNode, i: number, x: number) => {
114  const at = ` data-node="${i}" cx="${r(x)}" cy="${BY}"`
115  const role = ` data-role="${node.role}"`
116  const dashed = node.isWorking ? ' stroke-dasharray="3 3"' : ''
117
118  switch (node.role) {
119    case 'compare':
120      return `<circle cx="${r(x)}" cy="${BY}" r="17" fill="${A2}" opacity="0.35" filter="url(#glow)"/><circle${at} r="8"${role} fill="${A2}" stroke="${CARD}" stroke-width="3"/>`
121    case 'base':
122      return `<circle${at} r="7"${role} fill="${CARD}" stroke="${A1}" stroke-width="3"${dashed}/>`
123    case 'between':
124      return `<circle${at} r="4.5"${role} fill="${IN_RANGE}"/>`
125    default:
126      return node.isWorking
127        ? `<circle${at} r="5"${role} fill="${CARD}" stroke="${IN_RANGE}" stroke-width="2"${dashed}/>`
128        : `<circle${at} r="4.5"${role} fill="${DOT}"/>`
129  }
130}
131
132/** History: the commits in view on a line, the wave of their size above it, the pick aglow. */
133export const historyCard = ({ columns, nodes, title, stats, older, newer }: HistoryCardInput): string => {
134  const width = columns * CELL
135  const xs = nodes.map((_, i) => slotCenter(i) * CELL)
136  const tallest = Math.log10(1 + Math.max(1, ...nodes.map(node => node.churn)))
137  const ys = nodes.map(node => BY - 6 - (WAVE * Math.log10(1 + node.churn)) / tallest)
138  const left = 16
139  const right = width - 16
140  const wave = smooth([[left, BY], ...xs.map((x, i) => [x, ys[i]!] as const), [right, BY]])
141  const area = `${wave} L${right},${BY} L${left},${BY} Z`
142  const picked = nodes.map((node, i) => (node.role === 'outside' ? -1 : i)).filter(i => i >= 0)
143  const firstPicked = picked[0]
144  const lastPicked = picked[picked.length - 1]
145  const hasSpan = firstPicked !== undefined && lastPicked !== undefined
146  // A pick that runs out of view glows to the card's edge on that side.
147  const spanFrom = !hasSpan ? 0 : nodes[firstPicked]!.role === 'between' ? left : xs[firstPicked]!
148  const spanTo = !hasSpan ? 0 : nodes[lastPicked]!.role === 'between' ? right : xs[lastPicked]!
149  const parts = [
150    open(width, title, spanFrom, spanTo),
151    hasSpan ? `<ellipse cx="${r((spanFrom + spanTo) / 2)}" cy="${BY - 30}" rx="${r(Math.max(120, (spanTo - spanFrom) / 2 + 80))}" ry="90" fill="url(#halo)"/>` : '',
152    `<text x="24" y="30" font-size="15" fill="${INK2}">${esc(title)}</text>`,
153    statsText(width - 24, stats),
154    `<path d="${area}" fill="#18233c"/>`,
155    `<path d="${wave}" fill="none" stroke="#2f3d5c" stroke-width="2"/>`,
156  ]
157
158  if (hasSpan && spanTo > spanFrom) {
159    parts.push(
160      `<clipPath id="span"><rect x="${r(spanFrom)}" y="0" width="${r(spanTo - spanFrom)}" height="${H}"/></clipPath>`,
161      `<g clip-path="url(#span)"><path d="${area}" fill="url(#fill)"/><path d="${wave}" fill="none" stroke="url(#accent)" stroke-width="3" filter="url(#glow)"/></g>`,
162    )
163  }
164
165  parts.push(`<line x1="${left}" y1="${BY}" x2="${right}" y2="${BY}" stroke="${LINE}" stroke-width="3" stroke-linecap="round"/>`)
166
167  if (hasSpan && spanTo > spanFrom) {
168    parts.push(
169      `<line x1="${r(spanFrom)}" y1="${BY}" x2="${r(spanTo)}" y2="${BY}" stroke="url(#accent)" stroke-width="5" stroke-linecap="round" filter="url(#glow)"/>`,
170    )
171  }
172
173  nodes.forEach((node, i) => {
174    const x = xs[i]!
175    const isPicked = node.role === 'base' || node.role === 'compare'
176
177    parts.push(
178      `<g><title>${esc(node.tip)}</title>${nodeMark(node, i, x)}</g>`,
179      `<text x="${r(x)}" y="146" font-size="13" fill="${isPicked ? INK : MUTED}" text-anchor="middle">${esc(node.label)}</text>`,
180    )
181  })
182
183  if (older > 0) {
184    parts.push(`<text x="20" y="164" font-size="12" fill="${MUTED}">‹ ${older} older</text>`)
185  }
186
187  if (newer > 0) {
188    parts.push(`<text x="${width - 20}" y="164" font-size="12" fill="${MUTED}" text-anchor="end">${newer} newer ›</text>`)
189  }
190
191  parts.push('</svg>')
192
193  return parts.join('')
194}
195
196/** Spreads `count` dots from `x1` to `x2`, the last one at `x2`. */
197const spread = (count: number, x1: number, x2: number) =>
198  Array.from({ length: count }, (_, i) => (count === 1 ? x2 : x1 + ((x2 - x1) * i) / (count - 1)))
199
200/** Branches: the base line, the compare branch forking off it, each side's commits as dots. */
201export const branchCard = (input: BranchCardInput): string => {
202  const { columns, base, compare, ahead, behind, aheadTips, behindTips, mergeBase, stats } = input
203  const width = columns * CELL
204  const fork = Math.round(width * 0.22)
205  const top = 68
206  const tipX = width - 40
207  const aheadCount = Math.min(ahead, MAX_DOTS)
208  const behindCount = Math.min(behind, MAX_DOTS)
209  const aheadXs = spread(aheadCount, fork + 90, tipX)
210  const behindXs = spread(behindCount, fork + 90, tipX)
211  const label = `${compare} is ${ahead} ahead of and ${behind} behind ${base}`
212  const parts = [
213    open(width, label, fork, tipX),
214    `<ellipse cx="${r((fork + tipX) / 2)}" cy="${top}" rx="${r((tipX - fork) / 2 + 60)}" ry="70" fill="url(#halo)"/>`,
215    `<text x="24" y="30" font-size="15"><tspan fill="${INK}">${ahead} ahead</tspan><tspan dx="8" fill="${MUTED}">·</tspan><tspan dx="8" fill="${INK}">${behind} behind</tspan></text>`,
216    statsText(width - 24, stats),
217    `<line x1="16" y1="${BY}" x2="${width - 16}" y2="${BY}" stroke="${LINE}" stroke-width="3" stroke-linecap="round"/>`,
218    ...[0.25, 0.5, 0.75].map(at => `<circle cx="${r(fork * at)}" cy="${BY}" r="4.5" fill="${DOT}"/>`),
219  ]
220
221  behindXs.forEach((x, i) => {
222    const tip = behindTips[behindTips.length - behindCount + i] ?? ''
223    const isTip = i === behindCount - 1
224
225    parts.push(
226      `<g><title>${esc(tip)}</title><circle data-side="behind" cx="${r(x)}" cy="${BY}" r="${isTip ? 6 : 4.5}" fill="${isTip ? IN_RANGE : DOT}"/></g>`,
227    )
228  })
229
230  if (ahead > 0) {
231    parts.push(
232      `<path d="M${fork},${BY} C${fork + 40},${BY} ${fork + 36},${top} ${fork + 76},${top} L${tipX},${top}" fill="none" stroke="url(#accent)" stroke-width="3" stroke-linecap="round" filter="url(#glow)"/>`,
233    )
234  }
235
236  aheadXs.forEach((x, i) => {
237    const tip = aheadTips[aheadTips.length - aheadCount + i] ?? ''
238    const isTip = i === aheadCount - 1
239
240    parts.push(
241      `<g><title>${esc(tip)}</title>${isTip ? glowDot(x, top, ' data-side="ahead"') : `<circle data-side="ahead" cx="${r(x)}" cy="${top}" r="4" fill="#ffc6a6"/>`}</g>`,
242    )
243  })
244
245  if (ahead > aheadCount) {
246    parts.push(`<text x="${fork + 90}" y="${top - 16}" font-size="12" fill="${MUTED}">+${ahead - aheadCount} more</text>`)
247  }
248
249  if (behind > behindCount) {
250    parts.push(`<text x="${fork + 90}" y="${BY + 22}" font-size="12" fill="${MUTED}">+${behind - behindCount} more</text>`)
251  }
252
253  parts.push(
254    `<circle cx="${fork}" cy="${BY}" r="7" fill="${CARD}" stroke="${IN_RANGE}" stroke-width="2.5"/>`,
255    `<text x="${tipX}" y="${top - 18}" font-size="14" fill="${INK2}" text-anchor="end">${esc(compare)}</text>`,
256    `<text x="${tipX}" y="${BY + 26}" font-size="14" fill="${INK2}" text-anchor="end">${esc(base)}</text>`,
257    mergeBase === '' ? '' : `<text x="${fork}" y="${BY + 26}" font-size="12" fill="${MUTED}" text-anchor="middle">${esc(mergeBase)}</text>`,
258    '</svg>',
259  )
260
261  return parts.join('')
262}
263
hooks/git.ts 329 lines
1import type {
2  GitDiffBranch,
3  GitDiffBranchCompare,
4  GitDiffCommit,
5  GitDiffFile,
6  GitDiffPatch,
7  GitDiffStat,
8} from '../types'
9
10export type Run = (
11  argv: readonly string[],
12) => Promise<{ exitCode: number; stdout: string; stderr: string }>
13
14export type Loaded = {
15  status: 'ready' | 'no-repo' | 'no-commits' | 'error'
16  message: string
17  branch: string
18  commits: GitDiffCommit[]
19  hasWorking: boolean
20  untracked: number
21  baseOfOldest: string
22  branches: GitDiffBranch[]
23  /** The branch others are compared with by default: origin's default branch, else a main one. */
24  defaultBase: string
25}
26
27/** The revisions of one `git diff`: two, one (against the working tree), or `base...compare`. */
28export type Spec = readonly string[]
29
30export const LOG_LIMIT = 60
31export const BRANCH_COMMITS = 30
32/** Characters of hunks one file's diff may hold: a Code element takes at most 10000. */
33export const PATCH_LIMIT = 9000
34
35/** The tree with nothing in it, which a root commit is compared with. */
36export const EMPTY_TREE = {
37  sha1: '4b825dc642cb6eb9a060e54bf8d69288fbee4904',
38  sha256: '6ef19b41225c5369f1c104d45d8d85efa9b057b53b14b4b9b939dd74decc5321',
39} as const
40
41/** Characters of a commit message's body kept: enough for a pull request's title and a line or two. */
42const BODY_LIMIT = 300
43
44const RS = '\u001e'
45const US = '\u001f'
46const GS = '\u001d'
47const LOG_FORMAT = '--format=%x1e%H%x1f%h%x1f%ct%x1f%s%x1f%b%x1d'
48const REF_FORMAT = '--format=%(refname)%1f%(refname:short)%1f%(committerdate:unix)%1f%(symref)'
49const NUMSTAT = ['diff', '--numstat', '-z', '--no-renames']
50const PATCH = ['diff', '--no-color', '--no-ext-diff', '--no-renames', '-U3']
51const MAIN_BRANCHES = ['origin/main', 'origin/master', 'main', 'master']
52/** Control characters a Code element refuses: all but tab and newline. */
53const CONTROL = /[\u0000-\u0008\u000b-\u001f\u007f]/g
54
55const firstLine = (text: string) => text.trim().split(/\r?\n/)[0] ?? ''
56
57const countOf = (text: string, pattern: RegExp) => Number(pattern.exec(text)?.[1] ?? 0)
58
59/** `git log` with LOG_FORMAT and `--shortstat`: newest first in, oldest first out. */
60export const parseLog = (stdout: string): GitDiffCommit[] =>
61  stdout
62    .split(RS)
63    .filter(chunk => chunk.trim() !== '')
64    .map(chunk => {
65      // The message ends at GS; the shortstat, when there is one, follows it.
66      const end = chunk.lastIndexOf(GS)
67      const fields = end < 0 ? chunk : chunk.slice(0, end)
68      const stat = end < 0 ? '' : chunk.slice(end + 1)
69      const [sha = '', short = '', time = '', subject = '', ...body] = fields.split(US)
70
71      return {
72        sha,
73        short,
74        time: Number(time),
75        subject: subject.trim(),
76        body: body.join(US).replace(/\r/g, '').trim().slice(0, BODY_LIMIT),
77        added: countOf(stat, /(\d+) insertions?\(\+\)/),
78        deleted: countOf(stat, /(\d+) deletions?\(-\)/),
79      }
80    })
81    .filter(commit => commit.sha !== '' && Number.isFinite(commit.time))
82    .reverse()
83
84/** `git diff --numstat -z --no-renames`: `added TAB deleted TAB path NUL` per file. */
85export const parseNumstat = (stdout: string): GitDiffStat => {
86  const files: GitDiffFile[] = stdout
87    .split('\0')
88    .filter(record => record !== '')
89    .map(record => {
90      const [added = '', deleted = '', ...path] = record.split('\t')
91
92      return {
93        path: path.join('\t'),
94        added: added === '-' ? null : Number(added),
95        deleted: deleted === '-' ? null : Number(deleted),
96      }
97    })
98
99  return {
100    files,
101    added: files.reduce((sum, file) => sum + (file.added ?? 0), 0),
102    deleted: files.reduce((sum, file) => sum + (file.deleted ?? 0), 0),
103  }
104}
105
106/** `git for-each-ref` with REF_FORMAT; symbolic refs (`origin/HEAD`) left out. */
107export const parseBranches = (stdout: string): GitDiffBranch[] =>
108  stdout
109    .split(/\r?\n/)
110    .map(line => line.split(US))
111    .filter(([refname = '', , , symref = '']) => refname !== '' && symref === '')
112    .map(([refname = '', name = '', time = '']) => ({
113      name,
114      time: Number(time) || 0,
115      isRemote: refname.startsWith('refs/remotes/'),
116    }))
117
118/** `git rev-list --left-right --count base...compare`: the base's own commits, then the compare's. */
119export const parseLeftRight = (stdout: string) => {
120  const [behind = '0', ahead = '0'] = stdout.trim().split(/\s+/)
121
122  return { behind: Number(behind) || 0, ahead: Number(ahead) || 0 }
123}
124
125/** A hunk whose lines were cut: its header counted again from what is left. */
126const recount = ([header = '', ...body]: string[]): string[] => {
127  const match = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@(.*)$/.exec(header)
128
129  if (match === null) {
130    return [header, ...body]
131  }
132
133  const old = body.filter(line => line.startsWith(' ') || line.startsWith('-')).length
134  const now = body.filter(line => line.startsWith(' ') || line.startsWith('+')).length
135
136  return [`@@ -${match[1]},${old} +${match[2]},${now} @@${match[3]}`, ...body]
137}
138
139/**
140 * One file's `git diff` as a Code element takes it: the hunks alone, without control
141 * characters, whole hunks up to `limit` characters (the first one cut to fit if it must).
142 */
143export const toHunks = (diff: string, limit = PATCH_LIMIT): Omit<GitDiffPatch, 'path'> => {
144  const lines = diff.replace(/\r/g, '').replace(CONTROL, '�').split('\n')
145  const first = lines.findIndex(line => line.startsWith('@@'))
146
147  if (first < 0) {
148    return { hunks: '', isCut: false, note: /^Binary files /m.test(diff) ? 'binary' : 'empty' }
149  }
150
151  const body = lines.slice(first)
152
153  while (body.length > 0 && body[body.length - 1] === '') {
154    body.pop()
155  }
156
157  const hunks: string[][] = []
158
159  for (const line of body) {
160    if (line.startsWith('@@') || hunks.length === 0) {
161      hunks.push([line])
162    } else {
163      hunks[hunks.length - 1]!.push(line)
164    }
165  }
166
167  const kept: string[] = []
168  let size = 0
169
170  for (const hunk of hunks) {
171    const text = hunk.join('\n')
172    const extra = (kept.length === 0 ? 0 : 1) + text.length
173
174    if (size + extra <= limit) {
175      kept.push(text)
176      size += extra
177      continue
178    }
179
180    if (kept.length === 0) {
181      const head = [hunk[0] ?? '']
182      let headSize = head[0]!.length
183
184      for (const line of hunk.slice(1)) {
185        if (headSize + 1 + line.length > limit) {
186          break
187        }
188
189        head.push(line)
190        headSize += 1 + line.length
191      }
192
193      kept.push(recount(head).join('\n'))
194    }
195
196    return { hunks: kept.join('\n'), isCut: true, note: '' }
197  }
198
199  return { hunks: kept.join('\n'), isCut: false, note: '' }
200}
201
202const empty = (status: Loaded['status'], message: string, branch = ''): Loaded => ({
203  status,
204  message,
205  branch,
206  commits: [],
207  hasWorking: false,
208  untracked: 0,
209  baseOfOldest: '',
210  branches: [],
211  defaultBase: '',
212})
213
214const pickDefaultBase = (originHead: string, branches: GitDiffBranch[]) => {
215  const names = new Set(branches.map(branch => branch.name))
216
217  if (names.has(originHead)) {
218    return originHead
219  }
220
221  return MAIN_BRANCHES.find(name => names.has(name)) ?? branches[0]?.name ?? ''
222}
223
224/** What the oldest commit is compared with: its first parent, or the empty tree for a root. */
225const baseOf = async (run: Run, oldest: GitDiffCommit) => {
226  const parent = await run(['git', 'rev-parse', '--verify', '--quiet', `${oldest.sha}^`])
227
228  if (parent.exitCode === 0 && parent.stdout.trim() !== '') {
229    return parent.stdout.trim()
230  }
231
232  const format = await run(['git', 'rev-parse', '--show-object-format'])
233
234  return format.stdout.trim() === 'sha256' ? EMPTY_TREE.sha256 : EMPTY_TREE.sha1
235}
236
237export const loadTimeline = async (run: Run): Promise<Loaded> => {
238  const inside = await run(['git', 'rev-parse', '--is-inside-work-tree'])
239
240  if (inside.exitCode !== 0 || inside.stdout.trim() !== 'true') {
241    return empty('no-repo', firstLine(inside.stderr) || 'Not a git repository')
242  }
243
244  const [branchRun, head, dirty, others, refs, originHead] = await Promise.all([
245    run(['git', 'branch', '--show-current']),
246    run(['git', 'rev-parse', '--verify', '--quiet', 'HEAD']),
247    run(['git', ...NUMSTAT, 'HEAD', '--']),
248    run(['git', 'ls-files', '--others', '--exclude-standard', '-z']),
249    run(['git', 'for-each-ref', '--sort=-committerdate', REF_FORMAT, 'refs/heads', 'refs/remotes']),
250    run(['git', 'symbolic-ref', '-q', '--short', 'refs/remotes/origin/HEAD']),
251  ])
252  const branch = branchRun.stdout.trim() || 'detached HEAD'
253
254  if (head.exitCode !== 0) {
255    return empty('no-commits', `No commits yet on ${branch}`, branch)
256  }
257
258  // First parent only: a merged pull request is one commit, so each step is one pull request's diff.
259  const log = await run(['git', 'log', '--first-parent', '-n', String(LOG_LIMIT), LOG_FORMAT, '--shortstat'])
260
261  if (log.exitCode !== 0) {
262    return empty('error', firstLine(log.stderr) || 'git log failed', branch)
263  }
264
265  const commits = parseLog(log.stdout)
266  const branches = refs.exitCode === 0 ? parseBranches(refs.stdout) : []
267  const oldest = commits[0]
268
269  return {
270    status: 'ready',
271    message: '',
272    branch,
273    commits,
274    hasWorking: dirty.exitCode === 0 && dirty.stdout !== '',
275    untracked: others.exitCode === 0 ? others.stdout.split('\0').filter(path => path !== '').length : 0,
276    baseOfOldest: oldest === undefined ? '' : await baseOf(run, oldest),
277    branches,
278    defaultBase: pickDefaultBase(originHead.exitCode === 0 ? originHead.stdout.trim() : '', branches),
279  }
280}
281
282/** The files a diff of `spec` changes. */
283export const loadStat = async (run: Run, spec: Spec): Promise<GitDiffStat> => {
284  const result = await run(['git', ...NUMSTAT, ...spec, '--'])
285
286  if (result.exitCode !== 0) {
287    throw new Error(firstLine(result.stderr) || 'git diff failed')
288  }
289
290  return parseNumstat(result.stdout)
291}
292
293/** One file's diff of `spec`, as hunks; a failure is kept as the note. */
294export const loadPatch = async (run: Run, spec: Spec, path: string): Promise<GitDiffPatch> => {
295  const result = await run(['git', ...PATCH, ...spec, '--', path])
296
297  if (result.exitCode !== 0) {
298    return { path, hunks: '', isCut: false, note: firstLine(result.stderr) || 'git diff failed' }
299  }
300
301  return { path, ...toHunks(result.stdout) }
302}
303
304/** How far apart two branches are: each side's own commits, and where they split. */
305export const loadBranchCompare = async (run: Run, base: string, compare: string): Promise<GitDiffBranchCompare> => {
306  const side = (range: string) =>
307    run(['git', 'log', '--first-parent', '-n', String(BRANCH_COMMITS), LOG_FORMAT, '--shortstat', range])
308  const [counts, mergeBase, aheadLog, behindLog] = await Promise.all([
309    run(['git', 'rev-list', '--left-right', '--count', `${base}...${compare}`]),
310    run(['git', 'merge-base', base, compare]),
311    side(`${base}..${compare}`),
312    side(`${compare}..${base}`),
313  ])
314
315  if (counts.exitCode !== 0) {
316    throw new Error(firstLine(counts.stderr) || 'git rev-list failed')
317  }
318
319  const baseSha = mergeBase.exitCode === 0 ? mergeBase.stdout.trim() : ''
320  const baseLog = baseSha === '' ? null : await run(['git', 'log', '-1', LOG_FORMAT, baseSha])
321
322  return {
323    ...parseLeftRight(counts.stdout),
324    mergeBase: baseLog === null || baseLog.exitCode !== 0 ? null : (parseLog(baseLog.stdout)[0] ?? null),
325    aheadCommits: aheadLog.exitCode === 0 ? parseLog(aheadLog.stdout) : [],
326    behindCommits: behindLog.exitCode === 0 ? parseLog(behindLog.stdout) : [],
327  }
328}
329
hooks/layout.ts 78 lines
1/** Cells each paging arrow takes, either end of the commit buttons. */
2export const ARROW_W = 3
3
4/** Cells one commit takes: room for a `#1234` or short-sha button. */
5export const SLOT_W = 9
6
7const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
8
9const two = (n: number) => String(n).padStart(2, '0')
10
11/** `Jan 2, 2026`, in local time. */
12export const formatDate = (seconds: number): string => {
13  const date = new Date(seconds * 1000)
14
15  return `${MONTHS[date.getMonth()]} ${date.getDate()}, ${date.getFullYear()}`
16}
17
18/** `Jan 2`, in local time. */
19export const shortDate = (seconds: number): string => {
20  const date = new Date(seconds * 1000)
21
22  return `${MONTHS[date.getMonth()]} ${date.getDate()}`
23}
24
25/** `02:40 PM`, in local time. */
26export const formatTime = (seconds: number): string => {
27  const date = new Date(seconds * 1000)
28  const hours = date.getHours()
29
30  return `${two(hours % 12 || 12)}:${two(date.getMinutes())} ${hours < 12 ? 'AM' : 'PM'}`
31}
32
33/** `9:47 PM`, in local time. */
34const shortTime = (seconds: number): string => {
35  const date = new Date(seconds * 1000)
36  const hours = date.getHours()
37
38  return `${hours % 12 || 12}:${two(date.getMinutes())} ${hours < 12 ? 'AM' : 'PM'}`
39}
40
41const dayOf = (seconds: number) => new Date(seconds * 1000).toDateString()
42
43/** The label under each dot: its day where the day changes, else its time; null is the working tree. */
44export const dayLabels = (times: ReadonlyArray<number | null>): string[] =>
45  times.map((time, i) => {
46    if (time === null) {
47      return 'Now'
48    }
49
50    const before = i === 0 ? null : times[i - 1]
51
52    return before === null || before === undefined || dayOf(before) !== dayOf(time) ? shortDate(time) : shortTime(time)
53  })
54
55/** How many commit slots fit between the arrows; at least two, at most all of them. */
56export const visibleCount = (columns: number, total: number): number => {
57  const room = Math.floor((columns - 2 * ARROW_W) / SLOT_W)
58
59  return Math.max(2, Math.min(total, room))
60}
61
62/** The centre of slot `i`, in cells from the left edge. */
63export const slotCenter = (i: number): number => ARROW_W + i * SLOT_W + SLOT_W / 2
64
65const clampStart = (start: number, count: number, total: number) =>
66  Math.min(Math.max(0, total - count), Math.max(0, start))
67
68/** The leftmost visible node: `-1` means the newest window. */
69export const resolveStart = (start: number, count: number, total: number): number =>
70  start < 0 ? Math.max(0, total - count) : clampStart(start, count, total)
71
72/** One page left (`delta` -1) or right (1), overlapping by a node; `-1` once at the newest. */
73export const stepStart = (start: number, delta: number, count: number, total: number): number => {
74  const next = clampStart(resolveStart(start, count, total) + delta * Math.max(1, count - 1), count, total)
75
76  return next === Math.max(0, total - count) ? -1 : next
77}
78
hooks/model.ts 221 lines
1import type { GitDiffCommit, GitDiffStat, GitDiffTimeline } from '../types'
2import type { Loaded } from './git'
3
4export type Range = { from: number; to: number }
5
6/** The id of the uncommitted-changes node, after the last commit. */
7export const WORKING = 'working'
8
9export const INITIAL: GitDiffTimeline = {
10  status: 'loading',
11  message: '',
12  cwd: '',
13  branch: '',
14  mode: 'history',
15  commits: [],
16  hasWorking: false,
17  untracked: 0,
18  baseOfOldest: '',
19  from: 0,
20  to: 0,
21  start: -1,
22  isPinned: false,
23  anchor: '',
24  branches: [],
25  base: '',
26  compare: '',
27  branchCompare: null,
28  stat: null,
29  statKey: '',
30  statError: '',
31  open: [],
32  patches: [],
33}
34
35type Nodes = Pick<GitDiffTimeline, 'commits' | 'hasWorking'>
36type Picked = Nodes & Range & Pick<GitDiffTimeline, 'baseOfOldest' | 'mode' | 'base' | 'compare'>
37
38/** One id per timeline node, oldest first: commit shas, then `WORKING` when dirty. */
39export const nodeIds = (t: Nodes): string[] => [
40  ...t.commits.map(commit => commit.sha),
41  ...(t.hasWorking ? [WORKING] : []),
42]
43
44/** The revision node `index` stands for; -1 is what the oldest commit is compared with. */
45export const refOf = (t: Nodes & Pick<GitDiffTimeline, 'baseOfOldest'>, index: number): string =>
46  index < 0 ? t.baseOfOldest : (nodeIds(t)[index] ?? '')
47
48/** The revisions of the `git diff` the pick shows: two, one against the working tree, or base...compare. */
49export const diffSpec = (t: Picked): string[] => {
50  if (t.mode === 'branches') {
51    return [`${t.base}...${t.compare}`]
52  }
53
54  const to = refOf(t, t.to)
55
56  return to === WORKING ? [refOf(t, t.from)] : [refOf(t, t.from), to]
57}
58
59/** What a stat or a patch was read for. */
60export const selectionKey = (t: Picked): string =>
61  t.mode === 'branches' ? `${t.base}...${t.compare}` : `${refOf(t, t.from)}..${refOf(t, t.to)}`
62
63/** A commit on its own: against the node before it. */
64export const commitRange = (index: number): Range => ({ from: index - 1, to: index })
65
66/**
67 * A click on node `index`, as a date-range picker takes it: the first shows that commit
68 * on its own and waits; a second on another node compares the two; the next starts over.
69 */
70export const clickCommit = (t: Nodes & Pick<GitDiffTimeline, 'anchor'>, index: number): Range & { anchor: string } => {
71  const ids = nodeIds(t)
72  const waiting = t.anchor === '' ? -1 : ids.indexOf(t.anchor)
73
74  if (waiting < 0) {
75    return { ...commitRange(index), anchor: ids[index] ?? '' }
76  }
77
78  return waiting === index
79    ? { ...commitRange(index), anchor: '' }
80    : { from: Math.min(waiting, index), to: Math.max(waiting, index), anchor: '' }
81}
82
83/** The commits a history comparison takes in, newest first; the working tree is none of them. */
84export const rangeCommits = (t: Nodes & Range): GitDiffCommit[] =>
85  t.commits.slice(Math.max(0, t.from + 1), Math.min(t.to + 1, t.commits.length)).reverse()
86
87/** The newest step: the newest node on its own. */
88export const defaultRange = (total: number): Range => commitRange(total - 1)
89
90const grouped = (n: number) => String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
91
92/** The three phrases of a summary: `4 files changed`, `+172`, `−35`; null while unread. */
93export const describeStat = (stat: GitDiffStat | null) =>
94  stat === null
95    ? null
96    : {
97        files: `${stat.files.length} file${stat.files.length === 1 ? '' : 's'} changed`,
98        added: `+${grouped(stat.added)}`,
99        deleted: `−${grouped(stat.deleted)}`,
100      }
101
102/** A merge's subject by its pull request or branch; any other subject as it is. */
103const subjectName = (subject: string): string => {
104  const pull = /^Merge pull request #(\d+) from [^/\s]+\/(\S+)/.exec(subject)
105
106  if (pull !== null) {
107    return `PR #${pull[1]} ${pull[2]}`
108  }
109
110  const branch = /^Merge branch '([^']+)'/.exec(subject)
111
112  return branch === null ? subject : `Merge ${branch[1]}`
113}
114
115const firstLine = (text: string) => text.split('\n').find(line => line.trim() !== '')?.trim() ?? ''
116
117/** A GitLab merge's request number, from the `See merge request group/project!123` line it carries. */
118const mergeRequestOf = (commit: Pick<GitDiffCommit, 'subject' | 'body'>) =>
119  /^Merge branch '/.test(commit.subject) ? (/See merge request \S*!(\d+)/.exec(commit.body)?.[1] ?? null) : null
120
121/** A commit button's label: its pull or merge request number, else its short sha. */
122export const buttonLabel = (commit: GitDiffCommit): string => {
123  const pull = /^Merge pull request #(\d+)/.exec(commit.subject)
124
125  if (pull !== null) {
126    return `#${pull[1]}`
127  }
128
129  const request = mergeRequestOf(commit)
130
131  return request === null ? commit.short : `!${request}`
132}
133
134/**
135 * A commit as the timeline names it: a merge by its pull or merge request's title, else its
136 * subject. Whole: the row drawing it cuts it to the room it has.
137 */
138export const commitTitle = (commit: Pick<GitDiffCommit, 'subject' | 'body'>): string => {
139  const title = firstLine(commit.body)
140  const pull = /^Merge pull request #(\d+)/.exec(commit.subject)
141
142  if (pull !== null) {
143    return title === '' ? subjectName(commit.subject) : `#${pull[1]} ${title}`
144  }
145
146  const request = mergeRequestOf(commit)
147
148  return request !== null && title !== '' && !title.startsWith('See merge request')
149    ? `!${request} ${title}`
150    : subjectName(commit.subject)
151}
152
153/** The branches compared: kept while they exist, else origin's default against the current one. */
154const branchPick = (prev: GitDiffTimeline, loaded: Loaded, isSameRepo: boolean) => {
155  const names = new Set(loaded.branches.map(branch => branch.name))
156  const kept = (name: string) => (isSameRepo && names.has(name) ? name : '')
157  const base = kept(prev.base) || loaded.defaultBase || loaded.branches[0]?.name || ''
158  const current = names.has(loaded.branch) ? loaded.branch : ''
159  const compare =
160    kept(prev.compare) || current || loaded.branches.find(branch => branch.name !== base)?.name || base
161
162  return { base, compare }
163}
164
165/** The next state once git answered; keeps what the person picked while it still exists. */
166export const merge = (prev: GitDiffTimeline, loaded: Loaded, cwd: string): GitDiffTimeline => {
167  const isSameRepo = prev.cwd === cwd
168  const bare: GitDiffTimeline = {
169    ...INITIAL,
170    cwd,
171    status: loaded.status,
172    message: loaded.message,
173    branch: loaded.branch,
174    mode: isSameRepo ? prev.mode : 'history',
175  }
176
177  if (loaded.status !== 'ready') {
178    return bare
179  }
180
181  const next: GitDiffTimeline = {
182    ...bare,
183    commits: loaded.commits,
184    hasWorking: loaded.hasWorking,
185    untracked: loaded.untracked,
186    baseOfOldest: loaded.baseOfOldest,
187    branches: loaded.branches,
188    ...branchPick(prev, loaded, isSameRepo),
189  }
190  const ids = nodeIds(next)
191  const locate = (ref: string): number | null => {
192    if (ref === '') {
193      return null
194    }
195
196    if (ref === next.baseOfOldest) {
197      return -1
198    }
199
200    const index = ids.indexOf(ref)
201
202    return index < 0 ? null : index
203  }
204  const from = isSameRepo && prev.isPinned ? locate(refOf(prev, prev.from)) : null
205  const to = isSameRepo && prev.isPinned ? locate(refOf(prev, prev.to)) : null
206  const kept = from !== null && to !== null && to > from ? { from, to } : null
207  const merged: GitDiffTimeline = {
208    ...next,
209    ...(kept ?? defaultRange(ids.length)),
210    isPinned: kept !== null,
211    anchor: isSameRepo && ids.includes(prev.anchor) ? prev.anchor : '',
212    start: isSameRepo ? prev.start : -1,
213    branchCompare: isSameRepo ? prev.branchCompare : null,
214  }
215
216  // What is on screen stays until the fresh answer lands, unless the pick moved.
217  return selectionKey(merged) === prev.statKey
218    ? { ...merged, stat: prev.stat, statKey: prev.statKey, statError: prev.statError, open: prev.open, patches: prev.patches }
219    : merged
220}
221
types/index.d.ts 98 lines
1export type GitDiffCommit = {
2  sha: string
3  short: string
4  /** Commit time, seconds since the epoch. */
5  time: number
6  subject: string
7  /** The message after the subject, trimmed and cut short: a merge's pull request title lives here. */
8  body: string
9  /** Lines changed against the first parent: the height of the card's wave. */
10  added: number
11  deleted: number
12}
13
14export type GitDiffFile = {
15  path: string
16  /** Null for a binary file. */
17  added: number | null
18  deleted: number | null
19}
20
21export type GitDiffStat = {
22  files: GitDiffFile[]
23  added: number
24  deleted: number
25}
26
27export type GitDiffBranch = {
28  /** As git spells it short: `main`, `origin/main`. */
29  name: string
30  /** Last commit time, seconds since the epoch. */
31  time: number
32  isRemote: boolean
33}
34
35/** One file's diff as the files view draws it. */
36export type GitDiffPatch = {
37  path: string
38  /** Unified-diff hunks from the first `@@`, cut to fit a Code element; '' when there are none. */
39  hunks: string
40  /** True when hunks were left out or cut to fit. */
41  isCut: boolean
42  /** Why nothing is drawn: `binary`, `empty`, or what git said. */
43  note: string
44}
45
46export type GitDiffBranchCompare = {
47  /** Commits on the compare branch that the base lacks, and the other way round. */
48  ahead: number
49  behind: number
50  mergeBase: GitDiffCommit | null
51  /** Oldest first, the newest few of each side. */
52  aheadCommits: GitDiffCommit[]
53  behindCommits: GitDiffCommit[]
54}
55
56export type GitDiffTimeline = {
57  status: 'loading' | 'ready' | 'too-short' | 'no-repo' | 'no-commits' | 'error'
58  message: string
59  cwd: string
60  branch: string
61  /** `history` compares two points of this branch; `branches` compares two branches. */
62  mode: 'history' | 'branches'
63  /** Oldest first. */
64  commits: GitDiffCommit[]
65  /** True while tracked files differ from HEAD: one more node after the commits. */
66  hasWorking: boolean
67  untracked: number
68  /** What the oldest commit is compared with: its parent, or the empty tree for a root. */
69  baseOfOldest: string
70  /** The compared range, as node indexes: `from` is older than `to`; -1 is `baseOfOldest`. */
71  from: number
72  to: number
73  /** Index of the leftmost commit button; -1 follows the newest. */
74  start: number
75  /** True once the person picked a range, so a refresh keeps it. */
76  isPinned: boolean
77  /** The commit clicked first, waiting for a second click to compare with; '' when none waits. */
78  anchor: string
79  /** Newest first, as git sorts them. */
80  branches: GitDiffBranch[]
81  base: string
82  compare: string
83  branchCompare: GitDiffBranchCompare | null
84  stat: GitDiffStat | null
85  /** What `stat` (and `patches`) were read for: selectionKey of the pick. */
86  statKey: string
87  statError: string
88  /** Files whose diff is open in the files view, in the order opened. */
89  open: string[]
90  patches: GitDiffPatch[]
91}
92
93declare module 'claude-code' {
94  interface PluginState {
95    'git-diff-timeline': { timeline: GitDiffTimeline }
96  }
97}
98