fix(sync): stop bidirectional sync from clobbering local writes; add opt-in overwrite to move_page#48
Merged
Merged
Conversation
Bidirectional sync was clobbering local writes that landed between two
sync ticks. The old flow was:
fetch -> merge origin -> copyToWiki -> reindex -> copyFromWiki -> commit -> push
copyToWiki ran *before* copyFromWiki, so any wiki file the user had just
written (via update_page, delete_page, etc.) was overwritten with the
stale shadow-clone copy before we ever had a chance to commit and push
it. update_page would report success on the API but the change vanished
on the next 30s tick.
New flow is commit-then-merge:
copyFromWiki -> add -> commit (if any diff) -> fetch -> merge ->
copyToWiki -> reindex -> push
Now local edits are part of HEAD before the merge runs, so git's 3-way
merge does the right thing: local-only changes survive untouched, and
genuine remote+local conflicts surface as conflict markers (and via the
Status.Conflicts list) instead of being silently overwritten.
Also:
- copyFromWiki now mirrors deletions: clone files that no longer exist
in the wiki are removed so 'git add -A' propagates the deletion.
- copyToWiki and copyFromWiki skip writes for byte-identical files,
which stops the reindex churn we were seeing in the host's
mind-map.log (every page reported updated=N, unchanged=0 every
30s tick because every WriteFile bumped mtime).
Adds TestLocalUpdateSurvivesBidirectionalSync and
TestLocalDeleteSurvivesBidirectionalSync to lock in the fix. Both fail
on the pre-fix code.
MovePage gains a MoveOptions{Overwrite bool} parameter; the destination-
exists case now returns a typed sentinel (ErrDestinationExists) so the
MCP tool can recognize it and steer the agent toward the right recovery
(ask the user, then retry with overwrite=true) instead of failing
opaquely.
wiki.MovePage(ctx, from, to, wiki.MoveOptions{Overwrite: true})
When overwriting:
- Destination's index entry (title, body, outgoing links) is dropped
before the rename, so we don't leave stale rowid references.
- os.Rename atomically replaces the file on POSIX.
- Backlinks pointing at the destination from other pages are kept —
they reflect [[dest]] text in other pages' source markdown, which
is unchanged.
MCP tool changes:
- move_page input gets an optional overwrite bool.
- Tool description tells the agent to ask the user before overwriting.
- On destination-exists, the error message explicitly names the
recovery path ('retry with overwrite=true') instead of looking like
a generic move failure.
- Success response notes when an overwrite happened.
SKILL.md and README document the new flag with the explicit 'ask the
user first' guidance.
Tests:
- TestMovePageFailsWhenDestinationExists now asserts the typed error
via errors.Is and verifies both source and destination are
untouched after the rejection.
- TestMovePageOverwriteReplacesDestination covers the engine-level
overwrite path (content lands at dest, source gone, exactly one
indexed copy).
- TestMovePageDestinationExistsIsRecoverable and
TestMovePageOverwriteSucceeds cover the MCP tool's
reject-then-retry-with-confirmation flow.
Adds TestWikiUpdateAndDeleteThroughSync, which drives the exact
scenario the user reported: an agent calls wiki.UpdatePage /
wiki.DeletePage, the API returns success, then a sync tick fires.
Verifies the change survives on disk, in the wiki's index, AND
propagates to the remote.
The two earlier repro tests (TestLocal{Update,Delete}SurvivesBidirectionalSync)
already cover the file-level race; this one closes the loop by going
through the wiki package's public API with a real reindexer wired up.
It would have caught the regression in the same way, but it also
demonstrates that the index and on-disk state stay consistent across
sync.
The 'direction' argument was already supported by the underlying sync
engine (SyncPull / SyncPush / SyncBidirectional) and documented in
SKILL.md, but the register_sync MCP tool only accepted prefix+remote.
Agents that read the skill and tried to set 'direction: pull' got a
silent fallback to bidirectional. Close the gap by plumbing direction
through:
SyncConfig.AddMapping(prefix, remote, direction)
sync.Manager.RegisterMapping(prefix, remote, direction)
mcp SyncRegistrar.RegisterMapping(prefix, remote, direction)
register_sync tool input adds optional 'direction' string
Empty/missing direction normalizes to bidirectional, preserving the
default. Re-registering the same prefix replaces both remote and
direction (useful for switching a mapping from bidirectional to
pull-only without editing config.json by hand). Typos like
direction='two-way' return an explicit 'invalid direction' error
rather than being silently normalized, so the agent has a clear
signal.
Tool description and SKILL.md are rewritten to show the three
direction values and when to use each.
Tests:
- config: TestAddMapping covers direction set/update/normalize.
- sync: TestRegisterMapping now exercises re-register with a
different direction and empty-direction normalization.
- mcp: four new tests covering bidirectional (default), pull,
push, and rejection of an invalid direction string.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes the data-loss bug where
update_page/delete_pagereported success but the change vanished on the next sync tick, plus adds the long-requested opt-in overwrite tomove_page.The bug
In bidirectional sync the loop was:
copyToWikiran beforecopyFromWiki, so any wiki file the user had just written (viaupdate_page,delete_page, etc.) was overwritten with the stale shadow-clone copy before we ever had a chance to commit and push it. The API call returned success, the change appeared to land, then ~30s later the sync tick silently undid it. Same mechanism for deletes — the deleted file was re-pulled from the clone.This is consistent with the symptom in the host's
mind-map.logwhere every reindex pass reportedupdated=N, unchanged=0even with no real changes: everyWriteFilewas bumping mtimes and forcing a re-parse.The fix
Commit-then-merge ordering. New flow:
Local edits are part of HEAD before the merge runs, so git's 3-way merge does the right thing: local-only changes survive untouched, and genuine remote+local conflicts surface as conflict markers (and via
Status.Conflicts) instead of being silently overwritten.Two related cleanups in the same commit:
copyFromWikinow mirrors deletions: clone-side files no longer present in the wiki are removed, sogit add -Apropagates the deletion.copyToWikiandcopyFromWikiskip writes for byte-identical files, eliminating the reindex churn.move_pageopt-in overwriteEngine:
MovePage(ctx, from, to, MoveOptions{Overwrite: true}). Without that flag, returns the new typed sentinelErrDestinationExists.MCP tool:
move_pagegains an optionaloverwrite: true. On destination-exists it returns an error message that explicitly tells the agent to ask the user first, then retry — not a generic failure that invites a retry loop. SKILL.md and README updated with the same guidance.When overwriting: destination's index entry is dropped before the rename so we don't leave stale rowid references; backlinks pointing at the destination are kept (they reflect
[[dest]]text in other pages' markdown, which is unchanged).Commits
3cc2800fix(sync): commit local changes before merging remote— headline data-loss fix019f87efeat(wiki): opt-in overwrite for move_page594b12atest(sync): wiki API roundtrip through bidirectional syncTest coverage
All three new sync tests fail on pre-fix
mainand pass on this branch.internal/sync: +3TestLocalUpdateSurvivesBidirectionalSync— raw FS write survives a sync tick.TestLocalDeleteSurvivesBidirectionalSync— raw FS delete propagates instead of being undone.TestWikiUpdateAndDeleteThroughSync— same scenario but throughwiki.UpdatePage/wiki.DeletePagewith a real reindexer.internal/wiki: existing tests retained, +1 (TestMovePageOverwriteReplacesDestination), andTestMovePageFailsWhenDestinationExistsstrengthened to assert the typed error viaerrors.Is.internal/mcp: +2TestMovePageDestinationExistsIsRecoverable— verifies the agent gets an actionable message namingoverwrite=true.TestMovePageOverwriteSucceeds— exercises the reject-then-retry-with-confirmation flow.Verification
Run inside the project's devcontainer:
go test ./...— greengo test -race ./...— race-cleango vet ./...— cleango build -o /tmp/mind-map ./cmd/mind-map+npm run build(webui) — both succeed