Notices when the session's git repository falls behind its upstream, leaves work behind (uncommitted changes, stashes, a half-done rebase, a stale index.lock)…

English summary: git-nudge is a Claude Code mod that watches the session's git repository and tells you, under the prompt and in a band above it, when it falls behind its upstream (a forgotten pull), keeps commits you have not pushed, has uncommitted changes or old stashes from before, is in the middle of a rebase or merge, has a stale
index.lock, or keeps branches whose upstream is gone. It tells Claude when that changes. It changes the repository only three ways:git fetch(never pruning), a fast-forward you ask for, andgit branch -dof merged branches after you confirm. It calls no model and sends nothing anywhere but your own git remotes. Install:/plugin install git-nudge --marketplace nextscape/ns-mods. Needs git 2.29 or later.
Claude Code の mod(関数フックで書くプラグイン)です。セッションの git リポジトリを見張り、次のことをプロンプトの下と上に知らせます。
p で取り込めるindex.lock/git-nudge tidy でまとめて片付けられる状態が変わったときは、Claude にも伝えます。Claude には「ユーザーの指示なしに pull・rebase・push・ブランチの削除をしない」と添えます。
モデルは呼びません。 ネットワークに出るのは、利用者の remote への git fetch と git ls-remote だけです。
/plugin install git-nudge --marketplace nextscape/ns-mods
新しいセッションを開くと読み込まれます。
| 項目 | 内容 |
|---|---|
| Claude Code | 2.1.287 以降(mod が既定で有効になる版)。2.1.292 で動作を確認 |
| git | 2.29 以降。古い版では何もしません(/git-nudge で理由を表示) |
| OS | 問いません |
入れるだけで動きます。git リポジトリの中でセッションを開くと、裏で fetch して状態を調べます。
知らせることがあるときだけ、1行で出します。
main ↓3 ↑1 · 未コミット 5 · stash 2 · fetch 3分前
↓ は upstream にあって手元に無いコミット、↑ は手元にあって upstream に無いコミットの数です。ほかに upstream なし、upstream 消滅、rebase 中、lock、HEAD 切り離し、fetch 失敗 を出します。
手を打てることがあるときだけ、最大2行で出します。
origin/main より 3 遅れ p: 取り込む x: 閉じる
p: 取り込む は、遅れていて、追跡中のファイルに変更が無く、手元に独自のコミットも無いときだけ出ます。先に fetch してから git merge --ff-only @{u} を実行し、戻すためのコマンドを表示します。Claude の作業中は出ません。x: 閉じる で隠した項目は、状態が変わるまで出ません。| コマンド | 動作 |
|---|---|
/git-nudge | 状態を取り直して表示(fetch もする。最後に fetch が成功した時刻も出す) |
/git-nudge pull | fetch してから取り込み(帯の p と同じ) |
/git-nudge tidy | upstream が消えたブランチの片付け |
/git-nudge off / on | このセッションだけ止める/再開する |
/git-nudge tidy)git branch -d で消します。消したブランチは、戻すためのコマンド(git branch <名前> <SHA>)を表示します。git worktree prune は自分で実行してください)。git config で設定します。--global で全体、付けなければそのリポジトリだけに効きます。
git config --global git-nudge.fetchInterval 0 # 定期 fetch をやめる
git config git-nudge.enabled false # このリポジトリでは使わない
| キー | 既定 | 内容 |
|---|---|---|
git-nudge.enabled | true | false で、そのリポジトリでは何もしない |
git-nudge.fetchOnStart | true | セッション開始時に fetch する |
git-nudge.fetchInterval | 5 | 定期 fetch の間隔(分)。0 でオフ |
git-nudge.tellClaude | true | 状態が変わったときに Claude へ伝える |
git-nudge.band | true | 帯を出す。false ならステータス行だけ |
git-nudge.stashAgeDays | 7 | 何日より古い stash を帯で知らせるか |
読めない値は無視し、/git-nudge の「設定の問題」に表示します。
| 項目 | 内容 |
|---|---|
| モデル | 呼びません |
| ネットワーク | 利用者の remote への git fetch と git ls-remote(開始時と、既定で5分ごと。同じリポジトリを複数のセッションで開いていても重ねません) |
| 認証 | ログインの入力や画面は出しません(GIT_TERMINAL_PROMPT=0、GCM_INTERACTIVE=never)。SSH は、自分で ssh コマンド(GIT_SSH_COMMAND、GIT_SSH、core.sshCommand)を設定していなければ BatchMode=yes で動かし、ホスト鍵の確認やパスフレーズを聞きません。認証に失敗したら、そのセッションでは定期 fetch を止めて知らせます |
| 手元に残るもの | Claude Code の mod 用の保存領域(~/.claude/plugins/store/)に、リポジトリごとの最後の fetch の時刻と、片付けの案内を出した日 |
| 場面 | すること |
|---|---|
| セッション開始 | 開始を待たせずに、裏で fetch と状態の取得 |
| 定期(既定5分) | fetch と状態の取得 |
| Claude のターンの終わり | 状態の取得だけ(fetch しない)。Claude のコミットや push をすぐ反映する |
| プロンプトの送信 | 伝える内容が前回と変わっていれば、Claude への文脈に足す |
git status --porcelain=v2 --branch(--no-optional-locks 付き。index.lock を取らない)、git stash list、git rev-parse --git-path で見つけた rebase / merge などの印、git ls-remote --heads で調べます。--prune を付けず、git の自動メンテナンスも走らせません。git branch -d の3つだけです。push、ブランチの切り替え、index.lock の削除、git branch -D、worktree の削除はしません(知らせるだけ)。claude -p では出ません。Claude への伝達とステータス行は動きます)。claude plugin validate mods/git-nudge
claude plugin test mods/git-nudge
bash mods/git-nudge/tests/make-fixtures.sh # 本物の git で tests/fixtures.ts を作り直す(git と node が必要)
MIT(リポジトリの LICENSE)
hooks/register.ts 539 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { Settings, Snapshot } from '../types'
5import {
6 BAND_ROWS,
7 MINUTE,
8 RESOLVED,
9 TIDY_HINT_MIN,
10 ago,
11 noticeKey,
12 notices,
13 pullBlocker,
14 statusLine,
15 tellText,
16} from './judge'
17import {
18 BRANCH_FORMAT,
19 DEFAULTS,
20 MARKERS,
21 MIN_GIT,
22 absolutePath,
23 files,
24 firstLine,
25 isAuthError,
26 isSupported,
27 lines,
28 parseBranches,
29 parseConfig,
30 parseRemoteHeads,
31 parseStash,
32 parseStatus,
33 parseVersion,
34 prunableWorktrees,
35} from './parse'
36import { deletedReport, describeTidy, planTidy, tidyCandidates, withGoneUpstream } from './tidy'
37
38// Keeps an eye on the session's git repository: how far it is from its
39// upstream, what was left behind (uncommitted changes, stashes, a half-done
40// rebase, a stale index.lock) and which branches lost their upstream. It says
41// so under the prompt and in a band above it, tells Claude when that changes,
42// and changes the repository only three ways: fetch (never pruning), a
43// fast-forward the person asks for, and `git branch -d` of merged branches
44// after asking. It calls no model.
45
46const snapshotState = atom({ plugin: 'git-nudge', key: 'snapshot' } as const, null)
47const startState = atom({ plugin: 'git-nudge', key: 'start' } as const, null)
48const dismissedState = atom({ plugin: 'git-nudge', key: 'dismissed' } as const, [])
49const busyState = atom({ plugin: 'git-nudge', key: 'busy' } as const, false)
50
51const LOCAL_TIMEOUT = 10_000
52const REMOTE_TIMEOUT = 60_000
53// git asks nothing on a terminal it does not have, nor through Git Credential
54// Manager's window: a fetch that needs a login fails instead.
55const REMOTE_ENV = { GIT_TERMINAL_PROMPT: '0', GCM_INTERACTIVE: 'never' }
56const STATUS_ARGS = ['--no-optional-locks', 'status', '--porcelain=v2', '--branch']
57
58// What a refresh fetches for: the session's first look, the timer, or a
59// command that asks for it now.
60type FetchWhen = 'start' | 'timer' | 'force' | false
61
62type Ctx = {
63 off: boolean
64 interactive: boolean
65 // undefined until checked; null when git could not be run.
66 version: readonly [number, number] | null | undefined
67 root: string | null
68 // Why the mod is doing nothing, for /git-nudge; null while it works.
69 reason: string | null
70 settings: Settings
71 problems: string[]
72 // Refreshes run one after another on this chain, so none is dropped.
73 queue: Promise<void>
74 // Set while a pull runs, so a second press does not merge again.
75 pulling: boolean
76 timer: Timer | null
77 interval: number
78 authStopped: boolean
79 failed: { at: number; auth: boolean } | null
80 // What Claude was last told; null when nothing.
81 told: string | null
82 tidyHint: boolean
83 // git status timeouts in a row; /git-nudge suggests turning the mod off.
84 slow: number
85}
86
87type Run = { code: number; out: string; err: string }
88
89const fetchKey = (root: string) => `fetch:${root}`
90const hintKey = (root: string) => `tidy-hint:${root}`
91
92export const register: Register = on => {
93 const ctx: Ctx = {
94 off: false,
95 interactive: true,
96 version: undefined,
97 root: null,
98 reason: null,
99 settings: { ...DEFAULTS },
100 problems: [],
101 queue: Promise.resolve(),
102 pulling: false,
103 timer: null,
104 interval: 0,
105 authStopped: false,
106 failed: null,
107 told: null,
108 tidyHint: false,
109 slow: 0,
110 }
111
112 on('session.start', async ($, e, next) => {
113 ctx.interactive = e.isInteractive
114 // The first look (and fetch) runs once the session is up, not before it.
115 $.clock.after(0, () => refresh($, ctx, 'start'))
116 await $.command.register({
117 name: 'git-nudge',
118 description: 'git の遅れ・置き忘れを知らせる。pull で取り込み、tidy で片付け、off / on で停止と再開',
119 argumentHint: '[pull|tidy|on|off]',
120 immediate: true,
121 })
122 return next(e)
123 })
124
125 // /clear, /resume and /branch reset $.state: look again, and tell Claude afresh.
126 on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
127 ctx.told = null
128 $.clock.after(0, () => refresh($, ctx, false))
129 return next(e)
130 })
131
132 on('turn.start', async ($, e, next) => {
133 await update($, busyState, () => true)
134 return next(e)
135 })
136
137 on('turn.complete', async ($, e, next) => {
138 if (e.agentId === undefined) {
139 await update($, busyState, () => false)
140 // Claude may have committed or pushed: look again, without fetching.
141 $.clock.after(0, () => refresh($, ctx, false))
142 }
143 return next(e)
144 })
145
146 on('prompt.submit', async ($, e, next) => {
147 const text = await tell($, ctx)
148 return next(text === null ? e : { ...e, context: [...(e.context ?? []), text] })
149 // Telling only adds: if it throws, the prompt goes as typed.
150 }).catch(($, e, next) => next(e))
151
152 on('command.run', { command: 'git-nudge' }, async ($, e) => {
153 const arg = e.args.trim().toLowerCase()
154 if (arg === 'pull') return { text: await pull($, ctx) }
155 if (arg === 'tidy') return { text: await tidy($, ctx) }
156 if (arg === 'off') {
157 ctx.off = true
158 await rest($, ctx)
159 return { text: 'このセッションでは止めました(/git-nudge on で再開)' }
160 }
161 if (arg === 'on') ctx.off = false
162 else if (arg !== '') return { text: `知らない引数です: ${arg}(pull / tidy / on / off。省略すると状態を表示)` }
163 await refresh($, ctx, 'force')
164 return { text: await report($, ctx) }
165 })
166
167 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
168 const theirs = await next(e)
169 const snap = await read($, snapshotState)
170 if (ctx.off || !ctx.settings.band || snap === null) return theirs
171 const start = await read($, startState)
172 const dismissed = await read($, dismissedState)
173 const working = e.component === 'AbovePrompt' && e.props.isWorking
174 const busy = (await read($, busyState)) || working
175 const now = await $.clock.now()
176 const shown = notices(snap, start, ctx.settings, now, ctx.tidyHint)
177 .filter(notice => !dismissed.includes(noticeKey(notice)))
178 .slice(0, BAND_ROWS)
179 if (shown.length === 0) return theirs
180 const { Box, Text, Button } = $.ui.resolve(e)
181 // Letter hotkeys only: a digit on a band button fires from an empty
182 // prompt, so a typed "1" could pull.
183 const rows = shown.map((notice, index) =>
184 Box({
185 key: `row-${notice.id}`,
186 flexDirection: 'row',
187 columnGap: 2,
188 children: [
189 Text({ children: [notice.text] }),
190 ...(notice.pull && !busy
191 ? [Button({ key: 'pull', label: '取り込む', hotkey: 'p', plain: true, onPress: () => pullFromBand($, ctx) })]
192 : []),
193 ...(index === 0
194 ? [
195 Button({
196 key: 'close',
197 label: '閉じる',
198 hotkey: 'x',
199 plain: true,
200 onPress: () => update($, dismissedState, list => [...list, ...shown.map(noticeKey)]),
201 }),
202 ]
203 : []),
204 ],
205 }),
206 )
207 return Box({ flexDirection: 'column', children: [...rows, theirs] })
208 })
209}
210
211// Runs git. A start failure or a timeout comes back as code -1, never as a throw.
212async function git(
213 $: EngineInterface,
214 cwd: string,
215 args: string[],
216 options: { remote?: boolean; timeoutMs?: number } = {},
217): Promise<Run> {
218 try {
219 const result = await $.process.run(['git', ...args], {
220 cwd,
221 timeoutMs: options.timeoutMs ?? (options.remote ? REMOTE_TIMEOUT : LOCAL_TIMEOUT),
222 ...(options.remote ? { env: REMOTE_ENV } : {}),
223 })
224 return { code: result.exitCode, out: result.stdout, err: result.stderr }
225 } catch (error) {
226 return { code: -1, out: '', err: error instanceof Error ? error.message : String(error) }
227 }
228}
229
230async function exists($: EngineInterface, path: string): Promise<boolean> {
231 try {
232 return await $.fs.exists(path)
233 } catch {
234 return false
235 }
236}
237
238// Looks at the repository again, fetching when `when` asks for it, and
239// redraws. A refresh asked for while another runs waits for it and then runs,
240// so a pull's fetch or the look after a turn is never dropped. Never throws.
241function refresh($: EngineInterface, ctx: Ctx, when: FetchWhen): Promise<void> {
242 ctx.queue = ctx.queue.then(() => lookSafely($, ctx, when))
243 return ctx.queue
244}
245
246async function lookSafely($: EngineInterface, ctx: Ctx, when: FetchWhen): Promise<void> {
247 if (ctx.off) return
248 try {
249 await look($, ctx, when)
250 } catch (error) {
251 $.ui.log(`git-nudge: ${error instanceof Error ? error.message : String(error)}`, { to: 'debug' })
252 }
253}
254
255// Batch mode keeps ssh from asking for a host key or a passphrase, unless the
256// person chose their own ssh command (GIT_SSH_COMMAND, GIT_SSH or core.sshCommand).
257async function sshArgs($: EngineInterface, root: string): Promise<string[]> {
258 if ((await $.env.get('GIT_SSH_COMMAND')) || (await $.env.get('GIT_SSH'))) return []
259 const configured = await git($, root, ['config', '--get', 'core.sshCommand'])
260 return configured.code === 0 ? [] : ['-c', 'core.sshCommand=ssh -o BatchMode=yes']
261}
262
263async function look($: EngineInterface, ctx: Ctx, when: FetchWhen): Promise<void> {
264 const cwd = await $.session.cwd()
265 if (ctx.version === undefined) {
266 const version = await git($, cwd, ['version'])
267 ctx.version = version.code === 0 ? parseVersion(version.out) : null
268 }
269 if (ctx.version === null) return idle($, ctx, 'git が見つかりません')
270 if (!isSupported(ctx.version)) {
271 return idle($, ctx, `git ${MIN_GIT.join('.')} 以降が必要です(今は ${ctx.version.join('.')})`)
272 }
273 const top = await git($, cwd, ['rev-parse', '--show-toplevel'])
274 if (top.code !== 0) return idle($, ctx, 'git リポジトリの中ではありません')
275 const root = top.out.trim()
276 const config = await git($, root, ['config', '--get-regexp', '^git-nudge\\.'])
277 const { settings, problems } = parseConfig(config.code === 0 ? config.out : '')
278 ctx.settings = settings
279 ctx.problems = problems
280 if (!settings.enabled) {
281 stopTimer(ctx)
282 return idle($, ctx, 'git-nudge.enabled が false です')
283 }
284 if (root !== ctx.root) {
285 ctx.root = root
286 ctx.told = null
287 ctx.authStopped = false
288 ctx.failed = null
289 ctx.tidyHint = false
290 await update($, snapshotState, () => null)
291 await update($, startState, () => null)
292 }
293 ctx.reason = null
294 schedule($, ctx)
295
296 const now = await $.clock.now()
297 const prev = await read($, snapshotState)
298 const branches = parseBranches((await git($, root, ['for-each-ref', `--format=${BRANCH_FORMAT}`, 'refs/heads'])).out)
299 let status = await git($, root, STATUS_ARGS)
300 if (status.code !== 0) {
301 // Keep what is shown; a timeout counts toward suggesting enabled=false.
302 if (status.code === -1) ctx.slow += 1
303 $.ui.log(`git-nudge: git status failed: ${firstLine(status.err)}`, { to: 'debug' })
304 return
305 }
306 ctx.slow = 0
307 let parsed = parseStatus(status.out)
308 const remote = branches.find(branch => branch.name === parsed.branch.head)?.remote ?? null
309 let goneNames = prev?.goneNames ?? null
310 let tidyCount = prev?.tidyCount ?? null
311
312 if (when === 'force') ctx.authStopped = false
313 const wants =
314 when === 'force' ||
315 (when === 'start' && settings.fetchOnStart) ||
316 (when === 'timer' && settings.fetchInterval > 0)
317 if (wants && remote !== null && !ctx.authStopped && (when === 'force' || (await fetchDue($, root, settings, now)))) {
318 const ssh = await sshArgs($, root)
319 const fetched = await git(
320 $,
321 root,
322 ['-c', 'http.lowSpeedLimit=1000', '-c', 'http.lowSpeedTime=20', ...ssh, 'fetch', '--quiet', '--no-auto-maintenance', remote],
323 { remote: true },
324 )
325 if (fetched.code === 0) {
326 ctx.failed = null
327 await $.store.set(fetchKey(root), now)
328 const listed = await git($, root, [...ssh, 'ls-remote', '--heads', remote], { remote: true })
329 if (listed.code === 0) {
330 const heads = parseRemoteHeads(listed.out)
331 goneNames = withGoneUpstream(branches, heads, remote).map(branch => branch.name)
332 tidyCount = tidyCandidates(branches, heads, remote).length
333 }
334 status = await git($, root, STATUS_ARGS)
335 if (status.code === 0) parsed = parseStatus(status.out)
336 } else {
337 const auth = isAuthError(fetched.err)
338 ctx.failed = { at: now, auth }
339 if (auth) ctx.authStopped = true
340 $.ui.log(`git-nudge: fetch failed: ${firstLine(fetched.err)}`, { to: 'debug' })
341 }
342 }
343
344 const stash = parseStash((await git($, root, ['stash', 'list', '--format=%ct'])).out)
345 const markers = await git($, root, [
346 'rev-parse',
347 ...MARKERS.flatMap(([name]) => ['--git-path', name]),
348 '--git-path',
349 'index.lock',
350 ])
351 const paths = markers.code === 0 ? lines(markers.out).map(path => absolutePath(root, path)) : []
352 let operation: Snapshot['operation'] = null
353 for (const [index, [, kind]] of MARKERS.entries()) {
354 const path = paths[index]
355 if (path !== undefined && (await exists($, path))) {
356 operation = kind
357 break
358 }
359 }
360 const lockPath = paths[MARKERS.length]
361 const locked = lockPath !== undefined && (await exists($, lockPath))
362 const lastOk = await $.store.get(fetchKey(root))
363 const snapshot: Snapshot = {
364 root,
365 status: parsed,
366 stash,
367 operation,
368 lockSince: locked ? (prev?.lockSince ?? now) : null,
369 goneNames,
370 tidyCount,
371 fetch: { lastOk: typeof lastOk === 'number' ? lastOk : null, failed: ctx.failed },
372 at: now,
373 }
374 await update($, snapshotState, () => snapshot)
375 if ((await read($, startState)) === null) {
376 await update($, startState, () => ({ ahead: parsed.branch.ahead ?? 0, dirty: files(parsed.changes) }))
377 }
378 if (!ctx.tidyHint && tidyCount !== null && tidyCount >= TIDY_HINT_MIN) {
379 const today = new Date(now).toISOString().slice(0, 10)
380 if ((await $.store.get(hintKey(root))) !== today) {
381 ctx.tidyHint = true
382 await $.store.set(hintKey(root), today)
383 }
384 }
385 $.ui.status(statusLine(snapshot, settings, now))
386}
387
388// Whether the repository's last fetch, by any session, is older than the interval.
389async function fetchDue($: EngineInterface, root: string, settings: Settings, now: number): Promise<boolean> {
390 const last = await $.store.get(fetchKey(root))
391 if (typeof last !== 'number') return true
392 return now - last >= Math.max(settings.fetchInterval, 1) * MINUTE - 10_000
393}
394
395// Keeps the fetch timer at the configured interval.
396function schedule($: EngineInterface, ctx: Ctx) {
397 const minutes = ctx.settings.fetchInterval
398 if (ctx.timer !== null && ctx.interval === minutes) return
399 stopTimer(ctx)
400 ctx.interval = minutes
401 if (minutes > 0) ctx.timer = $.clock.every(minutes * MINUTE, () => refresh($, ctx, 'timer'))
402}
403
404function stopTimer(ctx: Ctx) {
405 ctx.timer?.cancel()
406 ctx.timer = null
407 ctx.interval = 0
408}
409
410// Nothing to look at: keep the reason for /git-nudge and clear what is shown.
411async function idle($: EngineInterface, ctx: Ctx, reason: string): Promise<void> {
412 ctx.reason = reason
413 ctx.root = null
414 await update($, snapshotState, () => null)
415 $.ui.status(undefined)
416}
417
418// /git-nudge off: stop the timer and clear what is shown.
419async function rest($: EngineInterface, ctx: Ctx): Promise<void> {
420 stopTimer(ctx)
421 await update($, snapshotState, () => null)
422 $.ui.status(undefined)
423}
424
425// The context to add to this prompt: the state when it differs from what
426// Claude was last told, a word that it cleared up, or null.
427async function tell($: EngineInterface, ctx: Ctx): Promise<string | null> {
428 if (ctx.off || !ctx.settings.tellClaude) return null
429 const snap = await read($, snapshotState)
430 if (snap === null) return null
431 const text = tellText(notices(snap, await read($, startState), ctx.settings, await $.clock.now(), false))
432 if (text === ctx.told) return null
433 const said = text ?? (ctx.told !== null ? RESOLVED : null)
434 ctx.told = text
435 return said
436}
437
438// Fast-forwards to the upstream when that is safe now, and says what happened.
439async function pull($: EngineInterface, ctx: Ctx): Promise<string> {
440 if (ctx.off) return 'git-nudge は止まっています(/git-nudge on で再開)'
441 if (ctx.pulling) return '取り込みの途中です。終わるまでお待ちください'
442 ctx.pulling = true
443 try {
444 return await fastForward($, ctx)
445 } finally {
446 ctx.pulling = false
447 }
448}
449
450const BUSY = 'Claude の作業中は取り込めません。作業が終わってから実行してください'
451
452async function fastForward($: EngineInterface, ctx: Ctx): Promise<string> {
453 if (await read($, busyState)) return BUSY
454 // Fetch first, as git pull does: the last look may have skipped its fetch.
455 await refresh($, ctx, 'force')
456 const snap = await read($, snapshotState)
457 if (snap === null) return ctx.reason ?? '状態を取得できませんでした'
458 const blocker = pullBlocker(snap)
459 if (blocker !== null) return `取り込みませんでした: ${blocker}`
460 const before = (await git($, snap.root, ['rev-parse', '--short', 'HEAD'])).out.trim()
461 // The fetch can take a while: a turn may have started meanwhile.
462 if (await read($, busyState)) return BUSY
463 const merged = await git($, snap.root, ['merge', '--ff-only', '@{u}'], { timeoutMs: REMOTE_TIMEOUT })
464 const after = (await git($, snap.root, ['rev-parse', '--short', 'HEAD'])).out.trim()
465 await refresh($, ctx, false)
466 if (merged.code !== 0) return `取り込めませんでした: ${firstLine(merged.err || merged.out)}`
467 return `取り込みました ${before} → ${after}(戻すには git reset --keep ${before})`
468}
469
470async function pullFromBand($: EngineInterface, ctx: Ctx): Promise<void> {
471 const message = await pull($, ctx)
472 $.ui.toast(message, { timeoutMs: 8000 })
473 $.ui.log(`git-nudge: ${message}`)
474}
475
476// Lists branches whose upstream is gone, asks, and deletes the merged ones.
477async function tidy($: EngineInterface, ctx: Ctx): Promise<string> {
478 if (!ctx.interactive) return '対話できない環境では片付けできません'
479 const cwd = await $.session.cwd()
480 const top = await git($, cwd, ['rev-parse', '--show-toplevel'])
481 if (top.code !== 0) return 'git リポジトリの中ではありません'
482 const root = top.out.trim()
483 const branches = parseBranches((await git($, root, ['for-each-ref', `--format=${BRANCH_FORMAT}`, 'refs/heads'])).out)
484 const head = (await git($, root, ['symbolic-ref', '--quiet', '--short', 'HEAD'])).out.trim()
485 const remote = branches.find(branch => branch.name === head)?.remote ?? 'origin'
486 const listed = await git($, root, [...(await sshArgs($, root)), 'ls-remote', '--heads', remote], { remote: true })
487 if (listed.code !== 0) return `${remote} のブランチ一覧を取得できませんでした: ${firstLine(listed.err)}`
488 const merged = new Set(lines((await git($, root, ['for-each-ref', '--merged', 'HEAD', '--format=%(refname:short)', 'refs/heads'])).out))
489 const plan = planTidy(tidyCandidates(branches, parseRemoteHeads(listed.out), remote), merged)
490 const prunable = prunableWorktrees((await git($, root, ['worktree', 'list', '--porcelain'])).out)
491 const defaultRef = await git($, root, ['symbolic-ref', '--quiet', '--short', `refs/remotes/${remote}/HEAD`])
492 const onDefault = defaultRef.code === 0 && head !== '' ? defaultRef.out.trim() === `${remote}/${head}` : null
493 const summary = describeTidy(plan, prunable, onDefault)
494 if (plan.deletable.length === 0) return summary
495
496 const yes = `消せるブランチをすべて消す(${plan.deletable.length} 本)`
497 let answer: string
498 try {
499 answer = await $.ui.ask(`upstream が消えたブランチのうち、${plan.deletable.length} 本を消せます。消しますか?`, {
500 header: '片付け',
501 options: [yes, 'やめる'],
502 })
503 } catch {
504 return `${summary}\n\n片付けを取りやめました(確認できませんでした)`
505 }
506 if (answer !== yes) return `${summary}\n\n片付けを取りやめました`
507 const done: typeof plan.deletable = []
508 const failed: Array<{ name: string; message: string }> = []
509 for (const branch of plan.deletable) {
510 const deleted = await git($, root, ['branch', '-d', branch.name])
511 if (deleted.code === 0) done.push(branch)
512 else failed.push({ name: branch.name, message: firstLine(deleted.err) })
513 }
514 await refresh($, ctx, false)
515 return `${summary}\n\n${deletedReport(done, failed)}`
516}
517
518// What /git-nudge prints.
519async function report($: EngineInterface, ctx: Ctx): Promise<string> {
520 if (ctx.off) return 'このセッションでは止めています(/git-nudge on で再開)'
521 if (ctx.reason !== null) return `何もしていません: ${ctx.reason}`
522 const slow =
523 ctx.slow >= 2 ? ['git status が時間切れになっています。重いリポジトリでは git config git-nudge.enabled false で止められます'] : []
524 const snap = await read($, snapshotState)
525 if (snap === null) return ['状態をまだ取得していません', ...slow].join('\n')
526 const now = await $.clock.now()
527 const list = notices(snap, await read($, startState), ctx.settings, now, true)
528 const out = [`リポジトリ: ${snap.root}`, `状態: ${statusLine(snap, ctx.settings, now) ?? '問題なし'}`]
529 if (list.length > 0) out.push('お知らせ:', ...list.map(notice => ` ${notice.text}`))
530 const when = [ctx.settings.fetchOnStart ? '開始時' : '', ctx.settings.fetchInterval > 0 ? `${ctx.settings.fetchInterval}分ごと` : '']
531 .filter(Boolean)
532 .join('と')
533 const last = snap.fetch.lastOk === null ? 'まだ成功していません' : `最後の成功: ${ago(now - snap.fetch.lastOk)}`
534 out.push(`fetch: ${when === '' ? 'オフ' : when}(${last})${ctx.authStopped ? '(認証の失敗で止めています)' : ''}`)
535 if (ctx.problems.length > 0) out.push('設定の問題:', ...ctx.problems.map(problem => ` ${problem}`))
536 out.push(...slow)
537 return out.join('\n')
538}
539hooks/judge.ts 141 lines1import type { Notice, Operation, Settings, Snapshot, Start } from '../types'
2import { files } from './parse'
3
4export const MINUTE = 60_000
5const HOUR = 60 * MINUTE
6const DAY = 24 * HOUR
7// index.lock is reported once it has been there this long.
8export const LOCK_AFTER = 10 * MINUTE
9// The band suggests /git-nudge tidy from this many branches.
10export const TIDY_HINT_MIN = 5
11// Notices the band shows at once.
12export const BAND_ROWS = 2
13
14const OPERATION_TEXT: Readonly<Record<Operation, string>> = {
15 rebase: 'rebase の途中です(git rebase --continue か --abort)',
16 merge: 'merge の途中です(git merge --continue か --abort)',
17 'cherry-pick': 'cherry-pick の途中です(git cherry-pick --continue か --abort)',
18 revert: 'revert の途中です(git revert --continue か --abort)',
19 bisect: 'bisect の途中です(終えるときは git bisect reset)',
20}
21
22// What Claude hears once what it was told has cleared up.
23export const RESOLVED = '[git-nudge] 先に伝えたリポジトリの状態は解消しました。'
24
25export function ago(ms: number): string {
26 if (ms < MINUTE) return 'たった今'
27 if (ms < HOUR) return `${Math.floor(ms / MINUTE)}分前`
28 if (ms < DAY) return `${Math.floor(ms / HOUR)}時間前`
29 return `${Math.floor(ms / DAY)}日前`
30}
31
32// The current branch's upstream is gone: pruned (status gives no
33// ahead/behind for it) or missing from the last ls-remote.
34export function isGone(snap: Snapshot): boolean {
35 const { head, upstream, ahead } = snap.status.branch
36 if (head === null || upstream === null) return false
37 return ahead === null || (snap.goneNames?.includes(head) ?? false)
38}
39
40// Why a fast-forward should not run now, or null when it may: the branch is
41// behind, has nothing of its own, no tracked file is changed and nothing is
42// half done. Untracked files do not stop it: git refuses on its own when one
43// would be overwritten.
44export function pullBlocker(snap: Snapshot): string | null {
45 const { branch, changes } = snap.status
46 if (snap.operation !== null) return OPERATION_TEXT[snap.operation]
47 if (branch.head === null) return 'HEAD が切り離されています'
48 if (branch.upstream === null) return 'upstream がありません'
49 if (isGone(snap)) return `${branch.upstream} は削除済みです`
50 if (!branch.behind) return '取り込むものがありません'
51 if (branch.ahead) return `${branch.upstream} と分岐しています(↓${branch.behind} ↑${branch.ahead})`
52 if (changes.tracked + changes.conflicted > 0) return '未コミットの変更があります'
53 return null
54}
55
56// Everything worth a word, most urgent first. `tidyHint` lets the tidy
57// suggestion in (once a day; the caller decides).
58export function notices(snap: Snapshot, start: Start | null, settings: Settings, now: number, tidyHint: boolean): Notice[] {
59 const out: Notice[] = []
60 const { branch, changes } = snap.status
61 const upstream = branch.upstream ?? 'upstream'
62 const gone = isGone(snap)
63 if (snap.operation !== null) out.push({ id: 'operation', text: OPERATION_TEXT[snap.operation], tell: true })
64 if (!gone && branch.ahead && branch.behind) {
65 out.push({ id: 'diverged', text: `${upstream} と分岐(↓${branch.behind} ↑${branch.ahead})。rebase か merge が必要`, tell: true })
66 } else if (!gone && branch.behind && snap.operation === null) {
67 if (changes.tracked + changes.conflicted > 0) {
68 out.push({
69 id: 'behind-dirty',
70 text: `${upstream} より ${branch.behind} 遅れ。未コミットの変更があるので、取り込みは手動で`,
71 tell: true,
72 })
73 } else {
74 out.push({ id: 'behind', text: `${upstream} より ${branch.behind} 遅れ`, pull: true, tell: true })
75 }
76 }
77 if (gone) out.push({ id: 'gone', text: `${upstream} は削除済み。マージ済みなら既定ブランチに戻ってください`, tell: true })
78 if (snap.lockSince !== null && now - snap.lockSince >= LOCK_AFTER) {
79 out.push({ id: 'lock', text: 'index.lock が残っています。git が動いていなければ削除してください', tell: true })
80 }
81 if (snap.fetch.failed?.auth) {
82 out.push({ id: 'auth', text: 'fetch が認証で失敗しました。このセッションでは定期 fetch を止めます', tell: false })
83 }
84 if (start !== null && start.ahead > 0 && branch.ahead === start.ahead) {
85 out.push({ id: 'unpushed', text: `前回からの未 push ${start.ahead} 件`, tell: true })
86 }
87 if (start !== null && start.dirty > 0 && files(changes) === start.dirty) {
88 out.push({ id: 'uncommitted', text: `前回の未コミット変更 ${start.dirty} ファイル`, tell: true })
89 }
90 const oldest = snap.stash.oldest
91 if (oldest !== null && now - oldest >= settings.stashAgeDays * DAY) {
92 out.push({ id: 'stash', text: `stash ${snap.stash.count} 件(最古 ${ago(now - oldest)})`, tell: false })
93 }
94 if (tidyHint && snap.tidyCount !== null && snap.tidyCount >= TIDY_HINT_MIN) {
95 out.push({ id: 'tidy', text: `upstream が消えたブランチ ${snap.tidyCount} 本 → /git-nudge tidy`, tell: false })
96 }
97 return out
98}
99
100// The one line under the prompt; undefined when there is nothing to say.
101export function statusLine(snap: Snapshot, settings: Settings, now: number): string | undefined {
102 const { branch, changes } = snap.status
103 let sync = ''
104 if (branch.head !== null && branch.upstream === null) sync = 'upstream なし'
105 else if (isGone(snap)) sync = 'upstream 消滅'
106 else {
107 sync = [branch.behind ? `↓${branch.behind}` : '', branch.ahead ? `↑${branch.ahead}` : ''].filter(Boolean).join(' ')
108 }
109 const notes: string[] = []
110 if (snap.operation !== null) notes.push(`${snap.operation} 中`)
111 const dirty = files(changes)
112 if (dirty > 0) notes.push(`未コミット ${dirty}`)
113 if (snap.stash.count > 0) notes.push(`stash ${snap.stash.count}`)
114 if (snap.lockSince !== null && now - snap.lockSince >= LOCK_AFTER) notes.push('lock')
115 const fetchOff = !settings.fetchOnStart && settings.fetchInterval === 0
116 const notable = branch.head === null || sync !== '' || notes.length > 0 || snap.fetch.failed !== null
117 if (!notable && !(fetchOff && snap.fetch.lastOk !== null)) return undefined
118 if (snap.fetch.failed !== null) {
119 notes.push(snap.fetch.lastOk === null ? 'fetch 失敗' : `fetch 失敗(最終成功 ${ago(now - snap.fetch.lastOk)})`)
120 } else if (snap.fetch.lastOk !== null) {
121 notes.push(`fetch ${ago(now - snap.fetch.lastOk)}`)
122 }
123 const head = [branch.head ?? 'HEAD 切り離し', sync].filter(Boolean).join(' ')
124 return [head, ...notes].join(' · ')
125}
126
127// What Claude is told about the notices it should know of, or null.
128export function tellText(list: Notice[]): string | null {
129 const told = list.filter(notice => notice.tell)
130 if (told.length === 0) return null
131 return [
132 `[git-nudge] このリポジトリの状態: ${told.map(notice => notice.text).join(' / ')}`,
133 'これは情報です。ユーザーの指示なしに pull・rebase・push・ブランチの削除をしないでください。',
134 ].join('\n')
135}
136
137// A dismissed notice stays hidden until its text changes.
138export function noticeKey(notice: Notice): string {
139 return `${notice.id}:${notice.text}`
140}
141hooks/parse.ts 188 lines1import type { Changes, LocalBranch, Operation, Settings, Status } from '../types'
2
3// fetch --no-auto-maintenance arrived in 2.29.
4export const MIN_GIT: readonly [number, number] = [2, 29]
5
6export const DEFAULTS: Settings = {
7 enabled: true,
8 fetchOnStart: true,
9 fetchInterval: 5,
10 tellClaude: true,
11 band: true,
12 stashAgeDays: 7,
13}
14
15// The fields `git for-each-ref` prints per local branch, tab-separated (git
16// refuses control characters in ref names).
17export const BRANCH_FORMAT =
18 '%(refname:short)%09%(objectname)%09%(upstream:remotename)%09%(upstream:remoteref)%09%(worktreepath)'
19
20// Files in the git dir that mark an operation left half done, in the order
21// they are asked for with `git rev-parse --git-path`.
22export const MARKERS: ReadonlyArray<readonly [string, Operation]> = [
23 ['rebase-merge', 'rebase'],
24 ['rebase-apply', 'rebase'],
25 ['MERGE_HEAD', 'merge'],
26 ['CHERRY_PICK_HEAD', 'cherry-pick'],
27 ['REVERT_HEAD', 'revert'],
28 ['BISECT_LOG', 'bisect'],
29]
30
31export function lines(out: string): string[] {
32 return out.split(/\r?\n/).filter(line => line !== '')
33}
34
35export function files(changes: Changes): number {
36 return changes.tracked + changes.untracked + changes.conflicted
37}
38
39// `git status --porcelain=v2 --branch`
40export function parseStatus(out: string): Status {
41 const branch: Status['branch'] = { head: null, upstream: null, ahead: null, behind: null }
42 const changes: Changes = { tracked: 0, untracked: 0, conflicted: 0 }
43 for (const line of lines(out)) {
44 if (line.startsWith('# branch.head ')) {
45 const head = line.slice('# branch.head '.length)
46 branch.head = head === '(detached)' ? null : head
47 } else if (line.startsWith('# branch.upstream ')) {
48 branch.upstream = line.slice('# branch.upstream '.length)
49 } else if (line.startsWith('# branch.ab ')) {
50 const counts = /^# branch\.ab \+(\d+) -(\d+)$/.exec(line)
51 if (counts) {
52 branch.ahead = Number(counts[1])
53 branch.behind = Number(counts[2])
54 }
55 } else if (line.startsWith('1 ') || line.startsWith('2 ')) {
56 changes.tracked += 1
57 } else if (line.startsWith('u ')) {
58 changes.conflicted += 1
59 } else if (line.startsWith('? ')) {
60 changes.untracked += 1
61 }
62 }
63 return { branch, changes }
64}
65
66// `git stash list --format=%ct`: one commit time (seconds) per stash.
67export function parseStash(out: string): { count: number; oldest: number | null } {
68 const times = lines(out)
69 .map(Number)
70 .filter(Number.isFinite)
71 return { count: times.length, oldest: times.length > 0 ? Math.min(...times) * 1000 : null }
72}
73
74// `git version`
75export function parseVersion(out: string): [number, number] | null {
76 const found = /git version (\d+)\.(\d+)/.exec(out)
77 return found ? [Number(found[1]), Number(found[2])] : null
78}
79
80export function isSupported(version: readonly [number, number] | null): boolean {
81 if (version === null) return false
82 return version[0] > MIN_GIT[0] || (version[0] === MIN_GIT[0] && version[1] >= MIN_GIT[1])
83}
84
85const SETTING_KEYS: Readonly<Record<string, keyof Settings>> = {
86 'git-nudge.enabled': 'enabled',
87 'git-nudge.fetchonstart': 'fetchOnStart',
88 'git-nudge.fetchinterval': 'fetchInterval',
89 'git-nudge.tellclaude': 'tellClaude',
90 'git-nudge.band': 'band',
91 'git-nudge.stashagedays': 'stashAgeDays',
92}
93
94// git's spellings of a boolean; a key with no value is true.
95function asBool(value: string): boolean | null {
96 const word = value.toLowerCase()
97 if (['', 'true', 'yes', 'on', '1'].includes(word)) return true
98 if (['false', 'no', 'off', '0'].includes(word)) return false
99 return null
100}
101
102// `git config --get-regexp ^git-nudge\.`: "key value" lines, later lines
103// winning (git lists system, global, then local). `problems` names what was
104// ignored, for /git-nudge to show.
105export function parseConfig(out: string): { settings: Settings; problems: string[] } {
106 const settings: Settings = { ...DEFAULTS }
107 const set = settings as Record<keyof Settings, boolean | number>
108 const problems: string[] = []
109 for (const line of lines(out)) {
110 const space = line.indexOf(' ')
111 const key = (space < 0 ? line : line.slice(0, space)).toLowerCase()
112 const value = space < 0 ? '' : line.slice(space + 1).trim()
113 const name = SETTING_KEYS[key]
114 if (name === undefined) {
115 problems.push(`${key} は知らない設定です`)
116 continue
117 }
118 if (typeof DEFAULTS[name] === 'boolean') {
119 const flag = asBool(value)
120 if (flag === null) problems.push(`${key}=${value} は true / false ではないので無視しました`)
121 else set[name] = flag
122 } else if (/^\d+$/.test(value)) {
123 set[name] = Number(value)
124 } else {
125 problems.push(`${key}=${value} は 0 以上の整数ではないので無視しました`)
126 }
127 }
128 return { settings, problems }
129}
130
131// `git ls-remote --heads <remote>`: the branch names on the remote.
132export function parseRemoteHeads(out: string): Set<string> {
133 const names = new Set<string>()
134 for (const line of lines(out)) {
135 const ref = line.split('\t')[1]
136 if (ref !== undefined && ref.startsWith('refs/heads/')) names.add(ref.slice('refs/heads/'.length))
137 }
138 return names
139}
140
141// `git for-each-ref --format=<BRANCH_FORMAT> refs/heads`
142export function parseBranches(out: string): LocalBranch[] {
143 return lines(out)
144 .map(line => {
145 const [name = '', oid = '', remote = '', remoteRef = '', ...rest] = line.split('\t')
146 const worktree = rest.join('\t')
147 return {
148 name,
149 oid,
150 remote: remote === '' ? null : remote,
151 remoteRef: remoteRef === '' ? null : remoteRef,
152 worktree: worktree === '' ? null : worktree,
153 }
154 })
155 .filter(branch => branch.name !== '')
156}
157
158// `git worktree list --porcelain`: worktrees git marks prunable (their
159// directory is gone).
160export function prunableWorktrees(out: string): string[] {
161 const found: string[] = []
162 let path: string | null = null
163 for (const line of out.split(/\r?\n/)) {
164 if (line.startsWith('worktree ')) path = line.slice('worktree '.length)
165 else if (line.startsWith('prunable') && path !== null) found.push(path)
166 else if (line === '') path = null
167 }
168 return found
169}
170
171// `git rev-parse --git-path` prints a path relative to where it ran (the
172// repository root here) unless it lies elsewhere, as a linked worktree's does.
173export function absolutePath(root: string, path: string): string {
174 return /^(?:[A-Za-z]:)?[\\/]/.test(path) ? path : `${root.replace(/[\\/]+$/, '')}/${path}`
175}
176
177const AUTH =
178 /authentication failed|could not read (?:username|password)|terminal prompts disabled|permission denied \(publickey|host key verification failed|access denied|invalid username or password|\b40[13]\b/i
179
180// Whether a fetch failed for want of a login, as opposed to the network.
181export function isAuthError(stderr: string): boolean {
182 return AUTH.test(stderr)
183}
184
185export function firstLine(text: string): string {
186 return lines(text)[0]?.trim() ?? ''
187}
188hooks/tidy.ts 70 lines1import type { LocalBranch } from '../types'
2
3const HEADS = 'refs/heads/'
4
5// Local branches tracking a branch on `remote` that the remote no longer has
6// (`heads` is what `git ls-remote --heads <remote>` listed).
7export function withGoneUpstream(branches: LocalBranch[], heads: ReadonlySet<string>, remote: string): LocalBranch[] {
8 return branches.filter(
9 branch =>
10 branch.remote === remote &&
11 branch.remoteRef !== null &&
12 branch.remoteRef.startsWith(HEADS) &&
13 !heads.has(branch.remoteRef.slice(HEADS.length)),
14 )
15}
16
17// What tidy may offer: those not checked out anywhere (the current branch is
18// checked out here, and git refuses to delete one checked out elsewhere).
19export function tidyCandidates(branches: LocalBranch[], heads: ReadonlySet<string>, remote: string): LocalBranch[] {
20 return withGoneUpstream(branches, heads, remote).filter(branch => branch.worktree === null)
21}
22
23export type TidyPlan = { deletable: LocalBranch[]; unconfirmed: LocalBranch[] }
24
25// `merged` holds the branches HEAD contains (`git for-each-ref --merged
26// HEAD`). Only those are deleted, so no commit is lost; `git branch -d` alone
27// would also accept a squashed branch whose stale remote-tracking ref holds it.
28export function planTidy(candidates: LocalBranch[], merged: ReadonlySet<string>): TidyPlan {
29 return {
30 deletable: candidates.filter(branch => merged.has(branch.name)),
31 unconfirmed: candidates.filter(branch => !merged.has(branch.name)),
32 }
33}
34
35// The listing tidy shows before it asks. `onDefault` is whether HEAD is the
36// remote's default branch; null when that is unknown.
37export function describeTidy(plan: TidyPlan, prunable: string[], onDefault: boolean | null): string {
38 const out: string[] = []
39 if (plan.deletable.length + plan.unconfirmed.length === 0) out.push('upstream が消えたブランチはありません。')
40 if (plan.deletable.length > 0) {
41 out.push(`消せるブランチ(HEAD にマージ済み): ${plan.deletable.length} 本`)
42 for (const branch of plan.deletable) out.push(` ${branch.name}`)
43 }
44 if (plan.unconfirmed.length > 0) {
45 out.push(`マージを確認できないブランチ(消しません): ${plan.unconfirmed.length} 本`)
46 for (const branch of plan.unconfirmed) {
47 out.push(` ${branch.name}(自分で確かめて消すなら: git branch -D ${branch.name})`)
48 }
49 if (onDefault === false) out.push('既定ブランチに移ってから実行すると、より多く片付けられます。')
50 }
51 if (prunable.length > 0) {
52 out.push(`ディレクトリが無い worktree: ${prunable.length} 件(消すなら: git worktree prune)`)
53 for (const path of prunable) out.push(` ${path}`)
54 }
55 return out.join('\n')
56}
57
58export function deletedReport(done: LocalBranch[], failed: Array<{ name: string; message: string }>): string {
59 const out: string[] = []
60 if (done.length > 0) {
61 out.push(`消しました: ${done.length} 本(戻すときは、各行のコマンドを実行)`)
62 for (const branch of done) out.push(` git branch ${branch.name} ${branch.oid}`)
63 }
64 if (failed.length > 0) {
65 out.push(`消せませんでした: ${failed.length} 本`)
66 for (const one of failed) out.push(` ${one.name}: ${one.message}`)
67 }
68 return out.join('\n')
69}
70types/index.d.ts 100 lines1// What `git status --porcelain=v2 --branch` says about the branch.
2export type Branch = {
3 // Branch name; null when HEAD is detached.
4 head: string | null
5 // Upstream such as "origin/main"; null when none is set.
6 upstream: string | null
7 // Commits ahead of / behind the upstream; null when no upstream is set or
8 // its remote-tracking ref is gone (pruned).
9 ahead: number | null
10 behind: number | null
11}
12
13// Entries in the working tree not yet committed. `tracked` counts changed
14// tracked files once each, staged or not.
15export type Changes = { tracked: number; untracked: number; conflicted: number }
16
17export type Status = { branch: Branch; changes: Changes }
18
19export type Operation = 'rebase' | 'merge' | 'cherry-pick' | 'revert' | 'bisect'
20
21export type Settings = {
22 enabled: boolean
23 fetchOnStart: boolean
24 // Minutes between fetches; 0 turns the timer off.
25 fetchInterval: number
26 tellClaude: boolean
27 band: boolean
28 stashAgeDays: number
29}
30
31// A local branch as `git for-each-ref` lists it (see BRANCH_FORMAT).
32export type LocalBranch = {
33 name: string
34 oid: string
35 // The remote and the branch on it this one tracks; null when none is set.
36 remote: string | null
37 remoteRef: string | null
38 // The worktree it is checked out in; null when none.
39 worktree: string | null
40}
41
42export type FetchState = {
43 // The last fetch that worked, by any session (ms since epoch); null when unknown.
44 lastOk: number | null
45 // Set while this session's last fetch failed.
46 failed: { at: number; auth: boolean } | null
47}
48
49export type Snapshot = {
50 root: string
51 status: Status
52 stash: { count: number; oldest: number | null }
53 operation: Operation | null
54 // When index.lock was first seen, refresh after refresh; null when absent.
55 lockSince: number | null
56 // Local branches whose upstream branch is gone from the remote, as of the
57 // last ls-remote; null when it has not run.
58 goneNames: string[] | null
59 // How many of those /git-nudge tidy would list; null when unknown.
60 tidyCount: number | null
61 fetch: FetchState
62 at: number
63}
64
65// What the session found when it first looked at the repository.
66export type Start = { ahead: number; dirty: number }
67
68export type NoticeId =
69 | 'operation'
70 | 'diverged'
71 | 'behind'
72 | 'behind-dirty'
73 | 'gone'
74 | 'lock'
75 | 'auth'
76 | 'unpushed'
77 | 'uncommitted'
78 | 'stash'
79 | 'tidy'
80
81export type Notice = {
82 id: NoticeId
83 text: string
84 // The band offers the pull button beside it.
85 pull?: true
86 // Claude is told about it.
87 tell: boolean
88}
89
90declare module 'claude-code' {
91 interface PluginState {
92 'git-nudge': {
93 snapshot: Snapshot | null
94 start: Start | null
95 dismissed: string[]
96 busy: boolean
97 }
98 }
99}
100