Merge Recovery Runbook¶
This page is the operator procedure for a coordinated merge that will not finish and will not close. It covers every refusal and stuck state GWZ 0.11.0 can leave you in, what each one means, what it did and did not change on disk, and how to get out.
Read gwz merge first for the ordinary flow: resolve,
gwz merge --continue, or gwz merge --abort. Come here when one of those
refuses, or when a rollback reported success and the result still looks wrong.
Nothing on this page happens automatically. Every step is something you decide to do, and several of them cannot be undone.
Four Rules Before You Touch Anything¶
- Stop other activity in the workspace. GWZ's mutation lock serializes
cooperating GWZ commands. It cannot protect a hand operation, an open
editor, or a raw
gitwriter. - Copy before you move. Take the evidence snapshot in
step 2 of the wedged-merge procedure
before any
mv,reset, orcheckouton this page. - Park and re-checkout; never delete, never hand-edit. No procedure here
deletes a merge record, a
refs/gwz/ref, agwz:-prefixed stash, or a stash bundle. Those may be the only surviving copy of your work, and nothing in this runbook asks you to remove them. - Never edit
.gitattributesto get past a refusal. It is the obvious workaround for cases A and C and it is the worst one: it silences the refusal without changing a single byte of what GWZ then writes. Each case below says what to do instead.
While a merge is open, gwz forall is blocked along with the other mutating
commands, so run per-repository commands directly with git.
Shell convention. Most blocks use git -C <member> … so you can stay at
the workspace root. Blocks that need a plain rm open with cd <member> and
then drop the -C. Destructive sequences are chained with && so that a
refused command stops the ones after it — keep the && when you paste.
Which Case Am I In¶
| What you saw | Case |
|---|---|
A refusal ending refusing before any ref or worktree mutation |
A |
| Abort or rollback reported success, but filtered files hold the wrong content | B |
participant is neither at the exact rollback before nor after state |
C |
merge '<id>' is open; this command is blocked… and no merge command can close it |
D |
| Git LFS files hold pointer text; everything reports clean | E |
| None of the above | F |
GWZ prints human errors as gwz: <Code>: <message>. Some codes are shared by
several unrelated refusals, so match the cases below on the message, not on
the code.
A. Refused: A Recovery Checkout Under A Configured Filter¶
How it presents¶
gwz: DirtyMember: recovery checkout would rewrite 'secrets/api.key' through configured foreign filter 'crypt' (filter.crypt.clean/process); refusing before any ref or worktree mutation
The quoted path is the first covered file the check reached, and the quoted
name is that path's filter attribute. There may be more; the check stops at
the first. A driver name that is not valid UTF-8 is reported as
'<non-utf8 filter name>'. DirtyMember is the general cleanliness-refusal
code and is not specific to this case — the trailing
refusing before any ref or worktree mutation is.
What it means¶
gwz merge --abort and coordinated rollback restore a worktree blob-exact:
they write the bytes git has stored, with content filters switched off, because
recovery verification compares those exact bytes. That is safe for git's own
filters and for Git LFS pointers, which all round-trip unchanged.
It is not safe for a filter that rewrites content on the way in — the encrypt-on-clean family, git-crypt being the common example. Written raw, such a path is immediately divergent from what the filter would produce, and the divergence appears only after the branch has already moved.
So GWZ checks first. For every path the checkout would write, it reads the
filter attribute and refuses if that driver has a filter.<name>.clean or
filter.<name>.process command in the effective configuration. lfs is
allowlisted by name.
What was changed¶
Nothing. The check runs before the worktree rewrite and before the branch ref moves. The merge stays open in exactly its previous state, and every repository is untouched. Re-running the same command without changing anything reproduces the same refusal.
Recovery¶
Pick one:
- Find and remove the driver configuration, run the recovery command, then put it back and repair the worktree.
Both keys matter and either one at any level triggers the refusal, so list every occurrence rather than the winning one:
git -C <member> config --show-origin --get-all filter.crypt.clean
git -C <member> config --show-origin --get-all filter.crypt.process
Unset each occurrence at the level that reported it, then re-run the recovery command:
git -C <member> config --local --unset-all filter.crypt.clean
git -C <member> config --global --unset-all filter.crypt.clean
gwz merge --abort
Now put the keys back — and repair, because the covered paths hold the stored form. This step is mandatory, and the obvious command for it does not work: see below.
- Run the operation from a checkout that does not configure the driver.
The attribute alone never triggers the refusal; the configured
cleanorprocesscommand does. The same repair applies afterwards.
Do not instead edit .gitattributes in the worktree to hide the coverage from
the check. That suppresses the refusal without changing what gets written, and
lands you in case B with no
record of why.
The mandatory repair, and why git checkout -- alone does nothing¶
After a filters-off recovery checkout, git's index holds the raw file's size and timestamp. Git therefore believes the path is up to date and will not run the filter again. Three things follow, and all three are traps:
git statusprints nothing, however wrong the content is;- a bare
git checkout -- <paths>is skipped as up to date and changes nothing; - a
git statuscheck afterwards reports success on the unrepaired worktree.
Remove the files first. That is what forces git to re-materialize them through the filter:
Verify by content, not by git status:
To sweep a whole member, defeat the stat cache first — touching the files makes git re-check them:
cd <member>
git ls-files -z | xargs -0 touch
git status --short # anything listed still holds the stored form
This is the same repair as case B, performed deliberately instead of discovered later. Do not skip it.
The deliberate false positives¶
GWZ refuses on the presence of a configured clean/process command, not on
proof that the driver changes content. It cannot run the driver to find out. A
custom filter that is genuinely idempotent — a formatter that is stable on
already-formatted bytes, a pass-through wrapper — is refused too.
That is intended. Failing closed costs you one manual step; guessing wrong costs a silently divergent worktree under a moved branch.
B. A Rollback Succeeded And Left Divergent Files¶
How it presents¶
gwz merge --abort (or a coordinated rollback) reports success, and every
status command you can think of agrees: gwz status, gwz merge --status,
and plain git status are all clean. What is wrong is the content of the
covered files — they hold the stored form rather than the form the filter
should produce.
This case has no ordinary symptom. A plain git status cannot see it: the
index holds the raw file's size and timestamp from the filters-off checkout, so
git believes the path is up to date and never re-runs the filter. To make git
look, touch the files first:
Anything listed there holds the stored form. Looking at the file works too:
Suspect this case when all of the following were true of the rollback:
.gitattributeswas itself among the files the rollback restored;- the
filter=coverage for the affected paths exists only in the restored state — the checkout brought the coverage back together with the bytes; - the driver is configured (
filter.<name>.cleanor.process).
The usual direction — rolling back a change that added coverage — is caught
by case A instead
and refuses safely. Reaching this case takes the inverse: rolling back across a
merge that had deleted the covering .gitattributes.
What was changed¶
The rollback completed. The branch ref moved and the worktree was rewritten.
The affected paths hold git's stored bytes, while the freshly restored
.gitattributes and the configured driver mean git now expects the filtered
form on disk.
Coordinated merge state is consistent; the divergence is confined to those paths in that worktree.
Why everything reports clean¶
GWZ's own status does not execute configured filter commands, so those paths look unchanged to it. In this release GWZ cannot detect this shape in advance and cannot see it afterwards.
Real git is blind to it too, for a different reason: the filters-off checkout
left the raw file's size and timestamp in the index, so git treats the path as
up to date and never re-runs the filter. That is why the detection above has to
touch the files first, and why the repair below has to rm them. Neither
step is optional.
Recovery¶
Remove the affected paths, then check them out again. The removal is what
forces git to re-materialize them through the filter — a bare
git checkout -- <paths> silently does nothing here, and a git status
afterwards will report success whether or not the repair happened.
Verify by content:
Or re-run the whole-member sweep from the detection step above and expect it to list nothing.
This overwrites the working copy of those paths. If you have real edits there, save them first.
C. Rollback Is Unavailable In A CRLF Worktree¶
How it presents¶
gwz merge --abort refuses with a message of the form:
--json output carries member_id and member_path naming the repository.
What it means¶
GWZ verifies this rollback by comparing worktree bytes against the recorded commits exactly. In a worktree that was materialized through a line-ending filter, the bytes on disk are the smudged form and match neither recorded commit, so GWZ cannot tell which state it is looking at and stops.
GWZ is refusing because it cannot prove the state, not because something is broken.
Which repositories are affected¶
- Adopted and legacy worktrees only — repositories created before GWZ
0.11.0, or cloned into the workspace by ordinary
git. Repositories GWZ creates or clones from 0.11.0 onward get repo-localcore.autocrlf=falseandcore.eol=lfpinned at birth, and a GWZ clone materializes its first worktree with filters off, so those repositories stay blob-exact. Check withgit -C <member> config --get core.autocrlf: the pins are ordinary config and a later hand edit can undo them. - The trigger is the filter configuration, not the operating system.
core.autocrlf=trueis the common Windows default, which is where this is usually met, but any host carrying that setting is affected, and the attribute-driven forms (eol=crlf,ident, a foreignfilter=) apply everywhere. - This is the
--no-ffrecovery path.--no-ffstarts always use it; an ordinary start can also reach it once its record is migrated during abort. Match on the message, not on how you started the merge.
What was changed¶
Nothing. The classification happens before any rollback mutation.
First: is the smudge from config, or from an attribute?¶
The repair below resets core.autocrlf and core.eol. It cannot touch
attribute-driven smudge, and if you run it against an attribute-driven member
it will report success and change nothing. Check before you start:
If that reports eol: crlf, ident: set, or a filter: value, the repair
below does not apply to this member. Go to
case F. Do not "fix" it by editing
.gitattributes — rule 4.
Recovery, for the config-driven case¶
git reset --harddestroys uncommitted work. Staged changes and unstaged edits to tracked files are gone; untracked and ignored files survive. Commit anything you want to keep before you paste the block. The block refuses to start if it finds either, but read this first.
Apply this only to a member that is not mid-conflict. A conflicted member is a normal way to reach this case, so the first two lines of the block exist to catch exactly that.
cd <member>
# 1. Guard. STOP if either line prints anything:
# the first means uncommitted work - commit it, or go to case F
# the second means a live conflict - go to case F, do not continue
git status --porcelain -uno
git ls-files -u
# 2. Only when both printed nothing. Chained with && so that a refusal stops
# everything after it. `git rm --cached -r .` is INDEX-ONLY: it empties the
# index and does not delete a single file.
git config core.autocrlf false &&
git config core.eol lf &&
git rm --cached -r . &&
git reset --hard
If you stop between the last two commands, the index is empty and every
file reports as both staged-deleted and untracked. Nothing has been deleted —
the files are all still there. Run git reset --hard to put the index back. In
that state do not run git clean, git commit -a, or git stash: each of
them would turn an intact worktree into a real deletion.
Verify by bytes, because git status is green either way:
Then retry gwz merge --abort.
The alternative — re-cloning the member through GWZ, which materializes it
blob-exact — needs gwz repo or gwz materialize, and both are blocked while
a merge is open. It is a fix for the general condition, not for a merge you are
currently stuck in. Use it once the workspace is unblocked, so the next merge
is not exposed.
A command that performs this normalization for you is planned. It is not part of 0.11.0. Use the manual steps above.
D. An Open Merge No Command Can Close¶
How it presents¶
Every mutating command refuses:
gwz: OpenOperation: merge 'merge_20260725_1234' is open; this command is blocked until it is recovered; use merge status, merge continue, or merge abort
Blocked while a merge is open: commit, capture, snapshot, pull, push,
materialize, forall, init --update, branch/tag/stash/repo mutation, and
starting another merge. Among those still available are status, ls, diff,
the list commands, gwz merge --status, and gwz add for staging conflict
resolution in a repository the record already lists as conflicted.
gwz merge --gc also runs while a merge is open — but it deletes archived
records, including ones step 4 below depends on. Do not run it while you are
working through this page.
You are in this case when the suggested commands cannot move it either:
gwz merge --continueandgwz merge --abortboth refuse, because the live state can no longer be verified — a member checkout was deleted or moved, history was force-rewritten and the recorded commits pruned, a preservation stash was dropped, or publication files were hand-edited; or- discovery itself fails, so even
gwz merge --statusdies:
or a MergeRecordUnreadable error naming a record GWZ cannot decode.
What it means¶
A coordinated merge is durable. Its record lives at
<workspace>/.gwz/merge/<merge-id>.yaml, and while that file is there GWZ
protects the half-finished operation by blocking coordinated mutation. When the
world can no longer produce the exact state the record expects, the operation
cannot be finished or undone — and the block does not lift by itself.
The procedure below parks the record so the workspace becomes usable again, preserving every byte of evidence. It does not finish the merge and it does not undo it.
There is deliberately no command that force-abandons a merge record. A record that cannot be completed or undone stays parked; parking it is the end state, not a step toward one.
Step 1: Stop everything¶
No GWZ command, no git command, no editor writing into the workspace, until
the procedure is done.
Step 2: Copy everything first¶
Nothing further is permitted until this exists.
# Records: open, archived, and anything already parked.
cp -a <workspace>/.gwz/merge /somewhere/outside/merge-backup
# Preservation bundles, if the directory exists.
cp -a <workspace>/.gwz/stash/bundles /somewhere/outside/bundles-backup
# In the workspace root repository AND in every member — save the output.
git -C <path> for-each-ref refs/gwz/
git -C <path> stash list
# Confirm the copies actually exist before going any further.
diff -r <workspace>/.gwz/merge /somewhere/outside/merge-backup # expect no output
If gwz merge --status still runs, save its output too, in both forms — it
prints the recorded pre-merge commits you will need in step 5:
If it does not run — which is the case when discovery itself failed — you are
not stuck. Those same commits are inside the record's own YAML, which you have
just copied and can read at any time, and each preserved branch tip is reachable
through its refs/gwz/merge/…/head backup ref. Step 5 says how to use both.
Step 3: Park the record¶
Move it. Do not delete it, and do not edit it in place.
mkdir -p <workspace>/.gwz/merge/quarantine
mv <workspace>/.gwz/merge/<merge-id>.yaml \
<workspace>/.gwz/merge/quarantine/<merge-id>.yaml
GWZ's open-record discovery reads only *.yaml files sitting directly
inside .gwz/merge/. A file under quarantine/ is invisible to it, exactly as
archived records under done/ are. The block lifts, ordinary workspace
commands work again, and the record bytes, backup refs, stashes, bundles, and
member worktrees are all untouched.
Two warnings:
- The protection is gone, not the problem. Member repositories stay in whatever half-merged state they were in: branches may sit at merged commits that were never published, and the workspace lock is still at its pre-merge baseline. Deal with the members deliberately in step 5.
- Keep
.gwz/merge/clean. Anything named*.yamldirectly in that directory is treated as an open merge record — a file or a directory alike. Put notes, copies, and exports somewhere else entirely.
Step 4: More than one record¶
If discovery reported multiple records, move them one at a time and decide about each one separately.
If the same id exists in both .gwz/merge/<id>.yaml and
.gwz/merge/done/<id>.yaml with different contents, an archive step was
interrupted. GWZ keeps both precisely so a human can choose. The open copy is
the authoritative one unless it is the damaged one. Move the copy you reject
into quarantine/ under a distinct name — for example
quarantine/<id>.rejected.yaml. Never delete either.
Step 5: Cleaning up inside a member¶
This is the complete list of what GWZ owns inside a member repository, and the only things there that are GWZ's to remove at all:
- refs named
refs/gwz/merge/<merge-id>/<owner-key>/head, where<owner-key>is the member id orroot; - one native git stash whose message is exactly
gwz:stash_<merge-id>: merge preservation.
Nothing else in a member repository belongs to GWZ — and nothing in this runbook asks you to remove even these two. They are the preserved copies of your work. If you ever do delete them, do it only once that work is somewhere else.
Half-merged integration branches are your branches, not GWZ's. To put one
back you need its recorded pre-merge commit. Three places have it: the
gwz merge --status output you saved in step 2, the parked record's own YAML
(copy it out and read the participant's before_commit), and — for work that
was preserved — the backup ref, which is a usable branch point on its own.
git -C <member> status
git -C <member> log --oneline -5
git -C <member> reset --hard <before-commit>
git reset --hard discards work. Check the log, git stash list, and any
refs/gwz/merge/…/head backup ref before you run it — a backup ref is a
branch point:
Step 6: Never touch¶
- anything under a member's
.git/other than the two items in step 5; .gwz/merge/done/, the archived records;- the bytes you parked under
quarantine/— copy them out to read them; .gwz/stash/bundles/, which may be the only surviving record of preserved work.
Step 7: Re-entry, if you repair the world¶
Parking is reversible. If you later restore what was missing — re-clone the deleted member from its remote, recover the stash — you can move the parked record back into the open slot.
Two checks first, both of which must pass:
# 1. The bytes must be the ones you parked. Compare against the step-2 copy.
diff <workspace>/.gwz/merge/quarantine/<merge-id>.yaml \
/somewhere/outside/merge-backup/<merge-id>.yaml # expect no output
# 2. The open slot must be empty. Restoring alongside another record turns a
# working workspace into the multiple-records wedge.
ls <workspace>/.gwz/merge/*.yaml # expect no output
Then move it back:
Resume with the ordinary gwz merge --continue or gwz merge --abort; whether
they can proceed still depends on the live repositories matching the record.
Editing a record's YAML by hand and putting it back is not supported. The record's internal consistency is what makes recovery safe; a hand-edited record has undefined behaviour. If you edited it, leave it parked — and remember that parked is a legitimate final state, not a failure to finish.
E. LFS Paths Hold Pointer Text¶
How it presents¶
Files that Git LFS manages contain the short LFS pointer text instead of the
real content. Both git status and gwz status report clean. This happens in
two situations, and they need different commands:
- after GWZ cloned or materialized the repository — the LFS objects were never downloaded at all;
- after a recovery checkout touched LFS-managed paths — the objects are normally already in the repository, only the working copy is the pointer.
What it means¶
GWZ materializes and restores blob-exact — the bytes git has stored. For an
LFS-managed path that is the pointer. Pointers round-trip through the LFS
filter unchanged, so nothing is dirty and nothing is inconsistent; only the
working copy's content is a surprise. This is why lfs is allowlisted by the
refusal in case A:
it cannot wedge anything.
No merge state is involved and nothing needs recovering.
Recovery¶
After a GWZ clone or materialize, the objects have to be fetched before
anything can be written. git lfs checkout only materializes objects that are
already local, so it is not the command here:
After a recovery checkout, the objects are already present and a checkout is enough:
If you are not sure which situation you are in, this form works for both, because the smudge filter fetches what it needs:
F. Stop And Collect Evidence¶
If what you are seeing does not match A-E, collect evidence before touching
anything. Save the exact error text, including the gwz: <Code>: prefix.
gwz merge --status
gwz --json merge --status
gwz status
# Per repository — the workspace root and every member.
git -C <path> status
git -C <path> log --oneline -5
git -C <path> for-each-ref refs/gwz/
git -C <path> stash list
# The record and artifact plane.
ls -la <workspace>/.gwz/merge
ls -la <workspace>/.gwz/merge/done
ls -la <workspace>/.gwz/stash/bundles
Then stop. In particular, do not:
- delete a merge record, or edit one in place;
- delete a ref under
refs/gwz/, agwz:-prefixed stash, or a stash bundle; - run
git reset --hard,git rm --cached,git checkout --,git clean, orgit stash dropin a member before you know which case you are in; - edit
.gitattributesto make a refusal go away; - move a record before the copy in step 2 exists.
With the evidence above in hand, the parking procedure in case D is the general unblock: it is reversible, it destroys nothing, and it leaves every artifact available for a later decision.