Skip to content

DRAFT: .git-aware fast-forward conflict resolution (closes bidirectional repo-roam R3; G5-git-5 FF half) — agent-drafted, needs review#513

Draft
Jesssullivan wants to merge 2 commits into
mainfrom
feat/git-ff-resolution
Draft

DRAFT: .git-aware fast-forward conflict resolution (closes bidirectional repo-roam R3; G5-git-5 FF half) — agent-drafted, needs review#513
Jesssullivan wants to merge 2 commits into
mainfrom
feat/git-ff-resolution

Conversation

@Jesssullivan

Copy link
Copy Markdown
Owner

Stacked on #512 (cleanup of the tcfs-sync conflict/reconcile chokepoints). Review/merge #512 first; this branch was cut from origin/cleanup/tcfs-sync-conflict-dead-dry, so the diff against main includes #512's commit until that lands.

Agent-drafted, needs review.

What

Implements .git-aware fast-forward conflict resolution for raw git-dir sync, closing the bidirectional repo-roam R3 case (the common "work on honey, pick up on neo" handoff). This is the FF half of G5-git-5. Divergent keep-both (T10/T11) is intentionally left as a separate follow-up — for divergent .git states this PR leaves the Conflict as-is (fail-closed).

Why bidirectional .git roam failed

compare_clocks saw each device independently tick its vclock on .git/refs/heads/<branch> + .git/index + .git/logs, so concurrent edits classified as Conflict with no git-ancestry check to demote a fast-forward; and the Conflict arm of execute_plan only records, never resolves.

Design

  1. Reclassifier at the conflict boundary (reclassify_git_ff_conflicts in reconcile.rs), run as a post-classification pass — only when git_ff_resolution is set and git_sync_mode == "raw". Generic compare_clocks for normal files is untouched.
  2. For a conflicting .git path: find the enclosing repo root + the branch ref, read the local commit SHA (live repo) and the remote commit SHA (remote ref blob), and probe ancestry locally via git merge-base --is-ancestor (classify_fast_forward in git_safety.rs):
    • remote ancestor of local → Push (LocalNewer)
    • local ancestor of remote → Pull (RemoteNewer)
    • neither / equal-but-different / a needed object not yet local → stays Conflict (fail-closed / defer)
    • the repo's .git conflicts are resolved atomically toward one winner; a repo is never split half push / half pull.
  3. Ordering: in both execute_plan (sequential) and the concurrent new-remote pull fast path, .git/objects/** + packs apply before refs (.git/refs/**, packed-refs, HEAD) so a ref never advances to an object not yet present locally.
  4. TOCTOU: acquire_git_lock (previously zero callers) is now wired into execute_plan via an RAII GitLockGuard, holding a per-repo .git/tcfs.lock across the apply window.
  5. Config: git_sync_mode + git_ff_resolution threaded through ReconcileConfig; an encryption context threaded through reconcile() (needed to read remote ref blobs). Wired in the daemon + CLI reconcile paths (FF on when sync_git_dirs + raw).

Test

crates/tcfs-sync/tests/git_ff_resolution.rs:

  • FF converges: two devices share a repo at C0; B commits C1 (C0 ancestor of C1); B's reconcile pushes (LocalNewer, not Conflict), A's reconcile pulls to C1 (RemoteNewer); both end at C1, git fsck --full clean both sides.
  • Divergent stays Conflict: B@C1, A@C2 (neither an ancestor), genuinely concurrent vclocks → the .git/refs/heads/main conflict remains a Conflict and is never reclassified.

Gate (local)

  • cargo fmt --all -- --check: clean
  • cargo clippy --workspace --all-targets: only the 2 pre-existing needless_borrow warnings in tcfsd/src/grpc.rs; no new warnings
  • cargo test -p tcfs-sync (incl. new FF test) + cargo test -p tcfsd: all pass

🤖 Generated with Claude Code

…ints

Behavior-preserving cleanup of the conflict-resolution chokepoints ahead of
the (separate) .git-aware conflict-resolution feature. No sync/conflict
behavior changes.

- reconcile.rs classify_path: delete the stale dead comment block trailing the
  match (the case it describes is already handled by the (Some, None, Some)
  arm). Comment-only.
- reconcile.rs list_remote_index: use the existing DIR_MARKER_SUFFIX const
  instead of the byte-identical literal "/.tcfs_dir". Value-identical.
- conflict.rs compare_clocks: collapse the Some(Ordering::Equal) and None arms
  into one Some(Ordering::Equal) | None arm; their ConflictInfo construction was
  byte-identical, so both still produce the same Conflict outcome.
- reconcile.rs: extract the identical outcome -> ReconcileAction tail shared by
  compare_both_exist and compare_both_exist_symlink into one private helper
  outcome_to_action(). Pure code motion.
- git_safety.rs: delete the dead pub fn restore_git_from_bundle (zero callers;
  superseded by restore_git_bundle_into).

Deliberately excludes the rejected-unsafe items (the Conflict-arm/mark_conflict
swap, the (true,true)=>None exhaustiveness arm, the UpToDate-on-hash-failure
policy, and the refs/heads/*.lock literal) — those are feature-PR territory.

Refs the G5-git-5 concurrent-write corruption gap in
docs/ops/repo-roam-test-plan-2026-06-08.md.
Add a fast-forward reclassifier at the reconcile conflict boundary for raw
git-sync mode. When a repo's .git/* paths conflict purely because each device
ticked its own vclock, and the local/remote branch tips are in a strict
ancestor (fast-forward) relationship, reclassify the repo's .git conflicts
atomically toward the FF-ahead winner:

- remote tip ancestor of local -> Push (LocalNewer)
- local tip ancestor of remote -> Pull (RemoteNewer)
- divergent / equal-but-different / missing object -> stays Conflict (fail-closed)

Generic compare_clocks for normal files is untouched. The reclassifier only
engages when git_ff_resolution is set and git_sync_mode is "raw".

Ordering: in both the sequential execute_plan path and the concurrent
new-remote pull fast path, .git objects/packs now apply before refs
(.git/refs/**, packed-refs, HEAD) so a ref never advances to an object not
yet present locally.

TOCTOU: wire acquire_git_lock (previously zero callers) into execute_plan via
an RAII GitLockGuard, holding a per-repo .git/tcfs.lock across the apply
window so a commit mid-collection cannot tear the push.

Config: thread git_sync_mode + git_ff_resolution through ReconcileConfig and
an encryption context through reconcile() (needed to read remote ref blobs for
the ancestry probe); wired in the daemon and CLI reconcile paths.

New test git_ff_resolution.rs reproduces R3: FF converges (B pushes, A pulls,
both end at C1, fsck clean) and a divergent case stays Conflict.

Stacked on #512 (cleanup of the conflict/reconcile chokepoints).
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