Skip to content

[#86] Sync → Refuse to overwrite local edits made mid sync#111

Merged
revett merged 2 commits into
mainfrom
revett/fix/86
Jul 21, 2026
Merged

[#86] Sync → Refuse to overwrite local edits made mid sync#111
revett merged 2 commits into
mainfrom
revett/fix/86

Conversation

@revett

@revett revett commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Resolves #86

Problem

  • A sync plans its actions from a snapshot taken at the start of the pass but executes them later, so the vault can change in the window between
  • A pull or pullDelete wrote over or deleted the local file without re checking it, silently discarding any edit made in that window, exactly the loss the conflict copy machinery exists to prevent
  • The same hole existed when restoring a remote edit onto a locally deleted path that the user had since recreated

Changes

  • Every destructive local write now re reads the file just before acting and verifies it still matches the snapshot the plan was made from
  • A file that drifted fails that action with changed locally mid sync; sync again to reconcile, the pass fails, and state.json never advances
  • The next pass sees the path changed on both sides and routes it through the existing conflict copy machinery, so the edit survives as a conflicted copy
  • Added regression tests at the execute level plus an end to end test reproducing the mid sync edit and the retry that preserves it

Why

  • No edit is ever silently lost is the core promise of the plugin; overwriting a fresh edit without a copy breaks it
  • Failing loudly and replanning on the next pass reuses the existing planner and conflict machinery rather than inventing a second reconciliation path at execution time
  • The unguardable race shrinks from the whole plan execution to the instant between the check and the write

Greptile Summary

This PR closes the silent-data-loss window in #86: a sync pass snapshot was taken once at the start but destructive local writes (pull, pullDelete, and conflict restores onto locally-deleted paths) executed later without re-verifying the file hadn't changed in the interim. The fix adds checkLocalDrift — a pre-write guard that re-reads and hashes the live file, failing the action when the content no longer matches the snapshot baseline, so the next pass sees both sides changed and routes the path through the existing conflict-copy machinery instead of discarding the edit silently.

  • checkLocalDrift correctly distinguishes four states: absent (safe to proceed), unreadable-but-present (fail with the read's own error, never treat as absent), in snapshot with matching hash (safe), and anything else (DRIFT_MESSAGE); the previously flagged hole where a pullDelete could silently delete an unreadable file is closed.
  • Snapshot passed by reference from takeSnapshot into executeSyncPlan via sync.ts; the same stat-gated-hash baseline the planner uses now drives the drift check, so no extra hashing work is needed for files that haven't changed.
  • No drift check on conflict with deletedSide "none"/"remote" — intentionally omitted because those branches read and preserve whatever local content is currently present before overwriting, so no data can be lost even if the file was edited mid-sync.

Confidence Score: 5/5

Safe to merge; the change only adds conservative guards that fail actions rather than silently discard data, and the previously flagged read-error hole in pullDelete is explicitly tested and closed.

Every destructive local write now re-verifies the file against the snapshot baseline before proceeding. The logic correctly separates the four states (absent, unreadable, hash-matched, drifted), the E2E retry test confirms the conflict-copy machinery takes over on the next pass, and the specific read-error scenario that was flagged in a prior review now has a dedicated regression test.

No files require special attention.

Important Files Changed

Filename Overview
src/sync/execute.ts Core change: adds checkLocalDrift guard before every destructive local write (pull, pullDelete, conflict with deletedSide: "local"). Logic correctly distinguishes absent (safe), unreadable (fail with read error), unknown (DRIFT_MESSAGE), and hash-matched (safe) cases.
src/sync/execute.test.ts Comprehensive regression coverage: drift on pull, pullDelete, conflict restore; read-error-on-present-file is refused; passing snapshot hash matches proceed; previously-flagged pullDelete read-error hole is explicitly exercised.
src/vault/vault.ts Adds fileExists to the Reader interface and exports hashBytes, giving checkLocalDrift the two primitives it needs. fileExists separates "absent" from "unreadable" so read failures on present files are never treated as safe.
src/vault/obsidian.ts Implements fileExists via vault.getFileByPath(path) !== null, consistent with how readFile already resolves files; correctly returns false for paths outside the vault index.
src/sync/sync.ts One-line change: passes the freshly-taken local snapshot into executeSyncPlan so the drift check has a baseline to compare against.
src/sync/sync.test.ts Adds the end-to-end mid-sync-edit scenario: first pass refuses the clobbering pull and leaves state.json unadvanced; retry sees a genuine conflict and produces a conflict copy, preserving the edit.
src/sync/fake.ts Adds fileExists to fakeReader closing over the same mutable files object as readFile, so mid-sync mutations in tests are visible to both methods — correctly mirroring a live filesystem.
src/vault/vault.test.ts Updates inline fakeReader fixtures to satisfy the expanded Reader interface; no logic changes.

Reviews (2): Last reviewed commit: "Address" | Re-trigger Greptile

Comment thread src/sync/execute.ts Outdated
@revett
revett merged commit 3a005eb into main Jul 21, 2026
10 checks passed
@revett
revett deleted the revett/fix/86 branch July 21, 2026 20:31
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.

Local edits made after the snapshot are overwritten without a conflict copy

1 participant