a73x

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