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 publishedmain.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:
- Only one side changed it (the other still matches the common ancestor): take that change. No conflict.
- Both sides changed it the same way (including both leaving it unchanged, or both deleting it): take that result. No conflict.
- 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.