a73x

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);
}