One-click Commit & Push: stages the active repo's changes, has Claude write a conventional commit message from the diff, commits and pushes (setting the…

<img width="391" height="379" alt="image" src="https://github.com/user-attachments/assets/6a3cb2d9-57cb-47e2-84c0-6cfc547499c3" />
Small mods for Claude Code: a live sidebar with one-click buttons for the things you do every session, and a repo picker. Works on Windows, macOS and Linux.
| Mod | What you get |
|---|---|
| sidebar | Model badge, context-window gauge, session cost, a 5-hour / weekly usage dial, and buttons for New chat, Commit & Push and Handoff |
| commit-push | One click to stage, commit (with a Claude-written message) and push |
| handoff | One click to save a handoff.md, clear the chat, and continue from it |
| repo | Pick a GitHub repo, clone it if needed, and move the session into it |
In Claude Code:
/plugin marketplace add erte-del/claude-mods
/plugin install sidebar@claude-mods
/plugin install repo@claude-mods
/reload-plugins
Or from a terminal:
claude plugin marketplace add erte-del/claude-mods
claude plugin install sidebar@claude-mods --scope user
claude plugin install repo@claude-mods --scope user
Installing sidebar also installs commit-push and handoff, because its buttons call them. repo is separate, since it opens a picker when a session starts; skip it if you don't want that.
Start a new session or run /reload-plugins and the sidebar appears. It docks beside the transcript on terminals 110 columns or wider, and otherwise shows as a bar above the prompt.
Requirements: Claude Code and git. repo also uses the GitHub CLI (gh, signed in with gh auth login) to list your repos.
Live status, redrawn after every turn:
Buttons, top to bottom: + New chat runs /clear. ⇡ Commit & Push and ⇄ Handoff run the mods below. ↻ Reload plugins runs /reload-plugins.
Click ⇡ Commit & Push, or type /commit-push.
git add -A).fix: handle empty repo list in picker. If that fails it falls back to naming the changed files.git push -u origin <branch>.It never force-pushes. It stops and leaves it to you when the branch is behind its upstream, there are unresolved merge conflicts, a merge or rebase is in progress, or HEAD is detached. Your files are only staged when it gets as far as committing.
It works on whatever folder the session is in, so it follows repo.
Click ⇄ Handoff, or type /handoff. Use it when your context is getting full.
handoff.md in the project root: what you're doing, current state, the exact next step, decisions made, gotchas and open questions. An existing handoff.md is overwritten./clear.handoff.md and carry on from its next step.If you interrupt, the turn errors, or Claude finishes without writing the file, it stops without clearing anything. The prompt is in mods/handoff/hooks/register.tsx if you want to change what goes in the file.
Opens a picker when a session starts with no repo chosen, or type /repo.
list to see them all, then a number to pick.owner/repo, or a git URL.~/repos, ~/Documents, ~/Desktop, ~/code, ~/projects, ~/dev, ~/src, ~/GitHub and a few more) and clones the repo if it can't find one.Change these in /config (each mod's settings are listed under its name).
| Mod | Setting | Default | What it does |
|---|---|---|---|
| sidebar | budgetUsd | 5 | The session cost that fills the cost bar |
| sidebar | ascii | off | Draw bars with # and - and skip the dial, for terminals that can't show block characters |
| repo | owner | your gh login | Whose repos to list: another user or an organization |
| repo | cloneDir | ~/repos | Where missing repos are cloned |
| repo | openOnStart | on | Open the picker when a session starts with no active repo |
claude plugin marketplace update claude-mods
claude plugin uninstall sidebar@claude-mods
Each mod lives in its own folder under mods/ with its tests next to the code (claude plugin test mods/<name>).
hooks/register.tsx 272 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { CommitPushRun } from '../types'
5
6export const IDLE: CommitPushRun = {
7 phase: 'idle',
8 step: '',
9 repo: '',
10 branch: '',
11 message: '',
12 sha: '',
13 target: '',
14 summary: '',
15 detail: '',
16}
17
18const run = atom({ plugin: 'commit-push', key: 'run' } as const, IDLE)
19
20const TYPES = 'feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert'
21const RULES = `You write git commit messages. Reply with ONE line only: a conventional commit subject,
22"<type>(<optional scope>): <summary>", type one of ${TYPES.split('|').join(', ')}.
23Imperative mood, lower case after the colon, no trailing period, at most 72 characters.
24Describe what the change does, not which files changed. No quotes, no code fences, nothing else.`
25
26// ---- git side: Plain `git` calls through $.process.run, so it behaves the same on
27// Windows, macOS and Linux. Never force-pushes; stops on conflicts, a merge/rebase in progress,
28// a detached HEAD, or a branch that is behind its upstream.
29
30const MAX_DIFF = 30000
31
32export type Prepared = {
33 ok: true
34 repo: string
35 branch: string
36 upstream: string | null
37 stat: string
38 names: string
39 diff: string
40 truncated: boolean
41}
42export type Refused = { ok: false; kind: 'nothing' | 'blocked' | 'error'; error: string }
43export type Committed = { ok: true; sha: string; branch: string; target: string; setUpstream: boolean; output: string }
44export type Failed = { ok: false; stage: 'commit' | 'push'; sha?: string; target?: string; error: string; hint?: string }
45
46type Out = { code: number; out: string; err: string; text: string }
47
48// One git call: stdout and stderr kept apart, `text` is both (stderr carries push/fetch errors).
49async function git($: any, cwd: string, args: string[], stdin?: string): Promise<Out> {
50 try {
51 const r = await $.process.run(['git', ...args], { cwd, stdin, timeoutMs: 120000, env: { GIT_TERMINAL_PROMPT: '0' } })
52 const out = String(r.stdout ?? '').trim()
53 const err = String(r.stderr ?? '').trim()
54 return { code: r.exitCode, out, err, text: [err, out].filter(Boolean).join('\n') }
55 } catch (e: any) {
56 const err = String(e?.message ?? e)
57 return { code: 127, out: '', err, text: err }
58 }
59}
60
61const refuse = (kind: Refused['kind'], error: string): Refused => ({ ok: false, kind, error })
62
63const IN_PROGRESS: [string, string][] = [
64 ['MERGE_HEAD', 'a merge'],
65 ['rebase-merge', 'a rebase'],
66 ['rebase-apply', 'a rebase'],
67 ['CHERRY_PICK_HEAD', 'a cherry-pick'],
68 ['REVERT_HEAD', 'a revert'],
69]
70
71async function upstreamOf($: any, repo: string): Promise<string | null> {
72 const u = await git($, repo, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'])
73 return u.code === 0 && u.out ? u.out : null
74}
75
76// Checks the repo, stages everything (git add -A) and returns the staged diff.
77export async function prepare($: any, dir: string): Promise<Prepared | Refused> {
78 const top = await git($, dir, ['rev-parse', '--show-toplevel'])
79 if (top.code === 127) return refuse('error', `git is not installed or not on PATH (${top.err})`)
80 if (top.code !== 0 || !top.out) return refuse('error', `not a git repository: ${dir}`)
81 const repo = top.out
82
83 const gitDir = (await git($, repo, ['rev-parse', '--absolute-git-dir'])).out
84 for (const [file, what] of IN_PROGRESS) {
85 if (gitDir && (await $.fs.exists(`${gitDir}/${file}`))) {
86 return refuse('blocked', `${what} is in progress. Finish or abort it yourself, then try again.`)
87 }
88 }
89 const unmerged = (await git($, repo, ['diff', '--name-only', '--diff-filter=U'])).out
90 if (unmerged) {
91 const files = unmerged.split('\n').slice(0, 10).join(', ')
92 return refuse('blocked', `unresolved merge conflicts in: ${files}. Resolve them yourself, then try again.`)
93 }
94
95 const head = await git($, repo, ['symbolic-ref', '--short', '-q', 'HEAD'])
96 if (head.code !== 0 || !head.out) return refuse('blocked', 'HEAD is detached (not on a branch). Check out a branch first.')
97 const branch = head.out
98
99 const status = await git($, repo, ['status', '--porcelain'])
100 if (!status.out) return refuse('nothing', `nothing to commit on ${branch} (working tree clean)`)
101
102 const upstream = await upstreamOf($, repo)
103 if (upstream) {
104 const configured = await git($, repo, ['config', '--get', `branch.${branch}.remote`])
105 const remote = configured.out || upstream.split('/')[0]
106 const fetched = await git($, repo, ['fetch', '--quiet', remote as string])
107 if (fetched.code !== 0) {
108 return refuse('error', `couldn't fetch ${remote} to check whether ${branch} is behind: ${fetched.text}`)
109 }
110 const behind = Number((await git($, repo, ['rev-list', '--count', 'HEAD..@{u}'])).out) || 0
111 if (behind > 0) {
112 return refuse(
113 'blocked',
114 `${branch} is behind ${upstream} by ${behind} commit(s). Pull (and resolve any conflicts) yourself, then try again. Nothing was staged or committed.`,
115 )
116 }
117 }
118
119 const add = await git($, repo, ['add', '-A'])
120 if (add.code !== 0) return refuse('error', `git add -A failed: ${add.text}`)
121
122 const stat = (await git($, repo, ['diff', '--cached', '--stat'])).out
123 if (!stat) return refuse('nothing', `nothing to commit on ${branch} (only ignored or no staged changes)`)
124 const names = (await git($, repo, ['diff', '--cached', '--name-status'])).out
125 let diff = (await git($, repo, ['-c', 'core.quotepath=off', 'diff', '--cached', '--no-color', '--no-ext-diff'])).out
126 const truncated = diff.length > MAX_DIFF
127 if (truncated) diff = diff.slice(0, MAX_DIFF)
128 return { ok: true, repo, branch, upstream, stat, names, diff, truncated }
129}
130
131export function pushHint(text: string): string | undefined {
132 if (/rejected|non-fast-forward|fetch first|Updates were rejected/i.test(text)) {
133 return 'The remote has commits you do not have. Pull (and resolve any conflicts) yourself, then push again. Nothing was force-pushed.'
134 }
135 if (/Could not resolve host|unable to access|Failed to connect|timed out|Connection (refused|reset)|network/i.test(text)) {
136 return 'Network problem reaching the remote. The commit is saved locally; push again once you are online.'
137 }
138 if (/Authentication failed|Permission denied|403|401|could not read Username|terminal prompts disabled|denied to/i.test(text)) {
139 return 'Authentication problem. Sign in to git (e.g. gh auth login, or the Git Credential Manager), then push again.'
140 }
141 if (/protected branch|hook declined|pre-receive/i.test(text)) {
142 return 'The remote refused the push (protected branch or server hook).'
143 }
144 return undefined
145}
146
147// Commits what prepare() staged with `message`, then pushes (-u when there is no upstream yet).
148export async function commitAndPush($: any, repo: string, message: string): Promise<Committed | Failed> {
149 const head = await git($, repo, ['symbolic-ref', '--short', '-q', 'HEAD'])
150 if (head.code !== 0 || !head.out) return { ok: false, stage: 'commit', error: 'HEAD is detached (not on a branch)' }
151 const branch = head.out
152
153 const c = await git($, repo, ['commit', '-F', '-', '--cleanup=strip'], `${message.trim()}\n`)
154 if (c.code !== 0) return { ok: false, stage: 'commit', error: c.text }
155 const sha = (await git($, repo, ['rev-parse', '--short', 'HEAD'])).out
156
157 const upstream = await upstreamOf($, repo)
158 let target: string
159 let setUpstream = false
160 let p: Out
161 if (upstream) {
162 target = upstream
163 p = await git($, repo, ['push'])
164 } else {
165 const remotes = (await git($, repo, ['remote'])).out.split('\n').filter(Boolean)
166 if (remotes.length === 0) {
167 return { ok: false, stage: 'push', sha, error: 'this repository has no remote to push to', hint: 'Add one with: git remote add origin <url>' }
168 }
169 const remote = remotes.includes('origin') ? 'origin' : (remotes[0] as string)
170 target = `${remote}/${branch}`
171 setUpstream = true
172 p = await git($, repo, ['push', '-u', remote, branch])
173 }
174 if (p.code !== 0) return { ok: false, stage: 'push', sha, target, error: p.text, hint: pushHint(p.text) }
175 return { ok: true, sha, branch, target, setUpstream, output: p.text }
176}
177
178// Turns the model's reply into one clean conventional-commit line.
179export function cleanMessage(reply: string): string {
180 const line =
181 reply
182 .split('\n')
183 .map(l => l.trim().replace(/^[`"'*\s]+|[`"'*\s]+$/g, ''))
184 .find(l => l.length > 0) ?? ''
185 const subject = line.replace(/\.$/, '')
186 if (!subject) return ''
187 const conventional = new RegExp(`^(${TYPES})(\\([^)]+\\))?!?: \\S`)
188 return conventional.test(subject) ? subject : `chore: ${subject}`
189}
190
191// Used when the model gives no answer: names what changed from `git diff --name-status`.
192export function fallbackMessage(names: string): string {
193 const files = names
194 .split('\n')
195 .map(l => l.split('\t').pop()?.trim() ?? '')
196 .filter(Boolean)
197 .map(p => p.split('/').pop() as string)
198 if (files.length === 0) return 'chore: update files'
199 if (files.length <= 3) return `chore: update ${files.join(', ')}`
200 return `chore: update ${files.slice(0, 2).join(', ')} and ${files.length - 2} more files`
201}
202
203async function writeMessage($: any, p: any): Promise<string> {
204 const prompt =
205 `Branch: ${p.branch}\n\nFiles:\n${p.names}\n\nStat:\n${p.stat}\n\nDiff${p.truncated ? ' (truncated)' : ''}:\n${p.diff}`
206 try {
207 const r = await $.model.complete({ model: 'haiku', system: RULES, prompt, maxTokens: 120, effort: 'low', timeoutMs: 45000 })
208 if (r.isAnswered) {
209 const msg = cleanMessage(r.text)
210 if (msg) return msg
211 }
212 } catch {
213 // fall through to the file-based message
214 }
215 return fallbackMessage(p.names)
216}
217
218async function set($: any, patch: Partial<CommitPushRun>): Promise<void> {
219 await update($, run, prev => ({ ...(prev ?? IDLE), ...patch }))
220}
221
222// The whole click: prepare (checks + git add -A) -> message from Claude -> commit + push.
223async function runFlow($: any): Promise<string> {
224 const current = await read($, run)
225 if (current.phase === 'running') return 'commit-push: already running'
226
227 await update($, run, () => ({ ...IDLE, phase: 'running', step: 'checking git status…' }))
228 const dir: string = await $.session.cwd()
229
230 const p: any = await prepare($, dir)
231 if (!p.ok) {
232 const phase = p.kind === 'nothing' || p.kind === 'blocked' ? p.kind : 'error'
233 const summary = phase === 'nothing' ? 'Nothing to commit' : phase === 'blocked' ? 'Stopped, needs you' : 'Failed'
234 await set($, { phase, step: '', summary, detail: p.error ?? '' })
235 return phase === 'nothing' ? `commit-push: ${p.error}` : `commit-push: ${summary.toLowerCase()} — ${p.error}`
236 }
237
238 await set($, { repo: p.repo, branch: p.branch, step: 'writing commit message…' })
239 const message = await writeMessage($, p)
240
241 await set($, { message, step: p.upstream ? `committing & pushing to ${p.upstream}…` : 'committing & pushing (setting upstream)…' })
242 const c: any = await commitAndPush($, p.repo, message)
243
244 if (c.ok) {
245 const summary = `Pushed ${c.sha} → ${c.target}${c.setUpstream ? ' (upstream set)' : ''}`
246 await set($, { phase: 'done', step: '', sha: c.sha, target: c.target, summary, detail: '' })
247 $.ui.toast(`✓ ${message}`)
248 return `commit-push: ${summary}\n ${message}`
249 }
250 if (c.stage === 'push') {
251 const summary = `Committed ${c.sha} locally, push failed`
252 const detail = [c.hint, c.error].filter(Boolean).join('\n')
253 await set($, { phase: 'error', step: '', sha: c.sha ?? '', target: c.target ?? '', summary, detail })
254 return `commit-push: ${summary}\n ${message}\n${detail}`
255 }
256 const summary = 'Commit failed (changes are staged)'
257 await set($, { phase: 'error', step: '', summary, detail: c.error ?? '' })
258 return `commit-push: ${summary}\n${c.error ?? ''}`
259}
260
261export const register: Register = on => {
262 on('session.start', async ($, e, next) => {
263 await $.command.register({
264 name: 'commit-push',
265 description: 'Stage all changes in the current repo, commit with a Claude-written message, and push',
266 })
267 return next(e)
268 })
269
270 on('command.run', { command: 'commit-push' }, async $ => ({ text: await runFlow($) }))
271}
272types/index.d.ts 20 lines1export type CommitPushPhase = 'idle' | 'running' | 'done' | 'nothing' | 'blocked' | 'error'
2
3export type CommitPushRun = {
4 phase: CommitPushPhase
5 step: string // what it is doing while running
6 repo: string // repo folder
7 branch: string
8 message: string // commit message used
9 sha: string // short sha once committed
10 target: string // remote/branch pushed to
11 summary: string // one line result
12 detail: string // error text or hint
13}
14
15declare module 'claude-code' {
16 interface PluginState {
17 'commit-push': { run: CommitPushRun }
18 }
19}
20