git-surgeon 0.1.2

Surgical git hunk control for AI agents
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
# git-surgeon

`git-surgeon` gives AI agents surgical control over git changes without
interactive prompts. Stage, unstage, or discard individual hunks. Commit hunks
directly with line-range precision. Restructure history by splitting commits or
folding fixes into earlier ones.

## Quick start

### 1. Install

```bash
# Shell
curl -fsSL https://raw.githubusercontent.com/raine/git-surgeon/main/scripts/install.sh | bash

# Cargo
cargo install git-surgeon

# Homebrew
brew install raine/git-surgeon/git-surgeon
```

### 2. Install the Claude Code Skill

```bash
# Register the plugin source
claude plugin marketplace add raine/git-surgeon
# Install the skill that teaches Claude Code to use git-surgeon
claude plugin install git-surgeon@git-surgeon
```

### 3. Use it

Ask Claude Code to make granular commits. It will use git-surgeon automatically
to stage individual hunks instead of entire files.

## Commands

- [`hunks`]#hunks — List hunks in the diff
- [`show`]#show — Show full diff for a specific hunk
- [`stage`]#stage — Stage hunks by ID
- [`commit`]#commit — Stage hunks and commit in one step
- [`unstage`]#unstage — Unstage hunks by ID
- [`discard`]#discard — Discard working tree changes for hunks
- [`fixup`]#fixup — Fold staged changes into an earlier commit
- [`undo`]#undo — Reverse-apply hunks from a commit
- [`split`]#split — Split a commit into multiple commits by hunk selection

---

### `hunks`

Lists all hunks with their IDs, file paths, function context, change counts, and
a preview of changed lines.

```bash
# List unstaged hunks
git-surgeon hunks

# List staged hunks
git-surgeon hunks --staged

# Filter to a specific file
git-surgeon hunks --file src/main.rs

# List hunks from a specific commit
git-surgeon hunks --commit HEAD
git-surgeon hunks --commit abc1234
```

#### Example output

```
a1b2c3d src/main.rs fn handle_request (+3 -1)
  -    let result = process(input);
  +    let result = match process(input) {
  +        Ok(v) => v,
  +        Err(e) => return Err(e),
  +    };

e4f5678 src/lib.rs (+1 -0)
  +use std::collections::HashMap;
```

Each line shows: `<hunk-id> <file> [function context] (+additions -deletions)`

---

### `show`

Shows the full diff (header + all lines) for a single hunk. Each line is
prefixed with a 1-based line number for use with `--lines`.

```bash
git-surgeon show a1b2c3d

# Show a hunk from a specific commit
git-surgeon show a1b2c3d --commit HEAD
```

#### Example output

```
@@ -1,4 +1,6 @@ fn main
1: context
2:-deleted line
3:+added line
4: context
```

Searches both unstaged and staged diffs when no `--commit` is specified.

---

### `stage`

Stages one or more hunks by ID. Equivalent to selectively answering "y" in
`git add -p`.

```bash
git-surgeon stage a1b2c3d
git-surgeon stage a1b2c3d e4f5678

# Stage only lines 5-30 of a hunk
git-surgeon stage a1b2c3d --lines 5-30
```

---

### `commit`

Stages hunks and commits them in a single step. Equivalent to running `stage`
followed by `git commit`. If the commit fails, the hunks are unstaged to restore
the original state. Refuses to run if the index already contains staged changes.

```bash
git-surgeon commit a1b2c3d e4f5678 -m "add pagination"

# With inline line ranges
git-surgeon commit a1b2c3d:1-11 e4f5678 -m "add pagination"
```

---

### `unstage`

Unstages one or more previously staged hunks, moving them back to the working
tree.

```bash
git-surgeon unstage a1b2c3d
git-surgeon unstage a1b2c3d e4f5678

# Unstage only lines 5-30 of a hunk
git-surgeon unstage a1b2c3d --lines 5-30
```

---

### `discard`

Discards working tree changes for specific hunks. This reverse-applies the
hunks, effectively running `git checkout -p` non-interactively.

```bash
git-surgeon discard a1b2c3d

# Discard only lines 5-30 of a hunk
git-surgeon discard a1b2c3d --lines 5-30
```

**Warning:** This permanently removes uncommitted changes for the specified
hunks.

---

### `fixup`

Folds currently staged changes into an earlier commit. Uses `git commit --amend`
for HEAD, or an autosquash rebase for older commits. Unstaged changes are
preserved via `--autostash`.

```bash
# Stage some hunks, then fixup an earlier commit
git-surgeon stage a1b2c3d
git-surgeon fixup abc1234

# Fixup HEAD (equivalent to git commit --amend --no-edit)
git-surgeon fixup HEAD
```

If the rebase hits a conflict, the repo is left in the conflict state for manual
resolution (`git rebase --continue` or `git rebase --abort`).

---

### `undo`

Reverse-applies hunks from a specific commit onto the working tree. Useful for
selectively reverting parts of a previous commit without reverting the entire
commit.

```bash
# List hunks from the commit to find IDs
git-surgeon hunks --commit HEAD

# Undo specific hunks
git-surgeon undo a1b2c3d --from HEAD
git-surgeon undo a1b2c3d e4f5678 --from HEAD~3

# Undo only lines 2-10 of a hunk
git-surgeon undo a1b2c3d --from HEAD --lines 2-10
```

The changes appear as unstaged modifications in the working tree. Fails
gracefully if context lines have changed since the commit (the patch no longer
applies cleanly).

---

### `split`

Splits an existing commit into multiple commits by selecting which hunks go into
each new commit. Works on HEAD (direct reset) or earlier commits (via rebase).

```bash
# Split HEAD into two commits
git-surgeon split HEAD \
  --pick a1b2c3d e4f5678 -m "add pagination" \
  --rest-message "filter deleted users"

# With subject + body (multiple -m flags, like git commit)
git-surgeon split HEAD \
  --pick a1b2c3d -m "Add pagination" -m "Adds page and per_page params." \
  --rest-message "Other changes" --rest-message "Body paragraph."

# With line ranges (id:range inline syntax)
# Repeat same ID for non-contiguous ranges
git-surgeon split abc1234 \
  --pick a1b2c3d:1-11 a1b2c3d:20-30 e4f5678 \
  -m "add pagination"

# Multiple splits (more than two commits)
git-surgeon split abc1234 \
  --pick a1b2c3d -m "add pagination" \
  --pick e4f5678 -m "filter deleted users" \
  --rest-message "remaining cleanup"
```

Each `--pick` group specifies hunk IDs (with optional `:start-end` line ranges)
followed by `-m`/`--message`. Use multiple `-m` flags to add a commit body
(joined with blank lines, like `git commit`). Remaining unpicked hunks are
committed with `--rest-message` (defaults to the original commit message if
omitted; supports multiple values for body).

Requires a clean working tree. For non-HEAD commits, uses interactive rebase
with `--autostash`.

## How hunk IDs work

IDs are 7-character hex strings derived from SHA-1 of the file path and hunk
content (the actual `+`/`-`/context lines, excluding the `@@` header). This
means:

- IDs are stable across line shifts — adding lines above a hunk doesn't change
  its ID
- IDs are deterministic — the same content always produces the same ID
- Collisions get a `-2`, `-3` suffix (e.g., `a1b2c3d-2`)

## Typical AI agent workflow

```bash
# 1. Agent makes changes to multiple files
# 2. Review what changed
git-surgeon hunks

# 3. Stage and commit hunks for feature A in one step
git-surgeon commit a1b2c3d e4f5678 -m "implement feature A"

# 4. Commit remaining hunks for feature B
git-surgeon commit f6g7h8i -m "implement feature B"
```

## Example: splitting a hunk across commits

A file has two unrelated changes (pagination + soft-delete filtering) that git
groups into a single hunk. The agent uses `show` to see line numbers, then
`--lines` to stage only the pagination part:

```
❯ Commit the changes separately

⏺ Bash(git diff)
  ⎿  diff --git i/users.py w/users.py
     index c3c39df..24929a5 100644
     --- i/users.py
     +++ w/users.py
     @@ -1,13 +1,17 @@
      import json

     -def list_users(db):
     +def list_users(db, page=1, per_page=50):
          """Return all active users."""
     -    users = db.query("SELECT * FROM users WHERE active = 1")
     -    return users
     +    offset = (page - 1) * per_page
     +    users = db.query("SELECT * FROM users WHERE active = 1 LIMIT ? OFFSET ?", per_page, offset)
     +    total = db.query("SELECT COUNT(*) FROM users WHERE active = 1")[0]
     +    return {"users": users, "total": total, "page": page}

      def get_user(db, user_id):
          """Fetch a single user by ID."""
     -    user = db.query("SELECT * FROM users WHERE id = ?", user_id)
     +    user = db.query("SELECT * FROM users WHERE id = ? AND deleted_at IS NULL", user_id)
     +    if not user:
     +        return None
          return user

⏺ There are two logical changes here: pagination for list_users and
  soft-delete filtering + null handling for get_user. Let me commit them
  separately using git-surgeon.

⏺ Skill(git-surgeon)
  ⎿  Successfully loaded skill

⏺ Bash(git-surgeon hunks)
  ⎿  64ba255 users.py (+8 -4)
       -def list_users(db):
       +def list_users(db, page=1, per_page=50):
       -    users = db.query("SELECT * FROM users WHERE active = 1")
       ... (+8 more lines)

⏺ Only one hunk. Let me see the full diff with line numbers to split it.

⏺ Bash(git-surgeon show 64ba255)
  ⎿  @@ -1,13 +1,17 @@
      1: import json
      2:
      3:-def list_users(db):
      4:+def list_users(db, page=1, per_page=50):
     ... +15 lines (ctrl+o to expand)

⏺ Bash(git-surgeon stage 64ba255 --lines 1-11)
  ⎿  64ba255

⏺ Bash(git commit -m "add pagination to list_users")
  ⎿  [feature-users e81ede5] add pagination to list_users
      1 file changed, 5 insertions(+), 3 deletions(-)

⏺ Bash(git add users.py && git commit -m "filter soft-deleted users in get_user")
  ⎿  [feature-users de99d59] filter soft-deleted users in get_user
      1 file changed, 3 insertions(+), 1 deletion(-)
```

## Example: splitting a commit that mixes concerns

A commit mixes three unrelated changes: logging, pagination, and active user
filtering. The agent uses `hunks --commit` to see the changes, `show --commit`
to examine line numbers, then `split` with line ranges to separate concerns:

```
❯ split the big pagination commit that mixes concerns into multiple commits

⏺ Bash(git log --oneline -5)
  ⎿  dda5d0e add readme
     945381f add pagination, logging, and active user filtering
     386b2fe initial app setup

⏺ Bash(git-surgeon hunks --commit 945381f)
  ⎿  a811273 config.py (+3 -1)
       -LOG_LEVEL = "INFO"
       +LOG_LEVEL = "DEBUG"
       +LOG_FORMAT = "%(asctime)s %(name)s %(levelname)s %(message)s"
       +MAX_PAGE_SIZE = 100

     37562e9 models.py (+11 -2)
       -    def __init__(self, id, name, email):
       +    def __init__(self, id, name, email, active=True):
       ... (+10 more lines)

     8c1940f server.py (+19 -4)
       +import logging
       +logging.basicConfig(level="INFO")
       +logger = logging.getLogger(__name__)
       ... (+16 more lines)

⏺ The commit mixes three concerns. Let me examine each hunk with line numbers.

⏺ Bash(git-surgeon show a811273 --commit 945381f)
  ⎿  @@ -1,4 +1,6 @@
     1: DATABASE_URL = "sqlite:///app.db"
     ... +6 lines
     5:+LOG_LEVEL = "DEBUG"
     6:+LOG_FORMAT = "%(asctime)s %(name)s %(levelname)s %(message)s"
     7:+MAX_PAGE_SIZE = 100

⏺ Bash(git-surgeon show 8c1940f --commit 945381f)
  ⎿  @@ -1,29 +1,44 @@
      1: from flask import Flask, jsonify, request
      2:+import logging
      ... +28 lines
     20:+    page = request.args.get("page", 1, type=int)
     21:+    per_page = request.args.get("per_page", 10, type=int)
     ... +8 lines
     34:+    logger.info(f"Fetching user {user_id}")
     ... +2 lines
     37:+        logger.warning(f"User {user_id} not found")
     ... +11 lines

⏺ Now I can split by picking specific hunks and line ranges for each concern.

⏺ Bash(git-surgeon split 945381f \
    --pick a811273:5-6 8c1940f:2 8c1940f:5-6 8c1940f:34 8c1940f:37 -m "add logging" \
    --pick a811273:7 8c1940f:20-29 -m "add pagination to user listing" \
    --rest-message "add active user filtering")
  ⎿  committed: add logging
     committed: add pagination to user listing
     committed: add active user filtering

⏺ Bash(git log --oneline -5)
  ⎿  64b55f1 add readme
     1722eeb add active user filtering
     e7171e3 add pagination to user listing
     ae9b485 add logging
     386b2fe initial app setup
```

## Requirements

- Git 2.0+
- Rust (for building from source)