SLOPSHOPPER

last-push

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

newspinnerguardcommandtoastprompt
★ 1v0.1.0MITupdated 2026-10-07pepperonas/last-push
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · last-push
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /last-push ⎿ last-push: last-push · ⎿ last-push: branch HEAD ⎿ last-push: commit none yet ⎿ last-push: push no remote configured ⎿ last-push: unpushed 0 commits ⎿ last-push: working 0 changed, 0 untracked ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ⟨Claude Code's own drawing⟩ ⎇ HEAD · no commits yet · no remote

Draws

Prompt hint
⟨Claude Code's own drawing⟩ ⎇ HEAD · no commits yet · no remote
README

<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>

⇡ last-push

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> &nbsp; <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>

version node tests engine tests lines of code

CI Claude Code mod tested with TypeScript Node runtime deps git surfaces network calls mutation tested Keep a Changelog SemVer last commit open issues repo size stars PRs welcome License: MIT

Donate with PayPal Rate celox.io on Google


[!NOTE] Read-only, local, no network. last-push runs a handful of read-only git commands in the session's folder — never fetch, pull, push or anything that writes. The push time comes from your repository's own reflog, so nothing ever talks to GitHub.

🎬 Demo

<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>

📸 Screenshots

<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>

What the line tells you

<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%">

Hover card and reminders

<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>

✨ Features

  • Last commit — commit 12m ago, from HEAD. Hover for the hash, subject and exact time.
  • Last push — ↑ 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.
  • Unpushed commits — ⇡2 unpushed: commits on HEAD that no remote has. Works without an upstream too.
  • Uncommitted files — ✎ 4 changed: modified, staged and untracked files.
  • Behind — ⇣3 behind: what your upstream has that you don't, as of your last fetch.
  • Colour that ages — waiting work starts green, turns yellow halfway to the stale time (4 h by default) and red from then on. Unpushed commits age by the oldest one; changed files by the last commit.
  • Reminders — a toast when commits have sat unpushed, or files uncommitted, longer than the stale time: ⇡ 2 commits on main not pushed for 6h — time to push?. Once per state, remembered across sessions.
  • Flash — a commit or a push lights up for a few seconds, with a ✨ for the push, so you see it land.
  • Branch — ⎇ main, or the short hash and detached.
  • Honest states — 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.
  • Repo-wide — the hover card also names the last push of any branch, so a new branch still tells you when the project was last pushed.
  • Follows git, wherever it runs — re-reads after every tool call (Claude ran git push), when you send a prompt, and every 15 s for what you do in another terminal or your IDE.
  • Compact mode — ⎇ main ●12m ↑3h ⇡2 ✎4; automatic below 80 columns.

📥 Install

Requirements

  • Claude Code with mods — mods are on by default in current releases; last-push is tested with 2.1.292.
  • git on your PATH. Nothing else: no account, no token, no network.

Option 1 — marketplace (recommended)

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).

Option 2 — skills folder

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.

Option 3 — one session

git clone https://github.com/pepperonas/last-push
claude --plugin-dir ./last-push

Option 4 — desktop app and SDK hosts

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" } }

🕹️ Usage

There is nothing to do: start Claude Code in a repository and the line is there. The command is for when you want more.

CommandWhat it does
/last-pushA 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 refreshRead the repository again now, and report
/last-push full · compact · offDisplay mode
/last-push reminders on · offToasts 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.

⚙️ Configuration

The defaults live in /config under last-push:

FieldValuesDefault
modefull compact offfull
reminderson / offon
remindAfterhours, 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.

🧠 How it works

 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)
  • The push time. Git keeps no "last pushed" field. What it does keep is a reflog for each remote-tracking ref (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.
  • Unpushed. 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.
  • Lock-free. Every call runs as 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.
  • Drawing. A 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.
  • When it reads. After every tool call (debounced: a burst of calls is one read 0.4 s after the last), when you send a prompt, every 15 s, and on /last-push. A read is ten small git processes; in a large repository git status dominates.

🔍 What it runs

<!-- 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.

Limits

  • Only pushes from this clone. The reflog is local: a push from another machine, or made before you cloned, shows as 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.
  • Reflogs expire. Git prunes reflog entries after 90 days by default (gc.reflogExpire); a branch last pushed before then reads no push recorded.
  • Behind is as of your last fetch — last-push never fetches.

🔒 Privacy

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.

🏛️ Architecture

FileRole
hooks/register.tsxThe mod: hooks, refresh scheduling, flash, reminders, /last-push
hooks/git.tsWhich git commands run and what their output means; collect() takes any runner
hooks/row.tsPure: the line, the hover card, the report, the reminders
hooks/format.tsPure: relative and clock times, the colour of an age
types/index.d.tsThe state contract (PluginState['last-push'])
tools/screenshots.tsRenders 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.

🧪 Testing

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)

❓ FAQ

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.

📝 Changelog

The full history is in CHANGELOG.md (Keep a Changelog).

  • 0.1.0 — last commit and push under the prompt, unpushed and uncommitted work, an age colour, reminders, flash, hover card, /last-push.

🤝 Contributing

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.

💛 Support

last-push is free and stays that way. If it saved you from a laptop that died with a day of unpushed work:

📄 License

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.

Source 5 files
hooks/register.tsx 219 lines
1import { 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}
219
hooks/format.ts 76 lines
1/** 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}
76
hooks/git.ts 131 lines
1/**
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)
131
hooks/row.ts 172 lines
1/**
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}
172
types/index.d.ts 60 lines
1export 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