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

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.
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 .
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.
| Key | Action |
|---|---|
j / k / ↑ / ↓ | Navigate |
Enter | Show diff for selected file |
Tab | Switch between tree and diff panels |
Esc | Close diff panel |
c / e | Collapse/expand repo or directory |
o | Open file in $EDITOR |
d | Discard changes, or remove a worktree (opens confirmation menu) |
p | Toggle diff panel position (right/bottom) |
r | Refresh |
q | Quit |
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").
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.
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.
+12 -3), compared to HEAD5m). A file that changed in the last 30 seconds shows in the title colorgh), logged in. Refreshes every 2 minutes and on rhooks/register.tsx 284 lines1import { 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}
284hooks/git.ts 167 lines1import 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}
167hooks/github.ts 61 lines1import 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}
61hooks/icons.ts 88 lines1// 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}
88hooks/tree.ts 162 lines1import 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}
162hooks/pulse.tsx 35 lines1import 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
35types/index.d.ts 64 lines1export 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