SLOPSHOPPER

repo-ledger

提示框上方列出這個對話動過、還沒 commit 的 repo:分支、未 commit 檔案數、未 push 的 commit、別的 worktree、本輪改了幾檔

newbandguardprocess
★ 1v0.1.1MITupdated 2026-10-06mangow314/mango-mods/repo-ledger
A shopper browsing a rack in a slop shop
README

repo-ledger

繁體中文版:README.zh-TW.md. The mod's on-screen text is in Traditional Chinese.

A Claude Code mod that draws one line above the prompt: the repos this conversation has touched that still have uncommitted changes. When you work across several repos at once (for example chezmoi, a vault, and mango-mods together), one glance before asking the agent to commit tells you what is still left.

Tested on: Claude Code 2.1.289 (0.1.0, loaded with --plugin-dir; tested two repos with one edited file each, one of them turning into a check mark after its commit, and showing alongside ctx-relay). The mods API is still in early access, so a Claude Code release may require changes.

The band

repo-ledger band in a live session (top line; the bottom line is ctx-relay)

A live Claude Code 2.1.290 session. The top line is repo-ledger: demo-app(main) 1 · 本輪 2 檔 ("2 files this turn"). With several repos they share one line, for example repoA(master) 1 · repoB(master) ✓ · 本輪 2 檔.

  • Each repo shows name(branch) followed by its count of uncommitted files (untracked files included, in orange); a clean repo shows a gray check mark.
  • ↑N: commits not yet pushed. ⎇N: extra worktrees of this repo.
  • 本輪 N 檔 ("N files this turn"): files inside repos edited with Edit / Write since you last sent a message; turns orange above 5 files, so you notice early when a lot is changing.
  • With no uncommitted files and no files edited this turn, the line is not drawn. Unpushed commits alone do not keep it on screen.
  • When another mod also draws a band (for example ctx-relay), the two lines stack. Which one is on top depends on load order: in the screenshot above, with all four mods loaded through --plugin-dir, repo-ledger is on top.
  • The icon needs a Nerd Font; without one, the line starts with a box.

Which repos it tracks

  • The repo containing any file changed with Edit / Write / NotebookEdit. Files outside a repo, such as a scratchpad, do not count.
  • Literal paths after cd, pushd, and git -C in Bash commands (paths starting with ~/ included).
  • A Bash git command with no cd / git -C counts the session's working directory.
  • The working directory is not listed from the start: until the conversation touches it, existing changes there are not shown. Once touched, every uncommitted file in the repo counts (including those from before the conversation started).

When it updates

At the end of every turn, after any Bash command containing git, and the first time a new repo is touched.

Known limits

  • Only literal paths are recognized: S=/x; cd "$S" or git -C "$(…)" cannot be seen through and are not tracked.
  • Files changed by Bash (sed -i, scripts, and so on) do not count toward 本輪 N 檔; if the repo is already tracked, they show up in the uncommitted count on the next refresh.
  • The text cd some-repo inside a heredoc or a commit message may also add that repo (one extra entry; the others are unaffected).
  • The name is the last component of the repo root: ~/.local/share/chezmoi shows as chezmoi, a vault as its folder name.
  • The tracked list survives /clear, and is read back from $.state after a hot reload. Neither has been tested live yet.
  • Needs git on PATH.

Risks

  • The mod runs git status and git worktree list in tracked repos on its own, so settings in the repo's own .git/config take effect. Merely cd-ing into an untrusted repo in the conversation triggers this.
  • core.fsmonitor is blocked: every git status runs with -c core.fsmonitor=false, so a repo's fsmonitor command never runs.
  • Other settings that run commands are not blocked. For example filter.<name>.clean: git status may run it while comparing file contents (an assumption, not tested). Use it only in repos you trust.
Source 2 files
hooks/register.tsx 204 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderElement, RenderInput } from 'claude-code'
3
4import type { Ledger, RepoRow } from '../types'
5
6// repo-ledger:提示框上方一行,列出這個對話動過、還沒 commit 的 repo。
7// 追蹤哪些 repo:Edit/Write/NotebookEdit 的檔案路徑、Bash 指令裡 cd/git -C 後面的字面路徑(有 $ 或反引號的不認),
8// 以及沒有 cd 就跑 git 時的工作目錄。工作目錄本身不會一開場就列:對話動到它之前,裡面原有的改動不算。
9// 什麼時候重算:每輪結束、Bash 跑過 git 指令之後、第一次碰到新 repo 時。
10// 沒有未 commit 的檔、本輪也沒改檔時整行不畫(只剩未 push 的 commit 不算)。
11//
12// /clear 之後 $.state 歸零、模組變數保留;hot reload 反過來(ctx-relay P1 probe 實測)。
13// 所以追蹤清單兩邊各存一份:模組變數撐過 clear,$.state 撐過 reload,session.start(reload 會重跑)把兩邊合併。
14
15// 和 ctx-relay 同一組 dark-daltonized 可分辨色
16const ORANGE = '#E69F00'
17const SKY = '#56B4E9'
18const LABEL = '#7d8794'
19const VALUE = '#f5f7fa'
20const GIT = '󰊢' // Nerd Font md-git U+F02A2
21// 本輪改超過這麼多檔,檔數變橘色(「改太大了」的早期提醒)
22const WIDE_TURN = 5
23
24const ledgerAtom = atom({ plugin: 'repo-ledger', key: 'ledger' } as const, { rows: [], turnFiles: 0 } as Ledger)
25const rootsAtom = atom({ plugin: 'repo-ledger', key: 'roots' } as const, [] as string[])
26
27// 追蹤中的 repo 根目錄(加入順序)
28const roots: string[] = []
29// 目錄 → repo 根目錄;不是 repo 記 null,不重問 git
30const rootOf = new Map<string, string | null>()
31// 本輪 Edit/Write 過的檔案(含子代理的)
32const turnFiles = new Set<string>()
33
34export const register: Register = on => {
35  on('session.start', async ($, e, next) => {
36    const result = await next(e)
37    for (const root of await read($, rootsAtom)) {
38      if (!roots.includes(root)) roots.push(root)
39    }
40    if (roots.length > 0) await refresh($)
41    return result
42  })
43
44  // 只有主對話有 turn.start:你送出訊息就是新的一輪
45  on('turn.start', async ($, e, next) => {
46    turnFiles.clear()
47    await update($, ledgerAtom, l => ({ ...l, turnFiles: 0 }))
48    return next(e)
49  })
50
51  on('tool.call', async ($, e, next) => {
52    const result = await next(e)
53    if (result.deny !== undefined || result.isError === true) return result
54    if (e.tool === 'Edit' || e.tool === 'Write' || e.tool === 'NotebookEdit') {
55      const file = await absolute($, await $.session.cwd(), e.tool === 'NotebookEdit' ? e.notebook_path : e.file_path)
56      const isNew = await track($, dirname(file))
57      // 只算 repo 裡的檔(scratchpad 這類暫存檔不算)
58      if (rootOf.get(dirname(file))) turnFiles.add(file)
59      if (isNew) await refresh($)
60      await update($, ledgerAtom, l => ({ ...l, turnFiles: turnFiles.size }))
61    } else if (e.tool === 'Bash') {
62      const cwd = await $.session.cwd()
63      const runsGit = /\bgit\b/.test(e.command)
64      const dirs = commandDirs(e.command)
65      // 完全沒有 cd/git -C 就跑 git:在工作目錄跑的(有但看不穿的,不猜)
66      if (runsGit && !MOVE_RE.test(e.command)) dirs.push(cwd)
67      let isNew = false
68      for (const dir of dirs) {
69        if (await track($, await absolute($, cwd, dir))) isNew = true
70      }
71      if (isNew || runsGit) await refresh($)
72    }
73    return result
74  })
75
76  on('turn.complete', async ($, e, next) => {
77    const result = await next(e)
78    if (!e.agentId) await refresh($)
79    return result
80  })
81
82  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
83    if (e.props.hasSurvey) {
84      return next(e)
85    }
86    // band 只有一個:先讓排在下面的 mod 畫,自己這行疊在它下面
87    const below = await next(e)
88    const mine = await drawBand($, e)
89    if (mine === null) return below
90    if (!below) return mine
91    const { Box } = $.ui.resolve(e)
92    return (
93      <Box flexDirection="column">
94        {below}
95        {mine}
96      </Box>
97    )
98  })
99}
100
101// Bash 指令裡 cd/pushd/git -C 後面的路徑;帶 $ 或反引號的看不穿,不認
102const DIR_RE = /(?:^|[\s;&|(])(?:cd|pushd)\s+(?:--\s+)?('[^']*'|"[^"]*"|[^\s;&|)]+)|\bgit\s+-C\s+('[^']*'|"[^"]*"|[^\s;&|)]+)/g
103const MOVE_RE = /(?:^|[\s;&|(])(?:cd|pushd)\s|\bgit\s+-C\s/
104
105function commandDirs(command: string): string[] {
106  const dirs: string[] = []
107  for (const m of command.matchAll(DIR_RE)) {
108    const dir = (m[1] ?? m[2] ?? '').replace(/^(['"])(.*)\1$/, '$2')
109    if (dir === '' || dir === '-' || /[$`]/.test(dir)) continue
110    dirs.push(dir)
111  }
112  return dirs
113}
114
115async function absolute($: EngineInterface, cwd: string, path: string): Promise<string> {
116  if (path.startsWith('/')) return path
117  if (path === '~' || path.startsWith('~/')) return `${(await $.env.get('HOME')) ?? ''}${path.slice(1)}`
118  return `${cwd}/${path}`
119}
120
121function dirname(path: string): string {
122  return path.slice(0, path.lastIndexOf('/')) || '/'
123}
124
125// 目錄所屬 repo 加進追蹤;回傳是否新加入
126async function track($: EngineInterface, dir: string): Promise<boolean> {
127  let root = rootOf.get(dir)
128  if (root === undefined) {
129    const run = await $.process.run(['git', '-C', dir, 'rev-parse', '--show-toplevel'], { timeoutMs: 5000 })
130    root = run.exitCode === 0 ? run.stdout.trim() || null : null
131    rootOf.set(dir, root)
132  }
133  if (root === null || roots.includes(root)) return false
134  roots.push(root)
135  await update($, rootsAtom, () => [...roots])
136  return true
137}
138
139async function refresh($: EngineInterface): Promise<void> {
140  const rows: RepoRow[] = []
141  for (const root of roots) {
142    const row = await inspect($, root)
143    if (row !== null) rows.push(row)
144  }
145  await update($, ledgerAtom, l => ({ ...l, rows }))
146  // /clear 把 $.state 歸零後,下一次重算就把模組變數的清單寫回去,之後的 hot reload 才讀得到
147  await update($, rootsAtom, () => [...roots])
148}
149
150async function inspect($: EngineInterface, root: string): Promise<RepoRow | null> {
151  // repo 自己的 .git/config 若設了 core.fsmonitor,status 會執行它:外來 repo 只 cd 進去就會觸發,關掉
152  const status = await $.process.run(['git', '-C', root, '-c', 'core.fsmonitor=false', 'status', '--porcelain=v1', '--branch'], { timeoutMs: 10_000 })
153  if (status.exitCode !== 0) return null
154  const [head = '', ...files] = status.stdout.split('\n')
155  const worktrees = await $.process.run(['git', '-C', root, 'worktree', 'list', '--porcelain'], { timeoutMs: 5000 })
156  const others = worktrees.exitCode === 0 ? worktrees.stdout.split('\n').filter(l => l.startsWith('worktree ')).length - 1 : 0
157  return {
158    root,
159    name: root.split('/').filter(Boolean).at(-1) ?? root,
160    branch: branchOf(head),
161    dirty: files.filter(l => l.trim() !== '').length,
162    ahead: Number(/\[ahead (\d+)/.exec(head)?.[1] ?? 0),
163    worktrees: Math.max(0, others),
164  }
165}
166
167// `## master...origin/master [ahead 1]`、`## No commits yet on main`、`## HEAD (no branch)`
168function branchOf(head: string): string {
169  const text = head.replace(/^## /, '')
170  const unborn = /^No commits yet on (\S+)/.exec(text)
171  if (unborn) return unborn[1] ?? text
172  return text.split(/\.\.\.| /)[0] ?? text
173}
174
175// 沒有東西要畫時回 null
176async function drawBand($: EngineInterface, e: RenderInput<'AbovePrompt'>): Promise<RenderElement | null> {
177  const ledger = await read($, ledgerAtom)
178  const isPending = ledger.rows.some(r => r.dirty > 0)
179  if (!isPending && ledger.turnFiles === 0) return null
180  const { Box, Text } = $.ui.resolve(e)
181  const parts: RenderElement[] = []
182  ledger.rows.forEach((r, i) => {
183    if (i > 0) parts.push(<Text color={LABEL}>{' · '}</Text>)
184    const isClean = r.dirty === 0 && r.ahead === 0
185    parts.push(<Text color={isClean ? LABEL : VALUE}>{`${r.name}(${r.branch})`}</Text>)
186    parts.push(isClean ? <Text color={LABEL}>{' ✓'}</Text> : <Text color={ORANGE} bold>{r.dirty > 0 ? ` ${r.dirty}` : ''}</Text>)
187    if (r.ahead > 0) parts.push(<Text color={SKY}>{` ↑${r.ahead}`}</Text>)
188    if (r.worktrees > 0) parts.push(<Text color={LABEL}>{` ⎇${r.worktrees}`}</Text>)
189  })
190  if (ledger.turnFiles > 0) {
191    parts.push(<Text color={LABEL}>{' · 本輪 '}</Text>)
192    parts.push(<Text color={ledger.turnFiles > WIDE_TURN ? ORANGE : VALUE} bold={ledger.turnFiles > WIDE_TURN}>{`${ledger.turnFiles}`}</Text>)
193    parts.push(<Text color={LABEL}>{' 檔'}</Text>)
194  }
195  return (
196    <Box flexDirection="row">
197      <Text color={LABEL} wrap="truncate-end">
198        {` ${GIT} `}
199        {parts}
200      </Text>
201    </Box>
202  )
203}
204
types/index.d.ts 18 lines
1// 一個 repo 的現況:git status --branch 與 git worktree list 的結果
2// dirty=未 commit 的檔案數(含未追蹤);ahead=還沒 push 的 commit 數;worktrees=這個 repo 另外開的 worktree 數
3export type RepoRow = { root: string; name: string; branch: string; dirty: number; ahead: number; worktrees: number }
4
5// band 讀的整份帳本。rows 照 repo 加入的順序,session 工作目錄的 repo 在最前;
6// turnFiles=本輪(上次送出訊息以來)Edit/Write 過的檔案數
7export type Ledger = { rows: RepoRow[]; turnFiles: number }
8
9declare module 'claude-code' {
10  interface PluginState {
11    'repo-ledger': {
12      ledger: Ledger
13      // 這個對話追蹤中的 repo 根目錄;hot reload 後由 session.start 讀回
14      roots: string[]
15    }
16  }
17}
18