When this project was last committed and pushed, in one line under the Claude Code prompt: unpushed commits, uncommitted files, staleness colour and a reminder

<a href="https://github.com/pepperonas/last-push"><img src="docs/social.png" alt="last-push — when this project was last committed and pushed, in one line under the Claude Code prompt" width="100%"></a>
A Claude Code mod that shows, right under the prompt, when the project you're in was last committed and pushed — and what is still waiting: unpushed commits, uncommitted files, a colour that ages from green to red, and a nudge when work goes stale.
<a href="#-install"><img alt="Install in 10 seconds" height="56" src="https://img.shields.io/badge/%E2%AC%87%EF%B8%8F_Install-in_10_seconds-2E9E5B?style=for-the-badge"></a> <a href="#-demo"><img alt="See it move" height="56" src="https://img.shields.io/badge/%F0%9F%8E%AC_Demo-see_it_move-7B4DFF?style=for-the-badge"></a>
<h3>👉 <code>/plugin marketplace add pepperonas/last-push</code> · <code>/plugin install last-push@pepperonas-last-push</code> — that's it.</h3>
[!NOTE] Read-only, local, no network. last-push runs a handful of read-only
gitcommands in the session's folder — neverfetch,pull,pushor anything that writes. The push time comes from your repository's own reflog, so nothing ever talks to GitHub.
<img src="docs/demo.gif" alt="Four changed files; Claude commits — the commit flashes and the line shows 1 unpushed; Claude pushes — 'pushed just now ✨' and 'in sync'." width="100%">
<sub>Four files changed. Claude commits: the commit flashes and <b>⇡1 unpushed</b> appears. Claude pushes: <b>pushed just now ✨</b>, then <b>✓ in sync</b>. No command needed — the line follows whatever runs git, Claude or you in another terminal.</sub>
<img src="docs/hero.png" alt="Claude Code with the last-push line under the prompt: main, commit 12m ago, pushed 3h ago, 2 unpushed, 4 changed" width="100%">
<sub>Where it lives: one line under the prompt's hint line. It sits happily below other mods' lines (e.g. <a href="https://github.com/pepperonas/usage-bars">usage-bars</a>); everything else in Claude Code stays as it was.</sub>
<img src="docs/states.png" alt="In sync; fresh unpushed work in green; waiting work in yellow; stale work in red; just pushed with a sparkle; behind; never pushed branch; no remote; detached HEAD; compact mode" width="100%">
<img src="docs/hover.png" alt="Hovering the line shows the repo path, upstream, last commit with hash and subject, absolute push times; reminder toasts for unpushed commits and uncommitted files" width="100%">
<sub>Every image above is rendered from the mod's own renderer (<code>hooks/row.ts</code>) by <code>npm run screenshots</code> — not drawn by hand.</sub>
commit 12m ago, from HEAD. Hover for the hash, subject and exact time.↑ pushed 3h ago: the moment this branch was last pushed from this clone, read from the reflog of its remote-tracking ref (every git push records update by push with a timestamp). Not the commit date — the push.⇡2 unpushed: commits on HEAD that no remote has. Works without an upstream too.✎ 4 changed: modified, staged and untracked files.⇣3 behind: what your upstream has that you don't, as of your last fetch.⇡ 2 commits on main not pushed for 6h — time to push?. Once per state, remembered across sessions.⎇ main, or the short hash and detached.never pushed for a new branch, no push recorded / up to date in a fresh clone (a clone or fetch is not a push, so no fake time), no remote for a local-only repo, and nothing at all outside a git repository.git push), when you send a prompt, and every 15 s for what you do in another terminal or your IDE.⎇ main ●12m ↑3h ⇡2 ✎4; automatic below 80 columns.PATH. Nothing else: no account, no token, no network.The repository is its own plugin marketplace. In Claude Code:
/plugin marketplace add pepperonas/last-push
/plugin install last-push@pepperonas-last-push
or from the shell:
claude plugin marketplace add pepperonas/last-push
claude plugin install last-push@pepperonas-last-push
The line appears as soon as a session starts inside a git repository. Update with /plugin marketplace update pepperonas-last-push, then claude plugin update last-push@pepperonas-last-push. Settings: /plugin configure last-push@pepperonas-last-push (every option has a default, so you can skip it).
Claude Code loads a plugin it finds in ~/.claude/skills/<name> by itself, in every session:
git clone https://github.com/pepperonas/last-push ~/.claude/skills/last-push
Update with git -C ~/.claude/skills/last-push pull. If you also install it from the marketplace, the marketplace copy wins.
git clone https://github.com/pepperonas/last-push
claude --plugin-dir ./last-push
Where you can't pass a flag, name the folder in CLAUDE_CODE_PLUGIN_DIRS — in your shell environment or in the env block of ~/.claude/settings.json:
{ "env": { "CLAUDE_CODE_PLUGIN_DIRS": "~/src/last-push" } }
There is nothing to do: start Claude Code in a repository and the line is there. The command is for when you want more.
| Command | What it does |
|---|---|
/last-push | A full report (branch, upstream, last commit and push with exact times, the repo's last push, unpushed, behind, working tree) and the commands |
/last-push refresh | Read the repository again now, and report |
/last-push full · compact · off | Display mode |
/last-push reminders on · off | Toasts when work goes stale; without on/off it toggles |
/last-push remind <hours> | How long until waiting work counts as stale (red + reminder); 0 turns both off |
The command runs immediately, even while Claude is working.
The defaults live in /config under last-push:
| Field | Values | Default |
|---|---|---|
mode | full compact off | full |
reminders | on / off | on |
remindAfter | hours, 0–720 (0 = never stale) | 4 |
A value the mod doesn't know falls back to the default. What you set with /last-push is stored and wins over /config, so a quick /last-push compact sticks across sessions.
session start ─┐
tool call ends ─┼─► read the repo (10 read-only git commands, in parallel) ──► $.state.snap ──► ui.render (PromptHint)
prompt sent ────┤ │ │ engine's hint line
every 15 s ─────┘ └─► compare with the last reading │ + the last-push line
commit moved? push newer? ──► flash (4 s) └─► stale? ──► toast, once per state
every 30 s ──────────────► redraw (relative times age)
refs/remotes/origin/main), and git push writes an entry update by push with the time into it. last-push reads those entries: the newest for this branch's ref (its upstream, else a remote branch of the same name) is pushed, the newest of all is the repository's last push. A fetch or a clone writes different entries, so they are never mistaken for a push.git log HEAD --not --remotes: every commit on HEAD that no remote-tracking ref holds. It needs no upstream, so a branch you never pushed still shows what's waiting.git --no-optional-locks (and GIT_OPTIONAL_LOCKS=0): git status would otherwise refresh the index and take index.lock — and your own git commit at that moment would fail.ui.render hook on the PromptHint component returns the engine's own hint line plus one row, so the shortcuts, pills and other mods' lines keep working./last-push. A read is ten small git processes; in a large repository git status dominates.<!-- git-commands -->
git rev-parse --show-toplevel
git symbolic-ref --short -q HEAD
git log -1 --format=%ct%x09%h%x09%s
git rev-parse --short HEAD
git rev-parse --abbrev-ref --symbolic-full-name @{u}
git remote
git for-each-ref --format=%(refname) refs/remotes
git log -g --all --grep-reflog=update by push --date=unix --format=%gD
git log --format=%ct%x09%h HEAD --not --remotes
git rev-list --count HEAD..@{u}
git status --porcelain
<!-- /git-commands -->
Each with --no-optional-locks, in the session's folder, 5 s timeout. That is the whole list — a drift guard in the test suite holds it to the code.
no push recorded / up to date instead of a time. Pushing to a URL rather than a named remote (git push https://… main) updates no remote-tracking ref and leaves no entry.gc.reflogExpire); a branch last pushed before then reads no push recorded.last-push makes no network request, reads no credentials and writes only its own key-value store (your settings, which reminders were shown). Its hooks are listed by claude plugin validate .: $.clock.*, $.command.register, $.process.run, $.session.cwd, $.state.*, $.store.*, $.ui.invalidate, $.ui.resolve, $.ui.toast.
| File | Role |
|---|---|
hooks/register.tsx | The mod: hooks, refresh scheduling, flash, reminders, /last-push |
hooks/git.ts | Which git commands run and what their output means; collect() takes any runner |
hooks/row.ts | Pure: the line, the hover card, the report, the reminders |
hooks/format.ts | Pure: relative and clock times, the colour of an age |
types/index.d.ts | The state contract (PluginState['last-push']) |
tools/screenshots.ts | Renders every image in this README from row.ts |
Everything that decides what is drawn is pure and engine-free — which is why it can be tested with plain Node and rendered into screenshots. collect() takes the command runner as an argument: the mod hands it $.process.run, the tests a real git.
There are two suites, and the split is deliberate.
Node suite — tests/*.spec.ts, plain node:test, no Claude Code needed; this is what CI runs. It covers the parsers on fixed output and then runs collect() against a real git in temporary repositories with a bare remote: never pushed → push (the time is the push, not the commit) → ahead → dirty → behind; a fresh clone (a fetch is not a push); a branch pushed without -u; detached HEAD; no repository; git missing. Plus the line in every state, the colours, the reminders, and drift guards that hold this README to the code: the version badge to plugin.json, package.json and the marketplace, the test-count badges to the real number of tests, every /config field and its default, every command, and the git command list above.
Engine suite — hooks/*.test.tsx, run by claude plugin test . against Claude Code's own engine with a pretend repository: the line is drawn on the terminal and desktop surfaces with the engine's hint kept, nothing outside a repository, a push after a tool call is noticed at once and flashes then settles, a commit made outside Claude Code shows up on the next poll, a stale state toasts exactly once, /last-push switches mode and stale time and settings survive a new session, userConfig sets the defaults, and every git call is read-only and lock-free.
Every guarded behaviour is mutated once. A test that has never been seen red is not an assurance. So each one gets its bug put back — the lock-free flag dropped, the reflog filter widened, the branch's push replaced by any push, the oldest unpushed commit mis-picked, ignored files counted, the age colour frozen, no push recorded collapsed into up to date, in sync while behind, a detached HEAD called never pushed, the reminder fired early or on every read, the push flash removed, settings not stored, polling off, the post-tool refresh dropped, the automatic compact mode removed, off ignored — and the suite must go red. Seventeen of seventeen did.
npm install # dev tools only; the mod itself has no dependencies
npm test # node suite (CI)
claude plugin test . # engine suite
claude plugin validate . # what the module hooks and calls
npm run screenshots # re-render docs/ (needs `npx playwright install chromium` and ffmpeg)
The line doesn't show up. It only appears inside a git repository (the folder Claude Code was started in, or any folder below a repository's top). /last-push tells you what the mod sees; if it says mode off, run /last-push full.
It says no push recorded, but I did push. The push happened somewhere else — another machine, before this clone existed, or to a URL instead of a named remote. Git only records pushes made from this clone, through a remote. The next git push from here fixes it.
Why not ask GitHub? That would need the network, a token and a provider per host — and it would answer when the remote changed, not when you pushed. The reflog answers exactly the question, offline, for every host.
Will it slow git down or get in the way? Its commands are read-only and run without optional locks, so they never block git commit. A read is ten quick processes; on a very large repository git status is the expensive one — /last-push off stops drawing, and the reminders follow the same reading.
I don't want the reminders. /last-push reminders off, or /last-push remind 0 to drop the red colouring too.
The full history is in CHANGELOG.md (Keep a Changelog).
/last-push.Issues and pull requests are welcome. Please keep both suites green (npm test, claude plugin test .) and put the bug back once before you trust a new test. A change to a command, a setting, a git command or the version usually needs its counterpart in this README in the same PR — the drift guards will point at it.
last-push is free and stays that way. If it saved you from a laptop that died with a day of unpushed work:
MIT © 2026 Martin Pfeffer · celox.io. See LICENSE.
last-push is an independent community project and is not affiliated with or endorsed by Anthropic. Claude and Claude Code are trademarks of Anthropic, PBC.
hooks/register.tsx 219 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, PluginOptions, Register, Timer } from 'claude-code'
3
4import type { Flash, Mode, Prefs, Snapshot } from '../types'
5import { COMPACT_BELOW, FLASH_MS, POLL_MS } from './format'
6import { collect, sameSnap } from './git'
7import type { Run } from './git'
8import { detailText, reminders, reportText, rowSegs } from './row'
9import type { Seg } from './row'
10
11const snapA = atom({ plugin: 'last-push', key: 'snap' } as const, null as Snapshot | null)
12const flashA = atom({ plugin: 'last-push', key: 'flash' } as const, null as Flash | null)
13const prefsA = atom({ plugin: 'last-push', key: 'prefs' } as const, { mode: 'full', reminders: true, remindAfter: 4 } as Prefs)
14
15const MODES: readonly Mode[] = ['full', 'compact', 'off']
16/** How many reminder keys are remembered, so each state reminds once. */
17const REMEMBER = 200
18/** A tool call may have committed, pushed or edited: look again shortly after it ends. */
19const AFTER_TOOL_MS = 400
20
21/** userConfig defaults, overridden by what /last-push stored. */
22export function prefsOf(options: PluginOptions, stored: unknown): Prefs {
23 const s = (stored && typeof stored === 'object' ? stored : {}) as Partial<Prefs>
24 const isMode = (v: unknown): v is Mode => MODES.includes(v as Mode)
25 const isBool = (v: unknown): v is boolean => typeof v === 'boolean'
26 const isHours = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v) && v >= 0 && v <= 720
27 const opt = <T,>(v: unknown, ok: (v: unknown) => v is T, d: T): T => (ok(v) ? v : d)
28 const base: Prefs = {
29 mode: opt(options.mode, isMode, 'full'),
30 reminders: opt(options.reminders, isBool, true),
31 remindAfter: opt(options.remindAfter, isHours, 4),
32 }
33 return {
34 mode: opt(s.mode, isMode, base.mode),
35 reminders: opt(s.reminders, isBool, base.reminders),
36 remindAfter: opt(s.remindAfter, isHours, base.remindAfter),
37 }
38}
39
40let busy = false
41let again = false
42let tickTimer: Timer | undefined
43let soonTimer: Timer | undefined
44let ticks = 0
45
46/** Toast what is due and was not shown for this state yet. */
47async function remind($: EngineInterface, snap: Snapshot | null, now: number): Promise<void> {
48 const due = reminders(snap, await read($, prefsA), now)
49 if (due.length === 0) return
50 const shown = (((await $.store.get('reminded').catch(() => undefined)) ?? []) as string[]).filter(k => typeof k === 'string')
51 const fresh = due.filter(r => !shown.includes(r.key))
52 if (fresh.length === 0) return
53 for (const r of fresh) $.ui.toast(r.text, { timeoutMs: 8000 })
54 await $.store.set('reminded', [...shown, ...fresh.map(r => r.key)].slice(-REMEMBER)).catch(() => undefined)
55}
56
57/** Read the repository again; redraw and highlight when something moved. */
58async function refresh($: EngineInterface): Promise<void> {
59 if (busy) {
60 again = true
61 return
62 }
63 busy = true
64 try {
65 do {
66 again = false
67 const cwd = await $.session.cwd().catch(() => undefined)
68 const run: Run = argv => $.process.run(argv, { cwd, timeoutMs: 5000, env: { GIT_OPTIONAL_LOCKS: '0', GIT_TERMINAL_PROMPT: '0' } })
69 const snap = await collect(run)
70 const now = await $.clock.now()
71 const old = await read($, snapA)
72 if (!sameSnap(old, snap)) {
73 if (old && snap && old.root === snap.root) {
74 if (snap.push && snap.push.at > (old.push?.at ?? 0)) await update($, flashA, () => ({ kind: 'push', at: now }))
75 else if (snap.commit && snap.commit.hash !== old.commit?.hash && snap.commit.at >= (old.commit?.at ?? 0)) {
76 await update($, flashA, () => ({ kind: 'commit', at: now }))
77 }
78 }
79 await update($, snapA, () => snap)
80 $.ui.invalidate('ui.render')
81 }
82 await remind($, snap, now)
83 } while (again)
84 } finally {
85 busy = false
86 }
87}
88
89/** One refresh shortly from now, however many tool calls end before it. */
90function soon($: EngineInterface, ms = AFTER_TOOL_MS): void {
91 soonTimer?.cancel()
92 soonTimer = $.clock.after(ms, () => {
93 soonTimer = undefined
94 void refresh($)
95 })
96}
97
98export const register: Register = (on, options) => {
99 on('session.start', async ($, e, next) => {
100 const stored = await $.store.get('prefs').catch(() => undefined)
101 await update($, prefsA, () => prefsOf(options, stored))
102 await $.command.register({
103 name: 'last-push',
104 description: 'last-push: when this project was last committed and pushed (/last-push help)',
105 argumentHint: '[full|compact|off|refresh|reminders on|off|remind <hours>]',
106 immediate: true,
107 })
108 const started = await next(e)
109 await refresh($)
110
111 // Relative times age, and commits made outside Claude Code (another
112 // terminal, an IDE) have no event of their own: look every POLL_MS.
113 tickTimer?.cancel()
114 ticks = 0
115 tickTimer = $.clock.every(1000, () => {
116 ticks += 1
117 void (async () => {
118 if (ticks % (POLL_MS / 1000) === 0) await refresh($)
119 const flash = await read($, flashA)
120 const now = await $.clock.now()
121 if (flash && now - flash.at <= FLASH_MS + 1000) $.ui.invalidate('ui.render')
122 else if (ticks % 30 === 0) $.ui.invalidate('ui.render')
123 })()
124 })
125 return started
126 })
127
128 // Any tool may have run `git commit`, `git push` or changed a file.
129 on('tool.call', async ($, e, next) => {
130 const result = await next(e)
131 soon($)
132 return result
133 }).catch(($, e, next) => next(e)) // never in the way of a tool call
134
135 // The prompt you just sent may follow a commit you made by hand.
136 on('prompt.submit', async ($, e, next) => {
137 const result = await next(e)
138 soon($, 0)
139 return result
140 }).catch(($, e, next) => next(e))
141
142 on('command.run', { command: 'last-push' }, async ($, e) => {
143 const [cmd = '', arg = ''] = e.args.trim().toLowerCase().split(/\s+/)
144 const prefs = await read($, prefsA)
145 const save = async (p: Prefs) => {
146 await update($, prefsA, () => p)
147 await $.store.set('prefs', p).catch(() => undefined)
148 $.ui.invalidate('ui.render')
149 }
150
151 if (MODES.includes(cmd as Mode)) {
152 await save({ ...prefs, mode: cmd as Mode })
153 return { text: `last-push: mode ${cmd}` }
154 }
155 if (cmd === 'reminders') {
156 const value = arg === 'on' ? true : arg === 'off' ? false : !prefs.reminders
157 await save({ ...prefs, reminders: value })
158 return { text: `last-push: reminders ${value ? 'on' : 'off'}` }
159 }
160 if (cmd === 'remind') {
161 const hours = Number(arg.replace(/h$/, ''))
162 if (!arg || !Number.isFinite(hours) || hours < 0 || hours > 720) {
163 return { text: `last-push: remind <hours>, e.g. /last-push remind 2 (now ${prefs.remindAfter}h; 0 turns reminders off)` }
164 }
165 await save({ ...prefs, remindAfter: hours, reminders: hours > 0 ? true : prefs.reminders })
166 return { text: hours > 0 ? `last-push: work counts as stale after ${hours}h` : 'last-push: stale colouring and reminders off (remind 0)' }
167 }
168
169 // refresh, help, status, or nothing: read again and report.
170 await refresh($)
171 const now = await $.clock.now()
172 const lines = [reportText(await read($, snapA), await read($, prefsA), now)]
173 if (cmd !== 'refresh') {
174 lines.push(
175 '',
176 'Commands:',
177 ' /last-push this report',
178 ' /last-push refresh read the repository again',
179 ' /last-push full|compact|off display',
180 ' /last-push reminders on|off toast when work goes stale',
181 ' /last-push remind <hours> when work counts as stale (0 = never)',
182 )
183 }
184 return { text: lines.join('\n') }
185 })
186
187 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
188 const prefs = await read($, prefsA)
189 const snap = await read($, snapA)
190 if (prefs.mode === 'off' || !snap) return next(e)
191
192 const { Box, Text } = $.ui.resolve(e)
193 const now = await $.clock.now()
194 const columns = e.viewport?.columns ?? 100
195 const compact = prefs.mode === 'compact' || columns < COMPACT_BELOW
196 const segs = rowSegs({ snap, prefs, now, compact, flash: await read($, flashA) })
197 const text = (s: Seg, k: string) => (
198 <Text key={k} color={s.color} backgroundColor={s.bg} dimColor={s.dim} bold={s.bold} wrap="truncate-end">
199 {s.text}
200 </Text>
201 )
202 return (
203 <Box flexDirection="column">
204 {await next(e)}
205 <Box key="last-push" flexDirection="row">
206 {segs.map((s, i) => text(s, `lp-${i}`))}
207 {compact ? null : (
208 <Box position="absolute" top={0} left={0} right={0} display="none" hover={{ display: 'flex' }} backgroundColor="#30363d">
209 <Text color="#e6edf3" wrap="truncate-end">
210 {detailText(snap, now)}
211 </Text>
212 </Box>
213 )}
214 </Box>
215 </Box>
216 )
217 })
218}
219hooks/format.ts 76 lines1/** Pure helpers: relative times, clock times, the colour of an age. */
2
3export const MIN = 60_000
4export const H = 60 * MIN
5export const D = 24 * H
6
7/** How long a commit or push stays highlighted after it was noticed. */
8export const FLASH_MS = 4000
9/** Below this many columns the line switches to its compact form by itself. */
10export const COMPACT_BELOW = 80
11/** How often the repository is read again while nothing else asks. */
12export const POLL_MS = 15_000
13
14export const GREEN = '#3fb950'
15export const YELLOW = '#d29922'
16export const RED = '#f85149'
17export const BLUE = '#79c0ff'
18export const MUTED = '#8b949e'
19
20/** `now`, `12m`, `3h`, `2d`, `5w`, `4mo`, `1y`: the short form used in the line. */
21export function age(ms: number): string {
22 const s = Math.max(0, Math.floor(ms / 1000))
23 if (s < 60) return 'now'
24 const m = Math.floor(s / 60)
25 if (m < 60) return `${m}m`
26 const h = Math.floor(m / 60)
27 if (h < 24) return `${h}h`
28 const d = Math.floor(h / 24)
29 if (d < 14) return `${d}d`
30 if (d < 60) return `${Math.floor(d / 7)}w`
31 if (d < 365) return `${Math.floor(d / 30)}mo`
32 return `${Math.floor(d / 365)}y`
33}
34
35/** `12m ago`, `just now`. */
36export const ago = (ms: number): string => {
37 const a = age(ms)
38 return a === 'now' ? 'just now' : `${a} ago`
39}
40
41const pad = (n: number) => String(n).padStart(2, '0')
42const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
43
44/** `14:02` today, `Oct 5 14:02` this year, `Oct 5 2025` before. */
45export function clockTime(at: number, now: number): string {
46 const d = new Date(at)
47 const n = new Date(now)
48 const hm = `${pad(d.getHours())}:${pad(d.getMinutes())}`
49 if (d.toDateString() === n.toDateString()) return hm
50 const day = `${MONTHS[d.getMonth()]} ${d.getDate()}`
51 return d.getFullYear() === n.getFullYear() ? `${day} ${hm}` : `${day} ${d.getFullYear()}`
52}
53
54const hex = (c: string) => [1, 3, 5].map(i => parseInt(c.slice(i, i + 2), 16))
55const mix = (a: string, b: string, t: number) => {
56 const [x, y] = [hex(a), hex(b)]
57 return '#' + x.map((v, i) => Math.round(v + (y[i]! - v) * t).toString(16).padStart(2, '0')).join('')
58}
59
60/**
61 * The colour of pending work by how long it has waited: green when fresh,
62 * yellow halfway to `staleMs`, red from `staleMs` on.
63 */
64export function tone(ageMs: number, staleMs: number): string {
65 const t = staleMs > 0 ? Math.min(1, Math.max(0, ageMs / staleMs)) : 0
66 return t < 0.5 ? mix(GREEN, YELLOW, t * 2) : mix(YELLOW, RED, (t - 0.5) * 2)
67}
68
69/** `1 file` / `3 files`. */
70export const plural = (n: number, one: string, many = `${one}s`) => `${n} ${n === 1 ? one : many}`
71
72/** `/Users/me/src/shop` → `~/src/shop` where a home folder is recognisable, else the path. */
73export function shortPath(p: string): string {
74 return p.replace(/^\/(Users|home)\/[^/]+/, '~')
75}
76hooks/git.ts 131 lines1/**
2 * Reading the repository: which git commands run, and what their output means.
3 * Pure apart from the runner it is handed, so the same code runs in the mod
4 * (`$.process.run`) and in the tests (a real `git` in a temporary folder).
5 *
6 * Nothing here talks to a remote. The push time comes from the reflog of the
7 * remote-tracking ref: every `git push` that moves `refs/remotes/<r>/<b>`
8 * writes an entry `update by push` with the time it happened.
9 */
10import type { Commit, Push, Snapshot } from '../types'
11
12export type RunResult = { exitCode: number; stdout: string }
13export type Run = (argv: readonly string[]) => Promise<RunResult>
14
15/** `--no-optional-locks`: never take index.lock, so a refresh can't collide with your own `git commit`. */
16export const GIT = ['git', '--no-optional-locks'] as const
17
18const ok = async (run: Run, args: readonly string[]): Promise<string | undefined> => {
19 try {
20 const r = await run([...GIT, ...args])
21 return r.exitCode === 0 ? r.stdout : undefined
22 } catch {
23 return undefined
24 }
25}
26
27/** `%ct\t%h\t%s` of `git log -1`. */
28export function parseHead(out: string | undefined): Commit | undefined {
29 const line = out?.split('\n')[0]?.trim()
30 if (!line) return undefined
31 const [ct, hash, ...subject] = line.split('\t')
32 const at = Number(ct) * 1000
33 if (!Number.isFinite(at) || !hash) return undefined
34 return { at, hash, subject: subject.join('\t') }
35}
36
37/** `%gD` lines of `git log -g --date=unix`, e.g. `refs/remotes/origin/main@{1791387974}`. Newest first. */
38export function parsePushes(out: string | undefined): Push[] {
39 const pushes: Push[] = []
40 for (const line of (out ?? '').split('\n')) {
41 const m = /^(refs\/remotes\/.+)@\{(\d+)\}$/.exec(line.trim())
42 if (m) pushes.push({ ref: m[1]!, at: Number(m[2]) * 1000 })
43 }
44 return pushes.sort((a, b) => b.at - a.at)
45}
46
47/** `%ct\t%h` lines of the commits no remote holds: count and the oldest one. */
48export function parseUnpushed(out: string | undefined): { ahead: number; oldest?: { at: number; hash: string } } {
49 let ahead = 0
50 let oldest: { at: number; hash: string } | undefined
51 for (const line of (out ?? '').split('\n')) {
52 const [ct, hash] = line.trim().split('\t')
53 const at = Number(ct) * 1000
54 if (!hash || !Number.isFinite(at)) continue
55 ahead += 1
56 if (!oldest || at < oldest.at) oldest = { at, hash }
57 }
58 return { ahead, oldest }
59}
60
61/** `git status --porcelain`: changed tracked files and untracked entries. */
62export function parseStatus(out: string | undefined): { changed: number; untracked: number } {
63 let changed = 0
64 let untracked = 0
65 for (const line of (out ?? '').split('\n')) {
66 if (line.length < 3) continue
67 if (line.startsWith('??')) untracked += 1
68 else if (!line.startsWith('!!')) changed += 1
69 }
70 return { changed, untracked }
71}
72
73/** Which remote-tracking ref stands for this branch: the upstream, else `<any remote>/<branch>`. */
74export function branchRef(upstream: string | undefined, branch: string, refs: readonly string[]): string | undefined {
75 if (upstream) return `refs/remotes/${upstream}`
76 return refs.find(r => r.endsWith(`/${branch}`) && r.startsWith('refs/remotes/'))
77}
78
79/**
80 * Read the repository the runner points at. `null` when it is not a git work
81 * tree (or git is missing): the mod then shows nothing.
82 */
83export async function collect(run: Run): Promise<Snapshot | null> {
84 const top = await ok(run, ['rev-parse', '--show-toplevel'])
85 if (top === undefined) return null
86 const root = top.trim()
87
88 const [sym, head, short, up, remotes, refs, pushes, unpushed, behind, status] = await Promise.all([
89 ok(run, ['symbolic-ref', '--short', '-q', 'HEAD']),
90 ok(run, ['log', '-1', '--format=%ct%x09%h%x09%s']),
91 ok(run, ['rev-parse', '--short', 'HEAD']),
92 ok(run, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}']),
93 ok(run, ['remote']),
94 ok(run, ['for-each-ref', '--format=%(refname)', 'refs/remotes']),
95 ok(run, ['log', '-g', '--all', '--grep-reflog=update by push', '--date=unix', '--format=%gD']),
96 ok(run, ['log', '--format=%ct%x09%h', 'HEAD', '--not', '--remotes']),
97 ok(run, ['rev-list', '--count', 'HEAD..@{u}']),
98 ok(run, ['status', '--porcelain']),
99 ])
100
101 const detached = sym === undefined
102 const branch = (detached ? short?.trim() : sym.trim()) || 'HEAD'
103 const upstream = up?.trim() || undefined
104 const refList = (refs ?? '').split('\n').map(r => r.trim()).filter(Boolean)
105 const ref = detached ? undefined : branchRef(upstream, branch, refList)
106 const all = parsePushes(pushes)
107 const { ahead, oldest } = parseUnpushed(unpushed)
108 const { changed, untracked } = parseStatus(status)
109
110 return {
111 root,
112 branch,
113 detached,
114 commit: parseHead(head),
115 upstream,
116 hasRemote: (remotes ?? '').trim().length > 0,
117 // a detached HEAD has no branch ref: it is on the remote when no commit of it is missing there
118 onRemote: detached ? unpushed !== undefined && ahead === 0 && refList.length > 0 : !!ref && refList.includes(ref),
119 push: ref ? all.find(p => p.ref === ref) : undefined,
120 repoPush: all[0],
121 ahead,
122 oldestUnpushed: oldest,
123 behind: Number((behind ?? '').trim()) || 0,
124 changed,
125 untracked,
126 }
127}
128
129/** Whether two readings would draw the same line (times included). */
130export const sameSnap = (a: Snapshot | null, b: Snapshot | null): boolean => JSON.stringify(a) === JSON.stringify(b)
131hooks/row.ts 172 lines1/**
2 * What is drawn, as plain styled text: the line under the prompt, the hover
3 * card, the `/last-push` report and the reminders. Engine-free, so the tests
4 * and the screenshot tool use exactly what the mod draws.
5 */
6import type { Flash, Prefs, Snapshot } from '../types'
7import { BLUE, FLASH_MS, GREEN, H, MUTED, YELLOW, age, ago, clockTime, plural, shortPath, tone } from './format'
8
9export type Seg = { text: string; color?: string; bg?: string; dim?: boolean; bold?: boolean }
10
11export type RowInput = {
12 snap: Snapshot
13 prefs: Prefs
14 now: number
15 compact: boolean
16 flash?: Flash | null
17}
18
19const staleMs = (p: Prefs) => Math.max(0, p.remindAfter) * H
20const flashing = (f: Flash | null | undefined, kind: Flash['kind'], now: number) => !!f && f.kind === kind && now - f.at < FLASH_MS
21
22/** The short label of the push state, or nothing when there is no remote to push to. */
23export function pushState(s: Snapshot): 'pushed' | 'never' | 'unknown' | 'synced' | 'no-remote' {
24 if (!s.hasRemote) return 'no-remote'
25 if (s.push) return 'pushed'
26 if (!s.onRemote) return 'never'
27 return s.ahead === 0 ? 'synced' : 'unknown'
28}
29
30/** Everything is committed and on the remote. */
31export const inSync = (s: Snapshot) => s.hasRemote && s.onRemote && s.ahead === 0 && s.behind === 0 && s.changed === 0 && s.untracked === 0 && !!s.commit
32
33/** The line under the prompt. */
34export function rowSegs({ snap: s, prefs, now, compact, flash }: RowInput): Seg[] {
35 const stale = staleMs(prefs)
36 const out: Seg[] = []
37 const sep = () => out.push({ text: compact ? ' ' : ' · ', dim: true })
38 const pending = s.ahead > 0
39
40 // branch
41 out.push({ text: '⎇ ', dim: true })
42 out.push({ text: s.branch, color: BLUE, bold: !compact })
43 if (s.detached) out.push({ text: compact ? '!' : ' detached', color: YELLOW })
44
45 // last commit
46 sep()
47 const cFlash = flashing(flash, 'commit', now)
48 if (!s.commit) out.push({ text: compact ? '●–' : 'no commits yet', dim: true })
49 else if (compact) out.push({ text: `●${age(now - s.commit.at)}`, color: cFlash ? GREEN : undefined, bold: cFlash, dim: !cFlash })
50 else {
51 out.push({ text: 'commit ', dim: !cFlash, color: cFlash ? GREEN : undefined, bold: cFlash })
52 out.push({ text: ago(now - s.commit.at), color: cFlash ? GREEN : undefined, bold: cFlash })
53 }
54
55 // last push
56 sep()
57 const pFlash = flashing(flash, 'push', now)
58 const pushColor = pFlash ? GREEN : pending && s.oldestUnpushed ? tone(now - s.oldestUnpushed.at, stale) : GREEN
59 switch (pushState(s)) {
60 case 'no-remote':
61 out.push({ text: compact ? '↑–' : 'no remote', dim: true })
62 break
63 case 'never':
64 out.push({ text: compact ? '↑never' : '↑ never pushed', color: s.commit ? pushColor : MUTED, dim: !s.commit })
65 break
66 case 'unknown':
67 out.push({ text: compact ? '↑?' : '↑ no push recorded', dim: true })
68 break
69 case 'synced':
70 out.push({ text: compact ? '↑✓' : '↑ up to date', color: GREEN })
71 break
72 case 'pushed':
73 if (compact) out.push({ text: `↑${age(now - s.push!.at)}`, color: pushColor, bold: pFlash })
74 else {
75 out.push({ text: '↑ pushed ', color: pushColor, dim: !pFlash && !pending })
76 out.push({ text: ago(now - s.push!.at), color: pushColor, bold: pFlash })
77 }
78 break
79 }
80 if (pFlash) out.push({ text: ' ✨', color: GREEN })
81
82 // what is still waiting
83 if (s.ahead > 0) {
84 sep()
85 const c = s.oldestUnpushed ? tone(now - s.oldestUnpushed.at, stale) : YELLOW
86 out.push({ text: compact ? `⇡${s.ahead}` : `⇡${s.ahead} unpushed`, color: c, bold: true })
87 }
88 if (s.behind > 0) {
89 sep()
90 out.push({ text: compact ? `⇣${s.behind}` : `⇣${s.behind} behind`, color: YELLOW })
91 }
92 const files = s.changed + s.untracked
93 if (files > 0) {
94 sep()
95 const c = s.commit ? tone(now - s.commit.at, stale) : YELLOW
96 out.push({ text: compact ? `✎${files}` : `✎ ${files} changed`, color: c })
97 }
98 if (inSync(s)) {
99 sep()
100 out.push({ text: compact ? '✓' : '✓ in sync', color: GREEN })
101 }
102 return out
103}
104
105/** The hover card: one line with the full picture. */
106export function detailText(s: Snapshot, now: number): string {
107 const parts = [shortPath(s.root)]
108 parts.push(s.upstream ? `${s.branch} → ${s.upstream}` : s.branch)
109 if (s.commit) parts.push(`${s.commit.hash} “${s.commit.subject}” ${clockTime(s.commit.at, now)}`)
110 if (s.push) parts.push(`pushed ${clockTime(s.push.at, now)}`)
111 if (s.repoPush && s.repoPush.ref !== s.push?.ref) parts.push(`repo pushed ${clockTime(s.repoPush.at, now)} (${s.repoPush.ref.replace('refs/remotes/', '')})`)
112 parts.push(`${s.ahead} ahead, ${s.behind} behind`)
113 parts.push(`${s.changed} changed, ${s.untracked} untracked`)
114 return ' ' + parts.join(' · ') + ' '
115}
116
117/** The report `/last-push` prints. */
118export function reportText(s: Snapshot | null, prefs: Prefs, now: number): string {
119 if (!s) return 'last-push: not inside a git repository.'
120 const L: string[] = [`last-push · ${shortPath(s.root)}`]
121 L.push(` branch ${s.branch}${s.detached ? ' (detached HEAD)' : ''}${s.upstream ? ` → ${s.upstream}` : ''}`)
122 L.push(s.commit ? ` commit ${ago(now - s.commit.at)} · ${clockTime(s.commit.at, now)} · ${s.commit.hash} ${s.commit.subject}` : ' commit none yet')
123 const st = pushState(s)
124 L.push(
125 ` push ${
126 st === 'pushed'
127 ? `${ago(now - s.push!.at)} · ${clockTime(s.push!.at, now)} · ${s.push!.ref.replace('refs/remotes/', '')}`
128 : st === 'no-remote'
129 ? 'no remote configured'
130 : st === 'never'
131 ? 'this branch was never pushed'
132 : st === 'synced'
133 ? 'up to date — no push recorded in this clone (cloned or fetched)'
134 : 'no push recorded in this clone'
135 }`,
136 )
137 if (s.repoPush && s.repoPush.ref !== s.push?.ref) {
138 L.push(` repo push ${ago(now - s.repoPush.at)} · ${s.repoPush.ref.replace('refs/remotes/', '')}`)
139 }
140 L.push(` unpushed ${plural(s.ahead, 'commit')}${s.oldestUnpushed ? `, oldest ${ago(now - s.oldestUnpushed.at)}` : ''}`)
141 if (s.behind) L.push(` behind ${plural(s.behind, 'commit')} (as of the last fetch)`)
142 L.push(` working ${s.changed} changed, ${s.untracked} untracked`)
143 L.push(` settings mode ${prefs.mode} · reminders ${prefs.reminders ? 'on' : 'off'} · stale after ${prefs.remindAfter}h`)
144 return L.join('\n')
145}
146
147export type Reminder = { key: string; text: string }
148
149/**
150 * Reminders that are due now. Each has a key that names the state it is about
151 * (the oldest unpushed commit, the last commit), so it fires once per state.
152 */
153export function reminders(s: Snapshot | null, prefs: Prefs, now: number): Reminder[] {
154 const stale = staleMs(prefs)
155 if (!s || !prefs.reminders || stale <= 0) return []
156 const out: Reminder[] = []
157 if (s.ahead > 0 && s.oldestUnpushed && s.hasRemote && now - s.oldestUnpushed.at >= stale) {
158 out.push({
159 key: `unpushed:${s.root}:${s.oldestUnpushed.hash}`,
160 text: `⇡ ${plural(s.ahead, 'commit')} on ${s.branch} not pushed for ${age(now - s.oldestUnpushed.at)} — time to push?`,
161 })
162 }
163 const files = s.changed + s.untracked
164 if (files > 0 && s.commit && now - s.commit.at >= stale) {
165 out.push({
166 key: `uncommitted:${s.root}:${s.commit.hash}`,
167 text: `✎ ${plural(files, 'file')} changed, last commit ${ago(now - s.commit.at)} — time to commit?`,
168 })
169 }
170 return out
171}
172types/index.d.ts 60 lines1export type Mode = 'full' | 'compact' | 'off'
2
3export type Prefs = {
4 mode: Mode
5 /** A toast when work has sat unpushed or uncommitted this long. */
6 reminders: boolean
7 /** Hours before work counts as stale: the colour reaches red, the reminder fires. */
8 remindAfter: number
9}
10
11/** A commit: committer time (ms), short hash, subject. */
12export type Commit = { at: number; hash: string; subject: string }
13
14/** A push recorded in a remote-tracking ref's reflog. */
15export type Push = { at: number; ref: string }
16
17/** Everything the line shows about the repository the session runs in. */
18export type Snapshot = {
19 /** The work tree's top folder. */
20 root: string
21 /** Branch name, or the short hash when HEAD is detached. */
22 branch: string
23 detached: boolean
24 /** HEAD; absent in a repository without commits. */
25 commit?: Commit
26 /** The branch's upstream, e.g. `origin/main`. */
27 upstream?: string
28 /** Whether the repository has any remote at all. */
29 hasRemote: boolean
30 /** Whether a remote-tracking ref for this branch exists. */
31 onRemote: boolean
32 /** The last push of this branch. */
33 push?: Push
34 /** The last push of any branch in this repository. */
35 repoPush?: Push
36 /** Commits on HEAD that no remote-tracking ref holds. */
37 ahead: number
38 /** The oldest of them. */
39 oldestUnpushed?: { at: number; hash: string }
40 /** Commits on the upstream that HEAD lacks (as of the last fetch). */
41 behind: number
42 /** Tracked files with changes, staged or not. */
43 changed: number
44 /** Untracked files (a new folder counts once). */
45 untracked: number
46}
47
48/** A short highlight after a commit or a push was noticed. */
49export type Flash = { kind: 'commit' | 'push'; at: number }
50
51declare module 'claude-code' {
52 interface PluginState {
53 'last-push': {
54 snap: Snapshot | null
55 prefs: Prefs
56 flash: Flash | null
57 }
58 }
59}
60