Skip to content

Merging and Conflict Resolution

This document is the canonical reference for how FlexVault merges changes and how conflicts are resolved. It is written in terms of fxv sync, but the same rules apply to any three-way merge, including cross-branch merges.

What sync does

fxv sync moves your workspace onto a newer published revision while keeping your in-progress local changes. It replays your local changes on top of the incoming revision.

Your draft and the incoming revision both descend from a common ancestor: the published revision your draft was based on. Because all three trees are known, FlexVault can compare them and combine the two sets of changes.

Throughout this document the examples use one consistent sync:

  • local main.100.12: your draft, based on published main.100.
  • incoming main.101: the newer published revision you are syncing to.
  • common ancestor main.100: the revision both sides diverged from.

File contents are labeled by the revision whose change produced them, so main.100 is the content as of the common ancestor, main.100.12 is content you changed locally, and main.101 is content changed in the incoming revision. Identical content on both sides shares a label. (absent) means the path does not exist (or was deleted), and (directory) means the path is a directory.

The three sides

  • local: your changes. Selected by fxv resolve --mine.
  • incoming: the changes you are merging in. Selected by fxv resolve --theirs.
  • common ancestor: the revision both sides diverged from, and the reference point the merge compares each side against.

The merge rule

FlexVault compares what local and incoming each did to a path, relative to the common ancestor. Directories are compared entry by entry, recursively, so two sides that change different files under the same directory never conflict. For any single path:

  1. Only one side changed it (the other still matches the common ancestor): take that change. No conflict.
  2. Both sides changed it the same way (including both leaving it unchanged, or both deleting it): take that result. No conflict.
  3. Both sides changed it differently: the path is in conflict. Your local result wins in your working files and the path is marked as conflicted, to be resolved as a whole. FlexVault never blends the two sides together.

Rule 3 covers the everyday conflicts: both editing the same file, both adding the same file with different content, or one editing a file while the other deletes it. In each, your local version is what you see until you resolve it.

File and directory transitions (uncommon)

Rarely, the two sides disagree about a path's shape: one turns a file into a directory (or a directory into a file), or one deletes a directory the other still has. The two shapes cannot be blended, so rule 3 still applies but "your local result" means the entire path as one unit: the file, or the whole directory with everything inside it, or its absence. --theirs likewise swaps in the incoming side's entire shape.

Most users will never encounter these. They are catalogued in the appendix.

Resolving a conflict

For each conflicted path:

  • fxv resolve --mine <path> keeps the local result for the whole path.
  • fxv resolve --theirs <path> replaces the whole path with the incoming result.
  • fxv resolve --undo <path> reverts a previous resolution, restoring the conflicted state.

--all resolves every conflicted path at once. Resolving a path clears its conflicted mark.

Conflicts do not stop a sync; they are recorded on the resulting revision. A revision with any conflicted path cannot be published until every conflict is resolved.

Notation

Each example is a table with columns local, incoming, common ancestor, the state after sync, and the two resolution outcomes. A conflicted result is marked (conflicted).

Most examples use ordinary names (a.txt, dir1/c.txt). Paths that change between a file and a directory are named swap (or swap1, swap2) to flag them as the confusing appendix cases.


Cases with no conflict

These are rules 1 and 2: one side changed a path, or both changed it identically.

Disjoint changes (you edited a.txt; incoming edited b.txt):

Path local incoming common ancestor after sync
a.txt main.100.12 main.100 main.100 main.100.12
b.txt main.100 main.101 main.100 main.101

Same change on both sides (both edited a.txt identically, both deleted b.txt):

Path local incoming common ancestor after sync
a.txt main.101 main.101 main.100 main.101
b.txt (absent) (absent) main.100 (absent)

Both keep a directory (you edited dir1/a.txt; incoming edited dir1/b.txt): the comparison recurses, and each child is a one-sided change, so nothing conflicts.

Path local incoming common ancestor after sync
dir1/a.txt main.100.12 main.100 main.100 main.100.12
dir1/b.txt main.100 main.101 main.100 main.101

Cases with a conflict

These are rule 3: both sides changed the same path differently, so local's result wins and the path is conflicted until resolved.

Both edited a file (edit vs edit):

Path local incoming common ancestor after sync --mine --theirs
a.txt main.100.12 main.101 main.100 main.100.12 (conflicted) main.100.12 main.101

Both added a file (add vs add):

Path local incoming common ancestor after sync --mine --theirs
a.txt main.100.12 main.101 (absent) main.100.12 (conflicted) main.100.12 main.101

One edited, one deleted (either direction; local's result wins):

Path local incoming common ancestor after sync --mine --theirs
a.txt main.100.12 (absent) main.100 main.100.12 (conflicted) main.100.12 (absent)
b.txt (absent) main.101 main.100 (absent) (conflicted) (absent) main.101

In both directions the local outcome wins in your working files, but the path stays conflicted so the disagreement is surfaced before publishing.


Summary

Situation After sync Conflicted?
Only one side changed a path that change no
Both sides made the same change that change no
Both changed the same path differently local result yes
Both kept a path as a directory recurse into contents no (at this path)

fxv resolve --mine takes the whole local result; fxv resolve --theirs takes the whole incoming result. Publishing is blocked until no conflicted paths remain.


Appendix: file and directory transitions

These are the uncommon cases from the merge rule: the two sides disagree about whether a path is a file, a directory, or absent. Rule 3 applies, but the path is resolved as a single unit; the contents of a directory are never merged against a file. The examples name the contested path swap.

A file became a directory

Locally you replaced the file swap with a directory containing swap/x; the incoming revision edited the file.

Path local incoming common ancestor after sync --mine --theirs
swap (directory) main.101 main.100 (directory) (conflicted) (directory) main.101
swap/x main.100.12 (absent) (absent) main.100.12 main.100.12 (absent)

--mine keeps the directory swap/ with swap/x; --theirs restores the single file swap.

A directory became a file

Locally you replaced the directory swap/ with a file; the incoming revision edited a file inside it.

Path local incoming common ancestor after sync --mine --theirs
swap main.100.12 (directory) (directory) main.100.12 (conflicted) main.100.12 (directory)
swap/x (absent) main.101 main.100 (absent) (absent) main.101

--mine keeps the file swap; --theirs restores the directory swap/ with swap/x at its incoming content.

Add a file vs add a directory

Neither side had swap; one side adds it as a file, the other as a directory.

Path local incoming common ancestor after sync --mine --theirs
swap (directory) main.101 (absent) (directory) (conflicted) (directory) main.101
swap/x main.100.12 (absent) (absent) main.100.12 main.100.12 (absent)

The local shape wins pending resolution. If the local side added the file and the incoming side the directory, the roles reverse: the local file wins, and --theirs restores the directory.

A winning directory keeps all of its contents

Because a conflicted directory is one unit, files you did not change are kept along with it. Here you left swap/a alone and added swap/b; the incoming revision replaced the directory with a file.

Path local incoming common ancestor after sync --mine --theirs
swap (directory) main.101 (directory) (directory) (conflicted) (directory) main.101
swap/a main.100 (absent) main.100 main.100 main.100 (absent)
swap/b main.100.12 (absent) (absent) main.100.12 main.100.12 (absent)

--mine keeps swap/ with both files, even though swap/a was unchanged; --theirs replaces the whole directory with the incoming file.

A directory edited on one side, deleted on the other

This is not a transition and not a whole-directory conflict: a deleted directory is just its files being deleted, so it is compared file by file. A file you edited becomes an ordinary edit vs delete conflict; files you did not touch simply follow the deletion.

You edited dir1/c.txt (leaving dir1/keep.txt alone); the incoming revision deleted the whole dir1/.

Path local incoming common ancestor after sync --mine --theirs
dir1/c.txt main.100.12 (absent) main.100 main.100.12 (conflicted) main.100.12 (absent)
dir1/keep.txt main.100 (absent) main.100 (absent) (absent) (absent)

Only dir1/c.txt is conflicted; dir1/keep.txt was untouched locally, so it follows the incoming deletion. dir1 survives only because it still holds the conflicted c.txt. The mirror (you delete the directory, the incoming revision edits inside) keeps the deletion of the untouched files and marks the edited one conflicted.

Delete vs type change

You deleted a path that the other side changed the shape of. Local's result (the deletion) wins, and --theirs restores the incoming side's whole shape.

You deleted a file; incoming turned it into a directory:

Path local incoming common ancestor after sync --mine --theirs
swap (absent) (directory) main.100 (absent) (conflicted) (absent) (directory)
swap/x (absent) main.101 (absent) (absent) (absent) main.101

You deleted a directory; incoming replaced it with a file:

Path local incoming common ancestor after sync --mine --theirs
swap (absent) main.101 (directory) (absent) (conflicted) (absent) main.101
swap/x (absent) (absent) main.100 (absent) (absent) (absent)

The mirror cases (local changes the shape, incoming deletes) resolve the same way with the roles swapped: local's new shape wins, and --theirs yields the deletion.