Skip to content

GitSync: two-way conflict resolution (newest-writer-wins) + force override#468

Merged
rbuergi merged 4 commits into
mainfrom
feat/gitsync-two-way
Jul 14, 2026
Merged

GitSync: two-way conflict resolution (newest-writer-wins) + force override#468
rbuergi merged 4 commits into
mainfrom
feat/gitsync-two-way

Conversation

@rbuergi

@rbuergi rbuergi commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Two-way GitHub sync (newest-writer-wins) + force override

Why

Update to latest (GitHub → mesh) was strictly git-first: it overwrote and pruned the live
Space from the repo, silently discarding edits made on the server between syncs. This is exactly
what clobbered a Space's locally-edited nodes on the next automatic sync — a node edited on the
server was overwritten by the older repo copy instead of being carried back to GitHub.

What

A per-source Two-way setting changes the import conflict rule to newest-writer-wins per node:

  • A node whose live LastModified is newer than the last recorded sync (LastSyncedAt) is
    preserved on update — neither overwritten nor pruned — so it flows back to GitHub on the next
    Commit ("newer on the server wins").
  • Nodes the server hasn't touched since the last sync still update from the repo as before.
  • Only active once a first sync has recorded a baseline (the initial import stays git-first).

Force is the escape hatch: git_hub_sync(op:"update", force:true) ignores two-way and re-applies
the repo state regardless — and now also bypasses the content-fingerprint short-circuit, so it
discards local edits even when the repo itself is unchanged.

Changes

  • GitHubSyncConfig.TwoWay (auto-renders in the GitHub Sync settings editor); SaveConfig gains twoWay.
  • StaticRepoImporter: new ImportConflictPolicy threaded through ImportSource/Import/Run;
    preserve on overwrite and on prune; force bypasses the Succeeded-fingerprint skip.
    Boot / built-in static-repo imports pass no policy → git-first behavior is unchanged.
  • Also hardened the import-marker read to use the tolerant ContentAs<ActivityLog> — a queried
    node's Content is a JsonElement, so the previous is ActivityLog {…} pattern silently
    mis-fired and re-imported every time.
  • ReimportAtCommit / PullRequestService.UpdateToLatest / GitHubActivityExtensions / MCP
    git_hub_sync gain a force flag.

Tests

  • ImportConflictPolicyTest (6 unit tests — the pure decision).
  • TwoWaySyncTest (integration — two-way keeps a server addition on update; force discards it).
  • GitHubExportImportTest still 4/4 (git-first still prunes — no regression).

🤖 Generated with Claude Code

rbuergi and others added 2 commits July 14, 2026 07:20
…rride

By default an 'Update to latest' is git-first: it overwrites and prunes the live node from the
repo, silently losing edits made on the server between syncs (this is what clobbered a Space's
locally-edited nodes on the next auto-sync). Add a per-source Two-way setting: on import, a node
changed on the server since the last sync (LastModified > LastSyncedAt) is PRESERVED — neither
overwritten nor pruned — so it is carried back to GitHub on the next commit ('newer on the server
wins'). A forced update overrides this and re-applies the repo state regardless (and bypasses the
content-fingerprint short-circuit, so it discards local edits even when the repo is unchanged).

- GitHubSyncConfig.TwoWay (auto-renders in the settings editor); SaveConfig gains twoWay.
- StaticRepoImporter: ImportConflictPolicy threaded through ImportSource/Import/Run; preserve on
  overwrite + on prune; force bypasses the Succeeded-fingerprint skip. Boot/built-in imports pass
  no policy -> unchanged git-first behavior. Also read the import marker via the tolerant
  ContentAs<ActivityLog> (a queried node's Content is a JsonElement, not the typed record — a raw
  'is ActivityLog' pattern silently mis-fires and re-imports every time).
- GitHubSyncService.ReimportAtCommit / PullRequestService.UpdateToLatest /
  GitHubActivityExtensions / MCP git_hub_sync gain a force flag.
- Doc: Two-way section in GitHubSync.md. Tests: ImportConflictPolicyTest (unit), TwoWaySyncTest.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR updates the GitSync “Update to latest” (GitHub → mesh) workflow to support optional two-way conflict resolution (“newest-writer-wins” per node) so server-side edits made between syncs are preserved instead of being overwritten/pruned, and adds a force override to deliberately re-apply repo state even when unchanged.

Changes:

  • Introduces GitHubSyncConfig.TwoWay and threads a new ImportConflictPolicy through the GitHub import path to preserve server-newer nodes on overwrite and prune.
  • Adds a force flag to update/reimport APIs and the MCP git_hub_sync tool, including bypassing the fingerprint short-circuit when forced.
  • Adds unit + integration tests for the decision policy and the two-way/force behavior, plus documentation updates (Architecture + What’s New).

Reviewed changes

Copilot reviewed 10 out of 10 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
test/MeshWeaver.Graph.Test/ImportConflictPolicyTest.cs Unit tests for the pure two-way/force preserve decision.
test/MeshWeaver.GitSync.Test/TwoWaySyncTest.cs Integration test covering two-way update preserving server additions and force discarding them.
src/MeshWeaver.Mcp/McpMeshPlugin.cs Adds force parameter to the MCP GitHub sync tool and passes it to update.
src/MeshWeaver.Graph/StaticRepoImporter.cs Implements per-node preserve-on-overwrite and preserve-on-prune via ImportConflictPolicy; hardens activity-marker typing; force bypasses fingerprint skip.
src/MeshWeaver.GitSync/PullRequestService.cs Threads force into “Update to latest” execution and logging.
src/MeshWeaver.GitSync/GitHubSyncService.cs Builds import conflict policy from config (TwoWay, LastSyncedAt) and threads force through reimport/import pipeline.
src/MeshWeaver.GitSync/GitHubSyncConfig.cs Adds TwoWay config flag and documents behavior.
src/MeshWeaver.GitSync/GitHubActivityExtensions.cs Adds force plumbing and user-facing activity messaging/title changes for force-update.
src/MeshWeaver.Documentation/Data/WhatsNew/2026-07-14-gitsync-two-way.md What’s New entry describing two-way + force behavior.
src/MeshWeaver.Documentation/Data/Architecture/GitHubSync.md Architecture documentation for two-way conflict rule and force update.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 363 to 367
logger?.LogInformation("Re-importing {Space} at {Ref} (twoWay={TwoWay}, force={Force})",
spacePath, commitish, config.TwoWay, force);
return FetchAndImport(repoUrl, commitish, config.Subdirectory, token, spacePath,
SyncIgnore.For(config), progress)
SyncIgnore.For(config), progress, policy)
.SelectMany(x => RecordLastSync(spacePath, x.CommitSha, sourceId).Select(_ => x.Result));

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — this was a real bug, fixed in 4a1e3d3. StaticRepoImportResult now carries a Preserved count and ReimportAtCommit skips advancing the last-sync baseline (RecordLastSync) when Preserved > 0: the mesh is ahead of the repo until those edits are committed back, so the baseline stays put and the next update still sees them as newer-on-server.

Comment on lines +41 to +46
// Two-way update to the branch HEAD: the server-added node is KEPT — not pruned as a
// repo-absent extra ("newer on the server wins; commit to carry it back").
await Sync.ReimportAtCommit(a, "main", UserId).Timeout(90.Seconds()).ToTask();
Assert.NotNull(await WaitForNode($"{a}/ServerOnly"));
Assert.NotNull(await WaitForNode($"{a}/Welcome"));

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 4a1e3d3 — TwoWaySyncTest now runs a second non-forced update before the forced one and asserts the server-only node still survives, which is exactly the regression guard for the baseline-advance bug above.

@github-actions

github-actions Bot commented Jul 14, 2026

Copy link
Copy Markdown

Test Results (shard 3)

 12 files  ±0   12 suites  ±0   3m 18s ⏱️ -48s
874 tests +1  874 ✅ +1  0 💤 ±0  0 ❌ ±0 
922 runs  +1  922 ✅ +1  0 💤 ±0  0 ❌ ±0 

Results for commit 4a1e3d3. ± Comparison against base commit d01fdcc.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Jul 14, 2026

Copy link
Copy Markdown

Test Results (shard 0)

889 tests  ±0   880 ✅ +1   4m 19s ⏱️ -34s
  4 suites ±0     9 💤 ±0 
  4 files   ±0     0 ❌  - 1 

Results for commit 4a1e3d3. ± Comparison against base commit d01fdcc.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Jul 14, 2026

Copy link
Copy Markdown

Test Results (shard 5)

1 253 tests  +1   1 252 ✅ +1   4m 56s ⏱️ -22s
   13 suites ±0       1 💤 ±0 
   13 files   ±0       0 ❌ ±0 

Results for commit 4a1e3d3. ± Comparison against base commit d01fdcc.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Jul 14, 2026

Copy link
Copy Markdown

Test Results (shard 2)

1 254 tests  +6   1 151 ✅ +6   5m 0s ⏱️ +15s
   11 suites ±0     103 💤 ±0 
   11 files   ±0       0 ❌ ±0 

Results for commit 4a1e3d3. ± Comparison against base commit d01fdcc.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Jul 14, 2026

Copy link
Copy Markdown

Test Results (shard 4)

1 649 tests  ±0   1 647 ✅ ±0   5m 12s ⏱️ -11s
   12 suites ±0       2 💤 ±0 
   12 files   ±0       0 ❌ ±0 

Results for commit 4a1e3d3. ± Comparison against base commit d01fdcc.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Jul 14, 2026

Copy link
Copy Markdown

Test Results (shard 1)

813 tests  +317   626 ✅ +316   5m 1s ⏱️ + 3m 29s
 10 suites +  1   187 💤 +  1 
 10 files   +  1     0 ❌ ±  0 

Results for commit 4a1e3d3. ± Comparison against base commit d01fdcc.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Jul 14, 2026

Copy link
Copy Markdown

Test Results

   62 files  +  1     62 suites  +1   27m 48s ⏱️ + 1m 48s
6 732 tests +325  6 430 ✅ +325  302 💤 +1  0 ❌  - 1 
6 780 runs  +325  6 478 ✅ +325  302 💤 +1  0 ❌  - 1 

Results for commit 4a1e3d3. ± Comparison against base commit d01fdcc.

♻️ This comment has been updated with latest results.

rbuergi and others added 2 commits July 14, 2026 08:06
…rection)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…e preserved

Addresses the Copilot review: ReimportAtCommit advanced LastSyncedAt after every import, so a
two-way update that preserved a server-newer node moved the baseline past it — the NEXT update no
longer saw it as newer-on-server and overwrote it (preservation lasted a single cycle). Surface a
Preserved count on StaticRepoImportResult and skip the last-sync record when Preserved > 0 (the mesh
is ahead of the repo until those edits are committed back, which advances the baseline). Extend
TwoWaySyncTest with a second non-forced update asserting the server node still survives.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@rbuergi rbuergi merged commit 895960b into main Jul 14, 2026
21 of 23 checks passed
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.

2 participants