Skip to content

fix(sync): stop bidirectional sync from clobbering local writes; add opt-in overwrite to move_page#48

Merged
aniongithub merged 4 commits into
mainfrom
fix/crud-issues
May 23, 2026
Merged

fix(sync): stop bidirectional sync from clobbering local writes; add opt-in overwrite to move_page#48
aniongithub merged 4 commits into
mainfrom
fix/crud-issues

Conversation

@aniongithub
Copy link
Copy Markdown
Owner

Fixes the data-loss bug where update_page / delete_page reported success but the change vanished on the next sync tick, plus adds the long-requested opt-in overwrite to move_page.

The bug

In bidirectional sync the loop was:

fetch → merge → copyToWiki → reindex → copyFromWiki → commit → push
                ^^^^^^^^^ clobbered local edits

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. 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.log where every reindex pass reported updated=N, unchanged=0 even with no real changes: every WriteFile was bumping mtimes and forcing a re-parse.

The fix

Commit-then-merge ordering. New flow:

copyFromWiki → add → commit (if any diff) → fetch → merge → copyToWiki → reindex → push

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:

  • copyFromWiki now mirrors deletions: clone-side files no longer present in the wiki are removed, so git add -A propagates the deletion.
  • Both copyToWiki and copyFromWiki skip writes for byte-identical files, eliminating the reindex churn.

move_page opt-in overwrite

Engine: MovePage(ctx, from, to, MoveOptions{Overwrite: true}). Without that flag, returns the new typed sentinel ErrDestinationExists.

MCP tool: move_page gains an optional overwrite: 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

Commit Summary
3cc2800 fix(sync): commit local changes before merging remote — headline data-loss fix
019f87e feat(wiki): opt-in overwrite for move_page
594b12a test(sync): wiki API roundtrip through bidirectional sync

Test coverage

All three new sync tests fail on pre-fix main and pass on this branch.

  • internal/sync: +3
    • TestLocalUpdateSurvivesBidirectionalSync — raw FS write survives a sync tick.
    • TestLocalDeleteSurvivesBidirectionalSync — raw FS delete propagates instead of being undone.
    • TestWikiUpdateAndDeleteThroughSync — same scenario but through wiki.UpdatePage / wiki.DeletePage with a real reindexer.
  • internal/wiki: existing tests retained, +1 (TestMovePageOverwriteReplacesDestination), and TestMovePageFailsWhenDestinationExists strengthened to assert the typed error via errors.Is.
  • internal/mcp: +2
    • TestMovePageDestinationExistsIsRecoverable — verifies the agent gets an actionable message naming overwrite=true.
    • TestMovePageOverwriteSucceeds — exercises the reject-then-retry-with-confirmation flow.

Verification

Run inside the project's devcontainer:

  • go test ./... — green
  • go test -race ./... — race-clean
  • go vet ./... — clean
  • go build -o /tmp/mind-map ./cmd/mind-map + npm run build (webui) — both succeed

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.
@aniongithub aniongithub merged commit 2eddb14 into main May 23, 2026
1 check passed
@aniongithub aniongithub deleted the fix/crud-issues branch May 23, 2026 20:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant