334955cf
Add user notification when sync creates merge commits
a73x 2026-03-21 16:21
dag::reconcile now returns (Oid, ReconcileOutcome) instead of just Oid, where ReconcileOutcome distinguishes AlreadyCurrent, LocalAhead, FastForward, and Merge. sync::reconcile_refs uses this to print descriptive messages like 'fast-forwarded' or 'merged' for each ref. Fixes: ebacad2f
diff --git a/src/dag.rs b/src/dag.rs index 9d19c19..b4091c9 100644 --- a/src/dag.rs +++ b/src/dag.rs @@ -165,7 +165,20 @@ pub fn walk_events(repo: &Repository, ref_name: &str) -> Result<Vec<(Oid, Event) Ok(events) } /// Reconcile a local ref with a remote ref. Returns the final tip OID. /// Outcome of reconciling a local ref with a remote ref. #[derive(Debug, Clone, PartialEq, Eq)] pub enum ReconcileOutcome { /// Both refs already point to the same commit. No action taken. AlreadyCurrent, /// Local is ahead of remote. No action taken. LocalAhead, /// Local was fast-forwarded to the remote tip. FastForward, /// A merge commit was created to reconcile divergent histories. Merge, } /// Reconcile a local ref with a remote ref. Returns the outcome and final tip OID. /// /// - If they're the same: no-op /// - If remote is ancestor of local: local is ahead, no-op @@ -177,25 +190,25 @@ pub fn reconcile( remote_ref: &str, merge_author: &crate::event::Author, signing_key: &ed25519_dalek::SigningKey, ) -> Result<Oid, Error> { ) -> Result<(Oid, ReconcileOutcome), Error> { let local_oid = repo.refname_to_id(local_ref)?; let remote_oid = repo.refname_to_id(remote_ref)?; if local_oid == remote_oid { return Ok(local_oid); return Ok((local_oid, ReconcileOutcome::AlreadyCurrent)); } let merge_base = repo.merge_base(local_oid, remote_oid)?; if merge_base == remote_oid { // Remote is ancestor of local — local is ahead return Ok(local_oid); return Ok((local_oid, ReconcileOutcome::LocalAhead)); } if merge_base == local_oid { // Local is ancestor of remote — fast-forward repo.reference(local_ref, remote_oid, true, "fast-forward reconcile")?; return Ok(remote_oid); return Ok((remote_oid, ReconcileOutcome::FastForward)); } // True fork — create merge commit with clock = max(local, remote) + 1 @@ -238,7 +251,7 @@ pub fn reconcile( &[&local_commit, &remote_commit], )?; Ok(oid) Ok((oid, ReconcileOutcome::Merge)) } /// Migrate a DAG ref so that every event with clock=0 gets a sequential clock diff --git a/src/sync.rs b/src/sync.rs index 22666f3..98a8d1d 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -523,7 +523,15 @@ fn reconcile_refs( let local_ref = format!("refs/collab/{}/{}", kind, id); if repo.refname_to_id(&local_ref).is_ok() { match dag::reconcile(repo, &local_ref, remote_ref, author, signing_key) { Ok(_) => println!(" Reconciled {} {:.8}", kind, id), Ok((_oid, outcome)) => { let action = match outcome { dag::ReconcileOutcome::AlreadyCurrent => "already current", dag::ReconcileOutcome::LocalAhead => "local ahead", dag::ReconcileOutcome::FastForward => "fast-forwarded", dag::ReconcileOutcome::Merge => "merged", }; println!(" Reconciled {} {:.8} ({})", kind, id, action); } Err(e) => eprintln!(" Failed to reconcile {} {:.8}: {}", kind, id, e), } } else { diff --git a/tests/collab_test.rs b/tests/collab_test.rs index 4f97d83..f67aa19 100644 --- a/tests/collab_test.rs +++ b/tests/collab_test.rs @@ -156,7 +156,8 @@ fn test_concurrent_comments_create_fork_and_reconcile() { repo.reference(&ref_name, alice_tip, true, "restore alice tip") .unwrap(); let merge_oid = dag::reconcile(&repo, &ref_name, &remote_ref, &alice(), &test_signing_key()).unwrap(); let (merge_oid, outcome) = dag::reconcile(&repo, &ref_name, &remote_ref, &alice(), &test_signing_key()).unwrap(); assert_eq!(outcome, dag::ReconcileOutcome::Merge); let merge_commit = repo.find_commit(merge_oid).unwrap(); assert_eq!(merge_commit.parent_count(), 2); @@ -263,8 +264,9 @@ fn test_fast_forward_reconcile() { repo.reference(&remote_ref, ahead_tip, true, "remote ahead") .unwrap(); let result = dag::reconcile(&repo, &ref_name, &remote_ref, &alice(), &test_signing_key()).unwrap(); let (result, outcome) = dag::reconcile(&repo, &ref_name, &remote_ref, &alice(), &test_signing_key()).unwrap(); assert_eq!(result, ahead_tip, "should fast-forward to remote tip"); assert_eq!(outcome, dag::ReconcileOutcome::FastForward); let events = dag::walk_events(&repo, &ref_name).unwrap(); assert_eq!(events.len(), 2); @@ -284,8 +286,9 @@ fn test_no_op_when_already_in_sync() { let remote_ref = format!("refs/collab/sync/origin/issues/{}", id); repo.reference(&remote_ref, tip, true, "same tip").unwrap(); let result = dag::reconcile(&repo, &ref_name, &remote_ref, &alice(), &test_signing_key()).unwrap(); let (result, outcome) = dag::reconcile(&repo, &ref_name, &remote_ref, &alice(), &test_signing_key()).unwrap(); assert_eq!(result, tip); assert_eq!(outcome, dag::ReconcileOutcome::AlreadyCurrent); } #[test] @@ -303,8 +306,9 @@ fn test_local_ahead_no_merge() { repo.reference(&remote_ref, root_oid, true, "remote behind") .unwrap(); let result = dag::reconcile(&repo, &ref_name, &remote_ref, &alice(), &test_signing_key()).unwrap(); let (result, outcome) = dag::reconcile(&repo, &ref_name, &remote_ref, &alice(), &test_signing_key()).unwrap(); assert_eq!(result, local_tip, "local should stay ahead"); assert_eq!(outcome, dag::ReconcileOutcome::LocalAhead); } // --------------------------------------------------------------------------- @@ -1399,3 +1403,96 @@ fn test_patch_show_json_output() { assert_eq!(value["reviews"].as_array().unwrap().len(), 1); } // --------------------------------------------------------------------------- // ReconcileOutcome tests // --------------------------------------------------------------------------- #[test] fn test_reconcile_outcome_merge_on_divergent_refs() { let tmp = TempDir::new().unwrap(); let repo = init_repo(tmp.path(), &alice()); let sk = test_signing_key(); let (ref_name, id) = open_issue(&repo, &alice(), "Divergent"); // Save root OID, then add a local comment let root_oid = repo.refname_to_id(&ref_name).unwrap(); add_comment(&repo, &ref_name, &alice(), "Local comment"); let local_tip = repo.refname_to_id(&ref_name).unwrap(); // Create a remote branch from root with a different comment let remote_ref = format!("refs/collab/sync/origin/issues/{}", id); repo.reference(&remote_ref, root_oid, true, "remote branch point") .unwrap(); add_comment(&repo, &remote_ref, &bob(), "Remote comment"); // Restore local tip repo.reference(&ref_name, local_tip, true, "restore local tip") .unwrap(); let (_oid, outcome) = dag::reconcile(&repo, &ref_name, &remote_ref, &alice(), &sk).unwrap(); assert_eq!(outcome, dag::ReconcileOutcome::Merge); } #[test] fn test_reconcile_outcome_fast_forward() { let tmp = TempDir::new().unwrap(); let repo = init_repo(tmp.path(), &alice()); let sk = test_signing_key(); let (ref_name, id) = open_issue(&repo, &alice(), "FF test"); let root_oid = repo.refname_to_id(&ref_name).unwrap(); // Remote is ahead: add a comment on the remote branch let remote_ref = format!("refs/collab/sync/origin/issues/{}", id); repo.reference(&remote_ref, root_oid, true, "remote start") .unwrap(); add_comment(&repo, &remote_ref, &bob(), "Remote only"); let remote_tip = repo.refname_to_id(&remote_ref).unwrap(); // Local stays at root repo.reference(&ref_name, root_oid, true, "reset local") .unwrap(); let (oid, outcome) = dag::reconcile(&repo, &ref_name, &remote_ref, &alice(), &sk).unwrap(); assert_eq!(outcome, dag::ReconcileOutcome::FastForward); assert_eq!(oid, remote_tip); } #[test] fn test_reconcile_outcome_already_current() { let tmp = TempDir::new().unwrap(); let repo = init_repo(tmp.path(), &alice()); let sk = test_signing_key(); let (ref_name, id) = open_issue(&repo, &alice(), "Same tip"); let tip = repo.refname_to_id(&ref_name).unwrap(); let remote_ref = format!("refs/collab/sync/origin/issues/{}", id); repo.reference(&remote_ref, tip, true, "same").unwrap(); let (oid, outcome) = dag::reconcile(&repo, &ref_name, &remote_ref, &alice(), &sk).unwrap(); assert_eq!(outcome, dag::ReconcileOutcome::AlreadyCurrent); assert_eq!(oid, tip); } #[test] fn test_reconcile_outcome_local_ahead() { let tmp = TempDir::new().unwrap(); let repo = init_repo(tmp.path(), &alice()); let sk = test_signing_key(); let (ref_name, id) = open_issue(&repo, &alice(), "Local ahead"); let root_oid = repo.refname_to_id(&ref_name).unwrap(); add_comment(&repo, &ref_name, &alice(), "Local only"); let local_tip = repo.refname_to_id(&ref_name).unwrap(); let remote_ref = format!("refs/collab/sync/origin/issues/{}", id); repo.reference(&remote_ref, root_oid, true, "remote behind") .unwrap(); let (oid, outcome) = dag::reconcile(&repo, &ref_name, &remote_ref, &alice(), &sk).unwrap(); assert_eq!(outcome, dag::ReconcileOutcome::LocalAhead); assert_eq!(oid, local_tip); } diff --git a/tests/crdt_test.rs b/tests/crdt_test.rs index 82000d5..4c64b91 100644 --- a/tests/crdt_test.rs +++ b/tests/crdt_test.rs @@ -204,7 +204,7 @@ fn reconcile_merge_clock_is_max_of_both_branches_plus_one() { dag::append_event(&repo, remote_ref, &comment, &sk).unwrap(); } let merge_oid = dag::reconcile(&repo, local_ref, remote_ref, &alice(), &sk).unwrap(); let (merge_oid, _) = dag::reconcile(&repo, local_ref, remote_ref, &alice(), &sk).unwrap(); // Remote max is 5, local max is 3, so merge should be 6 assert_eq!(read_event_clock(&repo, merge_oid), 6); }