Every 14 hours, safely fast-forwards the repos in ~/projects and toasts what changed

Keeps the git repos in ~/projects up to date, safely, and tells you what changed.
repo-sync script and shows a toast with the result (for example which repos got new commits, and which it did not touch).The script, bin/repo-sync, looks at each repo at ~/projects/<owner>/<repo>:
~/projects/_attic.~/.cache/repo-sync.log.Set REPO_SYNC_ROOT to use a different folder than ~/projects.
install -m 755 repo-sync/bin/repo-sync ~/.local/bin/repo-sync
/plugin install repo-sync --marketplace Mark-Hickey/claude-mods
You can also run repo-sync yourself in a terminal at any time.
claude plugin validate repo-sync
claude plugin test repo-synchooks/register.ts 53 lines1import type { Register } from 'claude-code'
2
3// Runs ~/.local/bin/repo-sync, which only fast-forwards clean, fully pushed repos.
4const SYNC = ['bash', '-c', '"$HOME/.local/bin/repo-sync"']
5const PERIOD_MS = 14 * 60 * 60 * 1000
6const CHECK_MS = 60 * 60 * 1000
7const LAST_RUN = 'lastRun'
8
9export type Result = { updated: string[]; skipped: string[] }
10
11// "updated owner/repo: +12" and "skip owner/repo: reason" lines; the rest is ignored.
12export function parseSync(stdout: string): Result {
13 const updated: string[] = []
14 const skipped: string[] = []
15 for (const line of stdout.split('\n')) {
16 if (line.startsWith('updated ')) updated.push(line.slice('updated '.length))
17 else if (line.startsWith('skip ')) skipped.push(line.slice('skip '.length))
18 }
19 return { updated, skipped }
20}
21
22export function toastText({ updated, skipped }: Result): string {
23 const head = updated.length === 0 ? '🔄 Repos: all up to date' : `🔄 Repos updated: ${updated.join(', ')}`
24 return skipped.length === 0 ? head : `${head} · left alone: ${skipped.join('; ')}`
25}
26
27export const isDue = (lastRun: number | null, now: number) => lastRun === null || now - lastRun >= PERIOD_MS
28
29export const register: Register = on => {
30 let isRunning = false
31
32 on('session.start', async ($, e, next) => {
33 const sync = async () => {
34 if (isRunning) return
35 const lastRun = ((await $.store.get(LAST_RUN)) ?? null) as number | null
36 if (!isDue(lastRun, Date.now())) return
37 isRunning = true
38 try {
39 const { exitCode, stdout } = await $.process.run(SYNC, { timeoutMs: 600_000 })
40 await $.store.set(LAST_RUN, Date.now())
41 $.ui.toast(exitCode === 0 ? toastText(parseSync(stdout)) : '🔄 Repos: the update check failed; run repo-sync to see why', { timeoutMs: 15_000 })
42 } finally {
43 isRunning = false
44 }
45 }
46
47 // Check hourly whether 14 hours have passed since the last run, so the period holds across restarts.
48 void sync()
49 $.clock.every(CHECK_MS, () => void sync())
50 return next(e)
51 })
52}
53