SLOPSHOPPER

commit-push

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…

newcommandtoastmodelprocess
★ 1v0.1.0no licenseupdated 2026-10-11erte-del/claude-mods/mods/commit-push
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · commit-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 › /commit-push ⎿ commit-push: commit-push: stopped, needs you — unresolved merge conflicts in: src/auth.ts, src/auth.test.ts. Resolve them y ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

claude-mods

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

ModWhat you get
sidebarModel badge, context-window gauge, session cost, a 5-hour / weekly usage dial, and buttons for New chat, Commit & Push and Handoff
commit-pushOne click to stage, commit (with a Claude-written message) and push
handoffOne click to save a handoff.md, clear the chat, and continue from it
repoPick a GitHub repo, clone it if needed, and move the session into it

Install

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.

What each mod does

sidebar

Live status, redrawn after every turn:

  • the current model
  • how full the context window is
  • what the session has cost so far
  • how much of your 5-hour and weekly limits you've used, as a dial, with time until each resets

Buttons, top to bottom: + New chat runs /clear. ⇡ Commit & Push and ⇄ Handoff run the mods below. ↻ Reload plugins runs /reload-plugins.

commit-push

Click ⇡ Commit & Push, or type /commit-push.

  1. Checks the current repo. If nothing changed, it says so and stops.
  2. Stages everything (git add -A).
  3. Sends the staged diff to Claude (Haiku) for a one-line conventional-commit message such as fix: handle empty repo list in picker. If that fails it falls back to naming the changed files.
  4. Commits, then pushes. If the branch has no upstream yet, it runs git push -u origin <branch>.
  5. Shows the commit message and the result in the sidebar. A failed push shows git's own error text plus a hint (network, sign-in, rejected, protected 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.

handoff

Click ⇄ Handoff, or type /handoff. Use it when your context is getting full.

  1. Asks Claude to write 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.
  2. Waits for the turn to finish and the file to appear.
  3. Runs /clear.
  4. Tells the fresh chat to read 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.

repo

Opens a picker when a session starts with no repo chosen, or type /repo.

  • Type part of a name to fuzzy-search your repos (matches on name, then description), list to see them all, then a number to pick.
  • Or type a full name, owner/repo, or a git URL.
  • It looks for an existing checkout in the usual places (~/repos, ~/Documents, ~/Desktop, ~/code, ~/projects, ~/dev, ~/src, ~/GitHub and a few more) and clones the repo if it can't find one.
  • Then it moves the session into that folder and tells Claude which repo is active.

Settings

Change these in /config (each mod's settings are listed under its name).

ModSettingDefaultWhat it does
sidebarbudgetUsd5The session cost that fills the cost bar
sidebarasciioffDraw bars with # and - and skip the dial, for terminals that can't show block characters
repoowneryour gh loginWhose repos to list: another user or an organization
repocloneDir~/reposWhere missing repos are cloned
repoopenOnStartonOpen the picker when a session starts with no active repo

Updating and removing

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

Source 2 files
hooks/register.tsx 272 lines
1import { 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}
272
types/index.d.ts 20 lines
1export 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