a73x

8a4caa17

Refactor patch merge to use git's native merge path; dedup DAG tree-building

a73x   2026-03-22 08:21

Replace manual ref-update + reset(Hard) in patch merge with git's native
merge when HEAD is on the target branch. Fall back to plumbing for bare
ref updates when on a different branch. Extract build_event_tree helper
in dag.rs to consolidate 4 repeated tree-building blocks.

Closes issues 8ade92ed, 3a5a87af.

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

diff --git a/src/dag.rs b/src/dag.rs
index 9e7b577..e9e42be 100644
--- a/src/dag.rs
+++ b/src/dag.rs
@@ -11,6 +11,24 @@ const MANIFEST_JSON: &[u8] = br#"{"version":1,"format":"git-collab"}"#;
/// Maximum allowed size for an event.json blob (1 MB).
pub const MAX_EVENT_BLOB_SIZE: usize = 1_048_576;

/// Build an event tree containing event.json, signature, pubkey, and manifest.json blobs.
/// Returns the tree OID.
fn build_event_tree(
    repo: &Repository,
    event_blob: Oid,
    sig_blob: Oid,
    pubkey_blob: Oid,
    manifest_blob: Oid,
) -> Result<Oid, Error> {
    let mut tb = repo.treebuilder(None)?;
    tb.insert("event.json", event_blob, 0o100644)?;
    tb.insert("signature", sig_blob, 0o100644)?;
    tb.insert("pubkey", pubkey_blob, 0o100644)?;
    tb.insert("manifest.json", manifest_blob, 0o100644)?;
    let tree_oid = tb.write()?;
    Ok(tree_oid)
}

/// Walk the entire DAG reachable from `tip` and return the maximum clock value.
/// Returns 0 if the DAG is empty or all events have clock 0 (pre-migration).
pub fn max_clock(repo: &Repository, tip: Oid) -> Result<u64, Error> {
@@ -69,12 +87,7 @@ pub fn create_root_event(
    let pubkey_blob = repo.blob(detached.pubkey.as_bytes())?;
    let manifest_blob = repo.blob(MANIFEST_JSON)?;

    let mut tb = repo.treebuilder(None)?;
    tb.insert("event.json", event_blob, 0o100644)?;
    tb.insert("signature", sig_blob, 0o100644)?;
    tb.insert("pubkey", pubkey_blob, 0o100644)?;
    tb.insert("manifest.json", manifest_blob, 0o100644)?;
    let tree_oid = tb.write()?;
    let tree_oid = build_event_tree(repo, event_blob, sig_blob, pubkey_blob, manifest_blob)?;
    let tree = repo.find_tree(tree_oid)?;

    let sig = author_signature(&event.author)?;
@@ -106,12 +119,7 @@ pub fn append_event(
    let pubkey_blob = repo.blob(detached.pubkey.as_bytes())?;
    let manifest_blob = repo.blob(MANIFEST_JSON)?;

    let mut tb = repo.treebuilder(None)?;
    tb.insert("event.json", event_blob, 0o100644)?;
    tb.insert("signature", sig_blob, 0o100644)?;
    tb.insert("pubkey", pubkey_blob, 0o100644)?;
    tb.insert("manifest.json", manifest_blob, 0o100644)?;
    let tree_oid = tb.write()?;
    let tree_oid = build_event_tree(repo, event_blob, sig_blob, pubkey_blob, manifest_blob)?;
    let tree = repo.find_tree(tree_oid)?;

    let sig = author_signature(&event.author)?;
@@ -230,12 +238,7 @@ pub fn reconcile(
    let pubkey_blob = repo.blob(detached.pubkey.as_bytes())?;
    let manifest_blob = repo.blob(MANIFEST_JSON)?;

    let mut tb = repo.treebuilder(None)?;
    tb.insert("event.json", event_blob, 0o100644)?;
    tb.insert("signature", sig_blob, 0o100644)?;
    tb.insert("pubkey", pubkey_blob, 0o100644)?;
    tb.insert("manifest.json", manifest_blob, 0o100644)?;
    let tree_oid = tb.write()?;
    let tree_oid = build_event_tree(repo, event_blob, sig_blob, pubkey_blob, manifest_blob)?;
    let tree = repo.find_tree(tree_oid)?;

    let sig = author_signature(merge_author)?;
@@ -290,12 +293,7 @@ pub fn migrate_clocks(
        let pubkey_blob = repo.blob(detached.pubkey.as_bytes())?;
        let manifest_blob = repo.blob(MANIFEST_JSON)?;

        let mut tb = repo.treebuilder(None)?;
        tb.insert("event.json", event_blob, 0o100644)?;
        tb.insert("signature", sig_blob, 0o100644)?;
        tb.insert("pubkey", pubkey_blob, 0o100644)?;
        tb.insert("manifest.json", manifest_blob, 0o100644)?;
        let tree_oid = tb.write()?;
        let tree_oid = build_event_tree(repo, event_blob, sig_blob, pubkey_blob, manifest_blob)?;
        let tree = repo.find_tree(tree_oid)?;

        let sig = author_signature(&migrated.author)?;
diff --git a/src/patch.rs b/src/patch.rs
index 86528d9..1b545cc 100644
--- a/src/patch.rs
+++ b/src/patch.rs
@@ -416,15 +416,72 @@ pub fn merge(repo: &Repository, id_prefix: &str) -> Result<PatchState, crate::er

    let base_ref = format!("refs/heads/{}", p.base_ref);
    let base_oid = repo.refname_to_id(&base_ref)?;

    // Fast-forward: the base must be an ancestor of head
    let head_oid = head_commit.id();
    if repo.graph_descendant_of(head_oid, base_oid)? {
        repo.reference(&base_ref, head_oid, true, "collab: merge patch")?;
    } else if head_oid == base_oid {

    // Check if HEAD is on the target branch — determines whether we can
    // use git's native merge (which updates ref + index + working tree in
    // one shot) or must fall back to plumbing for a bare ref update.
    let on_base_branch = repo
        .head()
        .ok()
        .map(|r| r.is_branch() && r.shorthand() == Some(&p.base_ref))
        .unwrap_or(false);

    if head_oid == base_oid {
        // Already at the same point, nothing to do
    } else if repo.graph_descendant_of(head_oid, base_oid)? {
        // Fast-forward
        repo.reference(&base_ref, head_oid, true, "collab: fast-forward merge")?;
        if on_base_branch {
            repo.checkout_head(Some(
                git2::build::CheckoutBuilder::new().force(),
            ))?;
        }
    } else if on_base_branch {
        // Non-fast-forward with HEAD on base branch — use git's native merge.
        // repo.merge() updates ref, index, and working tree in one operation.
        let annotated = repo.find_annotated_commit(head_oid)?;
        let mut checkout = git2::build::CheckoutBuilder::new();
        checkout.force().allow_conflicts(true);
        if let Err(e) = repo.merge(&[&annotated], None, Some(&mut checkout)) {
            if e.code() == git2::ErrorCode::Conflict {
                let _ = repo.cleanup_state();
                return Err(git2::Error::from_str(
                    "merge has conflicts; resolve manually then revise the patch",
                )
                .into());
            }
            return Err(e.into());
        }
        {
            let index = repo.index()?;
            if index.has_conflicts() {
                repo.cleanup_state()?;
                return Err(git2::Error::from_str(
                    "merge has conflicts; resolve manually then revise the patch",
                )
                .into());
            }
        }
        // Create the merge commit
        let base_commit = repo.find_commit(base_oid)?;
        let author = get_author(repo)?;
        let sig = crate::identity::author_signature(&author)?;
        let mut index = repo.index()?;
        let tree_oid = index.write_tree()?;
        let tree = repo.find_tree(tree_oid)?;
        let msg = format!("Merge patch {:.8}: {}", id, p.title);
        repo.commit(
            Some("HEAD"),
            &sig,
            &sig,
            &msg,
            &tree,
            &[&base_commit, &head_commit],
        )?;
        repo.cleanup_state()?;
    } else {
        // Not a fast-forward — create a merge commit on the base branch
        // Non-fast-forward with HEAD on a different branch — plumbing only
        let base_commit = repo.find_commit(base_oid)?;
        let author = get_author(repo)?;
        let sig = crate::identity::author_signature(&author)?;
@@ -448,20 +505,6 @@ pub fn merge(repo: &Repository, id_prefix: &str) -> Result<PatchState, crate::er
        )?;
    }

    // Update working tree + index if HEAD is on the target branch
    if let Ok(head_ref) = repo.head() {
        if head_ref.is_branch()
            && head_ref.shorthand() == Some(&p.base_ref)
        {
            let target_commit = head_ref.peel_to_commit()?;
            repo.reset(
                target_commit.as_object(),
                git2::ResetType::Hard,
                None,
            )?;
        }
    }

    // Record the merge event in the patch DAG
    let author = get_author(repo)?;
    let event = Event {
diff --git a/tests/collab_test.rs b/tests/collab_test.rs
index 3f4d915..022742a 100644
--- a/tests/collab_test.rs
+++ b/tests/collab_test.rs
@@ -982,6 +982,109 @@ fn test_merge_conflicting_patch_reports_error() {
    assert!(err_msg.contains("conflict"), "error should mention conflicts");
}

#[test]
fn test_merge_ff_updates_working_tree_when_on_base_branch() {
    // When HEAD is on the base branch, a fast-forward merge should update
    // the working tree and index (not just the ref).
    let tmp = TempDir::new().unwrap();
    let repo = init_repo(tmp.path(), &alice());
    make_initial_commit(&repo, "main");

    // Set HEAD to main and checkout so working tree matches
    repo.set_head("refs/heads/main").unwrap();
    repo.checkout_head(Some(git2::build::CheckoutBuilder::new().force())).unwrap();

    let tip = add_commit_on_branch(&repo, "main", "base.rs", b"base");
    // Sync working tree to main
    repo.checkout_head(Some(git2::build::CheckoutBuilder::new().force())).unwrap();

    repo.branch("feat", &repo.find_commit(tip).unwrap(), false).unwrap();
    add_commit_on_branch(&repo, "feat", "feat.rs", b"feature code");

    let id = patch::create(&repo, "FF merge test", "", "main", "feat", None).unwrap();
    patch::merge(&repo, &id).unwrap();

    // Working tree should contain feat.rs
    assert!(
        tmp.path().join("feat.rs").exists(),
        "working tree should contain feat.rs after ff merge on current branch"
    );
}

#[test]
fn test_merge_non_ff_updates_working_tree_when_on_base_branch() {
    // When HEAD is on the base branch and branches have diverged,
    // the merge should update the working tree with the merged result.
    let tmp = TempDir::new().unwrap();
    let repo = init_repo(tmp.path(), &alice());
    make_initial_commit(&repo, "main");

    repo.set_head("refs/heads/main").unwrap();
    repo.checkout_head(Some(git2::build::CheckoutBuilder::new().force())).unwrap();

    let tip = add_commit_on_branch(&repo, "main", "base.rs", b"base");
    repo.checkout_head(Some(git2::build::CheckoutBuilder::new().force())).unwrap();

    repo.branch("feat", &repo.find_commit(tip).unwrap(), false).unwrap();
    add_commit_on_branch(&repo, "feat", "feat.rs", b"feature code");
    // Diverge main so it's not a fast-forward
    add_commit_on_branch(&repo, "main", "main_only.rs", b"main only");
    repo.checkout_head(Some(git2::build::CheckoutBuilder::new().force())).unwrap();

    let id = patch::create(&repo, "Non-FF merge test", "", "main", "feat", None).unwrap();
    patch::merge(&repo, &id).unwrap();

    // Working tree should contain both files
    assert!(
        tmp.path().join("feat.rs").exists(),
        "working tree should contain feat.rs after non-ff merge"
    );
    assert!(
        tmp.path().join("main_only.rs").exists(),
        "working tree should still contain main_only.rs after non-ff merge"
    );

    // Should be a merge commit (2 parents)
    let main_tip = repo.refname_to_id("refs/heads/main").unwrap();
    let main_commit = repo.find_commit(main_tip).unwrap();
    assert_eq!(main_commit.parent_count(), 2, "should be a merge commit");
}

#[test]
fn test_merge_ff_does_not_touch_working_tree_when_on_different_branch() {
    // When HEAD is NOT on the base branch, merge should update the ref
    // but not touch the working tree.
    let tmp = TempDir::new().unwrap();
    let repo = init_repo(tmp.path(), &alice());
    make_initial_commit(&repo, "main");

    let tip = add_commit_on_branch(&repo, "main", "base.rs", b"base");
    repo.branch("feat", &repo.find_commit(tip).unwrap(), false).unwrap();
    add_commit_on_branch(&repo, "feat", "feat.rs", b"feature code");

    // Create a separate branch and switch HEAD to it
    repo.branch("other", &repo.find_commit(tip).unwrap(), false).unwrap();
    repo.set_head("refs/heads/other").unwrap();
    repo.checkout_head(Some(git2::build::CheckoutBuilder::new().force())).unwrap();

    let id = patch::create(&repo, "Off-branch merge", "", "main", "feat", None).unwrap();
    patch::merge(&repo, &id).unwrap();

    // Ref should be updated
    let base_tip = repo.refname_to_id("refs/heads/main").unwrap();
    let feat_tip = repo.refname_to_id("refs/heads/feat").unwrap();
    assert!(
        repo.graph_descendant_of(base_tip, feat_tip).unwrap() || base_tip == feat_tip,
        "base should contain the feature branch"
    );

    // Working tree should NOT have feat.rs (we're on 'other')
    assert!(
        !tmp.path().join("feat.rs").exists(),
        "working tree should not be modified when HEAD is on a different branch"
    );
}

// ---------------------------------------------------------------------------
// Phase 6: US4 — Implicit Revision (T027)
// ---------------------------------------------------------------------------