SLOPSHOPPER

touched-only

Stops `git add -A` and `commit -a` from sweeping in files Claude never touched, and hands Claude the exact paths to stage instead.

newguardcommand
v0.1.0MITupdated 2026-10-07KrisnaSantosa15/touched-only
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · touched-only
› 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 › /touched ⎿ touched-only: src/auth.ts ⎿ touched-only: src/audit.ts ⎿ touched-only: src/cache.ts ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

touched-only

Claude commits what Claude touched. Nothing else.

Claude Code mod Claude Code 2.1.287+ GitHub stars Last commit License: MIT

The Problem

You had a half-finished experiment in your working tree, a debug script, a local config tweak. Claude ran git add -A, and now all of it is in the commit. Maybe it's in the pushed PR too.

Telling Claude "only stage your own files" works until it doesn't. Broad staging is one keystroke away, and Claude has no record of which files it actually changed.

How touched-only Solves It

  1. Remembers: every file Claude changes through its Edit, Write and NotebookEdit tools is recorded for the session.
  2. Stops: git add -A, git add ., git add -u, git commit -a and friends are refused before they run.
  3. Hands over the fix: Claude gets the exact command to stage only its own files.
touched-only: broad staging can sweep in unrelated changes. Stage only the files Claude edited this session:
  git add -- src/app.ts 'docs/read me.md'
If the user really wants everything staged, ask them to run it.

Install

Prerequisites: Claude Code 2.1.287 or later (claude --version).

/plugin marketplace add KrisnaSantosa15/touched-only
/plugin install touched-only@touched-only

No config needed.

Verify: after Claude edits a file, type /touched. You'll see the files Claude has edited so far.

What It Stops

CommandWhy
git add -A, git add --allStages the whole tree
git add ., git add ./, git add :/, git add *Stages everything under a folder
git add -u, git add --updateStages every tracked change
git commit -a, git commit -am "...", git commit --allCommits every tracked change

It catches them inside chains too, like cd app && git add -A. Explicit paths, git add -p and plain git commit -m go through.

Files Claude changes from the shell (sed -i, mv, a code generator) aren't tracked, so Claude has to name those paths itself.

Need everything staged? Run git add -A yourself in your own terminal. The guard only checks the commands Claude runs.

Contributing

Issues and pull requests are welcome. Add a test for any behavior you change.

git clone https://github.com/KrisnaSantosa15/touched-only.git
cd touched-only && claude plugin validate . && claude plugin test .

Try your changes live with claude --plugin-dir ..

License

MIT. Use it, fork it, ship it.

Source 2 files
hooks/register.ts 54 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4const NAME = 'touched-only'
5const files = atom({ plugin: 'touched-only', key: 'files' } as const, [])
6
7const BROAD_STAGING =
8  /\bgit\s+(add\s+([^;&|]*\s)?(-A|--all|-u|--update|\.|\.\/|:\/|\*)(?=\s|$|[;&|])|commit\s+([^;&|]*\s)?(-[b-zB-Z]*a[a-zA-Z]*|--all)(?=\s|$|[;&|]))/
9
10const relative = (cwd: string, path: string) =>
11  path.startsWith(`${cwd}/`) ? path.slice(cwd.length + 1) : path
12
13const quote = (path: string) => (/^[\w./@+-]+$/.test(path) ? path : `'${path.replaceAll("'", "'\\''")}'`)
14
15const touched = async ($: EngineInterface) => {
16  const cwd = await $.session.cwd()
17  return (await read($, files)).map(path => relative(cwd, path))
18}
19
20export const register: Register = on => {
21  on('session.start', async ($, e, next) => {
22    await $.command.register({ name: 'touched', description: 'List the files Claude edited in this session' })
23    return next(e)
24  })
25
26  on('command.run', { command: 'touched' }, async $ => {
27    const list = await touched($)
28    return { text: list.length === 0 ? 'Claude has not edited any files yet.' : list.join('\n') }
29  })
30
31  on('tool.call', async ($, e, next) => {
32    const path = e.tool === 'Edit' || e.tool === 'Write' ? e.file_path : e.tool === 'NotebookEdit' ? e.notebook_path : undefined
33    const ran = await next(e)
34
35    if (path !== undefined && ran.deny === undefined && ran.isError !== true) {
36      await update($, files, list => (list.includes(path) ? list : [...list, path]))
37    }
38
39    return ran
40  })
41
42  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
43    if (!BROAD_STAGING.test(e.command)) return next(e)
44
45    const list = await touched($)
46    const hint =
47      list.length === 0
48        ? 'Claude has not edited any files this session, so name the paths to stage explicitly.'
49        : `Stage only the files Claude edited this session:\n  git add -- ${list.map(quote).join(' ')}`
50
51    return { deny: `${NAME}: broad staging can sweep in unrelated changes. ${hint}\nIf the user really wants everything staged, ask them to run it.` }
52  }).catch(($, e, next) => (next.called ? next(e) : { deny: `${NAME}: the guard itself failed, so the command was held.` }))
53}
54
types/index.d.ts 8 lines
1export type TouchedFiles = string[]
2
3declare module 'claude-code' {
4  interface PluginState {
5    'touched-only': { files: TouchedFiles }
6  }
7}
8