tests/sync_test.rs
Ref: Size: 55.6 KiB
//! End-to-end sync tests with two repos sharing a bare remote.
//!
//! Topology for every test:
//!
//! bare_remote <---push/fetch---> alice_repo
//! <---push/fetch---> bob_repo
mod common;
use std::process::{Command, Output};
use tempfile::TempDir;
use git2::Repository;
use git_collab::dag;
use git_collab::event::{Action, Event, ReviewVerdict};
use git_collab::signing;
use git_collab::state::{self, IssueState, IssueStatus, PatchState};
use git_collab::sync;
use common::{
add_comment, alice, bob, close_issue, create_tampered_event, create_unsigned_event, now,
open_issue, test_signing_key, ScopedTestConfig,
};
// ---------------------------------------------------------------------------
// Test cluster
// ---------------------------------------------------------------------------
struct TestCluster {
bare_dir: TempDir,
alice_dir: TempDir,
bob_dir: TempDir,
_config: ScopedTestConfig,
}
impl TestCluster {
fn new() -> Self {
let cluster = Self::new_without_collab_init();
sync::init(&cluster.alice_repo()).unwrap();
sync::init(&cluster.bob_repo()).unwrap();
cluster
}
fn new_without_collab_init() -> Self {
let config = ScopedTestConfig::new();
config.ensure_signing_key();
let bare_dir = TempDir::new().unwrap();
let bare_repo = Repository::init_bare(bare_dir.path()).unwrap();
{
let sig = git2::Signature::now("init", "init@test").unwrap();
let tree_oid = bare_repo.treebuilder(None).unwrap().write().unwrap();
let tree = bare_repo.find_tree(tree_oid).unwrap();
bare_repo
.commit(Some("refs/heads/main"), &sig, &sig, "init", &tree, &[])
.unwrap();
}
drop(bare_repo);
let alice_dir = TempDir::new().unwrap();
let bob_dir = TempDir::new().unwrap();
let alice_repo =
Repository::clone(bare_dir.path().to_str().unwrap(), alice_dir.path()).unwrap();
{
let mut config = alice_repo.config().unwrap();
config.set_str("user.name", "Alice").unwrap();
config.set_str("user.email", "alice@example.com").unwrap();
}
let bob_repo =
Repository::clone(bare_dir.path().to_str().unwrap(), bob_dir.path()).unwrap();
{
let mut config = bob_repo.config().unwrap();
config.set_str("user.name", "Bob").unwrap();
config.set_str("user.email", "bob@example.com").unwrap();
}
TestCluster {
bare_dir,
alice_dir,
bob_dir,
_config: config,
}
}
fn alice_repo(&self) -> Repository {
Repository::open(self.alice_dir.path()).unwrap()
}
fn bob_repo(&self) -> Repository {
Repository::open(self.bob_dir.path()).unwrap()
}
fn run_collab(&self, repo_dir: &std::path::Path, args: &[&str]) -> Output {
Command::new(env!("CARGO_BIN_EXE_git-collab"))
.args(args)
.current_dir(repo_dir)
.output()
.expect("failed to run git-collab")
}
fn run_collab_ok(&self, repo_dir: &std::path::Path, args: &[&str]) -> String {
let output = self.run_collab(repo_dir, args);
let stdout = String::from_utf8(output.stdout).unwrap();
let stderr = String::from_utf8(output.stderr).unwrap();
assert!(
output.status.success(),
"git-collab {:?} failed (exit {:?}):\nstdout: {}\nstderr: {}",
args,
output.status.code(),
stdout,
stderr
);
stdout
}
fn run_collab_err(&self, repo_dir: &std::path::Path, args: &[&str]) -> (String, String) {
let output = self.run_collab(repo_dir, args);
let stdout = String::from_utf8(output.stdout).unwrap();
let stderr = String::from_utf8(output.stderr).unwrap();
assert!(
!output.status.success(),
"expected git-collab {:?} to fail but it succeeded:\nstdout: {}\nstderr: {}",
args,
stdout,
stderr
);
(stdout, stderr)
}
/// Return the path to the bare remote directory.
fn bare_dir(&self) -> &std::path::Path {
self.bare_dir.path()
}
fn config_dir(&self) -> std::path::PathBuf {
self._config.config_dir()
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[test]
fn test_alice_creates_issue_bob_syncs_and_sees_it() {
let cluster = TestCluster::new();
let alice_repo = cluster.alice_repo();
let bob_repo = cluster.bob_repo();
let (_ref_name, id) = open_issue(&alice_repo, &alice(), "Bug from Alice");
sync::sync(&alice_repo, "origin").unwrap();
sync::sync(&bob_repo, "origin").unwrap();
let bob_ref = format!("refs/collab/issues/{}", id);
let state = IssueState::from_ref(&bob_repo, &bob_ref, &id).unwrap();
assert_eq!(state.title, "Bug from Alice");
assert_eq!(state.author.name, "Alice");
assert_eq!(state.status, IssueStatus::Open);
}
#[test]
fn test_cli_init_and_sync_transfer_issue_between_repos() {
let cluster = TestCluster::new_without_collab_init();
let alice_init = cluster.run_collab_ok(cluster.alice_dir.path(), &["init"]);
assert!(alice_init.contains("Configured remote 'origin'"));
assert!(alice_init.contains("Collab refspecs initialized."));
let bob_init = cluster.run_collab_ok(cluster.bob_dir.path(), &["init"]);
assert!(bob_init.contains("Configured remote 'origin'"));
assert!(bob_init.contains("Collab refspecs initialized."));
let issue_open = cluster.run_collab_ok(
cluster.alice_dir.path(),
&["issue", "open", "-t", "CLI sync issue"],
);
assert!(issue_open.contains("Opened issue"));
let issue_id = state::list_issues(&cluster.alice_repo())
.unwrap()
.into_iter()
.find(|issue| issue.title == "CLI sync issue")
.expect("issue created through CLI should exist locally")
.id;
let alice_sync = cluster.run_collab_ok(cluster.alice_dir.path(), &["sync", "origin"]);
assert!(alice_sync.contains("Fetching from 'origin'..."));
assert!(alice_sync.contains("Pushing to 'origin'..."));
assert!(alice_sync.contains("Sync complete."));
let bob_sync = cluster.run_collab_ok(cluster.bob_dir.path(), &["sync", "origin"]);
assert!(bob_sync.contains("Fetching from 'origin'..."));
assert!(bob_sync.contains("Sync complete."));
let bob_repo = cluster.bob_repo();
let bob_ref = format!("refs/collab/issues/{}", issue_id);
let bob_state = IssueState::from_ref(&bob_repo, &bob_ref, &issue_id).unwrap();
assert_eq!(bob_state.title, "CLI sync issue");
assert_eq!(bob_state.author.name, "Alice");
}
#[test]
fn test_cli_sync_reports_missing_remote_failure() {
let cluster = TestCluster::new_without_collab_init();
cluster.run_collab_ok(cluster.alice_dir.path(), &["init"]);
let (_stdout, stderr) = cluster.run_collab_err(cluster.alice_dir.path(), &["sync", "upstream"]);
assert!(stderr.contains("error:"));
assert!(stderr.contains("git fetch exited with status"));
}
#[test]
fn test_cli_sync_partial_failure_can_resume_successfully() {
let cluster = TestCluster::new_without_collab_init();
cluster.run_collab_ok(cluster.alice_dir.path(), &["init"]);
let alice_repo = cluster.alice_repo();
let (_ref1, id1) = open_issue(&alice_repo, &alice(), "CLI rejected issue");
let (_ref2, id2) = open_issue(&alice_repo, &alice(), "CLI accepted issue");
install_reject_hook(cluster.bare_dir(), &id1[..8]);
let (_stdout, stderr) = cluster.run_collab_err(cluster.alice_dir.path(), &["sync", "origin"]);
assert!(stderr.contains("Sync partially failed: 1 of 2 refs pushed."));
assert!(stderr.contains(&format!("refs/collab/issues/{}", id1)));
let state = sync::SyncState::load(&cluster.alice_repo()).expect("partial sync should save state");
assert_eq!(state.pending_refs.len(), 1);
assert!(state.pending_refs[0].0.contains(&id1));
let bare_repo = Repository::open_bare(cluster.bare_dir()).unwrap();
assert!(
bare_repo
.refname_to_id(&format!("refs/collab/issues/{}", id2))
.is_ok(),
"successful ref should still reach the remote"
);
remove_reject_hook(cluster.bare_dir());
let resume_output = cluster.run_collab_ok(cluster.alice_dir.path(), &["sync", "origin"]);
assert!(resume_output.contains("Resuming sync to 'origin'"));
assert!(resume_output.contains("Sync complete."));
assert!(sync::SyncState::load(&cluster.alice_repo()).is_none());
}
#[test]
fn test_bob_comments_on_alice_issue_then_sync() {
let cluster = TestCluster::new();
let alice_repo = cluster.alice_repo();
let bob_repo = cluster.bob_repo();
let (alice_ref, id) = open_issue(&alice_repo, &alice(), "Needs discussion");
sync::sync(&alice_repo, "origin").unwrap();
sync::sync(&bob_repo, "origin").unwrap();
let bob_ref = format!("refs/collab/issues/{}", id);
add_comment(&bob_repo, &bob_ref, &bob(), "I have thoughts on this");
sync::sync(&bob_repo, "origin").unwrap();
sync::sync(&alice_repo, "origin").unwrap();
let state = IssueState::from_ref(&alice_repo, &alice_ref, &id).unwrap();
assert_eq!(state.comments.len(), 1);
assert_eq!(state.comments[0].author.name, "Bob");
assert_eq!(state.comments[0].body, "I have thoughts on this");
}
#[test]
fn test_concurrent_comments_sync_convergence() {
let cluster = TestCluster::new();
let alice_repo = cluster.alice_repo();
let bob_repo = cluster.bob_repo();
let (alice_ref, id) = open_issue(&alice_repo, &alice(), "Concurrent comments");
sync::sync(&alice_repo, "origin").unwrap();
sync::sync(&bob_repo, "origin").unwrap();
add_comment(&alice_repo, &alice_ref, &alice(), "Alice's take");
let bob_ref = format!("refs/collab/issues/{}", id);
add_comment(&bob_repo, &bob_ref, &bob(), "Bob's take");
sync::sync(&alice_repo, "origin").unwrap();
sync::sync(&bob_repo, "origin").unwrap();
sync::sync(&alice_repo, "origin").unwrap();
let alice_state = IssueState::from_ref(&alice_repo, &alice_ref, &id).unwrap();
let bob_state = IssueState::from_ref(&bob_repo, &bob_ref, &id).unwrap();
assert_eq!(alice_state.comments.len(), 2, "Alice should see 2 comments");
assert_eq!(bob_state.comments.len(), 2, "Bob should see 2 comments");
let mut alice_bodies: Vec<&str> = alice_state
.comments
.iter()
.map(|c| c.body.as_str())
.collect();
let mut bob_bodies: Vec<&str> = bob_state.comments.iter().map(|c| c.body.as_str()).collect();
alice_bodies.sort();
bob_bodies.sort();
assert_eq!(alice_bodies, bob_bodies);
assert!(alice_bodies.contains(&"Alice's take"));
assert!(alice_bodies.contains(&"Bob's take"));
}
#[test]
fn test_both_create_different_issues() {
let cluster = TestCluster::new();
let alice_repo = cluster.alice_repo();
let bob_repo = cluster.bob_repo();
let (_, _alice_issue_id) = open_issue(&alice_repo, &alice(), "Alice's bug");
let (_, _bob_issue_id) = open_issue(&bob_repo, &bob(), "Bob's feature request");
sync::sync(&alice_repo, "origin").unwrap();
sync::sync(&bob_repo, "origin").unwrap();
sync::sync(&alice_repo, "origin").unwrap();
let alice_issues = state::list_issues(&alice_repo).unwrap();
let bob_issues = state::list_issues(&bob_repo).unwrap();
assert_eq!(alice_issues.len(), 2);
assert_eq!(bob_issues.len(), 2);
let alice_titles: Vec<&str> = alice_issues.iter().map(|i| i.title.as_str()).collect();
assert!(alice_titles.contains(&"Alice's bug"));
assert!(alice_titles.contains(&"Bob's feature request"));
}
#[test]
fn test_alice_closes_while_bob_comments() {
let cluster = TestCluster::new();
let alice_repo = cluster.alice_repo();
let bob_repo = cluster.bob_repo();
let (alice_ref, id) = open_issue(&alice_repo, &alice(), "Close vs comment");
sync::sync(&alice_repo, "origin").unwrap();
sync::sync(&bob_repo, "origin").unwrap();
close_issue(&alice_repo, &alice_ref, &alice());
let bob_ref = format!("refs/collab/issues/{}", id);
add_comment(&bob_repo, &bob_ref, &bob(), "But wait...");
sync::sync(&alice_repo, "origin").unwrap();
sync::sync(&bob_repo, "origin").unwrap();
sync::sync(&alice_repo, "origin").unwrap();
let alice_state = IssueState::from_ref(&alice_repo, &alice_ref, &id).unwrap();
let bob_state = IssueState::from_ref(&bob_repo, &bob_ref, &id).unwrap();
assert_eq!(alice_state.comments.len(), 1);
assert_eq!(bob_state.comments.len(), 1);
assert_eq!(alice_state.status, bob_state.status);
}
#[test]
fn test_sync_idempotent() {
let cluster = TestCluster::new();
let alice_repo = cluster.alice_repo();
let bob_repo = cluster.bob_repo();
let (alice_ref, id) = open_issue(&alice_repo, &alice(), "Idempotent test");
add_comment(&alice_repo, &alice_ref, &alice(), "A comment");
sync::sync(&alice_repo, "origin").unwrap();
sync::sync(&bob_repo, "origin").unwrap();
sync::sync(&bob_repo, "origin").unwrap();
let bob_ref = format!("refs/collab/issues/{}", id);
let state = IssueState::from_ref(&bob_repo, &bob_ref, &id).unwrap();
assert_eq!(state.comments.len(), 1, "no duplicate comments");
}
#[test]
fn test_three_user_convergence() {
let cluster = TestCluster::new();
let alice_repo = cluster.alice_repo();
let bob_repo = cluster.bob_repo();
let (alice_ref, id) = open_issue(&alice_repo, &alice(), "Three users");
sync::sync(&alice_repo, "origin").unwrap();
sync::sync(&bob_repo, "origin").unwrap();
add_comment(&alice_repo, &alice_ref, &alice(), "Alice's comment");
let bob_ref = format!("refs/collab/issues/{}", id);
add_comment(&bob_repo, &bob_ref, &bob(), "Bob's comment");
sync::sync(&alice_repo, "origin").unwrap();
sync::sync(&bob_repo, "origin").unwrap();
sync::sync(&alice_repo, "origin").unwrap();
let alice_state = IssueState::from_ref(&alice_repo, &alice_ref, &id).unwrap();
let bob_state = IssueState::from_ref(&bob_repo, &bob_ref, &id).unwrap();
assert_eq!(alice_state.comments.len(), 2);
assert_eq!(bob_state.comments.len(), 2);
}
#[test]
fn test_patch_review_across_repos() {
let cluster = TestCluster::new();
let alice_repo = cluster.alice_repo();
let bob_repo = cluster.bob_repo();
let event = Event {
timestamp: now(),
author: alice(),
action: Action::PatchCreate {
title: "Add feature X".to_string(),
body: "Please review".to_string(),
base_ref: "main".to_string(),
branch: "feature/x".to_string(),
fixes: None,
commit: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(),
tree: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".to_string(),
base_commit: None,
},
clock: 0,
};
let sk = test_signing_key();
let oid = dag::create_root_event(&alice_repo, &event, &sk).unwrap();
let id = oid.to_string();
let alice_ref = format!("refs/collab/patches/{}", id);
alice_repo
.reference(&alice_ref, oid, false, "patch create")
.unwrap();
sync::sync(&alice_repo, "origin").unwrap();
sync::sync(&bob_repo, "origin").unwrap();
let bob_ref = format!("refs/collab/patches/{}", id);
let review_event = Event {
timestamp: now(),
author: bob(),
action: Action::PatchReview {
verdict: ReviewVerdict::Approve,
body: "LGTM!".to_string(),
revision: 1,
},
clock: 0,
};
dag::append_event(&bob_repo, &bob_ref, &review_event, &sk).unwrap();
sync::sync(&bob_repo, "origin").unwrap();
sync::sync(&alice_repo, "origin").unwrap();
let state = PatchState::from_ref(&alice_repo, &alice_ref, &id).unwrap();
assert_eq!(state.reviews.len(), 1);
assert_eq!(state.reviews[0].verdict, ReviewVerdict::Approve);
assert_eq!(state.reviews[0].author.name, "Bob");
}
#[test]
fn test_concurrent_review_and_revise() {
let cluster = TestCluster::new();
let alice_repo = cluster.alice_repo();
let bob_repo = cluster.bob_repo();
let event = Event {
timestamp: now(),
author: alice(),
action: Action::PatchCreate {
title: "WIP feature".to_string(),
body: "".to_string(),
base_ref: "main".to_string(),
branch: "feature/wip".to_string(),
fixes: None,
commit: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(),
tree: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".to_string(),
base_commit: None,
},
clock: 0,
};
let sk = test_signing_key();
let oid = dag::create_root_event(&alice_repo, &event, &sk).unwrap();
let id = oid.to_string();
let alice_ref = format!("refs/collab/patches/{}", id);
alice_repo
.reference(&alice_ref, oid, false, "patch")
.unwrap();
sync::sync(&alice_repo, "origin").unwrap();
sync::sync(&bob_repo, "origin").unwrap();
let revise_event = Event {
timestamp: now(),
author: alice(),
action: Action::PatchRevision {
commit: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(),
tree: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".to_string(),
body: Some("Updated description".to_string()),
},
clock: 0,
};
dag::append_event(&alice_repo, &alice_ref, &revise_event, &sk).unwrap();
let bob_ref = format!("refs/collab/patches/{}", id);
let review_event = Event {
timestamp: now(),
author: bob(),
action: Action::PatchReview {
verdict: ReviewVerdict::RequestChanges,
body: "Needs work".to_string(),
revision: 1,
},
clock: 0,
};
dag::append_event(&bob_repo, &bob_ref, &review_event, &sk).unwrap();
sync::sync(&alice_repo, "origin").unwrap();
sync::sync(&bob_repo, "origin").unwrap();
sync::sync(&alice_repo, "origin").unwrap();
let alice_state = PatchState::from_ref(&alice_repo, &alice_ref, &id).unwrap();
let bob_state = PatchState::from_ref(&bob_repo, &bob_ref, &id).unwrap();
assert_eq!(alice_state.reviews.len(), 1);
assert_eq!(bob_state.reviews.len(), 1);
assert_eq!(alice_state.body, bob_state.body);
}
#[test]
fn test_multiple_rounds_of_sync() {
let cluster = TestCluster::new();
let alice_repo = cluster.alice_repo();
let bob_repo = cluster.bob_repo();
let (alice_ref, id) = open_issue(&alice_repo, &alice(), "Discussion thread");
sync::sync(&alice_repo, "origin").unwrap();
sync::sync(&bob_repo, "origin").unwrap();
let bob_ref = format!("refs/collab/issues/{}", id);
add_comment(&bob_repo, &bob_ref, &bob(), "First response");
sync::sync(&bob_repo, "origin").unwrap();
sync::sync(&alice_repo, "origin").unwrap();
add_comment(&alice_repo, &alice_ref, &alice(), "Thanks for the input");
sync::sync(&alice_repo, "origin").unwrap();
sync::sync(&bob_repo, "origin").unwrap();
close_issue(&bob_repo, &bob_ref, &bob());
sync::sync(&bob_repo, "origin").unwrap();
sync::sync(&alice_repo, "origin").unwrap();
let state = IssueState::from_ref(&alice_repo, &alice_ref, &id).unwrap();
assert_eq!(state.comments.len(), 2);
assert_eq!(state.status, IssueStatus::Closed);
assert_eq!(state.comments[0].body, "First response");
assert_eq!(state.comments[1].body, "Thanks for the input");
}
// ---------------------------------------------------------------------------
// T022: Signed issue sync succeeds
// ---------------------------------------------------------------------------
#[test]
fn test_signed_issue_sync_succeeds() {
let cluster = TestCluster::new();
let alice_repo = cluster.alice_repo();
let bob_repo = cluster.bob_repo();
// Alice creates a signed issue (open_issue uses signing)
let (_ref_name, id) = open_issue(&alice_repo, &alice(), "Signed bug report");
sync::sync(&alice_repo, "origin").unwrap();
// Bob syncs — should succeed since all events are signed
sync::sync(&bob_repo, "origin").unwrap();
// Verify issue is present on Bob's side
let bob_ref = format!("refs/collab/issues/{}", id);
let state = IssueState::from_ref(&bob_repo, &bob_ref, &id).unwrap();
assert_eq!(state.title, "Signed bug report");
assert_eq!(state.author.name, "Alice");
assert_eq!(state.status, IssueStatus::Open);
// Verify the event is actually signed
let results = signing::verify_ref(&bob_repo, &bob_ref).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].status, signing::VerifyStatus::Valid);
}
// ---------------------------------------------------------------------------
// T023: Unsigned event sync is rejected
// ---------------------------------------------------------------------------
#[test]
fn test_unsigned_event_sync_rejected() {
// Set up Alice's repo with an unsigned event directly via git2
let alice_dir = TempDir::new().unwrap();
let alice_repo = common::init_repo(alice_dir.path(), &alice());
// Create an unsigned event
let event = Event {
timestamp: now(),
author: alice(),
action: Action::IssueOpen {
title: "Unsigned issue".to_string(),
body: "No signature".to_string(),
relates_to: None,
},
clock: 0,
};
let oid = create_unsigned_event(&alice_repo, &event);
let id = oid.to_string();
let ref_name = format!("refs/collab/issues/{}", id);
alice_repo
.reference(&ref_name, oid, false, "unsigned issue")
.unwrap();
// Verify the ref directly — should show Missing status
let results = signing::verify_ref(&alice_repo, &ref_name).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].status, signing::VerifyStatus::Missing);
assert_eq!(results[0].commit_id, oid);
let error_msg = results[0].error.as_deref().unwrap();
assert!(
error_msg.contains("missing signature"),
"expected 'missing signature' in error, got: {}",
error_msg
);
}
// ---------------------------------------------------------------------------
// T024: Tampered event sync is rejected
// ---------------------------------------------------------------------------
#[test]
fn test_tampered_event_sync_rejected() {
// Set up a repo with a tampered event
let dir = TempDir::new().unwrap();
let repo = common::init_repo(dir.path(), &alice());
// Create a tampered event (signed then modified)
let event = Event {
timestamp: now(),
author: alice(),
action: Action::IssueOpen {
title: "Tampered issue".to_string(),
body: "Will be tampered".to_string(),
relates_to: None,
},
clock: 0,
};
let oid = create_tampered_event(&repo, &event);
let id = oid.to_string();
let ref_name = format!("refs/collab/issues/{}", id);
repo.reference(&ref_name, oid, false, "tampered issue")
.unwrap();
// Verify the ref — should show Invalid status
let results = signing::verify_ref(&repo, &ref_name).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].status, signing::VerifyStatus::Invalid);
assert_eq!(results[0].commit_id, oid);
let error_msg = results[0].error.as_deref().unwrap();
assert!(
error_msg.contains("invalid signature"),
"expected 'invalid signature' in error, got: {}",
error_msg
);
}
// ---------------------------------------------------------------------------
// T028: Merge commit during reconciliation has valid Ed25519 signature
// ---------------------------------------------------------------------------
#[test]
fn test_reconciliation_merge_commit_is_signed() {
let cluster = TestCluster::new();
let alice_repo = cluster.alice_repo();
let bob_repo = cluster.bob_repo();
// Alice creates an issue and syncs it to remote
let (alice_ref, id) = open_issue(&alice_repo, &alice(), "Divergent history test");
sync::sync(&alice_repo, "origin").unwrap();
// Bob syncs to get the issue
sync::sync(&bob_repo, "origin").unwrap();
let bob_ref = format!("refs/collab/issues/{}", id);
// Both add comments — creating divergent history
add_comment(&alice_repo, &alice_ref, &alice(), "Alice's divergent comment");
add_comment(&bob_repo, &bob_ref, &bob(), "Bob's divergent comment");
// Bob pushes his comment to remote
sync::sync(&bob_repo, "origin").unwrap();
// Alice syncs — this triggers reconciliation (merge commit) because
// Alice has a local comment and Bob's comment comes from remote
sync::sync(&alice_repo, "origin").unwrap();
// Walk the DAG and find the merge event
let events = dag::walk_events(&alice_repo, &alice_ref).unwrap();
let merge_events: Vec<_> = events
.iter()
.filter(|(_, e)| matches!(e.action, Action::Merge))
.collect();
assert!(
!merge_events.is_empty(),
"Expected at least one merge event after reconciliation"
);
// Verify ALL events on the ref have valid signatures (including the merge)
let results = signing::verify_ref(&alice_repo, &alice_ref).unwrap();
assert!(
results.len() >= 4,
"Expected at least 4 commits (open + 2 comments + merge), got {}",
results.len()
);
for result in &results {
assert_eq!(
result.status,
signing::VerifyStatus::Valid,
"Commit {} has status {:?}, expected Valid. Error: {:?}",
result.commit_id,
result.status,
result.error
);
}
// Verify the merge commit specifically is signed by the syncing user's key
// (the key stored in the test config dir, which sync::sync() loads)
let syncing_vk = signing::load_verifying_key(&cluster.config_dir()).unwrap();
let syncing_pubkey = base64::Engine::encode(
&base64::engine::general_purpose::STANDARD,
syncing_vk.to_bytes(),
);
// Find the merge commit and check its pubkey matches the syncing user's key
let tip = alice_repo.refname_to_id(&alice_ref).unwrap();
let commit = alice_repo.find_commit(tip).unwrap();
// The tip should be the merge commit (it's the most recent)
let tree = commit.tree().unwrap();
// Read event.json as a plain Event
let event_entry = tree.get_name("event.json").unwrap();
let event_blob = alice_repo.find_blob(event_entry.id()).unwrap();
let event: git_collab::event::Event = serde_json::from_slice(event_blob.content()).unwrap();
assert!(
matches!(event.action, Action::Merge),
"Expected tip commit to be a Merge event, got {:?}",
event.action
);
// Read pubkey from separate blob
let pk_entry = tree.get_name("pubkey").expect("pubkey blob should exist");
let pk_blob = alice_repo.find_blob(pk_entry.id()).unwrap();
let commit_pubkey = std::str::from_utf8(pk_blob.content()).unwrap().trim().to_string();
assert_eq!(
commit_pubkey, syncing_pubkey,
"Merge commit should be signed by the syncing user's key"
);
// Read signature from separate blob and verify
let sig_entry = tree.get_name("signature").expect("signature blob should exist");
let sig_blob = alice_repo.find_blob(sig_entry.id()).unwrap();
let sig_str = std::str::from_utf8(sig_blob.content()).unwrap().trim().to_string();
let detached = signing::DetachedSignature {
signature: sig_str,
pubkey: commit_pubkey,
};
let status = signing::verify_detached(&event, &detached).unwrap();
assert_eq!(
status,
signing::VerifyStatus::Valid,
"Merge commit signature must be valid"
);
}
// ---------------------------------------------------------------------------
// Sync Recovery Tests
// ---------------------------------------------------------------------------
/// Install a pre-receive hook in the bare repo that rejects refs matching a pattern.
fn install_reject_hook(bare_dir: &std::path::Path, reject_pattern: &str) {
let hooks_dir = bare_dir.join("hooks");
std::fs::create_dir_all(&hooks_dir).unwrap();
let hook_path = hooks_dir.join("pre-receive");
let script = format!(
r#"#!/bin/sh
while read oldrev newrev refname; do
case "$refname" in
*{}*)
echo "REJECT: $refname matches reject pattern" >&2
exit 1
;;
esac
done
exit 0
"#,
reject_pattern
);
std::fs::write(&hook_path, script).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&hook_path, std::fs::Permissions::from_mode(0o755)).unwrap();
}
}
/// Remove the pre-receive hook from the bare repo.
fn remove_reject_hook(bare_dir: &std::path::Path) {
let hook_path = bare_dir.join("hooks").join("pre-receive");
let _ = std::fs::remove_file(&hook_path);
}
// T005 [US4]: Per-ref push isolates failures
#[test]
fn test_per_ref_push_isolates_failures() {
let cluster = TestCluster::new();
let alice_repo = cluster.alice_repo();
// Create two issues
let (_ref1, id1) = open_issue(&alice_repo, &alice(), "Issue one");
let (_ref2, id2) = open_issue(&alice_repo, &alice(), "Issue two");
// Install hook that rejects one specific issue
install_reject_hook(cluster.bare_dir(), &id1[..8]);
// Sync should fail with PartialSync
let result = sync::sync(&alice_repo, "origin");
assert!(result.is_err(), "sync should fail when a ref is rejected");
let err = result.unwrap_err();
match &err {
git_collab::error::Error::PartialSync { succeeded, total } => {
assert_eq!(*succeeded, 1, "one ref should succeed");
assert_eq!(*total, 2, "two refs total");
}
other => panic!("expected PartialSync error, got: {:?}", other),
}
// Verify the non-rejected ref was pushed to the bare remote
let bare_repo = Repository::open_bare(cluster.bare_dir()).unwrap();
let pushed_ref = format!("refs/collab/issues/{}", id2);
assert!(
bare_repo.refname_to_id(&pushed_ref).is_ok(),
"non-rejected ref should be on remote"
);
// The rejected ref should NOT be on the remote
let rejected_ref = format!("refs/collab/issues/{}", id1);
assert!(
bare_repo.refname_to_id(&rejected_ref).is_err(),
"rejected ref should NOT be on remote"
);
}
// T006 [US4]: All refs push successfully — happy path unchanged
#[test]
fn test_all_refs_push_succeeds_unchanged_behavior() {
let cluster = TestCluster::new();
let alice_repo = cluster.alice_repo();
let (_ref1, _id1) = open_issue(&alice_repo, &alice(), "Happy issue");
// Sync should succeed
let result = sync::sync(&alice_repo, "origin");
assert!(result.is_ok(), "sync should succeed: {:?}", result.err());
}
// T010 [US1]: Partial failure reports failed refs
#[test]
fn test_partial_failure_reports_failed_refs() {
let cluster = TestCluster::new();
let alice_repo = cluster.alice_repo();
let (_ref1, id1) = open_issue(&alice_repo, &alice(), "Will fail");
let (_ref2, _id2) = open_issue(&alice_repo, &alice(), "Will succeed");
install_reject_hook(cluster.bare_dir(), &id1[..8]);
let result = sync::sync(&alice_repo, "origin");
assert!(result.is_err());
// Verify sync state was saved with the failed ref
let state = sync::SyncState::load(&alice_repo);
assert!(state.is_some(), "sync state should be saved");
let state = state.unwrap();
assert_eq!(state.remote, "origin");
assert_eq!(state.pending_refs.len(), 1, "one ref should be pending");
assert!(
state.pending_refs[0].0.contains(&id1),
"pending ref should be the rejected one"
);
assert!(
!state.pending_refs[0].1.is_empty(),
"error message should be recorded"
);
}
// T011 [US1]: Total failure reports all failed
#[test]
fn test_total_failure_reports_all_failed() {
let cluster = TestCluster::new();
let alice_repo = cluster.alice_repo();
let (_ref1, _id1) = open_issue(&alice_repo, &alice(), "Fail one");
let (_ref2, _id2) = open_issue(&alice_repo, &alice(), "Fail two");
// Reject ALL collab refs
install_reject_hook(cluster.bare_dir(), "refs/collab/");
let result = sync::sync(&alice_repo, "origin");
assert!(result.is_err());
match result.unwrap_err() {
git_collab::error::Error::PartialSync { succeeded, total } => {
assert_eq!(succeeded, 0, "no refs should succeed");
assert_eq!(total, 2, "two refs total");
}
other => panic!("expected PartialSync, got: {:?}", other),
}
let state = sync::SyncState::load(&alice_repo).unwrap();
assert_eq!(state.pending_refs.len(), 2, "all refs should be pending");
}
// T014 [US2]: Resume retries only failed refs
#[test]
fn test_resume_retries_only_failed_refs() {
let cluster = TestCluster::new();
let alice_repo = cluster.alice_repo();
let (_ref1, id1) = open_issue(&alice_repo, &alice(), "Will fail first time");
let (_ref2, _id2) = open_issue(&alice_repo, &alice(), "Will succeed first time");
// First sync: reject id1
install_reject_hook(cluster.bare_dir(), &id1[..8]);
let _ = sync::sync(&alice_repo, "origin");
// Verify state was saved
let state = sync::SyncState::load(&alice_repo);
assert!(state.is_some());
assert_eq!(state.unwrap().pending_refs.len(), 1);
// Remove the hook so all refs can push
remove_reject_hook(cluster.bare_dir());
// Resume sync should succeed
let result = sync::sync(&alice_repo, "origin");
assert!(result.is_ok(), "resume sync should succeed: {:?}", result.err());
// State should be cleared
assert!(
sync::SyncState::load(&alice_repo).is_none(),
"sync state should be cleared after successful resume"
);
}
// T015 [US2]: Resume clears state on full success
#[test]
fn test_resume_clears_state_on_full_success() {
let cluster = TestCluster::new();
let alice_repo = cluster.alice_repo();
let (_ref1, id1) = open_issue(&alice_repo, &alice(), "Issue for resume");
// Manually create sync state
let state = sync::SyncState {
remote: "origin".to_string(),
pending_refs: vec![(
format!("refs/collab/issues/{}", id1),
"previous error".to_string(),
)],
timestamp: chrono::Utc::now().to_rfc3339(),
};
state.save(&alice_repo).unwrap();
// Sync in resume mode
let result = sync::sync(&alice_repo, "origin");
assert!(result.is_ok(), "resume should succeed: {:?}", result.err());
// Verify state file is gone
let state_path = alice_repo.path().join("collab").join("sync-state.json");
assert!(
!state_path.exists(),
"sync-state.json should be deleted after successful resume"
);
}
// T016 [US2]: Resume updates state on continued failure
#[test]
fn test_resume_updates_state_on_continued_failure() {
let cluster = TestCluster::new();
let alice_repo = cluster.alice_repo();
let (_ref1, id1) = open_issue(&alice_repo, &alice(), "Will keep failing");
let (_ref2, id2) = open_issue(&alice_repo, &alice(), "Will succeed on retry");
// Manually create sync state with both refs pending
let state = sync::SyncState {
remote: "origin".to_string(),
pending_refs: vec![
(
format!("refs/collab/issues/{}", id1),
"previous error".to_string(),
),
(
format!("refs/collab/issues/{}", id2),
"previous error".to_string(),
),
],
timestamp: chrono::Utc::now().to_rfc3339(),
};
state.save(&alice_repo).unwrap();
// Install hook that only rejects id1
install_reject_hook(cluster.bare_dir(), &id1[..8]);
// Resume should partially fail
let result = sync::sync(&alice_repo, "origin");
assert!(result.is_err());
// State should be updated with only the still-failing ref
let new_state = sync::SyncState::load(&alice_repo).unwrap();
assert_eq!(
new_state.pending_refs.len(),
1,
"only the still-failing ref should remain"
);
assert!(
new_state.pending_refs[0].0.contains(&id1),
"the still-failing ref should be id1"
);
}
// T017 [US2]: No resume without state file
#[test]
fn test_no_resume_without_state_file() {
let cluster = TestCluster::new();
let alice_repo = cluster.alice_repo();
let (_ref1, _id1) = open_issue(&alice_repo, &alice(), "Normal sync");
// No sync state file — should run full flow
let result = sync::sync(&alice_repo, "origin");
assert!(result.is_ok(), "normal sync should succeed: {:?}", result.err());
}
// T018b [US2]: Resume cleans up stale sync refs
#[test]
fn test_resume_cleans_up_stale_sync_refs() {
let cluster = TestCluster::new();
let alice_repo = cluster.alice_repo();
let (_ref1, id1) = open_issue(&alice_repo, &alice(), "Issue for stale ref test");
// Push the issue first (normal sync)
sync::sync(&alice_repo, "origin").unwrap();
// Now manually create a stale sync ref
let tip = alice_repo
.refname_to_id(&format!("refs/collab/issues/{}", id1))
.unwrap();
alice_repo
.reference(
"refs/collab/sync/origin/issues/stale_ref",
tip,
true,
"create stale sync ref",
)
.unwrap();
// Create sync state to trigger resume mode
let state = sync::SyncState {
remote: "origin".to_string(),
pending_refs: vec![(
format!("refs/collab/issues/{}", id1),
"previous error".to_string(),
)],
timestamp: chrono::Utc::now().to_rfc3339(),
};
state.save(&alice_repo).unwrap();
// Resume sync
let result = sync::sync(&alice_repo, "origin");
assert!(result.is_ok(), "resume should succeed: {:?}", result.err());
// Verify stale sync ref was cleaned up
let stale_ref = alice_repo.refname_to_id("refs/collab/sync/origin/issues/stale_ref");
assert!(
stale_ref.is_err(),
"stale sync ref should be cleaned up during resume"
);
}
// T021 [US3]: Stale state detected and cleared when refs are already up to date
#[test]
fn test_stale_state_detected_and_cleared() {
let cluster = TestCluster::new();
let alice_repo = cluster.alice_repo();
let (_ref1, id1) = open_issue(&alice_repo, &alice(), "Already pushed issue");
// Push the issue normally first
sync::sync(&alice_repo, "origin").unwrap();
// Now create sync state as if the push had failed — but the refs are actually pushed
let state = sync::SyncState {
remote: "origin".to_string(),
pending_refs: vec![(
format!("refs/collab/issues/{}", id1),
"connection timed out".to_string(),
)],
timestamp: chrono::Utc::now().to_rfc3339(),
};
state.save(&alice_repo).unwrap();
// Resume sync — refs are already up to date
let result = sync::sync(&alice_repo, "origin");
assert!(
result.is_ok(),
"sync should succeed when refs already up to date: {:?}",
result.err()
);
// State should be cleared
assert!(
sync::SyncState::load(&alice_repo).is_none(),
"stale sync state should be cleared"
);
}
// T022 [US3]: State for different remote is ignored
#[test]
fn test_state_for_different_remote_ignored() {
let cluster = TestCluster::new();
let alice_repo = cluster.alice_repo();
let (_ref1, id1) = open_issue(&alice_repo, &alice(), "Issue for remote test");
// Create sync state for a different remote
let state = sync::SyncState {
remote: "upstream".to_string(),
pending_refs: vec![(
format!("refs/collab/issues/{}", id1),
"some error".to_string(),
)],
timestamp: chrono::Utc::now().to_rfc3339(),
};
state.save(&alice_repo).unwrap();
// Sync against origin — should run full flow, ignoring the upstream state
let result = sync::sync(&alice_repo, "origin");
assert!(
result.is_ok(),
"sync to origin should succeed ignoring upstream state: {:?}",
result.err()
);
}
// T026: Corrupted state file handled gracefully
#[test]
fn test_corrupted_state_file_handled_gracefully() {
let cluster = TestCluster::new();
let alice_repo = cluster.alice_repo();
let (_ref1, _id1) = open_issue(&alice_repo, &alice(), "Issue for corruption test");
// Write invalid JSON to sync-state.json
let collab_dir = alice_repo.path().join("collab");
std::fs::create_dir_all(&collab_dir).unwrap();
std::fs::write(collab_dir.join("sync-state.json"), "not valid json{{{").unwrap();
// Sync should proceed normally (treating as no state)
let result = sync::sync(&alice_repo, "origin");
assert!(
result.is_ok(),
"sync should succeed despite corrupted state: {:?}",
result.err()
);
// State file should be cleaned up
assert!(
!collab_dir.join("sync-state.json").exists(),
"corrupted state file should be deleted"
);
}
// ---------------------------------------------------------------------------
// Commit-link tests (src/commit_link.rs)
// ---------------------------------------------------------------------------
use git_collab::commit_link;
fn make_commit_with_message(repo: &Repository, message: &str) -> git2::Oid {
let sig = git2::Signature::now("Alice", "alice@example.com").unwrap();
// The TestCluster bare repo commits onto refs/heads/main but its HEAD
// remains the default refs/heads/master, so clones don't get a local
// refs/heads/main automatically. Make sure it exists before we extend it.
let parent_oid = if let Ok(r) = repo.find_reference("refs/heads/main") {
r.target().unwrap()
} else {
let remote_main = repo
.find_reference("refs/remotes/origin/main")
.expect("origin/main should exist on the cloned test repo");
let oid = remote_main.target().unwrap();
repo.reference("refs/heads/main", oid, false, "seed local main")
.unwrap();
oid
};
let parent = repo.find_commit(parent_oid).unwrap();
let tree_oid = parent.tree().unwrap().id();
let tree = repo.find_tree(tree_oid).unwrap();
repo.commit(
Some("refs/heads/main"),
&sig,
&sig,
message,
&tree,
&[&parent],
)
.unwrap()
}
#[test]
fn commit_link_scan_emits_event_for_matching_trailer() {
let cluster = TestCluster::new();
let alice_repo = cluster.alice_repo();
// Open an issue.
let (issue_ref, issue_id) = open_issue(&alice_repo, &alice(), "fix the walker");
// Create a commit whose trailer references that issue.
let message = format!("Fix walker\n\nIssue: {}", &issue_id[..8]);
let commit_oid = make_commit_with_message(&alice_repo, &message);
// Run the scanner directly (we test the sync integration in later tests).
let author = git_collab::identity::get_author(&alice_repo).unwrap();
let sk = signing::load_signing_key(
&signing::signing_key_dir().unwrap(),
)
.unwrap();
let emitted = commit_link::scan_and_link(&alice_repo, &author, &sk).unwrap();
assert_eq!(emitted, 1);
// Walk the issue's event log and find the link.
let issue = IssueState::from_ref_uncached(&alice_repo, &issue_ref, &issue_id).unwrap();
assert_eq!(issue.linked_commits.len(), 1);
assert_eq!(issue.linked_commits[0].commit, commit_oid.to_string());
}
#[test]
fn sync_entry_point_emits_commit_link_events_and_pushes_them() {
let cluster = TestCluster::new();
let alice_repo = cluster.alice_repo();
// Alice opens an issue and syncs it up so Bob will see it too.
let (_issue_ref, issue_id) = open_issue(&alice_repo, &alice(), "bug");
sync::sync(&alice_repo, "origin").unwrap();
// Alice makes a real commit with an Issue: trailer.
let message = format!("Fix the thing\n\nIssue: {}", &issue_id[..8]);
make_commit_with_message(&alice_repo, &message);
// Running sync should scan, emit the link, and push it.
sync::sync(&alice_repo, "origin").unwrap();
// Bob fetches and should see the link in the materialized issue.
let bob_repo = cluster.bob_repo();
sync::sync(&bob_repo, "origin").unwrap();
let bob_issue_ref = format!("refs/collab/issues/{}", issue_id);
let bob_issue = IssueState::from_ref_uncached(&bob_repo, &bob_issue_ref, &issue_id).unwrap();
assert_eq!(bob_issue.linked_commits.len(), 1);
}
#[test]
fn commit_link_scan_is_idempotent_across_runs() {
let cluster = TestCluster::new();
let alice_repo = cluster.alice_repo();
let (issue_ref, issue_id) = open_issue(&alice_repo, &alice(), "bug");
let message = format!("Fix thing\n\nIssue: {}", &issue_id[..8]);
make_commit_with_message(&alice_repo, &message);
let author = git_collab::identity::get_author(&alice_repo).unwrap();
let sk = signing::load_signing_key(&signing::signing_key_dir().unwrap()).unwrap();
assert_eq!(commit_link::scan_and_link(&alice_repo, &author, &sk).unwrap(), 1);
assert_eq!(commit_link::scan_and_link(&alice_repo, &author, &sk).unwrap(), 0);
assert_eq!(commit_link::scan_and_link(&alice_repo, &author, &sk).unwrap(), 0);
let issue = IssueState::from_ref_uncached(&alice_repo, &issue_ref, &issue_id).unwrap();
assert_eq!(issue.linked_commits.len(), 1);
}
#[test]
fn commit_link_scan_walks_all_local_branches_and_dedups_shared_ancestors() {
let cluster = TestCluster::new();
let alice_repo = cluster.alice_repo();
let (issue_ref, issue_id) = open_issue(&alice_repo, &alice(), "bug");
// Commit on main with the trailer. Both branches will reach it.
let message = format!("Fix\n\nIssue: {}", &issue_id[..8]);
let linked_commit = make_commit_with_message(&alice_repo, &message);
// Create a second branch pointing at the same commit.
{
let commit = alice_repo.find_commit(linked_commit).unwrap();
alice_repo.branch("feature-x", &commit, false).unwrap();
}
let author = git_collab::identity::get_author(&alice_repo).unwrap();
let sk = signing::load_signing_key(&signing::signing_key_dir().unwrap()).unwrap();
// Should emit exactly one event despite the commit being reachable from
// two branch tips.
assert_eq!(commit_link::scan_and_link(&alice_repo, &author, &sk).unwrap(), 1);
let issue = IssueState::from_ref_uncached(&alice_repo, &issue_ref, &issue_id).unwrap();
assert_eq!(issue.linked_commits.len(), 1);
}
#[test]
fn commit_link_scan_handles_multiple_issue_trailers_on_one_commit() {
let cluster = TestCluster::new();
let alice_repo = cluster.alice_repo();
let (issue_ref_a, id_a) = open_issue(&alice_repo, &alice(), "bug a");
let (issue_ref_b, id_b) = open_issue(&alice_repo, &alice(), "bug b");
let message = format!(
"Fix both\n\nIssue: {}\nIssue: {}",
&id_a[..8],
&id_b[..8]
);
make_commit_with_message(&alice_repo, &message);
let author = git_collab::identity::get_author(&alice_repo).unwrap();
let sk = signing::load_signing_key(&signing::signing_key_dir().unwrap()).unwrap();
assert_eq!(commit_link::scan_and_link(&alice_repo, &author, &sk).unwrap(), 2);
let issue_a = IssueState::from_ref_uncached(&alice_repo, &issue_ref_a, &id_a).unwrap();
let issue_b = IssueState::from_ref_uncached(&alice_repo, &issue_ref_b, &id_b).unwrap();
assert_eq!(issue_a.linked_commits.len(), 1);
assert_eq!(issue_b.linked_commits.len(), 1);
}
#[test]
fn commit_link_scan_skips_unknown_prefix_without_error() {
let cluster = TestCluster::new();
let alice_repo = cluster.alice_repo();
// No issue exists. Commit uses a completely unrelated prefix.
make_commit_with_message(
&alice_repo,
"Fix\n\nIssue: zzzzzzzz",
);
let author = git_collab::identity::get_author(&alice_repo).unwrap();
let sk = signing::load_signing_key(&signing::signing_key_dir().unwrap()).unwrap();
assert_eq!(commit_link::scan_and_link(&alice_repo, &author, &sk).unwrap(), 0);
}
#[test]
fn commit_link_scan_skips_genuinely_ambiguous_prefix() {
let cluster = TestCluster::new();
let alice_repo = cluster.alice_repo();
// Open 17 issues. By pigeonhole on the 16 possible hex first-chars,
// at least two of the resulting issue IDs must share a first character,
// guaranteeing we can construct an ambiguous one-char prefix.
let mut ids: Vec<String> = Vec::with_capacity(17);
for i in 0..17 {
let (_, id) = open_issue(&alice_repo, &alice(), &format!("issue {}", i));
ids.push(id);
}
// Find a first-char that has at least 2 matching IDs.
let mut counts: std::collections::HashMap<char, Vec<&str>> =
std::collections::HashMap::new();
for id in &ids {
let c = id.chars().next().unwrap();
counts.entry(c).or_default().push(id.as_str());
}
let (ambiguous_char, matching_ids) = counts
.iter()
.find(|(_, v)| v.len() >= 2)
.map(|(c, v)| (*c, v.clone()))
.expect("pigeonhole guarantees at least one shared first char among 17 hex IDs");
let ambiguous_prefix = ambiguous_char.to_string();
make_commit_with_message(
&alice_repo,
&format!("Touch\n\nIssue: {}", ambiguous_prefix),
);
let author = git_collab::identity::get_author(&alice_repo).unwrap();
let sk = signing::load_signing_key(&signing::signing_key_dir().unwrap()).unwrap();
// Ambiguous prefix must be skipped silently — no event emitted.
assert_eq!(
commit_link::scan_and_link(&alice_repo, &author, &sk).unwrap(),
0
);
// None of the candidate issues should have grown a commit-link event:
// each one still consists only of its IssueOpen event.
for id in &matching_ids {
let issue_ref = format!("refs/collab/issues/{}", id);
let state = IssueState::from_ref_uncached(&alice_repo, &issue_ref, id).unwrap();
assert!(
state.linked_commits.is_empty(),
"candidate issue {} should not have any linked commits, but has {}",
id,
state.linked_commits.len()
);
}
}
#[test]
fn commit_link_scan_skips_archived_issues_with_warning() {
let cluster = TestCluster::new();
let alice_repo = cluster.alice_repo();
let (_, issue_id) = open_issue(&alice_repo, &alice(), "old bug");
// Archive the issue via the state helper.
state::archive_issue_ref(&alice_repo, &issue_id).unwrap();
make_commit_with_message(
&alice_repo,
&format!("Reference old\n\nIssue: {}", &issue_id[..8]),
);
let author = git_collab::identity::get_author(&alice_repo).unwrap();
let sk = signing::load_signing_key(&signing::signing_key_dir().unwrap()).unwrap();
assert_eq!(commit_link::scan_and_link(&alice_repo, &author, &sk).unwrap(), 0);
// Confirm the archived ref did not accrue a new event: the archived
// DAG tip should still be the archive-time tip.
let archived_ref = format!("refs/collab/archive/issues/{}", issue_id);
let archived_state =
IssueState::from_ref_uncached(&alice_repo, &archived_ref, &issue_id).unwrap();
assert!(archived_state.linked_commits.is_empty());
}
#[test]
fn commit_link_scan_no_op_on_detached_head_with_no_branches() {
let cluster = TestCluster::new();
let alice_repo = cluster.alice_repo();
// Seed refs/heads/main so HEAD resolves to a commit we can detach onto.
// make_commit_with_message creates refs/heads/main if missing.
make_commit_with_message(&alice_repo, "seed for detached head test");
// Put HEAD in detached state pointing at the current main tip, then
// delete all local branches so scan_and_link has nothing to walk.
let head_oid = alice_repo
.find_reference("refs/heads/main")
.unwrap()
.target()
.unwrap();
alice_repo.set_head_detached(head_oid).unwrap();
alice_repo
.find_reference("refs/heads/main")
.unwrap()
.delete()
.unwrap();
let author = git_collab::identity::get_author(&alice_repo).unwrap();
let sk = signing::load_signing_key(&signing::signing_key_dir().unwrap()).unwrap();
// No branches to walk — silent no-op.
assert_eq!(commit_link::scan_and_link(&alice_repo, &author, &sk).unwrap(), 0);
}
#[test]
fn commit_link_scan_dedups_against_remote_originated_events() {
// Simulates the cross-machine dedup case: Bob's repo fetches a link
// event that Alice already emitted, then runs scan locally with the
// same commit reachable from his branches. He must not emit a
// duplicate.
let cluster = TestCluster::new();
let alice_repo = cluster.alice_repo();
let bob_repo = cluster.bob_repo();
// Both repos get the same issue.
let (_, issue_id) = open_issue(&alice_repo, &alice(), "bug");
sync::sync(&alice_repo, "origin").unwrap();
sync::sync(&bob_repo, "origin").unwrap();
// Alice writes a commit and pushes it to the bare remote so Bob can
// fetch it. First the regular git push; then sync for the link event.
let message = format!("Fix thing\n\nIssue: {}", &issue_id[..8]);
let linked_commit = make_commit_with_message(&alice_repo, &message);
// Push the branch so Bob sees the commit too.
let mut cmd = Command::new("git");
cmd.args(["push", "origin", "main"])
.current_dir(cluster.alice_dir.path());
assert!(cmd.status().unwrap().success());
sync::sync(&alice_repo, "origin").unwrap();
// Bob fetches both the branch and the collab link event.
let mut cmd = Command::new("git");
cmd.args(["fetch", "origin", "main:main"])
.current_dir(cluster.bob_dir.path());
assert!(cmd.status().unwrap().success());
sync::sync(&bob_repo, "origin").unwrap();
// At this point Bob's issue already has the link event from Alice.
// Re-running scan on Bob's repo must find the commit locally and
// decide "already linked", emitting zero events.
let author = git_collab::identity::get_author(&bob_repo).unwrap();
let sk = signing::load_signing_key(&signing::signing_key_dir().unwrap()).unwrap();
let emitted = commit_link::scan_and_link(&bob_repo, &author, &sk).unwrap();
assert_eq!(emitted, 0, "Bob must not duplicate Alice's link event");
// And the commit on Bob's side really is the one linked.
let bob_ref = format!("refs/collab/issues/{}", issue_id);
let bob_issue = IssueState::from_ref_uncached(&bob_repo, &bob_ref, &issue_id).unwrap();
assert_eq!(bob_issue.linked_commits.len(), 1);
assert_eq!(bob_issue.linked_commits[0].commit, linked_commit.to_string());
}
#[test]
fn cli_issue_show_renders_linked_commits_section() {
use common::TestRepo;
let repo = TestRepo::new("Alice", "alice@example.com");
let issue_id = repo.issue_open("fix the thing");
// Resolve the full id via --json so we can format a trailer prefix.
let full_id = {
let out = repo.run_ok(&["issue", "show", &issue_id, "--json"]);
let v: serde_json::Value = serde_json::from_str(&out).unwrap();
v["id"].as_str().unwrap().to_string()
};
let msg = format!("Fix a thing\n\nIssue: {}", &full_id[..8]);
repo.git(&["commit", "--allow-empty", "-m", &msg]);
// Set up a bare remote so sync has something to push to.
let bare = TempDir::new().unwrap();
Command::new("git")
.args(["init", "--bare"])
.current_dir(bare.path())
.status()
.unwrap();
repo.git(&["remote", "add", "origin", bare.path().to_str().unwrap()]);
repo.git(&["push", "-u", "origin", "main"]);
repo.run_ok(&["init"]);
repo.run_ok(&["sync"]);
let show = repo.run_ok(&["issue", "show", &issue_id]);
assert!(
show.contains("--- Linked Commits ---"),
"expected linked commits section, got:\n{}",
show
);
assert!(
show.contains("by Alice"),
"expected commit author rendered, got:\n{}",
show
);
assert!(
show.contains("(linked by Alice"),
"expected event author rendered, got:\n{}",
show
);
}