SLOPSHOPPER

git-graph

A pane that draws the commit graph of a local git repo, and a Portfolio pane with one activity card per local project

newpanebandcommandtoastprocess
v0.1.0MITupdated 2026-10-20Abdulhamid-Baghdadi/claude-git-graph
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · git-graph
│ ┃ Git graph ✕ › fix the failing auth test and add an audit log call │ ┃ on [ Refresh ] [ Switch repo ] │ ┃ 0 branches ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ Recent repos: ▾ ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ Repo folder: C:\Users\you\Desktop\my-project ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ [ Use this session's folder ] │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ No commits yet. │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ All 0 commits. Hover a commit for details. │ › /git-graph │ ⎿ git-graph: Git graph pane opened. │ │ [ ⎇ Git graph ] [ ▦ Portfolio ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
[ ⎇ Git graph ] [ ▦ Portfolio ]
Pane · Git graph
on [ Refresh ] [ Switch repo ] 0 branches Recent repos: ▾ Repo folder: C:\Users\you\Desktop\my-project ⏎ show [ Use this session's folder ] No commits yet. All 0 commits. Hover a commit for details.
Pane · Portfolio
0 projects updated just now [ Refresh ] [ Edit list ] No projects listed. Click a project's name to show it in the git graph · ✕ untracks it (restore under Edit list).
README

git-graph – a Claude Code mod for the Claude desktop app

Two panes inside the Claude desktop app: a Git graph of the repo you're working in, and a Portfolio with one activity card per local project. Two buttons above the prompt open them, so you can see branches, worktrees and unpushed work without leaving the conversation.

<img src="docs/git-graph.svg" alt="The Git graph pane drawing the history of a real repository, with branch lanes and merge commits" width="860">

<sub>Drawn by the mod's own <code>renderSvg()</code> from the history of <a href="https://github.com/Abdulhamid-Baghdadi/double-scara-robot-ros">double-scara-robot-ros</a>. It follows your light or dark theme.</sub>

Features

⎇ Git graph (/git-graph [repo path])

  • Commit graph with branch lanes, merge commits, and pills for branches, tags, remotes and worktrees.
  • Hover a commit to see its hash, author, date and refs.
  • A dashed "working tree" row lists uncommitted files (new, modified, renamed, deleted).
  • Commit all with your own message, then Push. A branch with no upstream is published to the first remote.
  • Opens on the session's folder. Switch repo from a recent-repos list or by pasting a path.
  • Loads 150 commits at a time, with Show more up to 3,000. Refreshes every 30 s and after every Claude turn.

▦ Portfolio (/portfolio)

  • One card per project: commits in the last 24 h, branches (and how many are active), worktrees, uncommitted files, unpushed commits, and the last commit.
  • A status chip on each card: Needs attention (unpushed commits or 10+ uncommitted files), Active, Idle N days or Can't read. Cards needing attention sort first.
  • Projects come from the repos you've opened in the git graph, plus any you add under Edit list. ✕ untracks a project, and you can restore it later.
  • Click a project's name to open it in the git graph.

Privacy and safety

  • Everything runs local git through the mod API. Nothing about your repos leaves the machine unless you press Push.
  • The Portfolio only reads. Its tests check that it never runs commit, push, fetch, checkout or any other command that writes.
  • Every git call passes -c core.fsmonitor=false -c diff.external= -c core.pager=cat, so a cloned repo's own config can't make the pane run programs.
  • Pushes run with GIT_TERMINAL_PROMPT=0. A push that needs credentials fails with a message instead of hanging.

Install

  1. Clone this repo somewhere permanent, for example ~/.claude/mods/git-graph: ``bash git clone https://github.com/Abdulhamid-Baghdadi/claude-git-graph.git ~/.claude/mods/git-graph ``
  2. Add the folder to CLAUDE_CODE_PLUGIN_DIRS in the env block of ~/.claude/settings.json: ``json { "env": { "CLAUDE_CODE_PLUGIN_DIRS": "C:\\Users\\you\\.claude\\mods\\git-graph" } } ``
  3. Start a new session in the Claude desktop app (Code tab). The ⎇ Git graph and ▦ Portfolio buttons appear above the prompt.

How it's built

FileRole
hooks/register.tsxEverything that talks to the engine ($): commands, panes, the buttons above the prompt, git calls, refresh timers
hooks/graph.tsPure helpers: parse git log, assign lanes and edges, render the graph as SVG, read git status lines
hooks/portfolio.tsPure helpers: merge the project list, parse branch/worktree/log output, decide each card's status
types/index.d.tsShared types (Snapshot, Commit, ProjectFacts, …)
tests/23 tests against a mocked engine and fake git output; no real repo is touched

A few details:

  • Lane layout: each commit gets a lane. Each parent link is an edge that leaves its child's lane, runs down a lane of its own, and lands in the parent's lane.
  • Size limit: the engine refuses an SVG longer than 131,072 characters. Long histories are drawn as a stack of row ranges, and any range still too big is split in half.

Develop

claude plugin validate .
claude plugin test .
npx -p typescript tsc -p .

All code that calls the engine ($) lives in hooks/register.tsx, because the engine follows $ only within one file.

License

MIT © Abdulhamid Baghdadi

Source 4 files
hooks/register.tsx 743 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { GitAction, ProjectCard, ProjectFacts, ProjectStatus, Snapshot } from '../types'
5import { LOG_FORMAT, fileChange, parseLog, renderSvg, shortWhen, withWorkingRow } from './graph'
6import type { FileChange } from './graph'
7import {
8  DEFAULT_PROJECTS,
9  LAST_FORMAT,
10  age,
11  branchActivity,
12  errorStatus,
13  factLines,
14  lastCommit,
15  linkedWorktrees,
16  pathKey,
17  projectList,
18  sortCards,
19  statusOf,
20} from './portfolio'
21
22const PANE = 'git-graph'
23// Commits loaded at first, and how many more each "Show more" adds, up to MAX_LIMIT.
24const LIMIT = 150
25const MAX_LIMIT = 3000
26// Rows per Svg: a long graph is a stack of them, each under SVG_MAX.
27const CHUNK = 50
28// Uncommitted files listed in the changes box.
29const MAX_FILES = 200
30const REFRESH_MS = 30_000
31const SVG_MAX = 131_072
32
33const repo = atom({ plugin: 'git-graph', key: 'repo' } as const, '')
34const repos = atom({ plugin: 'git-graph', key: 'repos' } as const, [] as string[])
35const snapshot = atom({ plugin: 'git-graph', key: 'snapshot' } as const, null as Snapshot | null)
36const error = atom({ plugin: 'git-graph', key: 'error' } as const, null as { repo: string; message: string } | null)
37const isPicking = atom({ plugin: 'git-graph', key: 'isPicking' } as const, false)
38const limit = atom({ plugin: 'git-graph', key: 'limit' } as const, LIMIT)
39const action = atom({ plugin: 'git-graph', key: 'action' } as const, null as GitAction | null)
40
41// A repo's own config must not run programs when the pane reads it: no fsmonitor hook, no external diff or pager.
42// Not covered: clean/smudge filters a repo's own config defines; only open repos you got by cloning or made yourself.
43const SAFE = ['-c', 'core.fsmonitor=false', '-c', 'diff.external=', '-c', 'core.pager=cat', '-c', 'log.showSignature=false']
44
45const basename = (path: string) => path.replace(/[\\/]+$/, '').split(/[\\/]/).pop() || path
46const lines = (text: string) => text.split(/\r?\n/).filter(line => line.trim().length > 0)
47
48async function git($: EngineInterface, cwd: string, args: string[]) {
49  return $.process.run(['git', ...SAFE, ...args], { cwd, timeoutMs: 15_000 })
50}
51
52async function toplevel($: EngineInterface, path: string): Promise<string> {
53  const top = await git($, path, ['rev-parse', '--show-toplevel'])
54  if (top.exitCode !== 0) throw new Error(top.stderr.trim() || `${path} is not a git repository`)
55  return top.stdout.trim()
56}
57
58async function load($: EngineInterface, path: string, count: number): Promise<Snapshot> {
59  const root = await toplevel($, path)
60  const [log, status, branch, head, counts, worktrees, heads] = await Promise.all([
61    git($, root, ['log', '--branches', '--remotes', '--tags', 'HEAD', '--date-order', '-n', String(count + 1), `--format=${LOG_FORMAT}`]),
62    git($, root, ['status', '--porcelain']),
63    git($, root, ['rev-parse', '--abbrev-ref', 'HEAD']),
64    git($, root, ['rev-parse', 'HEAD']),
65    git($, root, ['rev-list', '--left-right', '--count', '@{u}...HEAD']),
66    git($, root, ['worktree', 'list', '--porcelain']),
67    git($, root, ['for-each-ref', '--format=%(refname:short)', 'refs/heads']),
68  ])
69  const commits = log.exitCode === 0 ? parseLog(log.stdout) : []
70  const [behind, ahead] = counts.exitCode === 0 ? counts.stdout.trim().split(/\s+/).map(Number) : []
71  const worktreeBranches = worktrees.stdout
72    .split(/\r?\n\r?\n/)
73    .slice(1)
74    .flatMap(block => lines(block).filter(line => line.startsWith('branch refs/heads/')))
75    .map(line => line.slice('branch refs/heads/'.length))
76
77  return {
78    repo: root,
79    branch: branch.exitCode === 0 ? branch.stdout.trim() : '(no commits yet)',
80    head: head.exitCode === 0 ? head.stdout.trim() : '',
81    commits: commits.slice(0, count),
82    dirty: lines(status.stdout).length,
83    files: lines(status.stdout).slice(0, MAX_FILES),
84    ahead: ahead ?? null,
85    behind: behind ?? null,
86    branches: lines(heads.stdout).map(line => line.trim()),
87    worktreeBranches,
88    isTruncated: commits.length > count,
89    loadedAt: await $.clock.now(),
90  }
91}
92
93const sameGraph = (a: Snapshot | null, b: Snapshot) =>
94  a !== null && JSON.stringify({ ...a, loadedAt: 0 }) === JSON.stringify({ ...b, loadedAt: 0 })
95
96let isLoading = false
97
98async function remember($: EngineInterface, path: string) {
99  const list = await update($, repos, list => [path, ...list.filter(other => other !== path)].slice(0, 12))
100  await $.store.set('repos', list)
101}
102
103// Draws `path` (or the current repo) and makes it the current one when it loads.
104async function refresh($: EngineInterface, path?: string): Promise<string | undefined> {
105  const target = path ?? (await read($, repo))
106  if (!target || (isLoading && path === undefined)) return
107  isLoading = true
108  try {
109    const next = await load($, target, await read($, limit))
110    if (path !== undefined || (await read($, repo)) !== next.repo) {
111      await update($, repo, () => next.repo)
112      await remember($, next.repo)
113    }
114    await update($, snapshot, current => (sameGraph(current, next) && current?.repo === next.repo ? current : next))
115    await update($, error, () => null)
116    return next.repo
117  } catch (err) {
118    const message = err instanceof Error ? err.message : String(err)
119    await update($, error, () => ({ repo: target, message }))
120    return undefined
121  } finally {
122    isLoading = false
123  }
124}
125
126const open = ($: EngineInterface) => $.ui.open({ id: PANE, title: 'Git graph' })
127
128// The pane's own write commands, run only when the person presses Commit or Push.
129// No terminal prompt: a push that needs credentials fails instead of hanging.
130async function gitWrite($: EngineInterface, cwd: string, args: string[], timeoutMs: number) {
131  return $.process.run(['git', ...SAFE, ...args], { cwd, timeoutMs, env: { GIT_TERMINAL_PROMPT: '0' } })
132}
133
134const lastLine = (text: string) => lines(text).pop()?.trim() ?? ''
135
136let isActing = false
137
138const CHANGE_COLOR: Record<FileChange['kind'], string> = { new: 'success', deleted: 'error', renamed: 'suggestion', modified: 'warning' }
139
140async function act($: EngineInterface, busy: string, work: (root: string) => Promise<GitAction>) {
141  const snap = await read($, snapshot)
142  if (!snap || isActing) return
143  isActing = true
144  await update($, action, () => ({ kind: 'busy', text: busy }))
145  try {
146    const result = await work(snap.repo)
147    await update($, action, () => result)
148  } catch (err) {
149    await update($, action, () => ({ kind: 'error', text: err instanceof Error ? err.message : String(err) }))
150  } finally {
151    isActing = false
152    await refresh($)
153  }
154}
155
156// Stages everything (new, changed and deleted files) and commits it with `message`.
157function commitAll($: EngineInterface, message: string) {
158  const text = message.trim()
159  if (!text) return update($, action, () => ({ kind: 'error', text: 'Write a commit message first.' }))
160  return act($, 'Committing…', async root => {
161    const add = await gitWrite($, root, ['add', '-A'], 60_000)
162    if (add.exitCode !== 0) return { kind: 'error', text: lastLine(add.stderr) || 'git add failed' }
163    const commit = await gitWrite($, root, ['commit', '-m', text], 60_000)
164    if (commit.exitCode !== 0) return { kind: 'error', text: lastLine(commit.stderr || commit.stdout) || 'git commit failed' }
165    return { kind: 'ok', text: lines(commit.stdout)[0]?.trim() || 'Committed.' }
166  })
167}
168
169// Pushes the checked-out branch; one with no upstream is published to the first remote.
170function push($: EngineInterface) {
171  return act($, 'Pushing…', async root => {
172    const snap = await read($, snapshot)
173    let args = ['push']
174    if (snap && snap.ahead === null) {
175      const remotes = await git($, root, ['remote'])
176      const remote = lines(remotes.stdout)[0]?.trim()
177      if (!remote) return { kind: 'error', text: 'This repo has no remote to push to.' }
178      args = ['push', '-u', remote, snap.branch]
179    }
180    const out = await gitWrite($, root, args, 120_000)
181    if (out.exitCode !== 0) return { kind: 'error', text: lastLine(out.stderr) || 'git push failed' }
182    return { kind: 'ok', text: lastLine(out.stderr) || 'Pushed.' }
183  })
184}
185
186async function showMore($: EngineInterface) {
187  await update($, limit, count => Math.min(MAX_LIMIT, count + LIMIT))
188  await refresh($)
189}
190
191// Switches the git graph pane to `path` and brings it up; false when it isn't a repo.
192async function showInGraph($: EngineInterface, path: string): Promise<boolean> {
193  const found = await refresh($, path)
194  if (!found) return false
195  await update($, isPicking, () => false)
196  await open($)
197  return true
198}
199
200// Portfolio: one card per local project.
201
202const PORTFOLIO_PANE = 'portfolio'
203const PORTFOLIO_REFRESH_MS = 60_000
204// Repos read at once; each runs about ten git commands.
205const PARALLEL = 3
206
207const portfolio = atom({ plugin: 'git-graph', key: 'portfolio' } as const, [] as ProjectCard[])
208const portfolioAt = atom({ plugin: 'git-graph', key: 'portfolioAt' } as const, 0)
209const isPortfolioLoading = atom({ plugin: 'git-graph', key: 'isPortfolioLoading' } as const, false)
210const isPortfolioEditing = atom({ plugin: 'git-graph', key: 'isPortfolioEditing' } as const, false)
211
212const unquote = (path: string) => path.trim().replace(/^["']|["']$/g, '')
213const strings = (value: unknown) => (Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : [])
214
215// Read-only git only: `--no-optional-locks` keeps `status` from refreshing the index.
216async function readOnlyGit($: EngineInterface, cwd: string, args: string[]) {
217  return $.process.run(['git', ...SAFE, '--no-optional-locks', ...args], { cwd, timeoutMs: 15_000 })
218}
219
220async function readFacts($: EngineInterface, path: string, nowSec: number): Promise<ProjectFacts> {
221  const top = await readOnlyGit($, path, ['rev-parse', '--show-toplevel'])
222  if (top.exitCode !== 0) throw new Error(top.stderr.trim().split(/\r?\n/)[0] || 'Not a git repository')
223  const root = top.stdout.trim()
224  const [branch, recent, heads, worktrees, status, remotes, unpushed, upstream, last] = await Promise.all([
225    readOnlyGit($, root, ['rev-parse', '--abbrev-ref', 'HEAD']),
226    readOnlyGit($, root, ['rev-list', '--count', '--branches', `--since=@${nowSec - 86_400}`]),
227    readOnlyGit($, root, ['for-each-ref', '--format=%(committerdate:unix)', 'refs/heads']),
228    readOnlyGit($, root, ['worktree', 'list', '--porcelain']),
229    readOnlyGit($, root, ['status', '--porcelain']),
230    readOnlyGit($, root, ['remote']),
231    readOnlyGit($, root, ['rev-list', '--count', '--branches', '--not', '--remotes']),
232    readOnlyGit($, root, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}']),
233    readOnlyGit($, root, ['log', '-1', '--branches', `--format=${LAST_FORMAT}`]),
234  ])
235  const hasRemote = remotes.exitCode === 0 && remotes.stdout.trim().length > 0
236  return {
237    root,
238    branch: branch.exitCode === 0 ? branch.stdout.trim() : '(no commits yet)',
239    commits24h: recent.exitCode === 0 ? Number(recent.stdout.trim()) || 0 : 0,
240    ...branchActivity(heads.stdout, nowSec),
241    worktrees: worktrees.exitCode === 0 ? linkedWorktrees(worktrees.stdout) : 0,
242    dirty: status.stdout.split(/\r?\n/).filter(line => line.trim()).length,
243    hasRemote,
244    unpushed: hasRemote && unpushed.exitCode === 0 ? Number(unpushed.stdout.trim()) || 0 : null,
245    hasUpstream: upstream.exitCode === 0,
246    ...(last.exitCode === 0 ? lastCommit(last.stdout) : { lastCommitAt: null, lastSubject: '' }),
247  }
248}
249
250async function listed($: EngineInterface): Promise<{ paths: string[]; hidden: string[] }> {
251  const [recent, added, hidden] = await Promise.all([
252    $.store.get('repos'),
253    $.store.get('portfolio.added'),
254    $.store.get('portfolio.hidden'),
255  ])
256  return { paths: projectList(DEFAULT_PROJECTS, strings(recent), strings(added), strings(hidden)), hidden: strings(hidden) }
257}
258
259let isPortfolioRunning = false
260
261async function reloadPortfolio($: EngineInterface) {
262  if (isPortfolioRunning) return
263  isPortfolioRunning = true
264  await update($, isPortfolioLoading, () => true)
265  try {
266    // A deleted folder drops off the list; it comes back if the folder does.
267    const listedPaths = (await listed($)).paths
268    const present = await Promise.all(listedPaths.map(path => $.fs.exists(path).catch(() => true)))
269    const paths = listedPaths.filter((_, index) => present[index])
270    const nowSec = Math.floor((await $.clock.now()) / 1000)
271    const out: ProjectCard[] = new Array(paths.length)
272    let cursor = 0
273    const worker = async () => {
274      while (cursor < paths.length) {
275        const index = cursor++
276        const path = paths[index] ?? ''
277        try {
278          const facts = await readFacts($, path, nowSec)
279          out[index] = { path, facts, error: null, status: statusOf(facts, nowSec) }
280        } catch (err) {
281          const message = err instanceof Error ? err.message : String(err)
282          out[index] = { path, facts: null, error: message, status: errorStatus(message) }
283        }
284      }
285    }
286    await Promise.all(Array.from({ length: PARALLEL }, worker))
287    // Two listed folders inside one repo show as one card.
288    const seen = new Set<string>()
289    const unique = out.filter(card => {
290      const key = pathKey(card.facts?.root ?? card.path)
291      if (seen.has(key)) return false
292      seen.add(key)
293      return true
294    })
295    await update($, portfolio, () => sortCards(unique))
296    await update($, portfolioAt, () => nowSec)
297  } finally {
298    await update($, isPortfolioLoading, () => false)
299    isPortfolioRunning = false
300  }
301}
302
303async function addProject($: EngineInterface, raw: string) {
304  const path = unquote(raw)
305  if (!path) return
306  const key = pathKey(path)
307  const added = strings(await $.store.get('portfolio.added')).filter(other => pathKey(other) !== key)
308  const hidden = strings(await $.store.get('portfolio.hidden')).filter(other => pathKey(other) !== key)
309  await $.store.set('portfolio.added', [...added, path])
310  await $.store.set('portfolio.hidden', hidden)
311  await reloadPortfolio($)
312}
313
314async function removeProject($: EngineInterface, card: ProjectCard) {
315  const keys = new Set([pathKey(card.path), card.facts ? pathKey(card.facts.root) : ''])
316  const added = strings(await $.store.get('portfolio.added')).filter(other => !keys.has(pathKey(other)))
317  const hidden = strings(await $.store.get('portfolio.hidden')).filter(other => !keys.has(pathKey(other)))
318  await $.store.set('portfolio.added', added)
319  await $.store.set('portfolio.hidden', [...hidden, card.path])
320  await update($, portfolio, list => list.filter(other => other.path !== card.path))
321}
322
323async function restoreProject($: EngineInterface, path: string) {
324  const hidden = strings(await $.store.get('portfolio.hidden')).filter(other => pathKey(other) !== pathKey(path))
325  await $.store.set('portfolio.hidden', hidden)
326  await reloadPortfolio($)
327}
328
329const isShown = async ($: EngineInterface) => (await $.ui.panes()).some(pane => pane.id === PORTFOLIO_PANE && pane.isShown)
330
331const CHIP: Record<ProjectStatus['kind'], string> = {
332  attention: 'warning',
333  active: 'success',
334  idle: 'inactive',
335  error: 'error',
336}
337
338// From the plugin's `session.start`: the command, and a refresh while the pane is shown.
339async function startPortfolio($: EngineInterface) {
340  await $.command.register({
341    name: 'portfolio',
342    description: 'Show one card per local project: commits today, branches, uncommitted and unpushed work',
343  })
344  $.clock.every(PORTFOLIO_REFRESH_MS, () => {
345    void isShown($).then(shown => {
346      if (shown) void reloadPortfolio($)
347    })
348  })
349}
350
351// From the plugin's `turn.complete`.
352async function portfolioAfterTurn($: EngineInterface) {
353  const nowSec = Math.floor((await $.clock.now()) / 1000)
354  if (nowSec - (await read($, portfolioAt)) > 30 && (await isShown($))) void reloadPortfolio($)
355}
356
357export const register: Register = on => {
358  // Two buttons above the prompt, so neither pane needs a typed command.
359  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
360    if (e.props.hasSurvey) return next(e)
361    const { Box, Button } = $.ui.resolve(e)
362    return (
363      <Box flexDirection="row" gap={1}>
364        <Button
365          key="show-graph"
366          dimColor
367          label="⎇ Git graph"
368          onPress={() => {
369            void open($)
370            void refresh($)
371          }}
372        />
373        <Button
374          key="show-portfolio"
375          dimColor
376          label="▦ Portfolio"
377          onPress={() => {
378            void $.ui.open({ id: PORTFOLIO_PANE, title: 'Portfolio' })
379            void reloadPortfolio($)
380          }}
381        />
382      </Box>
383    )
384  })
385
386  on('session.start', async ($, e, next) => {
387    await $.command.register({
388      name: 'git-graph',
389      description: 'Show the git graph pane; pass a repo path to switch to it',
390      argumentHint: '[repo path]',
391    })
392    const stored = await $.store.get('repos')
393    if (Array.isArray(stored)) await update($, repos, () => stored.filter(item => typeof item === 'string'))
394
395    void (async () => {
396      const cwd = await $.session.cwd()
397      const found = await refresh($, cwd)
398      if (found) void open($)
399      else {
400        await update($, error, () => null)
401        const [last] = await read($, repos)
402        if (last && !(await read($, repo))) await refresh($, last)
403      }
404    })()
405    $.clock.every(REFRESH_MS, () => void refresh($))
406    await startPortfolio($)
407
408    return next(e)
409  })
410
411  on('turn.complete', async ($, e, next) => {
412    const result = await next(e)
413    void refresh($)
414    void portfolioAfterTurn($)
415    return result
416  })
417
418  on('command.run', { command: 'git-graph' }, async ($, e) => {
419    const path = e.args.trim().replace(/^["']|["']$/g, '')
420    await open($)
421    if (!path) {
422      void refresh($)
423      return { text: 'Git graph pane opened.' }
424    }
425    const found = await refresh($, path)
426    if (found) await update($, isPicking, () => false)
427    return { text: found ? `Git graph now shows ${found}.` : `Couldn't read a git repo at ${path}.` }
428  })
429
430  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
431    const table = $.ui.resolve(e)
432    const { Box, Text, Button } = table
433    const current = await read($, repo)
434    const snap = await read($, snapshot)
435    const problem = await read($, error)
436    const picking = (await read($, isPicking)) || !current
437    const known = await read($, repos)
438    const shown = snap && snap.repo === current ? snap : null
439
440    const choose = async (path: string) => {
441      const found = await refresh($, path)
442      if (found) await update($, isPicking, () => false)
443    }
444
445    const picker = (
446      <Box flexDirection="column" gap={1} marginBottom={1}>
447        {known.length > 0 && 'Select' in table && (
448          <table.Select
449            key="recent"
450            label="Recent repos"
451            value={known.includes(current) ? current : undefined}
452            options={known.map(path => ({ value: path, label: `${basename(path)}  ${path}` }))}
453            onSelect={path => void choose(path)}
454          />
455        )}
456        {'Input' in table && (
457          <table.Input
458            key="path"
459            label="Repo folder"
460            placeholder="C:\Users\you\Desktop\my-project"
461            submitLabel="show"
462            onSubmit={path => void choose(path.trim().replace(/^["']|["']$/g, ''))}
463          />
464        )}
465        <Box flexDirection="row" gap={1}>
466          <Button key="cwd" label="Use this session's folder" onPress={() => void $.session.cwd().then(choose)} />
467          {current && <Button key="cancel" label="Cancel" onPress={() => void update($, isPicking, () => false)} />}
468        </Box>
469      </Box>
470    )
471
472    const last = await read($, action)
473    const canPush = !!shown && !!shown.head && (shown.ahead === null || shown.ahead > 0)
474    const changes = shown && (shown.dirty > 0 || canPush || last) && (
475      <Box flexDirection="column" borderStyle="round" borderColor={shown.dirty > 0 ? 'warning' : 'inactive'} paddingX={1} marginBottom={1}>
476        {shown.dirty > 0 ? (
477          <Box flexDirection="column">
478            <Text bold>
479              {shown.dirty === 1 ? '1 uncommitted file' : `${shown.dirty} uncommitted files`}
480            </Text>
481            {shown.files.slice(0, 12).map(line => {
482              const change = fileChange(line)
483              return (
484                <Text wrap="truncate-middle">
485                  <Text color={CHANGE_COLOR[change.kind]}>{change.kind}</Text> {change.path}
486                </Text>
487              )
488            })}
489            {shown.dirty > 12 && <Text dimColor>…and {shown.dirty - 12} more</Text>}
490            {'Input' in table && (
491              <table.Input
492                key="commit-message"
493                label="Commit message"
494                placeholder="What did you change?"
495                submitLabel="Commit all"
496                onSubmit={message => void commitAll($, message)}
497              />
498            )}
499          </Box>
500        ) : (
501          <Text dimColor>No uncommitted changes.</Text>
502        )}
503        {canPush && (
504          <Box flexDirection="row" gap={1} marginTop={1}>
505            <Button
506              key="push"
507              variant="primary"
508              label={
509                shown.ahead === null
510                  ? `Publish ${shown.branch}`
511                  : `Push ${shown.ahead === 1 ? '1 commit' : `${shown.ahead} commits`}`
512              }
513              onPress={() => void push($)}
514            />
515          </Box>
516        )}
517        {last && (
518          <Text color={last.kind === 'error' ? 'error' : last.kind === 'ok' ? 'success' : undefined} dimColor={last.kind === 'busy'} wrap="wrap">
519            {last.text}
520          </Text>
521        )}
522      </Box>
523    )
524
525    const header = shown && (
526      <Box flexDirection="column" marginBottom={1}>
527        <Box flexDirection="row" gap={1} alignItems="center">
528          <Text bold>{basename(shown.repo)}</Text>
529          <Text dimColor>on {shown.branch}</Text>
530          <Box flexGrow={1} />
531          <Button key="refresh" label="Refresh" onPress={() => void refresh($)} />
532          <Button key="switch" label="Switch repo" onPress={() => void update($, isPicking, () => true)} />
533        </Box>
534        <Text dimColor wrap="wrap">
535          {[
536            `${shown.branches.length} ${shown.branches.length === 1 ? 'branch' : 'branches'}`,
537            shown.worktreeBranches.length > 0 && `${shown.worktreeBranches.length} worktrees (⎇)`,
538            shown.dirty > 0 && `${shown.dirty} uncommitted`,
539            shown.ahead !== null && shown.ahead > 0 && `${shown.ahead} not pushed`,
540            shown.behind !== null && shown.behind > 0 && `${shown.behind} to pull`,
541            shown.ahead === null && shown.head && 'no upstream',
542          ]
543            .filter(Boolean)
544            .join(' · ')}
545        </Text>
546      </Box>
547    )
548
549    const columns = e.props.bodyColumns
550    let graph
551    const shownCount = shown?.commits.length ?? 0
552    if (!shown) graph = !picking && <Text dimColor>Loading…</Text>
553    else if (shown.commits.length === 0) graph = <Text dimColor>No commits yet.</Text>
554    else if ('Svg' in table) {
555      const width = Math.round(Math.min(1100, Math.max(360, columns * 7.5)))
556      // The engine refuses an Svg past SVG_MAX characters, and with it the whole pane: draw the graph
557      // as a stack of row ranges, halving any range that is still too big.
558      const rows = withWorkingRow(shown).length
559      const pieces: { source: string; height: number; from: number }[] = []
560      const draw = (from: number, to: number) => {
561        const piece = renderSvg(shown, width, from, to)
562        if (piece.source.length > SVG_MAX && to - from > 1) {
563          const mid = from + Math.floor((to - from) / 2)
564          draw(from, mid)
565          draw(mid, to)
566        } else if (piece.source.length <= SVG_MAX) pieces.push({ ...piece, from })
567      }
568      for (let from = 0; from < rows; from += CHUNK) draw(from, Math.min(rows, from + CHUNK))
569      graph = (
570        <Box flexDirection="column">
571          {pieces.map(piece => (
572            <table.Svg
573              source={piece.source}
574              alt={`Commit graph of ${basename(shown.repo)}, from row ${piece.from + 1}`}
575              width={width}
576              height={piece.height}
577              isInteractive
578            />
579          ))}
580        </Box>
581      )
582    } else {
583      const room = Math.max(1, (e.viewport?.rows ?? 30) - 6)
584      graph = (
585        <Box flexDirection="column">
586          {withWorkingRow(shown)
587            .slice(0, room)
588            .map(commit => (
589              <Text wrap="truncate-end">
590                {commit.isWorking ? '◌' : '●'} {commit.subject} <Text dimColor>{shortWhen(commit.when)}</Text>
591              </Text>
592            ))}
593        </Box>
594      )
595    }
596
597    return (
598      <Box flexDirection="column">
599        {header}
600        {problem && (
601          <Text color="error" wrap="wrap">
602            {problem.message}
603          </Text>
604        )}
605        {picking && picker}
606        {changes}
607        {graph}
608        {shown && (
609          <Box flexDirection="row" gap={1} alignItems="center" marginTop={1}>
610            <Text dimColor>
611              {shown.isTruncated ? `Showing the latest ${shownCount} commits.` : `All ${shownCount} commits.`} Hover a commit for details.
612            </Text>
613            {shown.isTruncated && shownCount < MAX_LIMIT && (
614              <Button key="more" label={`Show ${LIMIT} more`} onPress={() => void showMore($)} />
615            )}
616          </Box>
617        )}
618      </Box>
619    )
620  })
621
622  on('command.run', { command: 'portfolio' }, async $ => {
623    await $.ui.open({ id: PORTFOLIO_PANE, title: 'Portfolio' })
624    void reloadPortfolio($)
625    return { text: 'Portfolio pane opened.' }
626  })
627
628  on('ui.render', { component: 'Pane', requestId: PORTFOLIO_PANE }, async ($, e) => {
629    const table = $.ui.resolve(e)
630    const { Box, Text, Button } = table
631    const list = await read($, portfolio)
632    const at = await read($, portfolioAt)
633    const loading = await read($, isPortfolioLoading)
634    const editing = await read($, isPortfolioEditing)
635    const nowSec = Math.floor((await $.clock.now()) / 1000)
636    const hidden = editing ? (await listed($)).hidden : []
637
638    const count = (kind: ProjectStatus['kind']) => list.filter(card => card.status.kind === kind).length
639    const summary = [
640      count('attention') > 0 && `${count('attention')} need attention`,
641      count('active') > 0 && `${count('active')} active`,
642      count('idle') > 0 && `${count('idle')} idle`,
643      count('error') > 0 && `${count('error')} unreadable`,
644    ]
645      .filter(Boolean)
646      .join(' · ')
647
648    const columns = Math.max(30, e.props.bodyColumns)
649    const perRow = Math.max(1, Math.floor(columns / 40))
650    const width = Math.floor((columns - (perRow - 1)) / perRow)
651
652    const open = async (card: ProjectCard) => {
653      const ok = await showInGraph($, card.facts?.root ?? card.path)
654      if (!ok) $.ui.toast(`Couldn't open ${basename(card.path)} in the git graph.`)
655    }
656
657    const card = (item: ProjectCard, index: number) => {
658      const color = CHIP[item.status.kind]
659      const name = basename(item.facts?.root ?? item.path)
660      return (
661        <Box
662          key={`card-${index}`}
663          flexDirection="column"
664          borderStyle="round"
665          borderColor={color}
666          paddingX={1}
667          width={width}
668          hover={{ borderColor: 'claude' }}
669        >
670          <Box flexDirection="row" gap={1}>
671            <Button key={`open-${index}`} plain label={name} onPress={() => void open(item)} />
672            <Box flexGrow={1} />
673            <Text color={color} bold>
674              {item.status.label}
675            </Text>
676            <Button key={`untrack-${index}`} plain dimColor label="✕" onPress={() => void removeProject($, item)} />
677          </Box>
678          {item.status.kind === 'attention' && (
679            <Text color={color} wrap="wrap">
680              {item.status.reasons.join(' · ')}
681            </Text>
682          )}
683          {item.facts ? (
684            factLines(item.facts, nowSec).map(line => (
685              <Text dimColor wrap="truncate-end">
686                {line}
687              </Text>
688            ))
689          ) : (
690            <Text dimColor wrap="wrap">
691              {item.error}
692            </Text>
693          )}
694          <Text dimColor wrap="truncate-middle">
695            {item.facts?.root ?? item.path}
696          </Text>
697        </Box>
698      )
699    }
700
701    return (
702      <Box flexDirection="column" gap={1}>
703        <Box flexDirection="row" gap={1} alignItems="center">
704          <Text bold>{list.length === 1 ? '1 project' : `${list.length} projects`}</Text>
705          <Text dimColor>
706            {loading ? 'reading…' : at > 0 ? `updated ${age(Math.max(0, nowSec - at))}` : ''}
707          </Text>
708          <Box flexGrow={1} />
709          <Button key="refresh" label="Refresh" onPress={() => void reloadPortfolio($)} />
710          <Button key="edit" label={editing ? 'Done' : 'Edit list'} onPress={() => void update($, isPortfolioEditing, value => !value)} />
711        </Box>
712        {summary && <Text>{summary}</Text>}
713        {editing && (
714          <Box flexDirection="column" gap={1}>
715            {'Input' in table && (
716              <table.Input
717                key="add"
718                label="Add a repo folder"
719                placeholder="C:\Users\you\Desktop\my-project"
720                submitLabel="add"
721                onSubmit={path => void addProject($, path)}
722              />
723            )}
724            {hidden.length > 0 && (
725              <Box flexDirection="row" flexWrap="wrap" gap={1}>
726                <Text dimColor>Removed:</Text>
727                {hidden.map((path, index) => (
728                  <Button key={`restore-${index}`} dimColor label={`↺ ${basename(path)}`} onPress={() => void restoreProject($, path)} />
729                ))}
730              </Box>
731            )}
732          </Box>
733        )}
734        {list.length === 0 && <Text dimColor>{loading ? 'Reading your projects…' : 'No projects listed.'}</Text>}
735        <Box flexDirection="row" flexWrap="wrap" columnGap={1} rowGap={1}>
736          {list.map(card)}
737        </Box>
738        <Text dimColor>Click a project's name to show it in the git graph · ✕ untracks it (restore under Edit list).</Text>
739      </Box>
740    )
741  })
742}
743
hooks/graph.ts 249 lines
1import type { Commit, Snapshot } from '../types'
2
3const FIELD = '\x1f'
4const RECORD = '\x1e'
5
6export const LOG_FORMAT = ['%H', '%P', '%D', '%an', '%cr', '%ci', '%s'].join('%x1f') + '%x1e'
7
8export function parseLog(stdout: string): Commit[] {
9  return stdout
10    .split(RECORD)
11    .map(record => record.replace(/^\s+/, ''))
12    .filter(record => record.length > 0)
13    .map(record => {
14      const [hash = '', parents = '', refs = '', author = '', when = '', date = '', ...subject] =
15        record.split(FIELD)
16      return {
17        hash,
18        parents: parents.split(' ').filter(Boolean),
19        refs: refs.split(', ').map(ref => ref.trim()).filter(Boolean),
20        author,
21        when,
22        date,
23        subject: subject.join(FIELD).trim(),
24      }
25    })
26}
27
28// Each commit gets a lane; each parent link is an edge that leaves its child's
29// lane, travels down `via`, and lands in the parent's lane at the parent's row.
30export type Edge = { fromRow: number; fromLane: number; via: number; toRow: number; toLane: number }
31export type Layout = { lanes: number[]; edges: Edge[]; width: number }
32
33export function layout(commits: readonly Commit[]): Layout {
34  const open: (string | null)[] = []
35  const lanes: number[] = []
36  const pending: { fromRow: number; fromLane: number; via: number; parent: string }[] = []
37  const rowOf = new Map(commits.map((commit, row) => [commit.hash, row]))
38  let width = 1
39
40  const claim = (hash: string) => {
41    const free = open.indexOf(null)
42    const lane = free === -1 ? open.length : free
43    open[lane] = hash
44    return lane
45  }
46
47  commits.forEach((commit, row) => {
48    let lane = open.indexOf(commit.hash)
49    if (lane === -1) lane = claim(commit.hash)
50    open.forEach((hash, other) => {
51      if (other !== lane && hash === commit.hash) open[other] = null
52    })
53    lanes[row] = lane
54
55    if (commit.parents.length === 0) open[lane] = null
56    commit.parents.forEach((parent, index) => {
57      let via = lane
58      if (index === 0) open[lane] = parent
59      else {
60        via = open.indexOf(parent)
61        if (via === -1) via = claim(parent)
62      }
63      pending.push({ fromRow: row, fromLane: lane, via, parent })
64    })
65    width = Math.max(width, open.length)
66  })
67
68  const edges = pending.map(({ fromRow, fromLane, via, parent }) => {
69    const toRow = rowOf.get(parent)
70    return toRow === undefined
71      ? { fromRow, fromLane, via, toRow: commits.length, toLane: via }
72      : { fromRow, fromLane, via, toRow, toLane: lanes[toRow] ?? via }
73  })
74
75  return { lanes, edges, width }
76}
77
78const COLORS = ['#7F77DD', '#1D9E75', '#D85A30', '#378ADD', '#D4537E', '#BA7517', '#639922', '#888780']
79const ROW = 24
80const LANE = 14
81const PAD = 12
82const CHAR = 6.6
83const TIP_MAX = 160
84
85const escape = (text: string) =>
86  text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
87
88const clip = (text: string, room: number) => {
89  const chars = Math.max(0, Math.floor(room / CHAR))
90  return text.length <= chars ? text : text.slice(0, Math.max(0, chars - 1)) + '…'
91}
92
93// "3 hours ago" -> "3h"; git's relative dates are English whatever the locale.
94export function shortWhen(when: string): string {
95  const match = /^(\d+) (second|minute|hour|day|week|month|year)s? ago/.exec(when)
96  if (!match) return when
97  const unit = { second: 's', minute: 'm', hour: 'h', day: 'd', week: 'w', month: 'mo', year: 'y' }[match[2] ?? '']
98  return `${match[1]}${unit ?? ''}`
99}
100
101type Pill = { text: string; kind: 'head' | 'local' | 'remote' | 'tag' | 'worktree' }
102
103export function pills(refs: readonly string[], branches: readonly string[], worktrees: readonly string[]): Pill[] {
104  const here = new Set(refs.map(ref => (ref.startsWith('HEAD -> ') ? ref.slice(8) : ref)))
105  const out: Pill[] = []
106  for (const ref of refs) {
107    if (ref === 'HEAD') out.push({ text: 'HEAD', kind: 'head' })
108    else if (ref.startsWith('HEAD -> ')) out.push({ text: ref.slice(8), kind: 'head' })
109    else if (ref.startsWith('tag: ')) out.push({ text: ref.slice(5), kind: 'tag' })
110    else if (branches.includes(ref)) out.push({ text: ref, kind: worktrees.includes(ref) ? 'worktree' : 'local' })
111    else {
112      // A remote branch sitting on the same commit as its local twin adds nothing.
113      const name = ref.slice(ref.indexOf('/') + 1)
114      if (name !== 'HEAD' && !here.has(name)) out.push({ text: ref, kind: 'remote' })
115    }
116  }
117  return out
118}
119
120export function withWorkingRow(snapshot: Snapshot): Commit[] {
121  if (snapshot.dirty === 0 || !snapshot.commits.some(commit => commit.hash === snapshot.head)) {
122    return snapshot.commits
123  }
124  const files = snapshot.dirty === 1 ? '1 file' : `${snapshot.dirty} files`
125  return [
126    {
127      hash: '',
128      parents: [snapshot.head],
129      refs: [],
130      author: '',
131      when: 'now',
132      date: '',
133      subject: `Uncommitted changes (${files})`,
134      isWorking: true,
135    },
136    ...snapshot.commits,
137  ]
138}
139
140// Rows `from` up to `to` of the whole graph (all of it by default): a long history is drawn as
141// several of these stacked, since the engine refuses one Svg past 131072 characters.
142export function renderSvg(snapshot: Snapshot, width: number, from = 0, to = Infinity): { source: string; height: number } {
143  const commits = withWorkingRow(snapshot)
144  const { lanes, edges, width: laneCount } = layout(commits)
145  const height = Math.max(1, commits.length) * ROW + 8
146  const top = from <= 0 ? 0 : 4 + from * ROW
147  const bottom = to >= commits.length ? height : 4 + to * ROW
148  const x = (lane: number) => PAD + lane * LANE
149  const y = (row: number) => 4 + row * ROW + ROW / 2
150  const color = (lane: number) => COLORS[lane % COLORS.length]
151  const textX = PAD + laneCount * LANE + 4
152  const parts: string[] = []
153
154  for (const edge of edges) {
155    const x0 = x(edge.fromLane)
156    const y0 = y(edge.fromRow)
157    const xv = x(edge.via)
158    const xt = x(edge.toLane)
159    const yt = edge.toRow >= commits.length ? height : y(edge.toRow)
160    if (yt < top || y0 > bottom) continue
161    const half = ROW / 2
162    let d = `M${x0},${y0}`
163    if (edge.toRow === edge.fromRow + 1 && edge.toRow < commits.length) {
164      d += x0 === xt ? ` L${xt},${yt}` : ` C${x0},${y0 + half} ${xt},${yt - half} ${xt},${yt}`
165    } else {
166      if (xv !== x0) d += ` C${x0},${y0 + half} ${xv},${y0 + half} ${xv},${y0 + ROW}`
167      d += ` L${xv},${yt - ROW}`
168      d += xt === xv ? ` L${xt},${yt}` : ` C${xv},${yt - half} ${xt},${yt - half} ${xt},${yt}`
169    }
170    const isWorking = commits[edge.fromRow]?.isWorking === true
171    parts.push(
172      `<path d="${d}" stroke="${color(edge.via)}" stroke-width="2" fill="none"${isWorking ? ' stroke-dasharray="3 3"' : ''}/>`,
173    )
174  }
175
176  commits.forEach((commit, row) => {
177    if (row < from || row >= to) return
178    const lane = lanes[row] ?? 0
179    const cy = y(row)
180    // Tooltips are most of the markup, and the engine refuses an Svg past 131072 characters: keep each short.
181    const short = (text: string, max: number) => (text.length > max ? `${text.slice(0, max - 1)}…` : text)
182    const tip = [
183      commit.isWorking ? commit.subject : `${commit.hash.slice(0, 8)}  ${short(commit.subject, TIP_MAX)}`,
184      commit.author && `${commit.author} · ${commit.date}`,
185      commit.refs.length > 0 && short(commit.refs.join(', '), TIP_MAX),
186    ]
187      .filter(Boolean)
188      .join('\n')
189    parts.push(`<g><title>${escape(tip)}</title>`)
190    parts.push(`<rect class="hit" x="0" y="${cy - ROW / 2}" width="${width}" height="${ROW}"/>`)
191    if (commit.isWorking) {
192      parts.push(`<circle cx="${x(lane)}" cy="${cy}" r="5" fill="none" stroke="${color(lane)}" stroke-width="2" stroke-dasharray="2 2"/>`)
193    } else if (commit.parents.length > 1) {
194      parts.push(`<circle cx="${x(lane)}" cy="${cy}" r="5" class="bg" stroke="${color(lane)}" stroke-width="2.5"/>`)
195    } else {
196      parts.push(`<circle cx="${x(lane)}" cy="${cy}" r="5" fill="${color(lane)}"/>`)
197    }
198
199    const when = shortWhen(commit.when)
200    const whenX = width - PAD
201    let cursor = textX
202    for (const pill of pills(commit.refs, snapshot.branches, snapshot.worktreeBranches)) {
203      const label = clip(pill.kind === 'worktree' ? `${pill.text} ⎇` : pill.text, 150)
204      const w = label.length * 6.1 + 10
205      if (cursor + w > whenX - 120) break
206      const stroke = pill.kind === 'remote' || pill.kind === 'tag' ? 'var(--muted)' : color(lane)
207      const fill = pill.kind === 'remote' || pill.kind === 'tag' ? 'none' : color(lane)
208      parts.push(
209        `<rect x="${cursor}" y="${cy - 8}" width="${w}" height="16" rx="4" fill="${fill}" fill-opacity="${fill === 'none' ? 0 : 0.18}" stroke="${stroke}" stroke-width="${pill.kind === 'head' ? 1.5 : 0.75}"/>`,
210        `<text x="${cursor + 5}" y="${cy + 4}" class="pill${pill.kind === 'head' ? ' strong' : ''}">${escape(label)}</text>`,
211      )
212      cursor += w + 4
213    }
214    const room = whenX - 34 - cursor
215    parts.push(
216      `<text x="${cursor}" y="${cy + 4}" class="${commit.isWorking ? 'msg working' : 'msg'}">${escape(clip(commit.subject, room))}</text>`,
217      `<text x="${whenX}" y="${cy + 4}" class="when" text-anchor="end">${escape(when)}</text>`,
218      `</g>`,
219    )
220  })
221
222  const style = [
223    'svg{--fg:#2c2c2a;--muted:#888780;--bg:#ffffff;--hover:rgba(0,0,0,0.05)}',
224    '@media (prefers-color-scheme:dark){svg{--fg:#e8e6df;--muted:#9c9a92;--bg:#262624;--hover:rgba(255,255,255,0.07)}}',
225    'text{font-family:system-ui,-apple-system,"Segoe UI",sans-serif;font-size:12px;fill:var(--fg)}',
226    '.pill{font-size:11px}.strong{font-weight:600}.when{fill:var(--muted);font-size:11px}',
227    '.working{fill:var(--muted);font-style:italic}.bg{fill:var(--bg)}',
228    '.hit{fill:transparent}g:hover .hit{fill:var(--hover)}',
229  ].join('')
230
231  const h = bottom - top
232  const source =
233    `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${h}" viewBox="0 ${top} ${width} ${h}">` +
234    `<style>${style}</style><rect class="bg" y="${top}" width="${width}" height="${h}"/>${parts.join('')}</svg>`
235  return { source, height: h }
236}
237
238export type FileChange = { kind: 'new' | 'deleted' | 'renamed' | 'modified'; path: string }
239
240// One `git status --porcelain` line ("XY path", or "R  old -> new").
241export function fileChange(line: string): FileChange {
242  const code = line.slice(0, 2)
243  const path = line.slice(3)
244  if (code === '??' || code.includes('A')) return { kind: 'new', path }
245  if (code.includes('D')) return { kind: 'deleted', path }
246  if (code.includes('R')) return { kind: 'renamed', path }
247  return { kind: 'modified', path }
248}
249
hooks/portfolio.ts 119 lines
1import type { ProjectCard, ProjectFacts, ProjectStatus } from '../types'
2
3const DAY = 86_400
4
5// A project with this many uncommitted files needs attention even if nothing is unpushed.
6export const DIRTY_ATTENTION = 10
7// A branch with a commit this recent counts as active.
8export const ACTIVE_BRANCH_DAYS = 7
9
10// Projects shown before any are added or opened in the git graph; none by default.
11export const DEFAULT_PROJECTS: readonly string[] = []
12
13const lines = (text: string) => text.split(/\r?\n/).filter(line => line.trim().length > 0)
14const plural = (n: number, one: string, many = `${one}s`) => `${n} ${n === 1 ? one : many}`
15
16// Windows paths compare without case, and git prints them with forward slashes.
17export const pathKey = (path: string) => path.trim().replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase()
18
19// The defaults, then the repos the git graph has shown, then ones added here; minus hidden ones.
20export function projectList(
21  defaults: readonly string[],
22  recent: readonly string[],
23  added: readonly string[],
24  hidden: readonly string[],
25): string[] {
26  const out = new Map<string, string>()
27  const gone = new Set(hidden.map(pathKey))
28  for (const path of [...defaults, ...recent, ...added]) {
29    const key = pathKey(path)
30    if (path.trim() && !gone.has(key) && !out.has(key)) out.set(key, path.trim())
31  }
32  return [...out.values()]
33}
34
35// `for-each-ref --format=%(committerdate:unix) refs/heads`
36export function branchActivity(stdout: string, nowSec: number): { branches: number; activeBranches: number } {
37  const dates = lines(stdout).map(line => Number(line.trim()))
38  return {
39    branches: dates.length,
40    activeBranches: dates.filter(at => Number.isFinite(at) && nowSec - at <= ACTIVE_BRANCH_DAYS * DAY).length,
41  }
42}
43
44// `worktree list --porcelain`: one block per worktree, the main checkout first.
45export function linkedWorktrees(stdout: string): number {
46  return Math.max(0, lines(stdout).filter(line => line.startsWith('worktree ')).length - 1)
47}
48
49export const LAST_FORMAT = '%ct%x1f%s'
50
51// `log -1 --branches --format=%ct%x1f%s`
52export function lastCommit(stdout: string): { lastCommitAt: number | null; lastSubject: string } {
53  const [at = '', ...subject] = stdout.trim().split('\x1f')
54  const time = Number(at)
55  return at && Number.isFinite(time)
56    ? { lastCommitAt: time, lastSubject: subject.join('\x1f').trim() }
57    : { lastCommitAt: null, lastSubject: '' }
58}
59
60export function age(seconds: number): string {
61  if (seconds < 60) return 'just now'
62  if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`
63  if (seconds < DAY) return `${Math.floor(seconds / 3600)}h ago`
64  if (seconds < 60 * DAY) return `${Math.floor(seconds / DAY)}d ago`
65  return `${Math.floor(seconds / (30 * DAY))}mo ago`
66}
67
68export function statusOf(facts: ProjectFacts, nowSec: number): ProjectStatus {
69  const reasons: string[] = []
70  if (facts.unpushed !== null && facts.unpushed > 0) reasons.push(`${plural(facts.unpushed, 'commit')} not pushed`)
71  if (facts.dirty >= DIRTY_ATTENTION) reasons.push(`${plural(facts.dirty, 'uncommitted file')}`)
72  if (reasons.length > 0) return { kind: 'attention', label: 'Needs attention', reasons }
73
74  if (facts.commits24h > 0) {
75    return { kind: 'active', label: 'Active', reasons: [`${plural(facts.commits24h, 'commit')} in the last 24h`] }
76  }
77  if (facts.lastCommitAt === null) return { kind: 'idle', label: 'No commits yet', reasons: [] }
78  const days = Math.max(0, Math.floor((nowSec - facts.lastCommitAt) / DAY))
79  return { kind: 'idle', label: days === 0 ? 'Idle today' : `Idle ${plural(days, 'day')}`, reasons: [] }
80}
81
82export const errorStatus = (message: string): ProjectStatus => ({ kind: 'error', label: "Can't read", reasons: [message] })
83
84const RANK: Record<ProjectStatus['kind'], number> = { attention: 0, active: 1, idle: 2, error: 3 }
85
86// Needs attention first, then active, then idle; most recent commit first within each.
87export function sortCards(cards: readonly ProjectCard[]): ProjectCard[] {
88  return [...cards].sort(
89    (a, b) =>
90      RANK[a.status.kind] - RANK[b.status.kind] ||
91      (b.facts?.lastCommitAt ?? -1) - (a.facts?.lastCommitAt ?? -1),
92  )
93}
94
95// The card's facts line by line, without the name and the chip.
96export function factLines(facts: ProjectFacts, nowSec: number): string[] {
97  const push = !facts.hasRemote
98    ? 'no remote'
99    : [
100        facts.unpushed ? `${facts.unpushed} not pushed` : 'all pushed',
101        !facts.hasUpstream && `${facts.branch} has no upstream`,
102      ]
103        .filter(Boolean)
104        .join(', ')
105  return [
106    [
107      `${facts.commits24h} today`,
108      `${plural(facts.branches, 'branch', 'branches')} (${facts.activeBranches} active)`,
109      facts.worktrees > 0 && plural(facts.worktrees, 'worktree'),
110    ]
111      .filter(Boolean)
112      .join(' · '),
113    `${facts.dirty} uncommitted · ${push}`,
114    facts.lastCommitAt === null
115      ? 'No commits yet'
116      : `${age(Math.max(0, nowSec - facts.lastCommitAt))}: ${facts.lastSubject || '(no subject)'}`,
117  ]
118}
119
types/index.d.ts 87 lines
1export type Commit = {
2  hash: string
3  parents: string[]
4  refs: string[]
5  author: string
6  when: string
7  date: string
8  subject: string
9  isWorking?: boolean
10}
11
12export type Snapshot = {
13  repo: string
14  branch: string
15  head: string
16  commits: Commit[]
17  dirty: number
18  // `git status --porcelain` lines ("XY path"), the first MAX_FILES of them.
19  files: string[]
20  ahead: number | null
21  behind: number | null
22  branches: string[]
23  worktreeBranches: string[]
24  isTruncated: boolean
25  loadedAt: number
26}
27
28export type LoadError = { repo: string; message: string }
29
30// The last commit or push run from the pane, and how it went.
31export type GitAction = { kind: 'busy' | 'ok' | 'error'; text: string }
32
33// One project's activity, read with read-only git commands.
34export type ProjectFacts = {
35  // git's toplevel for the listed folder.
36  root: string
37  branch: string
38  commits24h: number
39  branches: number
40  // Local branches with a commit in the last few days (ACTIVE_BRANCH_DAYS).
41  activeBranches: number
42  // Linked worktrees; the main checkout is not counted.
43  worktrees: number
44  dirty: number
45  hasRemote: boolean
46  // Commits on local branches that no remote has; null when there is no remote.
47  unpushed: number | null
48  // Whether the checked-out branch tracks an upstream.
49  hasUpstream: boolean
50  lastSubject: string
51  // Unix seconds of the newest commit on any local branch; null with no commits.
52  lastCommitAt: number | null
53}
54
55export type ProjectStatus = {
56  kind: 'attention' | 'active' | 'idle' | 'error'
57  label: string
58  reasons: string[]
59}
60
61export type ProjectCard = {
62  // The folder as listed.
63  path: string
64  facts: ProjectFacts | null
65  error: string | null
66  status: ProjectStatus
67}
68
69declare module 'claude-code' {
70  interface PluginState {
71    'git-graph': {
72      repo: string
73      repos: string[]
74      snapshot: Snapshot | null
75      error: LoadError | null
76      isPicking: boolean
77      portfolio: ProjectCard[]
78      portfolioAt: number
79      isPortfolioLoading: boolean
80      isPortfolioEditing: boolean
81      // How many commits the graph loads; "Show more" raises it.
82      limit: number
83      action: GitAction | null
84    }
85  }
86}
87