# Reorganizing a stack with git Mechanics for `stack-reorganize` in a git checkout; `SKILL.md` has the contract, the envision phase and the diagnosis. The commands assume a POSIX shell (on Windows, Git Bash or the MozillaBuild shell; cmd.exe eats the `^` in `HEAD^`). git has no operation log, no recorded conflicts and no `absorb`, so: back the branch up before the first rewrite and keep the backup until the series is submitted, prefer constructions that are correct by construction over long rebases that need resolving, and place part of a commit by rebuilding a tree at the target position. The preconditions' "no conflicts in the original commit series" is jj's; git records none, so a clean `git status` is the whole check. Untracked files another occupant of the tree left are no obstacle: leave them, and stage by path throughout the rebuild, never with `git add -A`, which would sweep them into a leaf. ``` git branch backup- # any name; backup- is the convention ``` A construction built on a detached HEAD leaves the branch at the old tip, so **finish by pointing the branch at the result**: ``` git branch -f HEAD ``` On the branch itself (`git reset --hard ` with it checked out) HEAD and the branch move together and this step falls away, but the reset moves whichever branch is checked out, so in a tree another agent can reach read `git branch --show-current` immediately before it. Check `git diff ` after each rewrite of the whole branch and at the last leaf of a construction, whose tree differs by design until then; for a reorder it is **empty**. With a residue commit on top, `git diff ^` is the empty one, and `git diff ` has to equal the residue commit's own diff, so that it holds nothing the series should carry. `git range-diff -s ` maps each old commit to its new one; without `-s` it also prints the interdiff of every `!` pair, the form for reading one pair. The markers: - `=`: a commit that moved with its context lines unchanged. - `!`: a moved commit whose context changed, a target that absorbed lines or lost an edit to a remainder now below it, or a narrowed commit the pairing still recognizes. The pair prints the old subject, so a reworded commit shows its old one. - `<`: an absorbed or dropped commit, with no counterpart on the right. - `>`: a new commit, with none on the left. A narrowed commit the pairing no longer recognizes shows twice, its old form as `<` and its new form as `>` on a line that starts `-:`, which a filter on a leading number drops. The content check stays the empty `git diff`. `!` does not separate moved context from real drift; whether a commit's changed lines are the same is one line each side, with `-U0` so that the id hashes no context and no offsets, only the lines themselves: ``` git show --format= -U0 | git patch-id --verbatim ``` The id covers the path headers, so a hunk re-targeted to another path, as a drop's cascade does, never matches; there compare the changed lines themselves: ``` diff <(git show --format= -U0 | grep '^[-+]' | grep -vE '^(\+\+\+|---) ' | sort) \ <(git show --format= -U0 | grep '^[-+]' | grep -vE '^(\+\+\+|---) ' | sort) ``` Empty means the same lines, in another file. Inventory the range's renames and deletions before picking a construction; they decide which ones are legal: ``` git diff --name-status ``` **An empty diff is blind to misattribution.** A conflict resolved by taking one side whole (`git checkout --theirs `) can carry a change out of the commit whose message explains it and into another one, with the net diff still empty and the linter clean. After a rebase that conflicted, read each rewritten commit's own diff and confirm it carries only the kind of change its subject claims: pick a marker that identifies the change which belongs elsewhere - a frontmatter key, a pref name, a function signature - and expect no hits in the commits that don't own it: ``` git show --format= -- | grep -E '^[-+]' ``` ## Measuring the churn Evidence for both churn shapes, a dead intermediate and a commit rewriting text from its own range: ``` bash /scripts/git-churn.sh [--origins | --lines] [] .. [-- ...] ``` The script is `scripts/git-churn.sh` beside this reference, `.claude/skills/stack-reorganize/scripts/git-churn.sh` from the repository root when the skill loaded from the checkout; run it from the repository root, since a pathspec is relative to the current directory. Run the copy the skill loaded from, never one inside the range under repair. `` is the commit below the range; a prefix the owner keeps as it is moves it up, and measurement and rebuild both start above it. The script prints the range's net total, the sum of its commits' own totals, the gap between them, and each commit's total; a range that keeps two copies of a directory in sync counts every line twice, so pass one copy as the pathspec, and a hand resolution during a rebuild goes into every copy. The gap is the add-then-remove churn, but its **size is not a threshold**: a hundred dead lines in a sixteen-hundred-line series reads as noise. Settle the question by tracing what is dead: a symbol present in the individual commits but absent from the net diff was built and torn out within the range. **Trace each dead symbol's lifecycle**: its *birth* (the commit whose diff adds its definition) and its *death* (the commit that removes it), e.g. `git show | grep -E '^[+-].*\bsymbol\b'` across the range. Where the churn is prose there is no symbol to grep; `--origins` blames every line a commit removes at that commit's parent and sums them by the commit that wrote them, with the paths under each origin: removals from inside the range are churn to absorb there, path by path, and removals of pre-range lines are the commit's own work. It prints the summary table first, so one run serves both, and ends with the files an in-range commit added that the tip lacks, the birth half of a dead file's lifecycle. Blame credits a line to the last commit that touched it and cannot tell a rewrite from an extension, so the in-range count is an upper bound; read each counted line. `--lines ` prints the ones that commit removes under their origin, with the commit's own additions by path, where the destination half of a move shows; without a carrier it prints every commit's, tens of kilobytes on a dozen-commit range. The carrier's hunk in `git show --format= -- ` shows the block around each line and the `+` line that replaced it, which tells an extension (own work) from a rewrite (churn). The origin's own `git show` shows, in the line's `-`/`+` pair, whether it rewrote the line or only touched it, which the block rule turns on. Attribute by block: a block belongs to the commit that wrote most of it, a blank line to the block it separates, and a line a commit only extended is that commit's own work. Then grep the tip for each dead symbol, `git grep -n '\bsymbol\b' -- `: a hit is a comment that still describes the dead model, and a rebuild that reproduces the tip byte for byte makes some commit write that stale comment at the symbol's birth. Collect the hits before building and fix them in a residue commit on top, which the owner is free to drop; the `git diff ` check then takes its residue form. A dead data shape has no one name: grep for its removed field names too, and read the doc comment of every function whose signature the replacement changed, since a description of the old shape can name no dead symbol at all. The span from the first birth to the last death carries the dead model and is often much smaller than the full range; resplitting only it captures most of the churn win. Confirm the sub-range holds the bulk of the gap by measuring it on its own. The per-commit column is each commit's size, not its churn; the `--origins` table names the carriers from the remover's side, and the commit that wrote the removed lines is the target. The gap and the origins count need not agree: a churned line counts twice in the gap (its addition and its removal) and once in the table, which counts removals only, and the gap also carries diff alignment, so expect the gap on either side of twice the count (scaffolding a move adds, a heading or blank lines the net diff never sees, pushes it up; alignment pushes it either way) and reconcile them no further. The gap does not add up across sub-ranges: two halves can each measure zero while the whole range measures more, because a commit above rewrites lines a commit below the split introduced. Measure the halves separately to find out whether the residue sits inside a part of the range you are allowed to touch. A small gap of either sign is alignment noise between the net diff and the per-commit diffs, not a finding. An `R` entry in `git diff --name-status ` means the full-range gap also carries rename alignment, which no reorganization removes: the net diff pairs the renamed file with its new name where the per-commit diffs see a rewrite plus a new file. Measure the range without the renaming commit, on whichever side of it the churn lies; where the renaming commit is itself a churn carrier, the `--origins` count stands in, since blame at the parent sees the file under its old name and carries no alignment. After a rebuild that keeps the rename, the gap is that alignment plus twice the remaining in-range count, so read the `--origins` total rather than the gap. ## Finding a forward reference Take each cross-reference the range adds - a section heading, a symbol, a file path, a flag - and confirm its definition exists at the commit that adds the reference, not just at the tip. In prose the candidates are the backtick-quoted headings, paths and hyphenated names and the double-quoted section titles the commit adds; a path that illustrates rather than refers (an example inside a sentence) is a hit to drop, not to check, and the pathspec keeps a code file's string literals out: ``` git show --format= -- '*.md' | grep '^+' | grep -oE '`(#[^`]*|[^`]*\.(md|sh|mjs|js|css|json|yml|toml)|[a-z]+(-[a-z]+)+)`|"[A-Z][^"]{3,60}"' | sort -u ``` The pattern is a starting point; adapt its alternatives to the forms the stack's prose uses. In code, a call to a symbol that does not exist yet fails at that commit's build or tests, but a comment naming one does not. Take the backticked identifiers and `{@link}` targets in the added comment lines, and the slash-qualified rule names that lint directives carry unquoted (`stylelint-disable-next-line plugin/rule`): ``` git show --format= | grep -E '^\+\s*(//|/?\*)' | grep -oE '`#?[A-Za-z_][A-Za-z0-9_.]*(\(\))?`|\{@link [^}]+\}|\b[a-z-]+/[a-z]+(-[a-z]+)+\b' | sort -u ``` Grep the definition's form, not the bare name, which the reference itself and a same-named section elsewhere also match; a method's is the name followed by `(` at the start of a line, where a call has a `.` before the name: ``` git show : | grep -c '^#* *' git grep -c -E '^\s*(static |async |get |set )*\(' -- ``` A lint rule's definition is its registration (the plugin's rule index and the linter's config); a file path's is the file itself, `git cat-file -e :`. Zero at that commit with a hit at the tip is a forward reference. Reorder so the introducing commit comes first, or absorb the reference into it. A reference to a name that exists at that depth is not a forward reference, whatever a later commit does with the name. The mirror defect, a comment at the tip naming a symbol the range removed, is what the tip grep under Measuring the churn finds. That stale comment is the residue commit's business, not this check's: a byte-exact rebuild makes some leaf write it at the symbol's birth, so exclude the tip's stale symbols from this grep and fix them on top. ## Splits Follow the **`stack-split-commit`** skill and its `references/git.md`, which also cover splitting a revision that is already in review and folding a fix into the commit that needs it. ## Drops The commands for `drop-superseded.md`, by its step. 1. **Bound the blast radius.** The two `git log` searches are in the shape reference itself. 2. **Drop it.** ``` git branch backup- # skip if the backup above already exists git rebase --onto ^ ``` 3. **Resolve the cascade.** The rebase halts at each conflict. A faithful cascade makes the reverser's pick come out empty, and `git rebase` drops it with `dropping ... -- patch contents already upstream`. That message is the confirmation to look for, and git prints it on the `Rebasing (n/m)` progress line behind a carriage return, so read the rebase output raw or through `tr '\r' '\n'`; a filter that drops `Rebasing` lines drops the message with them. Once a delete/modify entry's change sits at the pre-move location, `git rm ` resolves the conflicted entry. A hunk written by hand during the cascade takes its text from the backup tip byte for byte, as under Absorbs, at the path where the text lives at the tip: the dropped file's own path does not exist there. `GIT_EDITOR=true git rebase --continue` after each: the continue opens an editor on the resolved commit's message, which a non-interactive shell cannot answer. 4. **Finish coupled changes at their source.** `git commit --fixup=`, then `GIT_SEQUENCE_EDITOR=true git rebase -i --autosquash ` with the base named explicitly (`--autosquash` without `-i` works from git 2.44). 5. **Validate.** `git diff ` must be empty for a net-zero drop; `git reset --hard ` returns to the input where it is not. ## Absorbs Send a later commit's changes into the commits that introduced the text it rewrites. Three constructions, in order of how much they leave to resolve. **Where more than one commit is being absorbed, the absorbed commit is not the tip, or a path it touches belongs to several targets, take the bottom-up construction whatever the other paths do**; the others then cost one saved diff and one cascade per commit. The path partition only decides whether each bottom-up step is a whole-path restore or hand-written. Whatever the construction, commit every rebuilt commit with `git commit -C `, then `git commit --amend -F ` where the body changes: `git commit -m` or `-F` alone drops the author date and the `Differential Revision` trailer with no warning. **By fixup**, where each target's part is a whole file. Drop the absorbed commit, re-add each target's part as a fixup of that target, and squash them all in one rebase: ``` git rebase --onto ^ # the backup still holds it git show -- | git apply --index # once per target, then: git commit --fixup= GIT_SEQUENCE_EDITOR=true git rebase -i --autosquash # name the base explicitly; without -i it needs git 2.44 ``` Where a target's part is only a subset of a file, split `git show ` into pieces by hand and apply each with `git apply --index `; `git add -p` is interactive and unavailable here. A rework commit's hunks seldom split that way: one hunk interleaves removals for one target with additions for another, and a hunk computed against the tip's context fails to apply at a target's depth when a kept commit in between touched the same lines. When either happens, stop re-targeting hunks and take each block's final text from the backup tip, the bottom-up construction below. Abort on a conflict during the autosquash rather than resolving it, which misattributes per above, and take the path rebuild instead. **By path rebuild**, where the hunks partition by path. At each target commit, take the paths it owns straight from the backup tip, so the content is correct by construction: ``` git restore --source= --staged --worktree -- ``` `git restore --source` runs in no-overlay mode by default: every tracked path the pathspec matches and the source lacks is removed, so a directory pathspec at a target commit also carries the deletions and renames that belong to later commits, the misattribution above. Inventory them first, name files rather than directories where the target must keep a path the backup tip lacks, and stage by hand only what the pathspec did not name: a rename whose `` alone is named leaves `` in place, so `git rm ` goes with it. `git checkout -- ` never removes a path and aborts on one the tree lacks. Every restore in the bottom-up construction needs the same inventory: ``` git diff --name-status -- ``` The backup tip carries every later commit's contribution too, so where a commit above the target is being kept and touches the same paths, restoring overshoots by its part. Where that commit's depth is free, place it below the targets, so the top leaf is a whole-path restore and nothing overshoots; otherwise take its part back off: ``` git show | git apply -R --index ``` That holds while the later commit's hunks still match. Where the later commit's hunks no longer apply, do not lower the match context: a fuzzy apply lands at false matches and reports success. Edit the file by hand from the tip and judge it by its own diff and by the leaf above's, each of which must be one concern alone, and by the per-commit gates. **Bottom-up from the backup tip**, where one path belongs to several targets and so cannot be restored whole at any one of them. Reset to the commit below the range and build upward. Re-add a commit that moves unchanged with `git cherry-pick `, which keeps its author, message and trailer where `git show | git apply --index` fails on context that does not exist yet. Try the original patch first for every leaf: `git show | git apply --index --check` at the leaf's depth says whether it still applies, and a commit the absorbed commit removed little from usually does, so only the leaves that fail need hand-writing. A leaf found wrong after later leaves exist takes a fixup of it (the fixup construction above), not a fresh rebuild. Write the early commits that share the path by hand, taking each block's text from the backup tip (`git show :`) byte for byte rather than retyping it, so the later whole-path restore adds nothing back; for the leaf directly below the tip, start from the tip's file and delete the lines the top leaf owns, which keeps everything else byte-identical by construction. A file assembled outside the tree goes in through git, the `hash-object` route at the end of "Without touching the worktree" below. At each later commit restore the paths whose final content is that commit's. Commit every rebuilt commit, hand-written or restored, with `git commit -C ` so it keeps the author, date, subject and `Differential Revision` trailer (`git log -1 --format=%B | grep '^Differential Revision:'` confirms the trailer; `%(trailers)` does not parse that key); where the message must change but the author and date stay, `git commit -C ` then `git commit --amend -F `. Nothing rebases, so no conflict arises and the net diff is empty by construction. **Without touching the worktree**, where the checkout is not yours to edit, the same construction runs on a scratch index and loose objects, and the checkout stays at the old tip until the end. Assemble each hand-written file outside the tree from spans of the backup tip's blobs, then per commit: ``` idx=/tmp/reorg.idx # a scratch index; the real one stays untouched GIT_INDEX_FILE=$idx git read-tree # the commit below in the new series GIT_INDEX_FILE=$idx git update-index --add --cacheinfo ,$(git hash-object -w ), GIT_INDEX_FILE=$idx git update-index --force-remove GIT_AUTHOR_NAME= GIT_AUTHOR_EMAIL= GIT_AUTHOR_DATE= \ git commit-tree $(GIT_INDEX_FILE=$idx git write-tree) -p -F ``` `git log -1 --format='%an%n%ae%n%aD' ` supplies the author fields and `git log -1 --format=%B ` the message, which is what `git commit -C` copies; `git ls-tree ` prints the mode; a whole-path restore is `--cacheinfo ,$(git rev-parse :),`, and a rename is a remove plus an add. `read-tree` and `write-tree` each take seconds to tens of seconds on a mozilla-central-sized index, so budget about a minute per commit. Finish with one `git reset --hard ` on the checked-out branch, which `git branch -f` refuses to move. Where the checkout is yours, keep the on-branch build; a hand-written file assembled in an ignored directory is placed with `git hash-object -w`, `git update-index --add --cacheinfo ,,` and `git checkout-index -f -- `, which writes it through git rather than through a shell edit a harness may record. ## Reorder (sink / hoist) Inside a bottom-up rebuild a reorder is the order you build in. On its own, move a commit to a new depth by reordering the rebase todo, matching on subject text rather than hashes so the edit survives the rewriting: ``` GIT_SEQUENCE_EDITOR='