A quiet reminder in the status line and a toast when uncommitted work piles up in the chat's project

A quiet reminder to commit. CommitNudge stays out of sight until uncommitted work piles up in your project, then speaks up, so a crash or a bad command can't cost you hours of work.
my-app: 14 files, +420 −38 uncommitted · last commit 52 min ago
It only reminds. It never commits, stages, stashes or runs any git command that writes.
/commit-nudge: the changed files with their git status, the totals, and your last commit's time and message.Once you commit, or the working tree is clean again, the nudges start over.
Files include new, untracked ones. Lines are the lines added and removed in tracked files.
CommitNudge reads the git repository of the chat's folder after each of Claude's replies and every 2 minutes, with three read-only commands:
git status for the changed filesgit diff --shortstat HEAD for the changed linesgit log -1 for your last commitThey run with GIT_OPTIONAL_LOCKS=0, so git doesn't even refresh its index, and with a 5-second timeout. A chat outside a git repository is left alone.
Claude Code and git.
CommitNudge installs with the other mods in this repository. See the main README.
claude plugin validate .
claude plugin test .
The thresholds, levels and text live in hooks/format.ts.
hooks/register.tsx 184 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, ProcessRunResult, Register } from 'claude-code'
3
4import { commitAtSeconds, detailsOf, levelOf, parseLog, parseShortstat, parseStatus, projectOf, statusOf, stepOf, toastOf } from './format'
5import type { Snapshot } from './format'
6
7const topAtom = atom({ plugin: 'CommitNudge', key: 'top' } as const, null)
8const notifiedAtom = atom({ plugin: 'CommitNudge', key: 'notified' } as const, 0)
9const seenAtom = atom({ plugin: 'CommitNudge', key: 'seen' } as const, 0)
10
11const COMMAND = 'commit-nudge'
12const EVERY_MS = 120_000
13const TIMEOUT_MS = 5000
14const TOAST_MS = 10_000
15
16// No optional locks: a status that refreshes the index would be a write. The C
17// locale keeps `--shortstat` in English whatever the person's language.
18const GIT_ENV = { GIT_OPTIONAL_LOCKS: '0', LC_ALL: 'C' }
19
20type Sample = { top: string; snap: Snapshot; now: number }
21
22// One measurement runs at a time; one asked for meanwhile runs once after it,
23// so a turn's edits are never missed behind a timer's run.
24let isBusy = false
25let isQueued = false
26
27// Git's answer, or undefined when it could not be run or timed out.
28async function git($: EngineInterface, folder: string, args: string[]): Promise<ProcessRunResult | undefined> {
29 try {
30 return await $.process.run(['git', '-C', folder, ...args], { timeoutMs: TIMEOUT_MS, env: GIT_ENV })
31 } catch {
32 return undefined
33 }
34}
35
36// The repository's top folder, '' when the chat's folder is not in one, null
37// when that could not be told yet (git missing or slow): asked again next time.
38async function topOf($: EngineInterface) {
39 const known = await read($, topAtom)
40
41 if (known !== null) {
42 return known
43 }
44
45 const found = await git($, await $.session.cwd(), ['rev-parse', '--show-toplevel'])
46
47 if (found === undefined) {
48 return null
49 }
50
51 const top = found.exitCode === 0 ? found.stdout.trim() : ''
52 await update($, topAtom, () => top)
53
54 return top
55}
56
57// What the repository holds now: undefined when git failed, 'none' outside a repository.
58async function sample($: EngineInterface): Promise<Sample | 'none' | undefined> {
59 const top = await topOf($)
60
61 if (top === '') {
62 return 'none'
63 }
64
65 if (top === null) {
66 return undefined
67 }
68
69 const [status, diff, log] = await Promise.all([
70 git($, top, ['status', '--porcelain=v1', '-z', '--untracked-files=normal']),
71 git($, top, ['diff', '--shortstat', 'HEAD']),
72 git($, top, ['log', '-1', '--format=%ct%x09%s']),
73 ])
74
75 if (status === undefined || diff === undefined || log === undefined || status.exitCode !== 0) {
76 return undefined
77 }
78
79 const last = log.exitCode === 0 ? parseLog(log.stdout) : undefined
80
81 // Without a commit there is no HEAD to diff against, and lines count as 0.
82 if (last !== undefined && diff.exitCode !== 0) {
83 return undefined
84 }
85
86 const { added, removed } = last === undefined ? { added: 0, removed: 0 } : parseShortstat(diff.stdout)
87
88 return { top, snap: { changes: parseStatus(status.stdout), added, removed, last }, now: await $.clock.now() }
89}
90
91async function show($: EngineInterface, { top, snap, now }: Sample) {
92 const level = levelOf(snap, now)
93 const project = projectOf(top)
94 const step = stepOf(
95 { notified: await read($, notifiedAtom), seen: await read($, seenAtom) },
96 level,
97 snap.changes.length,
98 commitAtSeconds(snap),
99 )
100
101 await update($, notifiedAtom, () => step.notified)
102 await update($, seenAtom, () => step.seen)
103 await $.ui.status(level > 0 ? statusOf(project, snap, now) : undefined)
104
105 if (step.shouldToast) {
106 await $.ui.toast(toastOf(project, snap, now), { timeoutMs: TOAST_MS })
107 }
108}
109
110async function refresh($: EngineInterface) {
111 if (isBusy) {
112 isQueued = true
113
114 return
115 }
116
117 isBusy = true
118
119 try {
120 do {
121 isQueued = false
122 const got = await sample($)
123
124 if (got !== undefined && got !== 'none') {
125 await show($, got)
126 }
127 } while (isQueued)
128 } catch {
129 // A failed measurement leaves the line as it was; the next one tries again.
130 } finally {
131 isBusy = false
132 }
133}
134
135async function describe($: EngineInterface) {
136 try {
137 const got = await sample($)
138
139 if (got === 'none') {
140 return `Not a git repository: ${await $.session.cwd()}`
141 }
142
143 return got === undefined ? 'Could not read the repository. Is git installed?' : detailsOf(projectOf(got.top), got.snap, got.now)
144 } catch {
145 return 'Could not read the repository.'
146 }
147}
148
149async function begin($: EngineInterface) {
150 try {
151 await $.command.register({ name: COMMAND, description: 'Show uncommitted work in this project and the last commit' })
152
153 // Outside a repository there is nothing to measure for the whole session.
154 if ((await topOf($)) !== '') {
155 $.clock.every(EVERY_MS, () => void refresh($))
156 await refresh($)
157 }
158 } catch {
159 // The mod stays quiet; the next turn measures again.
160 }
161}
162
163export const register: Register = on => {
164 on('session.start', async ($, e, next) => {
165 const started = await next(e)
166 await begin($)
167
168 return started
169 })
170
171 on('turn.complete', async ($, e, next) => {
172 const done = await next(e)
173
174 // A subagent's turn is part of the main turn that spawned it.
175 if (e.agentId === undefined) {
176 await refresh($)
177 }
178
179 return done
180 })
181
182 on('command.run', { command: COMMAND }, async $ => ({ text: await describe($) }))
183}
184hooks/format.ts 222 lines1import type { Level } from '../types'
2
3const MINUTE = 60_000
4
5// Longest the status line may be, so it never wraps under the prompt.
6export const MAX_STATUS = 80
7
8export type Change = { code: string; path: string }
9
10export type Commit = {
11 // Milliseconds since the epoch.
12 at: number
13 subject: string
14}
15
16export type Snapshot = {
17 changes: Change[]
18 added: number
19 removed: number
20 last?: Commit
21}
22
23// Thresholds of the two nudge levels.
24const LEVEL_1 = { ageMs: 30 * MINUTE, lines: 150, files: 8 }
25const LEVEL_2 = { ageMs: 90 * MINUTE, lines: 600, files: 25 }
26
27// Entries of `git status --porcelain=v1 -z`: "XY path", NUL-ended, a rename or
28// copy followed by one more field holding where it came from.
29export function parseStatus(stdout: string): Change[] {
30 const fields = stdout.split('\0')
31 const changes: Change[] = []
32
33 for (let i = 0; i < fields.length; i++) {
34 const field = fields[i]
35
36 if (field === undefined || field.length < 4) {
37 continue
38 }
39
40 const code = field.slice(0, 2)
41 changes.push({ code, path: field.slice(3) })
42
43 if (code.includes('R') || code.includes('C')) {
44 i++
45 }
46 }
47
48 return changes
49}
50
51// " 3 files changed, 420 insertions(+), 38 deletions(-)", either part possibly missing.
52export function parseShortstat(stdout: string) {
53 const count = (pattern: RegExp) => Number(pattern.exec(stdout)?.[1] ?? 0)
54
55 return { added: count(/(\d+) insertions?\(\+\)/), removed: count(/(\d+) deletions?\(-\)/) }
56}
57
58// "<unix seconds>\t<subject>", or nothing when the repository has no commit.
59export function parseLog(stdout: string): Commit | undefined {
60 const line = stdout.replace(/\r?\n$/, '')
61 const tab = line.indexOf('\t')
62 const seconds = Number(tab < 0 ? line : line.slice(0, tab))
63
64 if (line === '' || Number.isNaN(seconds)) {
65 return undefined
66 }
67
68 return { at: seconds * 1000, subject: tab < 0 ? '' : line.slice(tab + 1) }
69}
70
71export const linesOf = (snap: Snapshot) => snap.added + snap.removed
72
73// Level 2 counts only a real commit's age: a new repository with one file is not overdue.
74export function levelOf(snap: Snapshot, now: number): Level {
75 const files = snap.changes.length
76
77 if (files === 0) {
78 return 0
79 }
80
81 const lines = linesOf(snap)
82 const ageMs = snap.last === undefined ? Infinity : now - snap.last.at
83
84 if (
85 (snap.last !== undefined && ageMs >= LEVEL_2.ageMs) ||
86 lines >= LEVEL_2.lines ||
87 files >= LEVEL_2.files
88 ) {
89 return 2
90 }
91
92 return ageMs >= LEVEL_1.ageMs && (lines >= LEVEL_1.lines || files >= LEVEL_1.files) ? 1 : 0
93}
94
95export type Notified = { notified: Level; seen: number }
96
97// What to toast after a measurement. The toast is armed again once the tree is
98// clean or a new commit has landed, and not otherwise: a level that falls and
99// comes back does not toast twice.
100export function stepOf(prev: Notified, level: Level, files: number, commitAt: number) {
101 const isRearmed = files === 0 || commitAt !== prev.seen
102 const base = isRearmed ? 0 : prev.notified
103
104 return { notified: base > level ? base : level, seen: commitAt, shouldToast: level > base }
105}
106
107export const commitAtSeconds = (snap: Snapshot) => (snap.last === undefined ? 0 : Math.round(snap.last.at / 1000))
108
109export function formatAge(ms: number) {
110 const minutes = Math.floor(Math.max(0, ms) / MINUTE)
111
112 if (minutes < 1) {
113 return '<1 min'
114 }
115
116 if (minutes < 60) {
117 return `${minutes} min`
118 }
119
120 const hours = Math.floor(minutes / 60)
121
122 if (hours < 24) {
123 return minutes % 60 > 0 ? `${hours} h ${minutes % 60} min` : `${hours} h`
124 }
125
126 const days = Math.floor(hours / 24)
127
128 return hours % 24 > 0 ? `${days} d ${hours % 24} h` : `${days} d`
129}
130
131const plural = (count: number, word: string) => `${count} ${word}${count === 1 ? '' : 's'}`
132
133export function projectOf(top: string) {
134 return top.split('/').filter(Boolean).at(-1) ?? 'repo'
135}
136
137function shorten(text: string, room: number) {
138 return text.length <= room ? text : `${text.slice(0, Math.max(1, room - 1))}…`
139}
140
141// "NoteOS: 14 files, +420 −38 uncommitted · last commit 52 min ago", cut to fit.
142export function statusOf(project: string, snap: Snapshot, now: number) {
143 const body = `${plural(snap.changes.length, 'file')}, +${snap.added} −${snap.removed} uncommitted`
144 const age = snap.last === undefined ? undefined : formatAge(now - snap.last.at)
145 const tail = age === undefined ? 'no commit yet' : `last commit ${age} ago`
146 const make = (name: string, end: string) => `${name}: ${body} · ${end}`
147 const full = make(project, tail)
148
149 if (full.length <= MAX_STATUS) {
150 return full
151 }
152
153 const room = MAX_STATUS - (full.length - project.length)
154
155 if (room >= 4) {
156 return make(shorten(project, room), tail)
157 }
158
159 // A very long figure: drop the name's room and the tail's words before cutting.
160 const compact = `${body} · ${age === undefined ? 'no commit' : `${age} ago`}`
161
162 return shorten(compact, MAX_STATUS)
163}
164
165export function toastOf(project: string, snap: Snapshot, now: number) {
166 const files = `${plural(snap.changes.length, 'uncommitted file')}`
167 const lines = snap.added > 0 ? ` (+${snap.added} lines)` : snap.removed > 0 ? ` (−${snap.removed} lines)` : ''
168
169 if (snap.last === undefined) {
170 return `${project}: ${files}${lines} and no commit yet.`
171 }
172
173 const ageMs = now - snap.last.at
174 const end = ageMs >= LEVEL_1.ageMs ? `no commit for ${formatAge(ageMs)}` : `the last commit was ${formatAge(ageMs)} ago`
175
176 return `${project}: ${files}${lines} and ${end}.`
177}
178
179const pad = (value: number) => String(value).padStart(2, '0')
180
181// Local time, "2026-10-07 14:05".
182export function formatClock(ms: number) {
183 const date = new Date(ms)
184
185 return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
186}
187
188const LISTED = 15
189const SUBJECT_MAX = 100
190
191// The /commit-nudge answer.
192export function detailsOf(project: string, snap: Snapshot, now: number) {
193 const files = snap.changes.length
194 const level = levelOf(snap, now)
195 const rows: string[] = []
196
197 if (files === 0) {
198 rows.push(`${project}: nothing uncommitted.`)
199 } else {
200 rows.push(`${project}: ${plural(files, 'changed file')}, +${snap.added} −${snap.removed} lines in tracked files.`)
201
202 for (const change of snap.changes.slice(0, LISTED)) {
203 rows.push(` ${change.code} ${change.path}`)
204 }
205
206 if (files > LISTED) {
207 rows.push(` and ${files - LISTED} more`)
208 }
209 }
210
211 if (snap.last === undefined) {
212 rows.push('Last commit: none yet.')
213 } else {
214 const subject = snap.last.subject === '' ? '' : ` "${shorten(snap.last.subject, SUBJECT_MAX)}"`
215 rows.push(`Last commit: ${formatAge(now - snap.last.at)} ago (${formatClock(snap.last.at)})${subject}`)
216 }
217
218 rows.push(level === 0 ? 'Nudge: none.' : `Nudge: level ${level}.`)
219
220 return rows.join('\n')
221}
222types/index.d.ts 16 lines1// How loud the nudge is: none, a first reminder, or an overdue one.
2export type Level = 0 | 1 | 2
3
4declare module 'claude-code' {
5 interface PluginState {
6 'CommitNudge': {
7 // The repository's top folder; null until looked up, '' when the chat's folder is not in one.
8 top: string | null
9 // The highest level toasted since the tree was last clean or a commit last landed; 0 before any.
10 notified: Level
11 // The last commit's time (seconds since the epoch, 0 for none) as of the previous measurement.
12 seen: number
13 }
14 }
15}
16