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.)