Skip to content

Sync Conflict Without an Ancestor

Adityo Guni Waluyo

When both sides change after the last sync, the pages mirror refuses to guess. One hash, one authority, alarm-first by design.

TL;DR

The sync classifier checks normalized body hashes against the last push to decide what changed. On this one-way setup, if both the repo and Plane edits diverge, it flags plane-changed and refuses to pull, keeping the repo as source of truth. You resolve it by pushing from the repo again, which resets the baseline and returns the status to clean.

I ran the status scan on my Plane pages mirror and one row came back red. Not repo-changed, not clean. It said plane-changed with a note underneath: git wins, pull is refused.

That was odd because both sides had clearly moved. I had edited the markdown in the repo after the last sync, and someone had edited the same page title and body inside Plane. I stared at the table for a second and thought the tool was just being cautious. My first guess was that it should try to merge both changes, or at least pull the Plane version and let me sort it out locally.

It does neither. That is intentional.

In pagesync.py, the function classify_page_status is deliberately refusing to be clever. On a one-way sync path, a state where both sides changed collapses into the plane-changed alarm. The repo is treated as the source of truth, the pull is refused, and the way to converge is to push again from the repo. The commit that introduced this is 74d9bb4 with the message feat(doc-sync): pages status classifier pagesync.py classify_page_status. The logic is small on purpose, and the reason behind it only made sense to me after I compared it to how git and rsync handle the same problem.

Six states from one comparison

The classifier does not look at names or timestamps to decide. It computes a sha256 of the normalized body on each side and compares that against what it remembered from the last successful sync. That memory is a small dict called last_sync, with two fields: repo_sha256 and plane_sha256. Together they act as a stand-in for a common ancestor that does not otherwise exist between Plane and git.

From those three hashes, current repo, current Plane, last sync, it returns one of six outcomes.

If both current hashes match the remembered ones, it is clean. If only the repo side moved, repo-changed and a push is safe. If only the Plane side moved, plane-changed. If both moved and the sync were two-way, it would be both-changed, but on the one-way path I use, that fourth case is folded into plane-changed and surfaced as an alarm. The fifth outcome is blocked, which covers pages that are locked or archived in Plane, pages where a marker has drifted, or pages that fall outside the configured subset. Then there are the edge cases around existence. A missing-page where Plane returns 403 or 404, and an orphan-page where a Plane page exists with no repo counterpart. The orphan case also carries a tier delta, so the scan can tell me whether the orphan is in the synced tier or in a different one.

When I first saw the table, I expected both-changed to appear as its own row. It never did, and that was the point I had missed. One-way sync has no merge step, so exposing a two-way conflict state would suggest an action that the tool will not take.

Why the hash is the only truth

It is tempting to use page names or updated_at timestamps. Plane updates those on every edit, and they are right there in the API response. But they are noisy. A rename without a body change, a touch by an automation, or a clock skew would all look like content changes.

The classifier ignores them for the decision and keeps them only as diagnostics. The sole authority is the sha256 of the normalized body. Normalization matters here because Plane and markdown can differ in trailing newlines or whitespace in ways that do not change what the reader sees. By hashing after normalization, the comparison is about substance.

That leaves last_sync as the only memory. Without it, every scan would be a blind two-way diff with no past. With it, the tool can distinguish did only one side move since we last agreed from did both sides move since we last agreed. That distinction is exactly what git gets for free from its history, and what this sync has to reconstruct by hand.

Borrowing git's stage 1 without git

Git merge is explicit about needing three versions. Pro Git describes it as stage 1 the common ancestor, stage 2 ours, and stage 3 theirs [1]. The git-merge documentation is built around the same idea, that a merge finds a common base and then tries to combine changes, stopping when a conflict cannot be resolved automatically [2]. If there is no ancestor, git will not pretend there is one. It stops and asks a person to decide.

My sync has the same shape but without a shared graph. The repo has its history, Plane has its own revisions, and there is no commit that belongs to both. So last_sync is saved at push time to serve as stage 1. At scan time the classifier has stage 2 in the repo hash and stage 3 in the Plane hash. If stage 2 and stage 3 both diverged from stage 1, that is a conflict. On a one-way path, the safe default is not to auto-merge and not to pull. Mark it as plane-changed, keep repo as winner, refuse the pull, and wait for a re-push to re-establish a shared base.

Reading rsync helped me get comfortable with this conservatism. By default rsync does a quick check on size and mtime before it decides to transfer, and with -c it reads and checksums everything [3]. It is a performance trade-off, not a correctness one. The decision about what to do is separate from the check. In the pages sync, the sha256 is always the check, there is no quick path, and the decision is deliberately narrow. Names and timestamps can be shown in the table to help me orient, but they never flip the outcome.

Once I understood that, the red row stopped looking like a bug. Both sides had moved since the last push, so there was no ancestor to trust for an automatic merge. The classifier did the only honest thing it could do. It told me the Plane side had diverged, reminded me that this path is repo-wins, and left the history alone until I push again. That push writes a new last_sync and the next scan returns to clean.

I kept the behavior as it was in 74d9bb4. I had considered teaching both-changed to appear on the one-way path as a separate label, but it would be a label without an action. Collapsing it into the plane-changed alarm keeps the operation honest. One direction, one authority, one hash to compare, and a person in the loop when the past has split.

Sources: [1] https://git-scm.com/book/en/v2/Git-Tools-Advanced-Merging [2] https://git-scm.com/docs/git-merge [3] https://download.samba.org/pub/rsync/rsync.1

Related articles