a73x

tests/revision_test.rs

Ref:   Size: 22.6 KiB

mod common;

use common::TestRepo;
use common::{alice, bob, init_repo, test_signing_key, now};

use git_collab::dag;
use git_collab::event::{Action, Event};
use git_collab::state::PatchState;

use tempfile::TempDir;

// ===========================================================================
// Revision recording on patch create
// ===========================================================================

#[test]
fn test_patch_create_records_revision_1() {
    let repo = TestRepo::new("Alice", "alice@example.com");
    let id = repo.patch_create("Feature X");

    let out = repo.run_ok(&["patch", "show", &id, "--json"]);
    let json: serde_json::Value = serde_json::from_str(&out).unwrap();
    let revisions = json["revisions"].as_array().unwrap();
    assert_eq!(revisions.len(), 1);
    assert_eq!(revisions[0]["number"], 1);
    assert!(!revisions[0]["commit"].as_str().unwrap().is_empty());
    assert!(!revisions[0]["tree"].as_str().unwrap().is_empty());
}

// ===========================================================================
// Auto-detect revision on comment
// ===========================================================================

#[test]
fn test_auto_detect_revision_on_comment() {
    let repo = TestRepo::new("Alice", "alice@example.com");

    // Create feature branch and patch
    repo.git(&["checkout", "-b", "feat-auto"]);
    repo.commit_file("v1.txt", "v1", "initial commit");
    let out = repo.run_ok(&["patch", "create", "-t", "Auto-detect test", "-B", "feat-auto"]);
    let id = out.trim().strip_prefix("Created patch ").unwrap().to_string();

    // Push a new commit to the branch
    repo.git(&["checkout", "feat-auto"]);
    repo.commit_file("v2.txt", "v2", "second commit");
    repo.git(&["checkout", "main"]);

    // Comment should auto-insert a PatchRevision first
    repo.run_ok(&["patch", "comment", &id, "-b", "Looks interesting"]);

    let out = repo.run_ok(&["patch", "show", &id, "--json"]);
    let json: serde_json::Value = serde_json::from_str(&out).unwrap();
    let revisions = json["revisions"].as_array().unwrap();
    assert_eq!(revisions.len(), 2, "should have 2 revisions (create + auto-detect)");
    assert_eq!(revisions[0]["number"], 1);
    assert_eq!(revisions[1]["number"], 2);
}

#[test]
fn test_no_revision_when_branch_unchanged() {
    let repo = TestRepo::new("Alice", "alice@example.com");

    repo.git(&["checkout", "-b", "feat-no-change"]);
    repo.commit_file("v1.txt", "v1", "initial commit");
    let out = repo.run_ok(&["patch", "create", "-t", "No change test", "-B", "feat-no-change"]);
    let id = out.trim().strip_prefix("Created patch ").unwrap().to_string();

    // Comment WITHOUT branch change — no new revision
    repo.run_ok(&["patch", "comment", &id, "-b", "Just a thought"]);

    let out = repo.run_ok(&["patch", "show", &id, "--json"]);
    let json: serde_json::Value = serde_json::from_str(&out).unwrap();
    let revisions = json["revisions"].as_array().unwrap();
    assert_eq!(revisions.len(), 1, "should still have only 1 revision");
}

// ===========================================================================
// Auto-detect revision on review
// ===========================================================================

#[test]
fn test_auto_detect_revision_on_review() {
    let repo = TestRepo::new("Alice", "alice@example.com");

    repo.git(&["checkout", "-b", "feat-review"]);
    repo.commit_file("v1.txt", "v1", "initial commit");
    let out = repo.run_ok(&["patch", "create", "-t", "Review test", "-B", "feat-review"]);
    let id = out.trim().strip_prefix("Created patch ").unwrap().to_string();

    // Push a new commit
    repo.git(&["checkout", "feat-review"]);
    repo.commit_file("v2.txt", "v2", "second commit");
    repo.git(&["checkout", "main"]);

    // Review should auto-insert a PatchRevision
    repo.run_ok(&["patch", "review", &id, "-v", "approve", "-b", "LGTM"]);

    let out = repo.run_ok(&["patch", "show", &id, "--json"]);
    let json: serde_json::Value = serde_json::from_str(&out).unwrap();
    let revisions = json["revisions"].as_array().unwrap();
    assert_eq!(revisions.len(), 2);

    // Review should be anchored to revision 2
    let reviews = json["reviews"].as_array().unwrap();
    assert_eq!(reviews.len(), 1);
    assert_eq!(reviews[0]["revision"], 2);
}

// ===========================================================================
// Explicit revise
// ===========================================================================

#[test]
fn test_revise_creates_revision() {
    let repo = TestRepo::new("Alice", "alice@example.com");

    repo.git(&["checkout", "-b", "feat-revise"]);
    repo.commit_file("v1.txt", "v1", "initial commit");
    let out = repo.run_ok(&["patch", "create", "-t", "Revise test", "-B", "feat-revise"]);
    let id = out.trim().strip_prefix("Created patch ").unwrap().to_string();

    // Push new commit
    repo.git(&["checkout", "feat-revise"]);
    repo.commit_file("v2.txt", "v2", "second commit");
    repo.git(&["checkout", "main"]);

    repo.run_ok(&["patch", "revise", &id, "-b", "Addressed feedback"]);

    let out = repo.run_ok(&["patch", "show", &id, "--json"]);
    let json: serde_json::Value = serde_json::from_str(&out).unwrap();
    let revisions = json["revisions"].as_array().unwrap();
    assert_eq!(revisions.len(), 2);
    assert_eq!(revisions[1]["body"].as_str().unwrap(), "Addressed feedback");
}

#[test]
fn test_revise_rejects_when_unchanged() {
    let repo = TestRepo::new("Alice", "alice@example.com");

    repo.git(&["checkout", "-b", "feat-revise-err"]);
    repo.commit_file("v1.txt", "v1", "initial commit");
    let out = repo.run_ok(&["patch", "create", "-t", "Revise error test", "-B", "feat-revise-err"]);
    let id = out.trim().strip_prefix("Created patch ").unwrap().to_string();

    // No new commit — revise should fail
    let err = repo.run_err(&["patch", "revise", &id]);
    assert!(err.contains("no changes since revision"));
}

// ===========================================================================
// Inline comments anchored to revisions
// ===========================================================================

#[test]
fn test_inline_comment_anchored_to_revision() {
    let repo = TestRepo::new("Alice", "alice@example.com");

    repo.git(&["checkout", "-b", "feat-inline"]);
    repo.commit_file("src/lib.rs", "fn hello() {}", "initial");
    let out = repo.run_ok(&["patch", "create", "-t", "Inline test", "-B", "feat-inline"]);
    let id = out.trim().strip_prefix("Created patch ").unwrap().to_string();

    // Comment on revision 1
    repo.run_ok(&[
        "patch", "comment", &id, "-b", "nit: naming", "-f", "src/lib.rs", "-l", "1",
    ]);

    let out = repo.run_ok(&["patch", "show", &id, "--json"]);
    let json: serde_json::Value = serde_json::from_str(&out).unwrap();
    let inline = json["inline_comments"].as_array().unwrap();
    assert_eq!(inline.len(), 1);
    assert_eq!(inline[0]["revision"], 1);
}

#[test]
fn test_inline_comment_explicit_revision() {
    let repo = TestRepo::new("Alice", "alice@example.com");

    repo.git(&["checkout", "-b", "feat-inline-rev"]);
    repo.commit_file("lib.rs", "fn a() {}", "v1");
    let out = repo.run_ok(&["patch", "create", "-t", "Inline rev test", "-B", "feat-inline-rev"]);
    let id = out.trim().strip_prefix("Created patch ").unwrap().to_string();

    // Push v2
    repo.git(&["checkout", "feat-inline-rev"]);
    repo.commit_file("lib.rs", "fn b() {}", "v2");
    repo.git(&["checkout", "main"]);

    // Auto-detect by commenting
    repo.run_ok(&["patch", "comment", &id, "-b", "trigger revision"]);

    // Now explicitly target revision 1
    repo.run_ok(&[
        "patch", "comment", &id, "-b", "old nit", "-f", "lib.rs", "-l", "1", "--revision", "1",
    ]);

    let out = repo.run_ok(&["patch", "show", &id, "--json"]);
    let json: serde_json::Value = serde_json::from_str(&out).unwrap();
    let inline = json["inline_comments"].as_array().unwrap();
    assert_eq!(inline.len(), 1);
    assert_eq!(inline[0]["revision"], 1);
}

#[test]
fn test_thread_comment_rejects_revision_flag() {
    let repo = TestRepo::new("Alice", "alice@example.com");

    repo.git(&["checkout", "-b", "feat-thread"]);
    repo.commit_file("x.txt", "x", "init");
    let out = repo.run_ok(&["patch", "create", "-t", "Thread test", "-B", "feat-thread"]);
    let id = out.trim().strip_prefix("Created patch ").unwrap().to_string();

    let err = repo.run_err(&["patch", "comment", &id, "-b", "note", "--revision", "1"]);
    assert!(err.contains("thread comments are not revision-scoped"));
}

// ===========================================================================
// patch show --revision N filters
// ===========================================================================

#[test]
fn test_show_revision_filter() {
    let repo = TestRepo::new("Alice", "alice@example.com");

    repo.git(&["checkout", "-b", "feat-show-rev"]);
    repo.commit_file("a.txt", "a", "v1");
    let out = repo.run_ok(&["patch", "create", "-t", "Show rev test", "-B", "feat-show-rev"]);
    let id = out.trim().strip_prefix("Created patch ").unwrap().to_string();

    // Comment on r1
    repo.run_ok(&[
        "patch", "comment", &id, "-b", "r1 comment", "-f", "a.txt", "-l", "1",
    ]);

    // Push v2 and comment on r2
    repo.git(&["checkout", "feat-show-rev"]);
    repo.commit_file("b.txt", "b", "v2");
    repo.git(&["checkout", "main"]);
    repo.run_ok(&[
        "patch", "comment", &id, "-b", "r2 comment", "-f", "b.txt", "-l", "1",
    ]);

    // Show --revision 1: should show r1 comment, not r2
    let out = repo.run_ok(&["patch", "show", &id, "--revision", "1"]);
    assert!(out.contains("r1 comment"));
    assert!(!out.contains("r2 comment"));

    // Show --revision 2: should show r2 comment, not r1
    let out = repo.run_ok(&["patch", "show", &id, "--revision", "2"]);
    assert!(out.contains("r2 comment"));
    assert!(!out.contains("r1 comment"));
}

// ===========================================================================
// Interdiff between revisions
// ===========================================================================

#[test]
fn test_interdiff_between_revisions() {
    let repo = TestRepo::new("Alice", "alice@example.com");

    repo.git(&["checkout", "-b", "feat-interdiff"]);
    repo.commit_file("a.txt", "hello", "v1");
    let out = repo.run_ok(&["patch", "create", "-t", "Interdiff test", "-B", "feat-interdiff"]);
    let id = out.trim().strip_prefix("Created patch ").unwrap().to_string();

    // Push v2
    repo.git(&["checkout", "feat-interdiff"]);
    repo.commit_file("b.txt", "world", "v2");
    repo.git(&["checkout", "main"]);

    // Create revision 2 via revise
    repo.run_ok(&["patch", "revise", &id]);

    // Interdiff between r1 and r2 should show b.txt added
    let out = repo.run_ok(&["patch", "diff", &id, "--between", "1", "2"]);
    assert!(out.contains("b.txt"));
}

#[test]
fn test_interdiff_single_arg_means_n_to_latest() {
    let repo = TestRepo::new("Alice", "alice@example.com");

    repo.git(&["checkout", "-b", "feat-between-single"]);
    repo.commit_file("a.txt", "hello", "v1");
    let out = repo.run_ok(&["patch", "create", "-t", "Between single test", "-B", "feat-between-single"]);
    let id = out.trim().strip_prefix("Created patch ").unwrap().to_string();

    // Push v2
    repo.git(&["checkout", "feat-between-single"]);
    repo.commit_file("c.txt", "new", "v2");
    repo.git(&["checkout", "main"]);
    repo.run_ok(&["patch", "revise", &id]);

    // --between 1 (single arg) = 1..latest
    let out = repo.run_ok(&["patch", "diff", &id, "--between", "1"]);
    assert!(out.contains("c.txt"));
}

#[test]
fn test_interdiff_nonexistent_revision_errors() {
    let repo = TestRepo::new("Alice", "alice@example.com");

    repo.git(&["checkout", "-b", "feat-bad-rev"]);
    repo.commit_file("a.txt", "a", "v1");
    let out = repo.run_ok(&["patch", "create", "-t", "Bad rev test", "-B", "feat-bad-rev"]);
    let id = out.trim().strip_prefix("Created patch ").unwrap().to_string();

    let err = repo.run_err(&["patch", "diff", &id, "--between", "1", "2"]);
    assert!(err.contains("revision 2 not found"));
}

#[test]
fn test_diff_revision_flag_shows_historical_diff() {
    let repo = TestRepo::new("Alice", "alice@example.com");

    repo.git(&["checkout", "-b", "feat-hist"]);
    repo.commit_file("a.txt", "hello", "v1");
    let out = repo.run_ok(&["patch", "create", "-t", "Hist diff test", "-B", "feat-hist"]);
    let id = out.trim().strip_prefix("Created patch ").unwrap().to_string();

    // Push v2 (adds b.txt)
    repo.git(&["checkout", "feat-hist"]);
    repo.commit_file("b.txt", "world", "v2");
    repo.git(&["checkout", "main"]);
    repo.run_ok(&["patch", "revise", &id]);

    // --revision 1 should show only a.txt
    let out = repo.run_ok(&["patch", "diff", &id, "--revision", "1"]);
    assert!(out.contains("a.txt"));
    assert!(!out.contains("b.txt"));
}

#[test]
fn test_diff_revision_and_between_mutually_exclusive() {
    let repo = TestRepo::new("Alice", "alice@example.com");

    repo.git(&["checkout", "-b", "feat-mutex"]);
    repo.commit_file("a.txt", "a", "v1");
    let out = repo.run_ok(&["patch", "create", "-t", "Mutex test", "-B", "feat-mutex"]);
    let id = out.trim().strip_prefix("Created patch ").unwrap().to_string();

    let err = repo.run_err(&["patch", "diff", &id, "--revision", "1", "--between", "1"]);
    assert!(err.contains("mutually exclusive"));
}

// ===========================================================================
// Patch log
// ===========================================================================

#[test]
fn test_patch_log() {
    let repo = TestRepo::new("Alice", "alice@example.com");

    repo.git(&["checkout", "-b", "feat-log"]);
    repo.commit_file("a.txt", "hello", "v1");
    let out = repo.run_ok(&["patch", "create", "-t", "Log test", "-B", "feat-log"]);
    let id = out.trim().strip_prefix("Created patch ").unwrap().to_string();

    // Push v2
    repo.git(&["checkout", "feat-log"]);
    repo.commit_file("b.txt", "world", "v2");
    repo.git(&["checkout", "main"]);
    repo.run_ok(&["patch", "revise", &id, "-b", "added b.txt"]);

    // Push v3
    repo.git(&["checkout", "feat-log"]);
    repo.commit_file("c.txt", "!", "v3");
    repo.git(&["checkout", "main"]);
    repo.run_ok(&["patch", "revise", &id]);

    let out = repo.run_ok(&["patch", "log", &id]);
    assert!(out.contains("r1"));
    assert!(out.contains("(initial)"));
    assert!(out.contains("r2"));
    assert!(out.contains("added b.txt"));
    assert!(out.contains("r3"));
}

#[test]
fn test_patch_log_json() {
    let repo = TestRepo::new("Alice", "alice@example.com");

    repo.git(&["checkout", "-b", "feat-log-json"]);
    repo.commit_file("a.txt", "a", "v1");
    let out = repo.run_ok(&["patch", "create", "-t", "Log JSON test", "-B", "feat-log-json"]);
    let id = out.trim().strip_prefix("Created patch ").unwrap().to_string();

    let out = repo.run_ok(&["patch", "log", &id, "--json"]);
    let json: serde_json::Value = serde_json::from_str(&out).unwrap();
    let revisions = json.as_array().unwrap();
    assert_eq!(revisions.len(), 1);
    assert_eq!(revisions[0]["number"], 1);
}

// ===========================================================================
// Reviews anchored to revisions
// ===========================================================================

#[test]
fn test_review_explicit_revision() {
    let repo = TestRepo::new("Alice", "alice@example.com");

    repo.git(&["checkout", "-b", "feat-rev-explicit"]);
    repo.commit_file("a.txt", "a", "v1");
    let out = repo.run_ok(&["patch", "create", "-t", "Rev review", "-B", "feat-rev-explicit"]);
    let id = out.trim().strip_prefix("Created patch ").unwrap().to_string();

    // Push v2
    repo.git(&["checkout", "feat-rev-explicit"]);
    repo.commit_file("b.txt", "b", "v2");
    repo.git(&["checkout", "main"]);
    repo.run_ok(&["patch", "revise", &id]);

    // Review targeting revision 1
    repo.run_ok(&[
        "patch", "review", &id, "-v", "request-changes", "-b", "fix r1", "--revision", "1",
    ]);

    let out = repo.run_ok(&["patch", "show", &id, "--json"]);
    let json: serde_json::Value = serde_json::from_str(&out).unwrap();
    let reviews = json["reviews"].as_array().unwrap();
    assert_eq!(reviews.len(), 1);
    assert_eq!(reviews[0]["revision"], 1);
}

#[test]
fn test_review_shows_revision_context_in_show() {
    let repo = TestRepo::new("Alice", "alice@example.com");

    repo.git(&["checkout", "-b", "feat-rev-show"]);
    repo.commit_file("a.txt", "a", "v1");
    let out = repo.run_ok(&["patch", "create", "-t", "Review show", "-B", "feat-rev-show"]);
    let id = out.trim().strip_prefix("Created patch ").unwrap().to_string();

    repo.run_ok(&["patch", "review", &id, "-v", "approve", "-b", "LGTM"]);

    let out = repo.run_ok(&["patch", "show", &id]);
    assert!(out.contains("(r1)"));
}

// ===========================================================================
// Dedup by commit OID
// ===========================================================================

#[test]
fn test_revision_dedup_by_commit_oid() {
    // Two comments with no branch change should not create duplicate revisions
    let repo = TestRepo::new("Alice", "alice@example.com");

    repo.git(&["checkout", "-b", "feat-dedup"]);
    repo.commit_file("a.txt", "a", "v1");
    let out = repo.run_ok(&["patch", "create", "-t", "Dedup test", "-B", "feat-dedup"]);
    let id = out.trim().strip_prefix("Created patch ").unwrap().to_string();

    // Push v2
    repo.git(&["checkout", "feat-dedup"]);
    repo.commit_file("b.txt", "b", "v2");
    repo.git(&["checkout", "main"]);

    // Two comments — first should auto-detect revision, second should not create another
    repo.run_ok(&["patch", "comment", &id, "-b", "first"]);
    repo.run_ok(&["patch", "comment", &id, "-b", "second"]);

    let out = repo.run_ok(&["patch", "show", &id, "--json"]);
    let json: serde_json::Value = serde_json::from_str(&out).unwrap();
    let revisions = json["revisions"].as_array().unwrap();
    assert_eq!(revisions.len(), 2, "should have exactly 2 revisions, not 3");
}

// ===========================================================================
// Revision count in show output
// ===========================================================================

#[test]
fn test_show_displays_revision_count() {
    let repo = TestRepo::new("Alice", "alice@example.com");

    repo.git(&["checkout", "-b", "feat-count"]);
    repo.commit_file("a.txt", "a", "v1");
    let out = repo.run_ok(&["patch", "create", "-t", "Count test", "-B", "feat-count"]);
    let id = out.trim().strip_prefix("Created patch ").unwrap().to_string();

    let out = repo.run_ok(&["patch", "show", &id]);
    assert!(out.contains("(r1)"));
}

// ===========================================================================
// D1: Concurrent PatchRevision dedup after DAG reconciliation
// ===========================================================================

#[test]
fn test_concurrent_revision_dedup_after_reconcile() {
    // Two collaborators independently detect the same branch change and
    // create PatchRevision events with the same commit OID. After DAG
    // reconciliation, only one revision boundary should exist.
    let tmp = TempDir::new().unwrap();
    let repo = init_repo(tmp.path(), &alice());

    // Create a branch with a commit
    let head = repo.head().unwrap().target().unwrap();
    let head_commit = repo.find_commit(head).unwrap();
    repo.branch("feat-concurrent", &head_commit, false).unwrap();

    let sk = test_signing_key();

    // Create a patch (revision 1)
    let tree_oid = head_commit.tree().unwrap().id();
    let create_event = Event {
        timestamp: now(),
        author: alice(),
        action: Action::PatchCreate {
            title: "Concurrent test".to_string(),
            body: "".to_string(),
            base_ref: "main".to_string(),
            branch: "feat-concurrent".to_string(),
            fixes: None,
            commit: head.to_string(),
            tree: tree_oid.to_string(),
            base_commit: None,
        },
        clock: 0,
    };
    let patch_oid = dag::create_root_event(&repo, &create_event, &sk).unwrap();
    let id = patch_oid.to_string();
    let ref_name = format!("refs/collab/patches/{}", id);
    repo.reference(&ref_name, patch_oid, false, "test").unwrap();

    // Push a new commit on the branch
    let sig = git2::Signature::now("Alice", "alice@example.com").unwrap();
    let blob = repo.blob(b"new content").unwrap();
    let mut tb = repo.treebuilder(Some(&head_commit.tree().unwrap())).unwrap();
    tb.insert("new.txt", blob, 0o100644).unwrap();
    let new_tree_oid = tb.write().unwrap();
    let new_tree = repo.find_tree(new_tree_oid).unwrap();
    let new_commit = repo.commit(
        Some("refs/heads/feat-concurrent"),
        &sig, &sig, "new commit", &new_tree, &[&head_commit],
    ).unwrap();

    let root_tip = repo.refname_to_id(&ref_name).unwrap();

    // Alice appends a PatchRevision for the new commit
    let rev_event = Event {
        timestamp: now(),
        author: alice(),
        action: Action::PatchRevision {
            commit: new_commit.to_string(),
            tree: new_tree_oid.to_string(),
            body: None,
        },
        clock: 0,
    };
    dag::append_event(&repo, &ref_name, &rev_event, &sk).unwrap();
    let alice_tip = repo.refname_to_id(&ref_name).unwrap();

    // Reset ref back to root, then Bob also appends same PatchRevision
    repo.reference(&ref_name, root_tip, true, "reset for bob").unwrap();
    let rev_event_bob = Event {
        timestamp: now(),
        author: bob(),
        action: Action::PatchRevision {
            commit: new_commit.to_string(),
            tree: new_tree_oid.to_string(),
            body: None,
        },
        clock: 0,
    };
    dag::append_event(&repo, &ref_name, &rev_event_bob, &sk).unwrap();
    let bob_tip = repo.refname_to_id(&ref_name).unwrap();

    // Reconcile: create a remote ref pointing to bob's tip, local to alice's tip
    let remote_ref = format!("refs/collab/sync/origin/patches/{}", id);
    repo.reference(&remote_ref, bob_tip, true, "remote").unwrap();
    repo.reference(&ref_name, alice_tip, true, "restore alice").unwrap();
    dag::reconcile(&repo, &ref_name, &remote_ref, &alice(), &sk).unwrap();

    // Materialize and verify: should have exactly 2 revisions (create + 1 deduped)
    let state = PatchState::from_ref(&repo, &ref_name, &id).unwrap();
    assert_eq!(
        state.revisions.len(), 2,
        "duplicate PatchRevision events with same commit OID should be deduped to one revision"
    );
    assert_eq!(state.revisions[0].number, 1);
    assert_eq!(state.revisions[1].number, 2);
    assert_eq!(state.revisions[1].commit, new_commit.to_string());
}

// (Merge policy tests removed — policy enforcement dropped in favour
//  of auto-detect merge via git graph.)