SLOPSHOPPER

sidegit

The git changes of every repo in the project folder, in a pane beside the chat, with the file Claude reads or edits marked

newpaneguardcommandprocesstimer
v0.1.0no licenseupdated 2026-10-22hermanschutte/sidegit/mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · sidegit
│ ┃ sidegit ✕ › fix the failing auth test and add an audit log call │ ┃ No git repositories found in /work/app. │ ⏺ Read(src/auth.ts) │ ⎿ 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 │ │ › /sidegit │ ⎿ sidegit: sidegit pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · sidegit
No git repositories found in /work/app.
README

sidegit

A terminal UI for monitoring git changes across multiple repositories. Runs in the directory where your projects live and shows a unified tree of every repo with uncommitted changes.

Built with Bubble Tea and Lip Gloss.

Install

go install github.com/hermanschutte/sidegit@latest

Or build from source:

git clone https://github.com/hermanschutte/sidegit.git
cd sidegit
go build -o sidegit .

Usage

Run sidegit in a directory containing git repositories:

cd ~/Projects
sidegit

It scans the current directory and up to two levels deep for git repos with uncommitted changes.

Keybindings

KeyAction
j / k / ↑ / ↓Navigate
EnterShow diff for selected file
TabSwitch between tree and diff panels
EscClose diff panel
c / eCollapse/expand repo or directory
oOpen file in $EDITOR
dDiscard changes, or remove a worktree (opens confirmation menu)
pToggle diff panel position (right/bottom)
rRefresh
qQuit

Configuration

Config lives at ~/.config/sidegit/config.yaml. A default file is created on first run.

diff_position: right  # right or bottom
scan_depth: 1
theme:
  cursor_bg: "237"
  border_focused: "12"
  border_normal: "8"
  title: "14"
  status_bar: "8"
  repo_name: "12"
  branch_name: "13"
  file_count: "7"
  folder_icon: "7"
  dir_name: "7"
  status_staged: "10"
  status_added: "10"
  status_deleted: "9"
  status_modified: "11"
  status_untracked: "8"
  default_icon: "7"
  worktree: "6"

Theme values accept ANSI color codes (0-255) or hex colors ("#FF79C6").

Claude Code hook

To show which Claude Code session edited each file, add this hook to ~/.claude/settings.json:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write|MultiEdit|NotebookEdit",
        "hooks": [{ "type": "command", "command": "sidegit claude-hook" }]
      }
    ]
  }
}

The hook writes the session name for each edited file to the sidegit folder in your user cache folder. It does not record changes that Claude makes with Bash (for example sed). A file that changes again after the edit, for example by hand, loses its session name.

Claude Code mod

The mod folder holds a version of sidegit that runs inside Claude Code, in a pane beside the chat. It shows the same tree of repos and changed files for the session's project folder. It marks the file that Claude reads (◉) or edits (✎), opens collapsed rows to show a file that Claude edits, and refreshes after each edit and each Bash command. It does not show pull requests, worktrees, session names or diffs. Use the sidegit binary for those.

Load it in a session:

claude --plugin-dir /path/to/sidegit/mod

The pane needs the fullscreen layout (/tui fullscreen) and a terminal at least 110 columns wide. /sidegit opens it. Tested on Claude Code 2.1.308.

The pane opens 56 columns wide. Change that with "Pane width" in /plugin configure sidegit. Drag the edge of the pane to change the width for the session.

Run the tests with claude plugin test mod.

Features

  • Scans for git repos automatically (current directory + two levels deep)
  • Shows linked git worktrees as child rows under their main repo
  • File watcher auto-refreshes when files change on disk
  • Colored inline diffs with staged/unstaged detection
  • Lines added and deleted per file (+12 -3), compared to HEAD
  • Time since each file changed (5m). A file that changed in the last 30 seconds shows in the title color
  • The Claude Code session that last edited each file, by the name in its terminal tab (needs the hook)
  • Pull request of the current branch on each repo row: number colored by state, check result (✓ ✗ ●) and unresolved review threads. Needs the GitHub CLI (gh), logged in. Refreshes every 2 minutes and on r
  • A macOS notification through iTerm2 when the checks of a PR finish or new review threads come in. With the repo open in several panes, only one pane sends it
  • Nerd Font file icons
  • Collapsible directory tree
  • Discard changes with confirmation menu
  • Fully configurable color theme
Source 7 files
hooks/register.tsx 284 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { FileStatus, PRStatus, Repo, Touch } from '../types'
5import { scanRepos } from './git'
6import { fetchPR } from './github'
7import { COMMENT_ICON, FOLDER_ICON, WORKTREE_ICON, fileIcon } from './icons'
8import { age, ancestorIds, buildRows, fitRepo } from './tree'
9
10const PANE = 'sidegit'
11const POLL_MS = 10_000 // the sidegit default poll_interval
12const PR_POLL_MS = 2 * 60_000 // PRs come from the GitHub API, so they refresh less often, as in sidegit
13const RECENT_MS = 30_000 // a file that changed less than this ago shows in the title color
14const TOUCH_MS = 8_000 // how long a file Claude read or wrote stays marked after the call
15const MAX_TOUCHES = 20
16
17// Keys of the Claude Code theme, so the pane follows the person's theme. In the Dracula
18// theme each one has the color the sidegit binary's Dracula config uses.
19const COLOR = {
20  title: 'claude',
21  statusBar: 'inactive',
22  repoName: 'permission',
23  branchName: 'suggestion',
24  fileCount: 'inactive',
25  dirName: 'inactive',
26  staged: 'success',
27  added: 'success',
28  deleted: 'error',
29  modified: 'warning',
30  untracked: 'inactive',
31  treeLines: 'inactive',
32  folderIcon: 'remember',
33  worktree: 'suggestion',
34  read: 'inactive',
35  write: 'claude',
36}
37const MARK = { read: '◉', write: '✎' }
38// The shimmer on a marked file name, in the theme colors Claude Code shimmers its own text
39// with: the mark color, and its shimmer color on the three letters of the band
40const PULSE = {
41  read: ['inactive', 'inactiveShimmer', 'inactiveShimmer'],
42  write: ['claude', 'claudeShimmer', 'claudeShimmer'],
43}
44
45const root = atom({ plugin: 'sidegit', key: 'root' } as const, '')
46const repos = atom({ plugin: 'sidegit', key: 'repos' } as const, [] as Repo[])
47const collapsed = atom({ plugin: 'sidegit', key: 'collapsed' } as const, [] as string[])
48const touches = atom({ plugin: 'sidegit', key: 'touches' } as const, [] as Touch[])
49const prs = atom({ plugin: 'sidegit', key: 'prs' } as const, {} as Record<string, PRStatus | null>)
50
51// A PR is kept by repo and branch, so after a branch switch the repo shows no PR until the next fetch
52const prKey = (repo: Repo) => `${repo.path}\n${repo.branch}`
53
54let refreshing: Promise<void> | null = null
55let again = false
56let fetchingPRs = false
57
58// refreshPRs asks GitHub for the PR of each repo's branch. A detached HEAD has no branch to ask about.
59async function refreshPRs($: EngineInterface): Promise<void> {
60  if (fetchingPRs) return
61  fetchingPRs = true
62  try {
63    const list = (await read($, repos)).filter(r => r.branch !== 'HEAD' && r.branch !== 'unknown')
64    const found = await Promise.all(list.map(async r => [prKey(r), await fetchPR($, r.path, r.branch)] as const))
65    await update($, prs, () => Object.fromEntries(found))
66  } catch (err) {
67    $.ui.log(`sidegit: ${String(err)}`)
68  } finally {
69    fetchingPRs = false
70  }
71}
72
73// refresh finds the repos under the project folder and reads the git status of each.
74// A call while a scan runs makes that scan run once more.
75function refresh($: EngineInterface): Promise<void> {
76  if (refreshing) {
77    again = true
78    return refreshing
79  }
80  refreshing = (async () => {
81    do {
82      again = false
83      const dir = await $.session.root()
84      const list = await scanRepos($, dir)
85      await update($, root, () => dir)
86      await update($, repos, () => list)
87    } while (again)
88  })()
89    .catch(err => $.ui.log(`sidegit: ${String(err)}`))
90    .finally(() => {
91      refreshing = null
92    })
93  return refreshing
94}
95
96// afterChange shows a change that Claude made. A file Claude wrote is revealed in the tree.
97async function afterChange($: EngineInterface, path: string): Promise<void> {
98  await refresh($)
99  if (!path) return
100  const reveal = new Set(ancestorIds(await read($, repos), path))
101  await update($, collapsed, ids => ids.filter(id => !reveal.has(id)))
102  await $.ui.scroll({ to: { key: `row:${path}` }, in: PANE }).catch(() => undefined)
103}
104
105function statusColor(f: FileStatus): string | undefined {
106  if (f.staged) return COLOR.staged
107  if (f.status === 'A') return COLOR.added
108  if (f.status === 'D') return COLOR.deleted
109  if (f.status === 'M') return COLOR.modified
110  if (f.status === '?') return COLOR.untracked
111  return undefined
112}
113
114// prParts is the PR status after a repo row, as the sidegit binary draws it: the number in the
115// color of its state, the result of the checks (open PRs only), and the count of unresolved
116// review threads. Without Nerd Font icons the threads get a speech balloon.
117function prParts(pr: PRStatus | null | undefined, icons: boolean): { text: string; color: string }[] {
118  if (!pr) return []
119  const state = pr.state === 'MERGED' ? COLOR.branchName : pr.state === 'CLOSED' ? COLOR.deleted : pr.draft ? COLOR.untracked : COLOR.added
120  const parts = [{ text: ` #${pr.number}`, color: state }]
121  if (pr.state === 'OPEN') {
122    if (pr.checks === 'SUCCESS') parts.push({ text: ' ✓', color: COLOR.added })
123    else if (pr.checks === 'FAILURE' || pr.checks === 'ERROR') parts.push({ text: ' ✗', color: COLOR.deleted })
124    else if (pr.checks === 'PENDING' || pr.checks === 'EXPECTED') parts.push({ text: ' ●', color: COLOR.modified })
125  }
126  if (pr.unresolved > 0) parts.push({ text: ` ${icons ? COMMENT_ICON : '💬'} ${pr.unresolved}`, color: COLOR.modified })
127  return parts
128}
129
130export const register: Register = (on, options) => {
131  // The "Pane width" setting in /config. A width the person drags wins.
132  const columns = typeof options?.width === 'number' ? Math.max(24, Math.round(options.width)) : 56
133
134  on('session.start', async ($, e, next) => {
135    await $.command.register({ name: 'sidegit', description: 'Show the git changes of every repo in the project folder' })
136    void $.ui.open({ id: PANE, title: 'sidegit', columns })
137    void refresh($).then(() => refreshPRs($))
138    $.clock.every(POLL_MS, () => {
139      void $.ui.panes().then(panes => (panes.some(p => p.id === PANE) ? refresh($) : undefined))
140    })
141    $.clock.every(PR_POLL_MS, () => {
142      void $.ui.panes().then(panes => (panes.some(p => p.id === PANE) ? refreshPRs($) : undefined))
143    })
144    return next(e)
145  })
146
147  on('command.run', { command: 'sidegit' }, async ($, e) => {
148    // A terminal docks a pane on the side only in the fullscreen layout, from 110 columns
149    const terminal = await $.session.surfaces().then(
150      surfaces => surfaces.includes('terminal'),
151      () => true,
152    )
153    if (terminal && !e.presentation.isFullscreen) return { text: 'sidegit shows on the side only in the fullscreen layout. Run /tui fullscreen, then /sidegit.' }
154    if (terminal && e.presentation.columns < 110) return { text: 'sidegit shows on the side only in a terminal at least 110 columns wide. Widen the terminal, then run /sidegit.' }
155    const opened = await $.ui.open({ id: PANE, title: 'sidegit', columns })
156    void refresh($).then(() => refreshPRs($))
157    return { text: opened.isPlaced ? 'sidegit pane opened.' : `The sidegit pane is not shown: ${opened.reason}` }
158  })
159
160  on('tool.call', async ($, e, next) => {
161    const kind = e.tool === 'Read' ? 'read' : e.tool === 'Edit' || e.tool === 'Write' || e.tool === 'NotebookEdit' ? 'write' : null
162    const path = e.tool === 'NotebookEdit' ? e.notebook_path : e.tool === 'Read' || e.tool === 'Edit' || e.tool === 'Write' ? e.file_path : ''
163    const id = e.tool_use_id
164    if (kind && path) {
165      const touch: Touch = { id, path, kind, running: true, at: await $.clock.now() }
166      await update($, touches, list => [...list.filter(t => t.path !== path), touch].slice(-MAX_TOUCHES))
167    }
168    const result = await next(e)
169    if (kind && path) {
170      const at = await $.clock.now()
171      await update($, touches, list => (result.deny ? list.filter(t => t.id !== id) : list.map(t => (t.id === id ? { ...t, running: false, at } : t))))
172      $.clock.after(TOUCH_MS, () => $.ui.invalidate('ui.render'))
173    }
174    if (!result.deny && (kind === 'write' || e.tool === 'Bash')) void afterChange($, kind === 'write' ? path : '')
175    return result
176  })
177
178  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
179    const { Box, Text, Button } = $.ui.resolve(e)
180    // The terminal and the desktop app draw a Client; VS Code and the mobile app do not
181    const Client = e.surface === 'terminal' || e.surface === 'desktop' ? $.ui.resolve(e).Client : undefined
182    // Nerd Font icons only on the terminal: the other surfaces draw with fonts that lack them
183    const icons = e.surface === 'terminal'
184    // On the terminal's main screen the pane that session start opened sits above the prompt
185    if (e.surface === 'terminal' && e.props.placement === 'inline') {
186      void $.ui.close({ id: PANE }).catch(() => undefined)
187      return <Box />
188    }
189    const [dir, list, closed, touched, prMap, now] = await Promise.all([read($, root), read($, repos), read($, collapsed), read($, touches), read($, prs), $.clock.now()])
190    const width = Math.max(20, e.props.bodyColumns)
191    const live = new Map(touched.filter(t => t.running || now - t.at < TOUCH_MS).map(t => [t.path, t]))
192    const latest = [...live.values()].pop()
193    const changes = list.reduce((n, r) => n + r.files.length, 0)
194    const toggle = (id: string) => void update($, collapsed, ids => (ids.includes(id) ? ids.filter(x => x !== id) : [...ids, id]))
195
196    if (list.length === 0) return <Text color={COLOR.statusBar}>{dir ? `No git repositories found in ${dir}.` : 'Scanning for git repositories…'}</Text>
197
198    return (
199      <Box flexDirection="column">
200        <Text color={COLOR.statusBar} wrap="truncate-end">
201          {` ${list.filter(r => !r.worktree).length} repo(s) | ${changes} change(s)`}
202          {latest ? <Text color={COLOR[latest.kind]}>{` | ${MARK[latest.kind]} ${latest.path.startsWith(`${dir}/`) ? latest.path.slice(dir.length + 1) : latest.path}`}</Text> : null}
203        </Text>
204        {buildRows(list, new Set(closed)).map(row => {
205          if (row.kind === 'repo') {
206            const tree = row.repo.worktree
207            const pr = prParts(prMap[prKey(row.repo)], icons)
208            // The tree lines, and the icon and its space, take columns from the name and branch
209            const fit = fitRepo(row.repo, width - row.guides.length - (icons ? 2 : 0), pr.reduce((n, p) => n + [...p.text].length, 0))
210            const button = (
211              <Button key={row.id} plain onPress={() => toggle(row.id)}>
212                {row.collapsed ? '▸ ' : '▾ '}
213                {icons ? <Text color={tree ? COLOR.worktree : COLOR.folderIcon}>{`${tree ? WORKTREE_ICON : FOLDER_ICON} `}</Text> : null}
214                <Text bold={!tree} color={tree ? COLOR.worktree : COLOR.repoName}>{fit.name}</Text>
215                {fit.branch ? <Text color={COLOR.branchName}>{` ${fit.branch}`}</Text> : null}
216                {fit.count ? <Text color={COLOR.fileCount}>{` ${fit.count}`}</Text> : null}
217                {fit.branch && row.repo.ahead > 0 ? <Text color={COLOR.added}>{` ↑${row.repo.ahead}`}</Text> : null}
218                {fit.branch && row.repo.behind > 0 ? <Text color={COLOR.deleted}>{` ↓${row.repo.behind}`}</Text> : null}
219                {fit.branch ? pr.map(p => <Text color={p.color}>{p.text}</Text>) : null}
220              </Button>
221            )
222            if (!row.guides) return button
223            // A worktree row sits under its main repo, after the tree lines
224            return (
225              <Box flexDirection="row">
226                <Text color={COLOR.treeLines}>{row.guides}</Text>
227                {button}
228              </Box>
229            )
230          }
231          if (row.kind === 'dir') {
232            return (
233              <Box flexDirection="row">
234                <Text color={COLOR.treeLines}>{row.guides}</Text>
235                <Button key={row.id} plain onPress={() => toggle(row.id)}>
236                  {row.collapsed ? '▸ ' : '▾ '}
237                  {icons ? <Text color={COLOR.folderIcon}>{`${FOLDER_ICON} `}</Text> : null}
238                  <Text bold color={COLOR.dirName}>{row.name}</Text>
239                </Button>
240              </Box>
241            )
242          }
243          const f = row.file
244          const touch = live.get(row.id)
245          const since = now - f.mtimeMs
246          const recent = f.mtimeMs > 0 && since < RECENT_MS
247          const name = row.id.slice(row.id.lastIndexOf('/') + 1)
248          const icon = fileIcon(name)
249          // While a file is marked, the mark takes the place of its icon
250          const pulse =
251            Client && touch ? (
252              <Client key={`pulse:${row.id}`} module="./pulse.tsx" props={{ text: ` ${MARK[touch.kind]} ${name}`, colors: PULSE[touch.kind] }} />
253            ) : null
254          return (
255            <Box key={`row:${row.id}`} flexDirection="row">
256              <Box flexShrink={1} overflow="hidden">
257                <Text wrap="truncate-end">
258                  <Text color={COLOR.treeLines}>{row.guides}</Text>
259                  <Text bold={f.staged} color={statusColor(f)}>{f.status}</Text>
260                  {pulse ? null : touch ? (
261                    <Text bold color={COLOR[touch.kind]}>{` ${MARK[touch.kind]} ${name}`}</Text>
262                  ) : (
263                    <Text>
264                      {icons ? <Text color={icon.color}>{` ${icon.icon}`}</Text> : null}
265                      <Text bold={recent} color={recent ? COLOR.title : undefined}>{` ${name}`}</Text>
266                    </Text>
267                  )}
268                </Text>
269                {pulse}
270              </Box>
271              <Box flexGrow={1} />
272              <Text>
273                {f.added > 0 ? <Text color={COLOR.added}>{` +${f.added}`}</Text> : null}
274                {f.deleted > 0 ? <Text color={COLOR.deleted}>{` -${f.deleted}`}</Text> : null}
275                {f.mtimeMs > 0 ? <Text color={recent ? COLOR.title : COLOR.untracked}>{` ${age(since)}`}</Text> : null}
276              </Text>
277            </Box>
278          )
279        })}
280      </Box>
281    )
282  })
283}
284
hooks/git.ts 167 lines
1import type { EngineInterface } from 'claude-code'
2
3import type { FileStatus, Repo, StatusCode } from '../types'
4
5const TIMEOUT_MS = 15_000
6// An untracked file over 1 MB counts as 0 lines, as in sidegit.
7const MAX_COUNT_BYTES = 1 << 20
8
9// findRepos returns the git repos in root and up to two levels below it, root first.
10export async function findRepos($: EngineInterface, root: string): Promise<string[]> {
11  const run = await $.process.run(['find', root, '-maxdepth', '3', '-name', '.git', '-type', 'd'], { timeoutMs: TIMEOUT_MS })
12  return parseFind(run.stdout, root)
13}
14
15export function parseFind(stdout: string, root: string): string[] {
16  const repos: string[] = []
17  for (const line of stdout.split('\n')) {
18    if (!line.endsWith('/.git')) continue
19    const repo = line.slice(0, -'/.git'.length)
20    const rel = repo === root ? '' : repo.slice(root.length + 1)
21    // Hidden folders are skipped, as in sidegit
22    if (rel.split('/').some(part => part.startsWith('.'))) continue
23    repos.push(repo)
24  }
25  return repos.sort((a, b) => (a === root ? -1 : b === root ? 1 : a < b ? -1 : a > b ? 1 : 0))
26}
27
28// scanRepos reads every repo in root and up to two levels below it, each followed by its
29// linked worktrees, as the sidegit binary does. A worktree whose folder is gone is left out.
30export async function scanRepos($: EngineInterface, root: string): Promise<Repo[]> {
31  const paths = await findRepos($, root).catch(() => [])
32  const groups = await Promise.all(
33    paths.map(async path => {
34      const [repo, linked] = await Promise.all([readRepo($, root, path), git($, path, ['worktree', 'list', '--porcelain'])])
35      const worktrees = (await Promise.all(parseWorktrees(linked).map(async wt => ((await $.fs.exists(wt)) ? wt : '')))).filter(Boolean)
36      if (worktrees.length === 0) return [repo]
37      // A worktree inside the repo shows as an untracked folder there; its own row shows it.
38      // git reports worktree paths with symlinks resolved, so the repo path is resolved too.
39      const real = (await $.fs.stat(path, { resolve: true }).catch(() => undefined))?.realPath ?? path
40      repo.files = repo.files.filter(f => {
41        const abs = `${real}/${f.path.replace(/\/$/, '')}`
42        return !worktrees.some(wt => abs === wt || abs.startsWith(`${wt}/`))
43      })
44      const rows = await Promise.all(worktrees.map(async wt => ({ ...(await readRepo($, root, wt)), name: wt.slice(wt.lastIndexOf('/') + 1), worktree: true })))
45      return [repo, ...rows]
46    }),
47  )
48  return groups.flat()
49}
50
51// parseWorktrees returns the paths of the linked worktrees in `git worktree list --porcelain`.
52// The first entry is the main worktree, the repo itself, and is left out.
53export function parseWorktrees(out: string): string[] {
54  return out
55    .split('\n')
56    .filter(line => line.startsWith('worktree '))
57    .map(line => line.slice('worktree '.length))
58    .slice(1)
59}
60
61export async function readRepo($: EngineInterface, root: string, path: string): Promise<Repo> {
62  const [status, numstat] = await Promise.all([
63    git($, path, ['status', '--porcelain=v2', '--branch', '--untracked-files=all', '-z']),
64    git($, path, ['diff', 'HEAD', '--numstat', '--no-renames', '-z']),
65  ])
66  const repo: Repo = { path, name: path === root ? path : path.slice(root.length + 1), ...parseStatus(status), worktree: false }
67  const counts = parseNumstat(numstat)
68  await Promise.all(
69    repo.files.map(async f => {
70      const abs = `${path}/${f.path}`
71      try {
72        const stat = await $.fs.stat(abs)
73        f.mtimeMs = stat.mtimeMs
74        if (f.status === '?') {
75          f.added = stat.kind === 'file' && stat.size <= MAX_COUNT_BYTES ? await countLines($, abs, stat.mtimeMs) : 0
76          return
77        }
78      } catch {
79        // a deleted file
80      }
81      const c = counts.get(f.path)
82      if (c) [f.added, f.deleted] = c
83    }),
84  )
85  return repo
86}
87
88// git returns the command's output, or '' when it fails (a repo without commits has no HEAD to diff).
89async function git($: EngineInterface, repo: string, args: string[]): Promise<string> {
90  try {
91    const run = await $.process.run(['git', '--no-optional-locks', '-C', repo, ...args], { timeoutMs: TIMEOUT_MS })
92    return run.exitCode === 0 ? run.stdout : ''
93  } catch {
94    return ''
95  }
96}
97
98export function parseStatus(out: string): Pick<Repo, 'branch' | 'ahead' | 'behind' | 'files'> {
99  const result = { branch: 'unknown', ahead: 0, behind: 0, files: [] as FileStatus[] }
100  const records = out.split('\0')
101  for (let i = 0; i < records.length; i++) {
102    const rec = records[i] ?? ''
103    if (rec.startsWith('# branch.head ')) {
104      const head = rec.slice('# branch.head '.length)
105      result.branch = head === '(detached)' ? 'HEAD' : head
106    } else if (rec.startsWith('# branch.ab ')) {
107      const m = /^# branch\.ab \+(\d+) -(\d+)$/.exec(rec)
108      if (m) [result.ahead, result.behind] = [Number(m[1]), Number(m[2])]
109    } else if (rec.startsWith('1 ') || rec.startsWith('2 ')) {
110      // 1 XY sub mH mI mW hH hI path
111      // 2 XY sub mH mI mW hH hI Xscore path, then the original path as the next record
112      const fields = rec.split(' ')
113      const path = fields.slice(rec[0] === '1' ? 8 : 9).join(' ')
114      if (rec[0] === '2') i++
115      const file = fromXY(fields[1] ?? '..', path)
116      if (file) result.files.push(file)
117    } else if (rec.startsWith('? ')) {
118      result.files.push(newFile(rec.slice(2), '?', false))
119    }
120  }
121  return result
122}
123
124// fromXY prefers the unstaged change; with none it shows the staged one.
125function fromXY(xy: string, path: string): FileStatus | null {
126  const staged = xy[0] ?? '.'
127  const unstaged = xy[1] ?? '.'
128  if (unstaged !== '.') return newFile(path, statusCode(unstaged), false)
129  if (staged !== '.') return newFile(path, statusCode(staged), true)
130  return null
131}
132
133function statusCode(c: string): StatusCode {
134  return c === 'A' || c === 'D' || c === 'R' || c === 'C' ? c : 'M'
135}
136
137function newFile(path: string, status: StatusCode, staged: boolean): FileStatus {
138  return { path, status, staged, added: 0, deleted: 0, mtimeMs: 0 }
139}
140
141// parseNumstat returns added and deleted lines per tracked file. Binary files get no entry.
142export function parseNumstat(out: string): Map<string, [number, number]> {
143  const counts = new Map<string, [number, number]>()
144  for (const rec of out.split('\0')) {
145    const m = /^(\d+)\t(\d+)\t([\s\S]*)$/.exec(rec)
146    if (m) counts.set(m[3] ?? '', [Number(m[1]), Number(m[2])])
147  }
148  return counts
149}
150
151const lineCache = new Map<string, { mtimeMs: number; lines: number }>()
152
153// countLines counts the lines of an untracked text file. A binary file counts as 0.
154async function countLines($: EngineInterface, abs: string, mtimeMs: number): Promise<number> {
155  const hit = lineCache.get(abs)
156  if (hit?.mtimeMs === mtimeMs) return hit.lines
157  let lines = 0
158  try {
159    const text = await $.fs.read(abs)
160    if (typeof text === 'string' && !text.includes('\0')) lines = text.split('\n').length - (text === '' || text.endsWith('\n') ? 1 : 0)
161  } catch {
162    // unreadable counts as 0
163  }
164  lineCache.set(abs, { mtimeMs, lines })
165  return lines
166}
167
hooks/github.ts 61 lines
1import type { EngineInterface } from 'claude-code'
2
3import type { PRStatus } from '../types'
4
5const PR_QUERY = `query($owner:String!,$name:String!,$branch:String!){
6  repository(owner:$owner,name:$name){
7    pullRequests(headRefName:$branch,first:1,orderBy:{field:CREATED_AT,direction:DESC}){
8      nodes{ number state isDraft
9        reviewThreads(first:100){ nodes{ isResolved } }
10        commits(last:1){ nodes{ commit{ statusCheckRollup{ state } } } } } } } }`
11
12// fetchPR asks GitHub through the gh CLI for the newest pull request of the branch, as the
13// sidegit binary does. It returns null when the branch has no PR, the repo is not on
14// GitHub, or gh fails.
15export async function fetchPR($: EngineInterface, repoPath: string, branch: string): Promise<PRStatus | null> {
16  try {
17    // gh fills {owner} and {repo} from the git remote of the folder it runs in
18    const run = await $.process.run(
19      ['gh', 'api', 'graphql', '-F', 'owner={owner}', '-F', 'name={repo}', '-f', `branch=${branch}`, '-f', `query=${PR_QUERY}`],
20      { cwd: repoPath, timeoutMs: 30_000 },
21    )
22    return run.exitCode === 0 ? parsePR(run.stdout) : null
23  } catch {
24    return null
25  }
26}
27
28type Response = {
29  data?: {
30    repository?: {
31      pullRequests?: {
32        nodes?: {
33          number: number
34          state: string
35          isDraft: boolean
36          reviewThreads?: { nodes?: { isResolved: boolean }[] }
37          commits?: { nodes?: { commit?: { statusCheckRollup?: { state: string } | null } }[] }
38        }[]
39      }
40    }
41  }
42}
43
44export function parsePR(stdout: string): PRStatus | null {
45  let resp: Response
46  try {
47    resp = JSON.parse(stdout) as Response
48  } catch {
49    return null
50  }
51  const n = resp.data?.repository?.pullRequests?.nodes?.[0]
52  if (!n) return null
53  return {
54    number: n.number,
55    state: n.state,
56    draft: n.isDraft,
57    checks: n.commits?.nodes?.[0]?.commit?.statusCheckRollup?.state ?? '',
58    unresolved: (n.reviewThreads?.nodes ?? []).filter(t => !t.isResolved).length,
59  }
60}
61
hooks/icons.ts 88 lines
1// Nerd Font icons, as in the sidegit binary (tree.go), codepoints from nvim-web-devicons.
2
3export const FOLDER_ICON = '\uf07b'
4export const WORKTREE_ICON = '\ue725'
5// The icon before the count of unresolved review threads on a PR
6export const COMMENT_ICON = '\uf075'
7const DEFAULT_ICON = '\uf15b'
8// The theme key for an icon with no color of its own: the text color, as the binary's
9// default_icon is the Dracula foreground
10const DEFAULT_COLOR = 'text'
11
12const BY_EXT: Record<string, string> = {
13  '.go': '\ue627', // seti-go
14  '.js': '\ue60c', // seti-javascript
15  '.ts': '\ue628', // seti-typescript
16  '.tsx': '\ue7ba', // seti-react
17  '.jsx': '\ue625', // seti-react
18  '.py': '\ue606', // seti-python
19  '.rb': '\ue791', // seti-ruby
20  '.rs': '\ue68b', // seti-rust
21  '.java': '\ue738', // dev-java
22  '.c': '\ue61e', // seti-c
23  '.cpp': '\ue61d', // seti-cpp
24  '.h': '\ue61e', // seti-c
25  '.cs': '\u{F0911}', // md-language_csharp
26  '.php': '\ue608', // seti-php
27  '.swift': '\ue755', // dev-swift
28  '.kt': '\ue634', // seti-kotlin
29  '.html': '\ue736', // dev-html5
30  '.css': '\ue6b8', // seti-css
31  '.scss': '\ue603', // seti-sass
32  '.json': '\ue60b', // seti-json
33  '.yaml': '\ue615', // seti-yaml
34  '.yml': '\ue615', // seti-yaml
35  '.toml': '\ue6b2', // seti-toml
36  '.xml': '\u{F05C0}', // md-xml
37  '.md': '\uf48a', // oct-markdown
38  '.sh': '\ue795', // seti-shell
39  '.bash': '\ue795', // seti-shell
40  '.zsh': '\ue795', // seti-shell
41  '.fish': '\ue795', // seti-shell
42  '.sql': '\ue706', // dev-database
43  '.svg': '\u{F0721}', // md-svg
44  '.png': '\ue60d', // seti-image
45  '.jpg': '\ue60d', // seti-image
46  '.jpeg': '\ue60d', // seti-image
47  '.gif': '\ue60d', // seti-image
48  '.vue': '\ue6a0', // seti-vue
49  '.svelte': '\ue697', // seti-svelte
50  '.lua': '\ue620', // seti-lua
51  '.vim': '\ue62b', // seti-vim
52  '.lock': '\ue672', // seti-lock
53  '.env': '\uf462', // oct-key
54  '.gitignore': '\ue702', // dev-git
55  '.mod': '\ue627', // seti-go
56  '.sum': '\ue627', // seti-go
57}
58
59// Whole file names, matched before the extension
60const BY_NAME: Record<string, string> = {
61  Dockerfile: '\u{F01A8}', // md-docker
62  Makefile: '\ue673', // seti-makefile
63  LICENSE: '\ue62f', // seti-license
64  '.gitignore': '\ue702', // dev-git
65  'go.mod': '\ue627', // seti-go
66  'go.sum': '\ue627', // seti-go
67}
68
69// File-type colors, which no Claude Code theme has
70const COLORS: Record<string, string> = {
71  '.go': '#00ADD8', '.js': '#CBCB41', '.ts': '#519ABA', '.tsx': '#1354BF',
72  '.jsx': '#20C2E3', '.py': '#FFBC03', '.rb': '#701516', '.rs': '#DEA584',
73  '.java': '#CC3E44', '.html': '#E44D26', '.css': '#663399', '.scss': '#F55385',
74  '.json': '#CBCB41', '.yaml': '#6D8086', '.yml': '#6D8086', '.toml': '#9C4221',
75  '.md': '#DDDDDD', '.sh': '#4D5A5E', '.bash': '#4D5A5E', '.zsh': '#4D5A5E',
76  '.php': '#A074C4', '.swift': '#E37933', '.kt': '#7F52FF', '.lua': '#51A0CF',
77  '.vue': '#8DC149', '.svelte': '#FF3E00', '.sql': '#DAD8D8', '.lock': '#BBBBBB',
78  '.env': '#FAF743', '.gitignore': '#F54D27',
79}
80
81// fileIcon returns the icon of a file name and its color: a hex color, or a theme key.
82export function fileIcon(name: string): { icon: string; color: string } {
83  const ext = name.includes('.') ? name.slice(name.lastIndexOf('.')).toLowerCase() : ''
84  const icon = BY_NAME[name] ?? BY_EXT[ext]
85  if (!icon) return { icon: DEFAULT_ICON, color: DEFAULT_COLOR }
86  return { icon, color: COLORS[ext] ?? DEFAULT_COLOR }
87}
88
hooks/tree.ts 162 lines
1import type { FileStatus, Repo } from '../types'
2
3export type Row =
4  | { kind: 'repo'; id: string; repo: Repo; guides: string; collapsed: boolean }
5  | { kind: 'dir'; id: string; name: string; guides: string; collapsed: boolean }
6  | { kind: 'file'; id: string; file: FileStatus; guides: string }
7
8type Dir = { name: string; path: string; dirs: Map<string, Dir>; files: FileStatus[] }
9
10// The id of a file row is the file's absolute path.
11const repoId = (repo: Repo) => `repo:${repo.path}`
12const dirId = (repo: Repo, dir: string) => `dir:${repo.path}/${dir}`
13
14// buildRows lays out the repos as sidegit does: in a repo its folders, its root files and
15// then its linked worktrees, in a folder its files and then its subfolders. A collapsed
16// row hides its children.
17export function buildRows(repos: Repo[], collapsed: ReadonlySet<string>): Row[] {
18  const rows: Row[] = []
19  repos.forEach((repo, i) => {
20    if (repo.worktree) return // drawn under its main repo
21    const worktrees: Repo[] = []
22    for (let j = i + 1; repos[j]?.worktree; j++) worktrees.push(repos[j] as Repo)
23    addRepo(repo, worktrees, '', '', rows, collapsed)
24  })
25  return rows
26}
27
28function addRepo(repo: Repo, worktrees: Repo[], guides: string, prefix: string, rows: Row[], collapsed: ReadonlySet<string>): void {
29  const id = repoId(repo)
30  rows.push({ kind: 'repo', id, repo, guides, collapsed: collapsed.has(id) })
31  if (collapsed.has(id)) return
32  const top = folderTree(repo.files)
33  addChildren(repo, [...sortedDirs(top), ...top.files, ...worktrees], prefix, rows, collapsed)
34}
35
36function addChildren(repo: Repo, children: (Dir | FileStatus | Repo)[], prefix: string, rows: Row[], collapsed: ReadonlySet<string>): void {
37  children.forEach((child, i) => {
38    const last = i === children.length - 1
39    const guides = prefix + (last ? '└ ' : '├ ')
40    if ('worktree' in child) {
41      addRepo(child, [], guides, prefix + (last ? '  ' : '│ '), rows, collapsed)
42      return
43    }
44    if (!('dirs' in child)) {
45      rows.push({ kind: 'file', id: `${repo.path}/${child.path.replace(/\/$/, '')}`, file: child, guides })
46      return
47    }
48    const id = dirId(repo, child.path)
49    rows.push({ kind: 'dir', id, name: child.name, guides, collapsed: collapsed.has(id) })
50    if (!collapsed.has(id)) addChildren(repo, [...child.files, ...sortedDirs(child)], prefix + (last ? '  ' : '│ '), rows, collapsed)
51  })
52}
53
54function folderTree(files: FileStatus[]): Dir {
55  const top: Dir = { name: '', path: '', dirs: new Map(), files: [] }
56  for (const f of files) {
57    // An untracked folder (a nested repo) ends in "/" and shows as one row
58    const parts = f.path.replace(/\/$/, '').split('/')
59    let dir = top
60    for (const name of parts.slice(0, -1)) {
61      let sub = dir.dirs.get(name)
62      if (!sub) {
63        sub = { name, path: dir.path ? `${dir.path}/${name}` : name, dirs: new Map(), files: [] }
64        dir.dirs.set(name, sub)
65      }
66      dir = sub
67    }
68    dir.files.push(f)
69  }
70  return top
71}
72
73function sortedDirs(dir: Dir): Dir[] {
74  return [...dir.dirs.values()].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
75}
76
77// ancestorIds returns the ids of the repo and folder rows that hold the file at path. A file
78// of a worktree inside its main repo's folder belongs to the worktree, the deepest repo,
79// and a worktree row sits under its main repo.
80export function ancestorIds(repos: Repo[], path: string): string[] {
81  const holders = repos.filter(r => path.startsWith(`${r.path}/`))
82  const repo = holders.sort((a, b) => b.path.length - a.path.length)[0]
83  if (!repo) return []
84  const parts = path.slice(repo.path.length + 1).split('/').slice(0, -1)
85  const ids = [repoId(repo), ...parts.map((_, i) => dirId(repo, parts.slice(0, i + 1).join('/')))]
86  const main = repo.worktree ? repos.slice(0, repos.indexOf(repo)).findLast(r => !r.worktree) : undefined
87  return main ? [repoId(main), ...ids] : ids
88}
89
90// fitRepo shortens the name and branch of a repo row to fit width, as sidegit does.
91// prWidth is the cells the PR status takes after them. An empty branch means the row
92// only has room for the name, and then shows no ahead, behind or PR status either.
93export function fitRepo(repo: Repo, width: number, prWidth = 0): { name: string; branch: string; count: string } {
94  const avail = width - 2 // the arrow and a space
95  const branch = `[${repo.branch}]`
96  const count = `(${repo.files.length})`
97  const ab = (repo.ahead > 0 ? ` ↑${repo.ahead}` : '') + (repo.behind > 0 ? ` ↓${repo.behind}` : '')
98  if (repo.name.length + 1 + branch.length + 1 + count.length + ab.length + prWidth <= avail) return { name: repo.name, branch, count }
99  let room = avail - count.length - ab.length - prWidth - 2
100  let shownCount = count
101  if (room < 7) {
102    room = avail - ab.length - prWidth - 1
103    shownCount = ''
104  }
105  const [name, shownBranch] = fitNameAndBranch(repo.name, branch, room)
106  if (name && shownBranch) return { name, branch: shownBranch, count: shownCount }
107  return { name: truncatePath(repo.name, Math.max(1, avail)), branch: '', count: '' }
108}
109
110// fitNameAndBranch gives about 60% of the room to the name and 40% to the branch,
111// and gives the room one of them does not need to the other.
112function fitNameAndBranch(name: string, branch: string, avail: number): [string, string] {
113  if (name.length + branch.length <= avail) return [name, branch]
114  const minName = 3
115  const minBranch = 4
116  if (avail < minName + minBranch) return [truncatePath(name, Math.max(1, avail)), '']
117  let nameSpace = Math.max(minName, Math.floor((avail * 3) / 5))
118  let branchSpace = avail - nameSpace
119  if (branchSpace < minBranch) {
120    branchSpace = minBranch
121    nameSpace = avail - branchSpace
122  }
123  if (name.length <= nameSpace && branch.length > branchSpace) {
124    branchSpace = avail - name.length
125    nameSpace = name.length
126  } else if (branch.length <= branchSpace && name.length > nameSpace) {
127    nameSpace = avail - branch.length
128    branchSpace = branch.length
129  }
130  return [truncatePath(name, nameSpace), truncateBranch(branch, branchSpace)]
131}
132
133// truncatePath shortens a path from the left to fit width, as "…/Projects/gitbar".
134export function truncatePath(path: string, width: number): string {
135  if (path.length <= width) return path
136  if (width <= 3) return '...'
137  const parts = path.split('/')
138  let result = parts[parts.length - 1] ?? ''
139  if (result.length + 2 > width) return '…' + result.slice(result.length - (width - 1))
140  for (let i = parts.length - 2; i >= 0; i--) {
141    const candidate = `${parts[i]}/${result}`
142    if (candidate.length + 2 > width) break // 2 for the "…/" prefix
143    result = candidate
144  }
145  return result === path ? path : `…/${result}`
146}
147
148// truncateBranch shortens "[branch]" and keeps the brackets.
149function truncateBranch(branch: string, width: number): string {
150  if (branch.length <= width) return branch
151  if (width <= 2) return ''
152  if (width === 3) return '[…]'
153  return `[${branch.slice(1, 1 + width - 3)}…]`
154}
155
156// age shows how long ago a file changed: "12s", "5m", "3h" or "2d".
157export function age(ms: number): string {
158  const s = Math.max(0, Math.floor(ms / 1000))
159  const text = s < 60 ? `${s}s` : s < 3600 ? `${Math.floor(s / 60)}m` : s < 86400 ? `${Math.floor(s / 3600)}h` : `${Math.floor(s / 86400)}d`
160  return text.padStart(3)
161}
162
hooks/pulse.tsx 35 lines
1import type { ClientModule } from 'claude-code'
2
3// colors runs from the text's own color to the color of the letter at the center of the band.
4export type PulseProps = { text: string; colors: string[] }
5type Local = { phase: number }
6
7const TICK_MS = 60
8// Frames with no band between two passes, about half a second
9const PAUSE_TICKS = 8
10
11// Pulse draws text with a shimmer: a narrow band of lighter letters that moves across
12// the text from the first letter to the last, one letter per frame, then pauses.
13const Pulse: ClientModule<PulseProps, Local> = (props, surface) => {
14  const { Text } = surface.elements
15  if (surface.state === undefined) {
16    surface.setState({ phase: 0 })
17    surface.every(TICK_MS, () => surface.setState({ phase: (surface.state?.phase ?? 0) + 1 }))
18  }
19  const chars = [...props.text]
20  const top = props.colors.length - 1
21  // The band enters before the first letter and leaves after the last
22  const band = ((surface.state?.phase ?? 0) % (chars.length + 2 * top + PAUSE_TICKS)) - top
23  return (
24    <Text wrap="truncate-end">
25      {chars.map((ch, i) => (
26        <Text bold color={props.colors[Math.max(0, top - Math.abs(i - band))]}>
27          {ch}
28        </Text>
29      ))}
30    </Text>
31  )
32}
33
34export default Pulse
35
types/index.d.ts 64 lines
1export type StatusCode = 'M' | 'A' | 'D' | 'R' | 'C' | '?'
2
3export type FileStatus = {
4  /** relative to the repo */
5  path: string
6  status: StatusCode
7  staged: boolean
8  /** lines added compared to HEAD (all lines for an untracked file) */
9  added: number
10  /** lines deleted compared to HEAD */
11  deleted: number
12  /** 0 for a deleted file */
13  mtimeMs: number
14}
15
16export type Repo = {
17  /** absolute */
18  path: string
19  /** relative to the scan root; the absolute path for the root repo; the folder name for a worktree */
20  name: string
21  branch: string
22  ahead: number
23  behind: number
24  files: FileStatus[]
25  /** true for a linked worktree, which follows its main repo in the list */
26  worktree: boolean
27}
28
29/** A file Claude read or wrote. */
30export type Touch = {
31  id: string
32  /** absolute */
33  path: string
34  kind: 'read' | 'write'
35  running: boolean
36  at: number
37}
38
39/** The newest GitHub pull request of a branch. */
40export type PRStatus = {
41  number: number
42  /** OPEN, MERGED or CLOSED */
43  state: string
44  draft: boolean
45  /** SUCCESS, FAILURE, ERROR, PENDING or EXPECTED; empty without checks */
46  checks: string
47  /** review threads not resolved yet */
48  unresolved: number
49}
50
51declare module 'claude-code' {
52  interface PluginState {
53    sidegit: {
54      root: string
55      repos: Repo[]
56      /** ids of the collapsed repo and folder rows */
57      collapsed: string[]
58      touches: Touch[]
59      /** by repo path and branch; null when the branch has no PR */
60      prs: Record<string, PRStatus | null>
61    }
62  }
63}
64