Skip to content

gwz merge

Merge one source ref into the current branch of each selected workspace repository.

gwz merge <source> [--dry-run] [--ff-only] [--no-ff] [--filesystem-strict] [-m <message>]
gwz merge --remote <name> [<ref>] [--wait <secs>]
gwz merge --status [<merge-id>]
gwz merge --continue
gwz merge --abort [--preserve]
gwz merge --gc [<merge-id>]

With no selection, the workspace root and all active members participate. Explicit member selection remains partial. For the previous member-only default, use --target @all --no-target @root.

Quick start

Preview the merge without changing any repository:

gwz merge feature/refactor --dry-run

The preview performs an in-memory merge for every participant that would need a true merge. It reports whether each such merge is predicted to be clean and lists predicted conflict paths. The result is advisory: starting the merge repeats preflight under the workspace mutation lock.

Git paths that are not safely representable as ordinary UTF-8 are shown in a quoted, byte-safe form such as "config-\xFF.toml". That spelling is a stable diagnostic, not a path value to copy back into a filesystem command.

Start it:

gwz merge feature/refactor

Supply a custom body for any merge commits created by the operation:

gwz merge feature/refactor -m "Merge the refactor series"

GWZ normalizes CRLF and bare CR line endings to LF, removes trailing newlines, and appends its mandatory merge and operation identity lines. Empty, whitespace-only, and NUL-containing messages are rejected before any repository is changed. The exact final message is recorded once and reused by restart and conflict resolution. -m does not change the separate root composition-publication message.

If a repository conflicts, inspect the coordinated operation:

gwz merge --status

Resolve and stage the reported files, then continue:

gwz add path/to/resolved-file
gwz merge --continue

Or safely restore every participant to its pre-merge state:

gwz merge --abort

If successful participants have accumulated safe post-merge work, preserve it before rollback:

gwz merge --abort --preserve

Do not substitute raw git merge --abort. It knows about only one repository and cannot restore a coordinated workspace operation.

Choosing participants

The source ref is resolved independently in every selected repository.

# All active members; root excluded
gwz merge feature/refactor

# Two members only
gwz --target mem_app --target mem_docs merge feature/refactor

# Workspace root only
gwz --target @root merge feature/refactor

# Members plus the workspace root
gwz --target mem_app --target @root merge feature/refactor

Selection is frozen from the pre-merge manifest. Members remain in manifest order and a selected root is appended last. A root merge cannot add, remove, reorder, or rename participants in the operation already under way.

@all and bare --all include root and members. Use --target @root for a root-only merge.

The coordinated state machine

Most successful merges pass through Executing and Finalizing too quickly to notice. The other states exist so interruption and partial progress remain recoverable.

State Meaning What to do
Idle No merge is open. Start a merge.
Executing GWZ is applying the frozen plan in order. If interrupted, run gwz merge --status.
AwaitingResolution At least one repository has an expected Git conflict. Other independent participants may already have completed. Resolve and stage conflicts, then continue or abort.
Halted An unexpected Git or host failure stopped later participants. Inspect status, repair the reported cause, then continue or abort.
Finalizing Participant results are verified and workspace composition evidence is being published. Continue after interruption; GWZ resumes the recorded publication step.
Preserving A preserve-abort is recording and verifying backup refs or coordinated stashes before rollback. If interrupted, rerun gwz merge --abort --preserve.
RecoveryRequired Live Git state no longer exactly matches a safe recovery point. Follow the drift report and restore the exact expected state before retrying.
RollingBack A coordinated abort is being persisted and applied in reverse order. Retry gwz merge --abort after interruption.
Completed Results and composition evidence were verified and the operation was closed. No recovery action is needed.
Aborted Every affected repository was restored and the operation was closed. No recovery action is needed.

Each participant also has its own state, such as UpToDate, FastForwarded, Merged, Conflicted, Continued, Failed, Unattempted, RolledBack, or Aborted. This is why one workspace operation can be awaiting resolution while another repository has already merged successfully.

A merge left open by GWZ 0.13 or earlier

GWZ 0.14 has one merge implementation. A merge record written by 0.13.x or earlier is not a merge this build can act on, and it is not nothing either: it occupies the merge slot, so every merge verb and every command that a merge blocks refuses with one sentence.

gwz: OpenOperation: this is a pre-0.14 merge; use gwz 0.13.0 (the last release before 0.14) to continue or abort

--status, --continue and --abort all refuse alike, so the usual open-merge advice does not apply here and is deliberately not printed. The whole remedy is the release named in the sentence: install a 0.13.x build, finish or abort the merge with it, then return to 0.14.

Before upgrading, close any merge you have open. Run gwz merge --status on 0.13.x and either gwz merge --continue or gwz merge --abort it. There is no conversion step and no upgrade path for an open record.

Merges that were already closed are unaffected. Archived records under the retention directory still project read-only through gwz merge --status <id>, and gwz merge --gc never deletes an archive it cannot read.

What happens when a merge starts

GWZ first preflights every selected repository before mutating any of them. It requires an attached, born target branch, a clean index and worktree, no unrelated Git sequencer state, a resolvable source ref, and shared source/target history.

It then:

  1. persists the frozen participant plan and exact pre-merge state;
  2. executes members in manifest order;
  3. continues past expected conflicts so independent later members can run;
  4. stops after an unexpected backend or host failure and marks later participants Unattempted;
  5. executes an explicitly selected workspace root last;
  6. verifies every recorded result; and
  7. publishes the updated lock and merge marker in one checked root composition commit.

The accepted workspace lock remains at its exact pre-merge baseline while the operation is open. Durable participant results, rather than a partially advanced lock, are the source of truth for status and recovery.

True merge commits use:

Merge '<source>' into '<target-branch>'

GWZ-Merge-ID: <merge-id>
GWZ-Operation-ID: <operation-id>

GWZ rejects unrelated histories; it does not implicitly enable Git's --allow-unrelated-histories.

Requiring fast-forwards everywhere

Use --ff-only when the entire selected workspace must advance without creating a merge commit:

gwz merge feature/refactor --ff-only

This is a selection-wide guarantee. GWZ completes preflight for every participant and rejects the whole operation before local mutation if any changing repository would need a true merge. Up-to-date repositories remain valid no-ops.

Resolving conflicts

An expected conflict remains in that participant's ordinary Git merge state. Edit the conflicted files normally, but stage them with gwz add. While a merge is open, gwz add accepts only repositories recorded as conflicted and rejects the entire add if the selection includes a clean or unrelated repository.

gwz merge --status
gwz add repos/app/src/config.rs
gwz merge --continue

Before each Git mutation, GWZ records the exact pending action. After a crash or lost connection, status classifies that action as not started, at the expected conflict, completed exactly, or ambiguous. Only exact states are adopted automatically.

When the workspace root participates

Root participation is opt-in because the root contains the manifest, lock, and composition history used to coordinate the workspace.

The root must already have a commit and must pass the same attached-branch and clean-state checks as a member. Its pre-merge manifest and lock are read from the recorded root commit, so recovery does not depend on the current files remaining parseable.

Consequently:

  • gwz merge --status, gwz add, gwz merge --continue, and gwz merge --abort still work when a root conflict has left the live manifest or lock with conflict markers;
  • finalization reloads merged root metadata only after the root merge succeeds;
  • selected member identities, paths, and source identities must still match the frozen operation; and
  • the root participant keeps its merge-result commit while GWZ verifies the later composition evidence internally during publication and recovery.

If root metadata attempts to redefine an in-flight member, finalization fails closed. The operation remains open and can be inspected or aborted using its durable pre-merge record.

Status and drift

gwz merge --status is strictly read-only. It reports:

  • the operation and publication states;
  • recorded before, source, result, and live commits;
  • conflict paths and pending-action state;
  • participant and operation drift;
  • continue and abort eligibility; and
  • the root merge result and the current publication step. Composition evidence is verified internally but is not currently a separate response field.

Post-merge work is never silently discarded. A branch switch, new commit, modified index, changed worktree, foreign Git operation, missing object, or changed publication artifact blocks unsafe recovery. Preserve or remove that work and restore the exact state named by status before retrying.

With no id, status inspects the open merge or reports Idle. Closed records can be inspected while they are retained:

gwz merge --status merge_20260725_1234

An id-qualified closed response is historical: it has open: false and does not inspect or reopen the repositories.

Coordinated abort

Abort preflights every participant and all root publication evidence before its first mutation. If any repository is unsafe to roll back, nothing is changed.

Rollback follows the reverse of execution:

  1. remove an incomplete composition-evidence commit, if present;
  2. restore the explicitly merged root to its pre-merge commit;
  3. restore members in reverse execution order;
  4. verify the exact baseline manifest and lock bytes; and
  5. close the operation as Aborted.

If abort itself is interrupted, rerun the same command. Durable rollback progress makes the operation restart-safe.

If abort refuses, if it reports success and the result still looks wrong, or if neither continue nor abort can close the operation, see the Merge Recovery Runbook.

Preserving work before abort

gwz merge --abort --preserve is an explicit, conservative escape hatch for work created after a participant merged successfully. It accepts only an attached recorded target branch, no unresolved entries, no active or foreign Git operation, and a live commit equal to or descended from the recorded merge result. A rewound or divergent branch, partial conflict resolution, branch switch, or ambiguous state rejects the whole preserve attempt before rollback. GWZ verifies that every still-conflicted repository retains its original conflict index and conflict-marker file contents; edit or stage that work only after deciding to continue, or preserve it manually before aborting. If Git created a conflict but GWZ was interrupted before recording the original marker snapshot, --continue may still reconcile and resolve the conflict, but preserve-abort refuses to infer the original from later live bytes.

The root is also eligible when a member-only merge has already recorded its composition commit. Root work created before that composition evidence exists is rejected with manual-preservation guidance so candidate metadata cannot be mistaken for user work. Automatic root worktree preservation is likewise conservative when the composition commit created the root's first commit; committed descendants can still be retained through the reported backup ref.

Depending on what changed, GWZ creates:

  • refs/gwz/merge/<merge-id>/<member-id>/head for committed member work;
  • refs/gwz/merge/<merge-id>/root/head for committed root work; and
  • one coordinated stash_<merge-id> bundle for staged, unstaged, and untracked work. Ignored files are not included.

The operation enters Preserving before artifact creation. Every required artifact is verified and recorded before the existing reverse-order abort path can begin. If creation is interrupted, rerun the same preserve command; GWZ checks the recorded ref targets and stash object ids rather than duplicating them. Plain gwz merge --abort rejects an operation in Preserving, because it must not bypass artifact reconciliation or verification.

A mutating GWZ command holds the workspace mutation lock for its complete service call. Do not edit a selected repository or run a separate mutating Git command while that call is executing. The lock serializes cooperating GWZ commands, but it cannot freeze an editor or a raw filesystem/Git writer during native stash creation; such concurrent writes have the same unsupported race as running git stash while another process edits the checkout.

Preserved work is not reapplied automatically because it was created against the post-merge tree. The human, JSON, and JSONL responses report every ref, commit, stash id, and native stash object id. Inspect or branch from a backup ref with Git. Restore the coordinated stash deliberately after abort:

gwz stash apply stash_<merge-id>

This restore works for preservation rows owned by members and by @root. After applying and checking the recovered work, the same deterministic bundle can be dropped, including an explicit root selection:

gwz --target @root stash drop stash_<merge-id>

Retention and cleanup

GWZ keeps the latest 20 ordinary closed merge records for local diagnostics. Records that own preservation evidence are exempt from automatic retention.

# Apply ordinary retention only
gwz merge --gc

# Remove one retained record and its verified private backup refs
gwz merge --gc merge_20260725_1234

Explicit GC preflights every recorded ref before deleting any of them and refuses to run while a coordinated merge is open. A missing ref is accepted on retry, but a ref pointing at a different commit fails closed. After its refs are deleted, the archived merge record is removed. Successful GC output lists only preservation artifacts that remain, such as native stash object ids; it does not present deleted backup refs as recoverable.

GC never deletes native stashes or coordinated stash bundles. After recovering or intentionally abandoning the preserved changes, remove those separately:

gwz stash drop stash_<merge-id>

Machine output

Use --json for one structured response or --jsonl for lifecycle events followed by the terminal response:

gwz --target mem_app --target @root --jsonl merge feature/refactor
gwz --json merge --status
gwz --json merge --status merge_20260725_1234
gwz --json merge --gc merge_20260725_1234

Machine results identify root rows with target_id: "@root", path: ".", and target_kind: "Root". Errors retain the same structured target fields, so consumers do not need to extract repository identity from human text. Record-version failures likewise include record_context with the merge id, readable schema/version pair, required semantic wave when known, and legacy mode when applicable.

--no-ff

--no-ff always creates a merge commit, even where a fast-forward is possible. It is the counterpart to --ff-only; supplying both together is rejected. A --no-ff start publishes a two-parent integration commit. Every start writes the same coordinated merge record — ordinary, --ff-only, custom-message and --no-ff alike — so status, continue, abort and recovery behave identically whichever way the merge was started. A start on a filesystem that cannot support crash recovery still runs; see Crash recovery and filesystems.

Merge also rejects unrelated operation policies supplied explicitly: --sync, --jobs, --max-per-host, --progress-interval, --partial, and --force. Diagnostics name the option that must be removed. --remote is the exception: on merge it is not an operation policy at all, but the local clone family selector described below.

gwz branch --merge <source> remains a deprecated compatibility spelling. It constructs the same first-class merge request.

Merging from a local clone (--remote <name>)

gwz merge --remote <name> integrates work from another working copy of this workspace on the same machine — a member of the local clone family — instead of resolving a Git ref:

gwz merge --remote A
gwz merge --remote C lane/agent-17

With no ref, the source is the named member's HEAD commit, resolved independently for every paired participant. With a ref, that ref is resolved inside the named member, again per participant. Either way the receiving participant's current branch is the merge target, and everything after that — preflight, the coordinated record, conflicts, continue, abort, retention — is the ordinary merge lifecycle described above.

Before the engine runs, the source commit is fetched into each receiving repository under one fresh, retained ref, refs/gwz/local-imports/<transfer-id>, and that ref is what the engine merges; status output names it as the source. Import refs are kept after completion and after abort — see Local Clones. As with any merge, the root participates only when selected, so gwz --target @root merge --remote A is how the clone's root commits come across; without it they stay unpreserved, and gwz local dispose A says so.

On merge the name is family-only. A name that is not a ready family member is refused with unknown_local, whose message says which case it is — a name the index does not hold (or a reserved one), or a row that is creating or disposing rather than ready. It never falls back to a Git remote, so gwz merge --remote origin is a family miss rather than a fetch. The mirror image also holds: a bare gwz merge A resolves the Git ref A in each receiving repository and never means the family member A.

The selector is accepted only when starting a merge — not with --continue, --abort, --status or --gc — and family bindings are resolved from the index at operation time. They are never written into gwz.conf/gwz.yml and never become Git remotes.

Waiting for the family lock (--wait <secs>)

A family merge takes the family lock before it imports anything, and holds it across the import and the merge itself. While another family operation holds it — a gwz local clone, a gwz local dispose, a gwz local disband, or another family merge — the merge refuses immediately:

error: another local-family operation holds the family lock

That is the right answer for a person at a terminal and the wrong one for two unattended invocations fired for one request. --wait <secs> keeps retrying instead:

gwz merge --remote A --wait 120

GWZ retries the try-lock at a short fixed interval until the deadline; there is no blocking acquisition, so the wait stays portable and is bounded by the number you give. Only a busy lock is retried — a malformed index, an unsupported platform lock or an I/O failure is the answer, immediately. A wait that wins the lock rereads the family index before it resolves <name>, so a merge that waited behind a create or a dispose is answered by the family that operation left, not by the view it saw before the wait.

Omitting --wait, or passing --wait 0, is the unchanged behaviour: one attempt, then the refusal above.

--wait is accepted only together with --remote <name>. An ordinary merge takes no family lock, so gwz merge feature/x --wait 30 is refused rather than accepted and ignored. The same spelling and the same semantics are on gwz local clone, gwz local dispose and gwz local disband.

Crash recovery and filesystems

A merge runs on every filesystem. Crash recovery — GWZ's ability to prove on restart which artifacts an interrupted start had created — is a capability of the volume, decided once when the merge starts, not a gate in front of the command.

Where the volume can prove a durable filesystem identity, the merge records its artifacts as before and nothing is printed. Where it cannot, GWZ prints one warning on stderr and the merge continues without that recording:

warning: crash recovery is unsupported on btrfs (no durable filesystem identity). Merge will continue. Use --filesystem-strict to refuse.

The filesystem is named, or unknown when it cannot be named, and the parenthetical is exactly one of no durable filesystem identity, remote filesystem, or volatile filesystem. The merge itself is unchanged: same participants, same order, same verification, same published composition evidence. --continue and --abort of an open merge, and Ctrl-C of a running one, never depended on crash recovery and behave identically.

--filesystem-strict turns that warning back into a refusal, before any lease, record, or Git mutation. Use it where an unrecoverable interrupted merge is unacceptable. It is a start-only flag: supplying it with --continue, --abort, --status or --gc is rejected. There is no environment variable and no configuration key.

The bar is identity, not a filesystem name. On Linux a volume is above it when FS_IOC_GETFSUUID answers with a volume UUID and name_to_handle_at returns a persistent handle, which admits ext4, xfs and f2fs alike; btrfs is below it because it never publishes its UUID to the kernel's VFS, kernels before 6.9 have no such call at all, tmpfs and ramfs are refused as volatile because their contents do not survive power loss, and network mounts (NFS, SMB/CIFS, SSHFS and other FUSE mounts) are named as remote. On macOS the bar is a local APFS or HFS+ volume; on Windows, open-by-file-ID capability, a successful nonzero 128-bit identity query, a local volume GUID and required case/handle probes. The Windows filesystem name is diagnostic only.

Volumes without persistent file handles

Some volumes are below the bar and additionally cannot answer for a file across a rename — overlayfs mounted without nfs_export, and some FUSE mounts. The merge still runs there. Its record is written directly rather than through the checked artifact boundary: still staged, renamed, flushed and read back to verify its bytes, and still refusing to overwrite an existing record; what it cannot carry is the catalog, which such a volume cannot host at all.

There is no second warning. The one above gains a clause instead:

warning: crash recovery is unsupported on overlay (no durable filesystem identity). Merge will continue. Use --filesystem-strict to refuse. Selected-root and --preserve abort may refuse until the workspace is on a handle-capable volume.

That clause is the remaining limit, and it falls on abort rather than on start. A plain abort still clears the record — it touches no checked artifact. An abort that must re-verify one — a selected root's manifest and lock, a preservation bundle under --preserve, or the merge's published evidence — needs handles this volume does not have, and refuses. One escape works: copy the whole workspace onto a volume that proves handles (a local APFS or HFS+ volume on macOS; ext4, xfs or f2fs on Linux; a Windows volume proving persistent file IDs) and run gwz merge --abort there, adding --preserve if that was the door that refused.

A power loss mid-merge on such a volume is operator cleanup rather than something GWZ recovers: nothing on that volume can prove after a reboot which objects the interrupted attempt created.

--json, porcelain and --jsonl consumers read the decision from the response's crash_recovery object rather than from stderr. Its handles_ok field is false on such a volume, true on a below-bar volume whose handles work, and null above the bar; see Machine Output.