git-loom 0.20.0

A Git CLI tool that weaves together multiple feature branches into integration branches
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
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
# Spec 007: Fold

## Overview

`git loom fold` is a polymorphic command that combines a source into a target.
The action performed depends on the types of the arguments, detected
automatically via the shared resolution system (short IDs, filenames, commit
hashes, branch names).

## Why Fold?

Working with stacked branches and integration workflows requires frequent
small structural operations:

- "This file change should have been in that commit"
- "This fixup commit should be folded into the original"
- "This commit belongs on a different branch"
- "This file doesn't belong in this commit"
- "This file should be in a different commit"

Each of these is doable with raw git, but requires different multi-step
incantations (interactive rebase with fixup, cherry-pick + rebase to remove,
etc.). `fold` unifies them under one verb: **fold source into target**.

## CLI

```bash
git-loom fold <target>
git-loom fold <source>... <target>
git-loom fold --create <commit>... <new-branch>
git-loom fold -p [<files>...] <commit>
git-loom fold -p <commit1> <commit2>
git-loom fold -p <commit> zz
```

**Arguments:**

- `<target>`: The target to fold into. Can be a commit (hash, partial hash,
  short ID) or a branch name (full name or short ID).
- `<source>...`: One or more sources to fold into the target. Sources can be
  filenames, commit hashes, partial hashes, short IDs, or branch names.

When only a target is provided (single argument), the currently staged files
are folded into the target commit. If nothing is staged, an error is returned:
`"Nothing to commit"`.

When two or more arguments are provided, the last argument is the target and
all preceding arguments are sources.

**Flags:**

- `--create` / `-c`: Create a new branch from the source commit(s) and move
  them there. The target must be a branch name that does not yet exist.
  Accepts one or more commit sources. The branch is created at the upstream
  merge-base and the commits are moved into it — whether they were loose
  commits on the integration line or already on an existing branch. Multiple
  commits are ordered oldest-first so the new branch preserves their history
  order. If the named branch already exists, the commits are moved onto it
  with a warning.
- `-p` / `--patch`: Hunk-level fold mode. Opens an interactive hunk picker
  instead of operating at the file level. Has three forms (see Patch Mode
  below).

## Type Dispatch

The command resolves each argument's type, then dispatches based on the
combination:

| Source(s) | Target | Action | Multi-source? |
|-----------|--------|--------|---------------|
| *(staged)* | Commit | Amend staged: fold currently staged files into the commit | No |
| File(s) | Commit | Amend: stage files into the commit | Yes |
| Unstaged (`zz`) | Commit | Amend all: stage all changed files into the commit | No |
| Commit | Commit | Fixup: absorb source into target | No |
| Commit | Branch | Move: relocate commit to the branch | No |
| Commit | Unstaged (`zz`) | Uncommit: remove commit, put changes in working directory | No |
| CommitFile | Unstaged (`zz`) | Uncommit file: remove one file from a commit to working directory | No |
| CommitFile | Commit | Move file: move one file's changes from one commit to another | No |
| Commit | New branch (`-c`) | Create: make a new branch and move the commit(s) into it | Yes |

CommitFile sources use the `commit_sid:index` format shown by `git loom status -f`
(e.g. `fa:0` for the first file in commit `fa`).

**Invalid combinations** produce an error:

- Single-arg with nothing staged: `"Nothing to commit"`
- Single-arg with non-commit target: `"Target must be a commit when folding staged files"`
- File + Branch: `"Cannot fold files into a branch. Target a specific commit."`
- Branch + anything: `"Cannot fold a branch. Use 'git loom branch' for branch operations."`
- Unstaged (`zz`) + non-Commit target: `"Cannot fold files into unstaged — files are already in the working directory."` / `"Cannot fold files into a branch. Target a specific commit."`
- Unstaged (`zz`) with clean working tree: `"No changes to fold — working tree is clean"`
- Mixed files and commits as sources: `"Cannot mix file and commit sources."`
- Multiple commit sources: `"Only one commit source is allowed."`
- CommitFile + Branch: `"Cannot fold a commit file into a branch. Target a specific commit or use 'zz' to uncommit."`

## What Happens

### Case 0: Staged Files + Commit (Single-Argument)

Folds currently staged files into an existing commit. This is the single-argument
form: `git-loom fold <target>`. Only files in the git index (staged) are folded;
unstaged changes to the same files are preserved.

**Behavior:**

- The git index must have at least one staged change.
  Error if nothing staged: `"Nothing to commit"`
- The target must resolve to a commit.
  Error if not: `"Target must be a commit when folding staged files"`
- Works for any commit in history, including HEAD.
- Unstaged changes (including to the same files) are preserved automatically.

**What changes:**

- The target commit absorbs the staged file changes (new hash)
- All descendant commits get new hashes (same content/messages)

### Case 1: File(s) + Commit (Amend)

Folds file changes into an existing commit, effectively amending that commit
to include the current working tree changes for the specified files.

**Behavior:**

- The specified files must have changes (staged or unstaged).
  Error if a file has no changes: `"File '<path>' has no changes to fold."`
- Works for any commit in history, including HEAD.
- Uncommitted changes in other files are preserved automatically.

**What changes:**

- The target commit absorbs the file changes (new hash)
- All descendant commits get new hashes (same content/messages)

### Case 1b: Unstaged (`zz`) + Commit (Amend All)

Shorthand for folding all working tree changes into a commit. Equivalent to
listing every changed file individually. If `zz` appears alongside individual
file arguments, `zz` takes precedence and all changed files are folded.

**Behavior:**

- The working tree must have at least one changed file (staged or unstaged).
  Error if clean: `"No changes to fold — working tree is clean"`
- Works for any commit in history, including HEAD.

**What changes:**

- Same as Case 1 — the target commit absorbs all file changes.

### Case 2: Commit + Commit (Fixup)

Folds the source commit into the target commit. The source commit's changes
are absorbed into the target, and the source commit disappears from history.
The target commit keeps its original message.

**Behavior:**

- The source must be a descendant of the target (source is newer).
  Error if not: `"Source commit must be newer than target commit."`
- The operation is atomic: either it completes fully or the repository is
  left unchanged.
- Uncommitted changes are preserved automatically.

**What changes:**

- Target commit absorbs source's changes (new hash)
- Source commit is removed from history
- All commits after the target get new hashes

**What stays the same:**

- Target commit's message
- Commit topology (minus the removed commit)
- Other branches not in the ancestry chain

### Case 3: Commit + Branch (Move)

Moves a commit from its current position to the tip of the target branch.
The commit is removed from its source branch and relocated in a single rebase
operation.

**Behavior:**

- The commit is removed from its source branch and appended to the target
  branch's tip in a single atomic operation.
- Both source and target branch refs are updated automatically.
- If the target branch has no section in the Weave graph (e.g. it sits at
  the merge-base with no commits of its own), a branch section and merge
  entry are created automatically before moving the commit.
- If the target branch shares its tip with other co-located branches, only
  the target branch advances; co-located branches remain unaffected.
- Uncommitted changes are preserved automatically.

**Conflict handling:**

- If the operation encounters conflicts, it stops and lets the user resolve
  them with standard git tools.

**What changes:**

- The commit moves to the target branch's section (new hash)
- The commit is removed from its original branch
- Affected commits in both branches get new hashes
- Branch refs are updated automatically

### Case 4: Commit + Unstaged (`zz`) (Uncommit)

Uncommits a commit, removing it from history and placing its changes in the
working directory as unstaged modifications. The target is specified using
`zz`, the reserved short ID for the unstaged working directory.

**Behavior:**

- **HEAD commit**: Performs a mixed reset (`git reset HEAD~1`), which moves
  HEAD back one commit and leaves the commit's changes as unstaged
  modifications.
- **Non-HEAD commit**: Captures the commit's diff, drops the commit from
  history via Weave rebase, then applies the diff to the working directory.
- Uncommitted changes in other files are preserved automatically.

**What changes:**

- The target commit is removed from history
- The commit's changes appear in the working directory as unstaged modifications
- All descendant commits get new hashes (for non-HEAD case)

**What stays the same:**

- Other commits' content and messages
- Other branches not in the ancestry chain
- Existing uncommitted changes in the working directory

### Case 5: CommitFile + Unstaged (`zz`) (Uncommit File)

Removes a single file's changes from a commit and places them in the working
directory as unstaged modifications. The commit itself is preserved, minus the
file's changes. The source uses the `commit_sid:index` format shown by
`git loom status -f`.

**Behavior:**

- The file must have changes in the specified commit.
  Error if not: `"File '<path>' has no changes in commit <short_hash>"`
- **HEAD commit**: Reverse-applies the file's diff, amends HEAD to exclude
  the file, then re-applies the diff to the working directory.
- **Non-HEAD commit**: Reverse-applies the file's diff, creates a temp commit,
  fixups the temp into the target via Weave rebase, then re-applies the diff
  to the working directory.
- Uncommitted changes in other files are preserved automatically.

**What changes:**

- The target commit loses the file's changes (new hash)
- The file's changes appear in the working directory as unstaged modifications
- All descendant commits get new hashes (for non-HEAD case)

**What stays the same:**

- The target commit's message
- All other files in the target commit
- Other commits' content and messages
- Existing uncommitted changes in the working directory

### Case 6: CommitFile + Commit (Move File)

Moves a single file's changes from one commit to another. The file is removed
from the source commit and added to the target commit in a single atomic
rebase operation. The source uses the `commit_sid:index` format.

**Behavior:**

- Source and target must be different commits.
  Error if same: `"Source and target are the same commit"`
- The file must have changes in the source commit.
  Error if not: `"File '<path>' has no changes in commit <short_hash>"`
- Creates two temp commits (one reverse, one forward) and fixups both into
  their respective targets via a single Weave rebase.
- Uncommitted changes are preserved automatically.

**What changes:**

- The source commit loses the file's changes (new hash)
- The target commit gains the file's changes (new hash)
- All commits between/after the affected commits get new hashes

**What stays the same:**

- Both commits' messages
- All other files in both commits
- Commit topology
- Other branches not in the ancestry chain

## Patch Mode (`-p`)

The `-p` flag switches fold to hunk-level granularity. There are three forms,
detected by the argument types:

### Form 1: Pick working-tree hunks → fold into commit

```bash
git-loom fold -p [<files>...] <commit>
```

Opens an interactive hunk picker showing the current working-tree diff,
optionally filtered to the listed files (file paths or `zz` for all). After
the user selects hunks, the selection is staged and folded into the target
commit — identical in effect to `fold <staged files> <commit>` but at hunk
granularity.

**Error if no hunks selected:** `"No hunks selected"`.

**What changes:**

- The target commit absorbs the selected working-tree hunks (new hash).
- All descendant commits get new hashes (same content and messages).

**What stays the same:**

- Unselected working-tree changes remain unstaged.
- Other commits' content and messages.

### Form 2: Pick hunks from a commit → move into another commit

```bash
git-loom fold -p <commit1> <commit2>
```

Detected when both arguments resolve to commits. Opens the commit-diff hunk
picker for `<commit1>`. Selected hunks are removed from `<commit1>` and added
to `<commit2>` in a single two-phase edit-and-continue rebase. `<commit1>`
must be newer (a descendant of `<commit2>`).

**Constraints:**

- Source and target must be different commits.
- Source must be newer than target: `"Source commit must be newer than target commit"`.
- At least one hunk must be selected: `"No hunks selected"`.
- Binary files and deleted files are not supported: `"No text hunks selected — binary and deleted files are not supported with -p"`.

**What changes:**

- `<commit1>` loses the selected hunks (new hash).
- `<commit2>` gains the selected hunks (new hash).
- All commits between/after the affected commits get new hashes.

**What stays the same:**

- Both commits' messages.
- All other hunks in both commits.
- Commit topology.

### Form 3: Pick hunks from a commit → uncommit to working tree

```bash
git-loom fold -p <commit> zz
```

Detected when the target is `zz`. Opens the commit-diff hunk picker for
`<commit>`. Selected hunks are removed from the commit and applied to the
working directory as unstaged modifications. The commit itself remains in
history, minus the selected hunks.

**Constraints:** same as Form 2 (no binary/deleted files).

**What changes:**

- The commit loses the selected hunks (new hash).
- The selected hunks appear in the working directory as unstaged modifications.
- All descendant commits get new hashes (for non-HEAD case).

**What stays the same:**

- The commit's message.
- All unselected hunks in the commit.
- Other uncommitted changes in the working directory.

### Patch mode conflict handling

All `-p` forms use **hard-fail** conflict handling: if a conflict occurs during
the internal rebase, the operation is aborted automatically and the repository
is returned to its original state. `loom continue` / `loom abort` are not
supported for `-p` forms.

Pre-existing staged changes are always saved aside and restored regardless of
outcome.

## Target Resolution

Arguments are resolved via `resolve_arg()` with `accept = [Commit, CommitFile, File, Unstaged]` — see spec 002 for the resolution algorithm.

Arguments are resolved using the shared resolution strategy (see Spec 002)
with an additional filesystem fallback:

1. **Standard resolution** is tried first — handles branch names, git
   references (hashes, `HEAD`, etc.), and short IDs.
2. **Filesystem fallback** — if standard resolution fails, the argument is
   checked as a filesystem path. If the path exists and has uncommitted
   changes, it resolves as a file target.

This means arguments can be:

- **Filenames**: paths to files with changes in the working tree
- **Commit hashes**: full or partial git hashes
- **Branch names**: local branch names
- **Short IDs**: the compact IDs shown by `git-loom status`
- **Git references**: `HEAD`, `HEAD~2`, etc.

The command distinguishes sources from the target purely by position (last
argument is target).

## Conflict Recovery

The following non-`-p` operations support resumable conflict handling (`loom
continue` / `loom abort`):

| Operation | `LoomState.context` |
|-----------|---------------------|
| Files into commit | `op: "FilesIntoCommit"` — original commit hash, file count, saved staged patch |
| Commit into commit (fixup) | `op: "CommitIntoCommit"` — source and target hashes |
| Commit to branch (move) | `op: "CommitToBranch"` — commit hash, branch name |
| Commit to unstaged (uncommit) | `op: "CommitToUnstaged"` — commit hash, captured diff |

When `loom continue` is called after conflict resolution, `after_continue`
reads the saved context, cleans up the tracking branch (`_loom-track`), and
prints the success message. If the `CommitToUnstaged` diff cannot be
re-applied (because conflict resolution changed the surrounding context), the
diff is saved to `.git/loom/unapplied.patch` for manual recovery.

All `-p` (patch mode) operations use **hard-fail**: no `LoomState` is saved and
`loom continue` / `loom abort` are not available. An auto-abort restores the
repository on any conflict.

## Prerequisites

- Git 2.38 or later
- Must be in a git repository with a working tree
- For short ID arguments: must have upstream tracking configured

## Examples

### Fold staged files into a commit

```bash
git add src/auth.rs
git-loom fold ab
# Folds the staged changes in src/auth.rs into commit ab
```

### Amend a file into a commit

```bash
git-loom status
# Shows: │●  ab  72f9d3 Fix login bug
# Working tree has changes to src/auth.rs

git-loom fold src/auth.rs ab
# Stages src/auth.rs and amends it into commit ab
```

### Amend all working tree changes into a commit

```bash
git-loom status
# Shows: │●  ab  72f9d3 Fix login bug
# Working tree has changes to src/auth.rs, src/main.rs

git-loom fold zz ab
# Stages all changed files and amends them into commit ab
```

### Amend multiple files into HEAD

```bash
git-loom fold src/main.rs src/lib.rs HEAD
# Stages both files and amends them into the HEAD commit
```

### Fixup a commit into an earlier one

```bash
git-loom status
# Shows:
# │●  c1  aaa111 Add feature X
# │●  c2  bbb222 Fix typo in feature X   ← this should be part of c1

git-loom fold c2 c1
# c2's changes are absorbed into c1, c2 disappears
```

### Move a commit to another branch

```bash
git-loom status
# Shows commit d0 on the current branch, and branch feature-b exists

git-loom fold d0 feature-b
# Commit d0 is moved to the tip of feature-b and removed from current branch
```

### Uncommit a commit to the working directory

```bash
git-loom status
# Shows: │●  ab  72f9d3 Fix login bug

git-loom fold ab zz
# Removes the commit and puts its changes in the working directory
```

### Uncommit a file from a commit

```bash
git-loom status -f
# Shows:
# │●  ab  72f9d3 Fix login bug
#      ab:0 M  src/auth.rs
#      ab:1 M  src/main.rs

git-loom fold ab:1 zz
# Removes src/main.rs changes from commit ab, puts them in the working directory
```

### Move a file between commits

```bash
git-loom status -f
# Shows:
# │●  c1  aaa111 Add feature X
#      c1:0 M  src/feature.rs
# │●  c2  bbb222 Add feature Y
#      c2:0 M  src/feature.rs
#      c2:1 A  src/other.rs    ← this should be in c1

git-loom fold c2:1 c1
# Moves src/other.rs from c2 to c1
```

### Using full git hashes

```bash
git-loom fold abc123d def456a
# Fixup commit abc123d into commit def456a
```

### Using filenames

```bash
git-loom fold README.md HEAD
# Amend README.md changes into HEAD
```

### Fold selected working-tree hunks into a commit

```bash
git-loom status
# │●  ab  72f9d3 Fix login bug
# Working tree has changes to src/auth.rs

git-loom fold -p ab
# → hunk picker opens showing all working-tree changes
# User selects specific hunks → they are staged and folded into ab
```

### Move selected hunks from one commit to another

```bash
git-loom status
# │●  c1  aaa111 Add feature X
# │●  c2  bbb222 Add feature Y (contains a hunk that belongs in c1)

git-loom fold -p c2 c1
# → commit-diff picker opens for c2
# User selects the misplaced hunk → it is removed from c2 and added to c1
# ✓ Moved hunk(s) from `bbb222` (now `ccc333`) into `aaa111` (now `ddd444`)
```

### Uncommit selected hunks to working tree

```bash
git-loom status
# │●  ab  72f9d3 Refactor auth (too many changes mixed in)

git-loom fold -p ab zz
# → commit-diff picker opens for ab
# User selects hunks to extract → they are removed from ab and appear unstaged
# ✓ Uncommitted hunk(s) from `72f9d3` (now `8a3b2c`) to working directory
```

## Design Decisions

### Single Verb, Multiple Actions

Rather than separate commands (`amend`, `fixup`, `move`), fold uses type
dispatch to choose the action. This was chosen because:

- **Conceptual unity**: all three operations are "put X into Y"
- **Discoverability**: one command to learn instead of three
- **Consistency**: same argument syntax regardless of operation
- **Simplicity**: the short ID system already knows the types

### Source Before Target

The argument order `<source> <target>` was chosen because:

- It reads naturally: "fold X into Y"
- It matches common CLI conventions (e.g., `cp source dest`, `mv source dest`)
- The target is always singular (one commit or one branch)
- Multiple sources (files) naturally extend to the left

### Fixup Over Squash

For commit+commit, fold uses fixup semantics (keep target's message) rather
than squash (concatenate messages). This was chosen because:

- The typical use case is "this fix belongs in that commit" — the target's
  message is already correct
- If the user wants to change the message, they can `fold` then `reword`
- Simpler mental model: source disappears, target stays as-is

### True Move for Commit+Branch

The commit+branch case performs a true move (remove from source, add to target)
rather than a copy. This was chosen because:

- "Fold commit into branch" implies the commit becomes part of that branch
- Leaving a copy behind would be surprising and create duplicate work
- If users want a copy, they can use `git cherry-pick` directly

### Atomic Operations

All fold operations are atomic: either they complete fully or the repository
is left in its original state. The user is never left in a partially-applied
state that requires manual recovery.

### Automatic Working Tree Preservation

All operations automatically preserve uncommitted changes in the working tree.
Users don't need to manually stash before folding.

### Move Handles Co-Located Branches

When moving a commit to a branch that shares its tip with other co-located
branches, only the target branch advances to include the moved commit.
Co-located branches remain unaffected, pointing at their original tip.

### Patch Mode as a Flag, Not Separate Forms

Hunk-level folding is exposed as `-p` on the existing `fold` command rather
than separate commands (`fold-hunks`, `fold -h`, etc.). The three `-p` dispatch
forms (working-tree→commit, commit→commit, commit→unstaged) mirror the
file-level cases and keep the same "fold X into Y" mental model at finer
granularity. Reusing `fold -p` avoids command proliferation and keeps the
help text cohesive.

### Two-Phase Rebase for Commit-to-Commit Patch Move

Moving hunks between two non-HEAD commits requires two separate edit-and-continue
rebases: phase 1 removes the selected hunks from the source commit; phase 2
adds them to the target commit. A single rebase cannot do both atomically
because the target commit's new OID is not known until phase 1 completes. The
`_loom-track` temporary branch is used to carry the target commit's pre-phase-1
OID through to phase 2.