a73x

117a3f44

Add end-to-end test coverage

a73x   2026-04-03 17:17


diff --git a/tests/cli_test.rs b/tests/cli_test.rs
index 1ca1b58..8014bfb 100644
--- a/tests/cli_test.rs
+++ b/tests/cli_test.rs
@@ -618,6 +618,43 @@ fn test_init_no_remotes() {
}

// ===========================================================================
// Top-level status/log commands
// ===========================================================================

#[test]
fn test_status_command_reports_summary_and_recent_items() {
    let repo = TestRepo::new("Alice", "alice@example.com");
    repo.issue_open("Status bug");
    repo.patch_create("Status patch");

    let out = repo.run_ok(&["status"]);
    assert!(out.contains("Issues:  1 open, 0 closed"), "unexpected status output: {}", out);
    assert!(out.contains("Patches: 1 open, 0 merged, 0 closed"), "unexpected status output: {}", out);
    assert!(out.contains("Recently updated:"), "unexpected status output: {}", out);
    assert!(out.contains("[issue]") && out.contains("Status bug"), "unexpected status output: {}", out);
    assert!(out.contains("[patch]") && out.contains("Status patch"), "unexpected status output: {}", out);
}

#[test]
fn test_log_command_shows_recent_collab_events_and_limit() {
    let repo = TestRepo::new("Alice", "alice@example.com");
    let issue_id = repo.issue_open("Logged issue");
    repo.run_ok(&["issue", "comment", &issue_id, "-b", "Logged comment"]);
    repo.patch_create("Logged patch");

    let out = repo.run_ok(&["log"]);
    assert!(out.contains("IssueOpen issue"), "unexpected log output: {}", out);
    assert!(out.contains("IssueComment issue"), "unexpected log output: {}", out);
    assert!(out.contains("PatchCreate patch"), "unexpected log output: {}", out);
    assert!(out.contains("open \"Logged issue\""), "unexpected log output: {}", out);
    assert!(out.contains("Logged comment"), "unexpected log output: {}", out);
    assert!(out.contains("create \"Logged patch\""), "unexpected log output: {}", out);

    let limited = repo.run_ok(&["log", "-n", "2"]);
    assert_eq!(limited.lines().count(), 2, "expected exactly 2 log lines, got: {}", limited);
}

// ===========================================================================
// Full scenario tests
// ===========================================================================

@@ -881,3 +918,41 @@ fn test_patch_delete_then_show_errors() {
    repo.run_ok(&["patch", "delete", &id]);
    repo.run_err(&["patch", "show", &id]);
}

// ===========================================================================
// Dashboard smoke
// ===========================================================================

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

    let output = repo.run_dashboard_smoke("q");
    let stdout = String::from_utf8_lossy(&output.stdout);
    let stderr = String::from_utf8_lossy(&output.stderr);

    assert!(
        output.status.success(),
        "dashboard should exit cleanly\nstdout: {}\nstderr: {}",
        stdout,
        stderr
    );
}

#[test]
fn test_dashboard_smoke_with_seeded_collab_data_exits_cleanly() {
    let repo = TestRepo::new("Alice", "alice@example.com");
    repo.issue_open("Dashboard issue");
    repo.patch_create("Dashboard patch");

    let output = repo.run_dashboard_smoke("q");
    let stdout = String::from_utf8_lossy(&output.stdout);
    let stderr = String::from_utf8_lossy(&output.stderr);

    assert!(
        output.status.success(),
        "dashboard with seeded data should exit cleanly\nstdout: {}\nstderr: {}",
        stdout,
        stderr
    );
}
diff --git a/tests/common/mod.rs b/tests/common/mod.rs
index d8b2da7..14be07c 100644
--- a/tests/common/mod.rs
+++ b/tests/common/mod.rs
@@ -1,7 +1,14 @@
#![allow(dead_code)]

use std::path::Path;
use std::process::{Command, Output};
use std::env;
use std::ffi::OsString;
use std::io::{Read, Write};
use std::net::{SocketAddr, TcpListener, TcpStream};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Output, Stdio};
use std::sync::{Mutex, MutexGuard, OnceLock};
use std::thread;
use std::time::{Duration, Instant};

use ed25519_dalek::SigningKey;
use git2::Repository;
@@ -45,6 +52,127 @@ pub fn setup_signing_key(config_dir: &Path) {
    git_collab::signing::generate_keypair(config_dir).expect("generate test keypair");
}

fn test_env_lock() -> &'static Mutex<()> {
    static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
    LOCK.get_or_init(|| Mutex::new(()))
}

fn set_or_remove_env(key: &str, value: Option<OsString>) {
    match value {
        Some(value) => env::set_var(key, value),
        None => env::remove_var(key),
    }
}

pub struct TestHome {
    root: TempDir,
    home_dir: PathBuf,
    xdg_config_home: PathBuf,
    git_config_global: PathBuf,
}

impl TestHome {
    pub fn new() -> Self {
        let root = TempDir::new().unwrap();
        let home_dir = root.path().join("home");
        let xdg_config_home = root.path().join("xdg-config");
        std::fs::create_dir_all(&home_dir).unwrap();
        std::fs::create_dir_all(&xdg_config_home).unwrap();

        let git_config_global = root.path().join("gitconfig");
        std::fs::write(&git_config_global, "").unwrap();

        Self {
            root,
            home_dir,
            xdg_config_home,
            git_config_global,
        }
    }

    pub fn home_dir(&self) -> &Path {
        &self.home_dir
    }

    pub fn config_home(&self) -> &Path {
        &self.xdg_config_home
    }

    pub fn collab_config_dir(&self) -> PathBuf {
        self.xdg_config_home.join("git-collab")
    }

    pub fn ensure_signing_key(&self) {
        let config_dir = self.collab_config_dir();
        if !config_dir.join("signing-key").exists() {
            setup_signing_key(&config_dir);
        }
    }

    pub fn apply_to_command<'a>(&self, cmd: &'a mut Command) -> &'a mut Command {
        let _ = &self.root;
        cmd.env("HOME", &self.home_dir)
            .env("XDG_CONFIG_HOME", &self.xdg_config_home)
            .env("GIT_CONFIG_GLOBAL", &self.git_config_global)
            .env("GIT_CONFIG_NOSYSTEM", "1")
    }

    fn apply_to_process(&self) {
        let _ = &self.root;
        env::set_var("HOME", &self.home_dir);
        env::set_var("XDG_CONFIG_HOME", &self.xdg_config_home);
        env::set_var("GIT_CONFIG_GLOBAL", &self.git_config_global);
        env::set_var("GIT_CONFIG_NOSYSTEM", "1");
    }
}

pub struct ScopedTestConfig {
    _lock: MutexGuard<'static, ()>,
    env: TestHome,
    old_home: Option<OsString>,
    old_xdg_config_home: Option<OsString>,
    old_git_config_global: Option<OsString>,
    old_git_config_nosystem: Option<OsString>,
}

impl ScopedTestConfig {
    pub fn new() -> Self {
        let lock = test_env_lock().lock().unwrap();
        let test_home = TestHome::new();
        let old_home = env::var_os("HOME");
        let old_xdg_config_home = env::var_os("XDG_CONFIG_HOME");
        let old_git_config_global = env::var_os("GIT_CONFIG_GLOBAL");
        let old_git_config_nosystem = env::var_os("GIT_CONFIG_NOSYSTEM");
        test_home.apply_to_process();

        Self {
            _lock: lock,
            env: test_home,
            old_home,
            old_xdg_config_home,
            old_git_config_global,
            old_git_config_nosystem,
        }
    }

    pub fn config_dir(&self) -> PathBuf {
        self.env.collab_config_dir()
    }

    pub fn ensure_signing_key(&self) {
        self.env.ensure_signing_key();
    }
}

impl Drop for ScopedTestConfig {
    fn drop(&mut self) {
        set_or_remove_env("HOME", self.old_home.take());
        set_or_remove_env("XDG_CONFIG_HOME", self.old_xdg_config_home.take());
        set_or_remove_env("GIT_CONFIG_GLOBAL", self.old_git_config_global.take());
        set_or_remove_env("GIT_CONFIG_NOSYSTEM", self.old_git_config_nosystem.take());
    }
}

/// Create a non-bare repo in a directory with user identity configured
/// and an initial empty commit on `main`.
pub fn init_repo(dir: &Path, author: &Author) -> Repository {
@@ -175,37 +303,47 @@ pub fn add_review(repo: &Repository, ref_name: &str, author: &Author, verdict: R
/// A temporary git repository for end-to-end CLI testing.
pub struct TestRepo {
    pub dir: TempDir,
    env: TestHome,
}

impl TestRepo {
    /// Create a new repo with user identity and an initial empty commit on `main`.
    /// Also ensures a signing key exists in the default config dir.
    /// Also ensures a signing key exists in an isolated temp config dir.
    pub fn new(name: &str, email: &str) -> Self {
        let dir = TempDir::new().unwrap();
        git(dir.path(), &["init", "-b", "main"]);
        git(dir.path(), &["config", "user.name", name]);
        git(dir.path(), &["config", "user.email", email]);
        git(dir.path(), &["commit", "--allow-empty", "-m", "initial"]);

        // Ensure signing key exists for CLI operations
        let config_dir = dirs::config_dir()
            .unwrap_or_else(|| {
                let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
                std::path::PathBuf::from(home).join(".config")
            })
            .join("git-collab");
        if !config_dir.join("signing-key").exists() {
            setup_signing_key(&config_dir);
        }
        let env = TestHome::new();
        git_with_env(dir.path(), &["init", "-b", "main"], &env);
        git_with_env(dir.path(), &["config", "user.name", name], &env);
        git_with_env(dir.path(), &["config", "user.email", email], &env);
        git_with_env(dir.path(), &["commit", "--allow-empty", "-m", "initial"], &env);
        env.ensure_signing_key();

        TestRepo { dir, env }
    }

        TestRepo { dir }
    pub fn home_dir(&self) -> &Path {
        self.env.home_dir()
    }

    pub fn config_dir(&self) -> PathBuf {
        self.env.collab_config_dir()
    }

    pub fn apply_env<'a>(&self, cmd: &'a mut Command) -> &'a mut Command {
        self.env.apply_to_command(cmd)
    }

    pub fn cli_command(&self) -> Command {
        let mut cmd = Command::new(env!("CARGO_BIN_EXE_git-collab"));
        self.apply_env(&mut cmd);
        cmd.current_dir(self.dir.path());
        cmd
    }

    /// Run git-collab and return raw output.
    pub fn run(&self, args: &[&str]) -> Output {
        Command::new(env!("CARGO_BIN_EXE_git-collab"))
        self.cli_command()
            .args(args)
            .current_dir(self.dir.path())
            .output()
            .expect("failed to run git-collab")
    }
@@ -238,6 +376,40 @@ impl TestRepo {
        String::from_utf8(output.stderr).unwrap()
    }

    /// Run `git-collab dashboard` in a pseudo-terminal, feed input, and return raw output.
    pub fn run_dashboard_smoke(&self, input: &str) -> Output {
        let mut command = Command::new("script");
        self.apply_env(&mut command);
        let mut child = command
            .args([
                "-qec",
                &format!("{} dashboard", env!("CARGO_BIN_EXE_git-collab")),
                "/dev/null",
            ])
            .current_dir(self.dir.path())
            .stdin(Stdio::piped())
            .stdout(Stdio::piped())
            .stderr(Stdio::piped())
            .spawn()
            .expect("failed to launch dashboard in pty");

        if let Some(mut stdin) = child.stdin.take() {
            stdin.write_all(input.as_bytes()).unwrap();
        }

        let deadline = Instant::now() + Duration::from_secs(5);
        loop {
            if let Some(_status) = child.try_wait().expect("failed to poll dashboard process") {
                return child.wait_with_output().expect("failed to collect dashboard output");
            }
            if Instant::now() >= deadline {
                let _ = child.kill();
                return child.wait_with_output().expect("failed to collect timed out dashboard output");
            }
            thread::sleep(Duration::from_millis(20));
        }
    }

    /// Open an issue and return the 8-char short ID.
    pub fn issue_open(&self, title: &str) -> String {
        let out = self.run_ok(&["issue", "open", "-t", title]);
@@ -269,7 +441,9 @@ impl TestRepo {

    /// Run a git command in this repo and return stdout.
    pub fn git(&self, args: &[&str]) -> String {
        let output = Command::new("git")
        let mut command = Command::new("git");
        self.apply_env(&mut command);
        let output = command
            .args(args)
            .current_dir(self.dir.path())
            .output()
@@ -296,6 +470,158 @@ impl TestRepo {
    }
}

pub struct HttpResponse {
    pub status_line: String,
    pub body: String,
}

pub struct ServerHarness {
    root: TempDir,
    repo_name: String,
    work_repo: TestRepo,
    server: Child,
    http_addr: SocketAddr,
}

impl ServerHarness {
    pub fn new(repo_name: &str) -> Self {
        let root = TempDir::new().unwrap();
        let repos_dir = root.path().join("repos");
        std::fs::create_dir_all(&repos_dir).unwrap();

        let bare_repo_dir = repos_dir.join(format!("{repo_name}.git"));
        git_cmd(root.path(), &["init", "--bare", bare_repo_dir.to_str().unwrap()]);

        let work_repo = TestRepo::new("Alice", "alice@example.com");
        work_repo.git(&["remote", "add", "origin", bare_repo_dir.to_str().unwrap()]);

        let authorized_keys = root.path().join("authorized_keys");
        std::fs::write(&authorized_keys, "").unwrap();

        let http_addr = pick_loopback_addr();
        let ssh_addr = pick_loopback_addr();
        let config_path = root.path().join("server.toml");
        std::fs::write(
            &config_path,
            format!(
                "repos_dir = {:?}\nhttp_bind = \"{}\"\nssh_bind = \"{}\"\nauthorized_keys = {:?}\nsite_title = \"git-collab test\"\n",
                repos_dir,
                http_addr,
                ssh_addr,
                authorized_keys,
            ),
        )
        .unwrap();

        let server = Command::new(env!("CARGO_BIN_EXE_git-collab-server"))
            .args(["--config", config_path.to_str().unwrap()])
            .stdout(Stdio::piped())
            .stderr(Stdio::piped())
            .spawn()
            .expect("failed to start git-collab-server");

        let mut harness = Self {
            root,
            repo_name: repo_name.to_string(),
            work_repo,
            server,
            http_addr,
        };
        harness.wait_until_ready();
        harness
    }

    pub fn repo_name(&self) -> &str {
        &self.repo_name
    }

    pub fn work_repo(&self) -> &TestRepo {
        &self.work_repo
    }

    pub fn push_head(&self) {
        self.work_repo.git(&["push", "-u", "origin", "main"]);
    }

    pub fn push_collab_refs(&self) {
        self.work_repo
            .git(&["push", "origin", "refs/collab/*:refs/collab/*"]);
    }

    pub fn get_ok(&self, path: &str) -> HttpResponse {
        let response = self.get(path);
        assert!(
            response.status_line.contains("200"),
            "expected 200 for {path}, got {}\nbody:\n{}",
            response.status_line,
            response.body
        );
        response
    }

    pub fn get(&self, path: &str) -> HttpResponse {
        let mut stream = TcpStream::connect(self.http_addr)
            .unwrap_or_else(|e| panic!("failed to connect to http server on {}: {}", self.http_addr, e));
        stream
            .write_all(
                format!(
                    "GET {} HTTP/1.1\r\nHost: {}\r\nConnection: close\r\n\r\n",
                    path, self.http_addr
                )
                .as_bytes(),
            )
            .unwrap();
        let mut raw = Vec::new();
        stream.read_to_end(&mut raw).unwrap();
        let raw = String::from_utf8(raw).unwrap();
        let (head, body) = raw.split_once("\r\n\r\n").unwrap_or((&raw, ""));
        let status_line = head.lines().next().unwrap_or("").to_string();
        HttpResponse {
            status_line,
            body: body.to_string(),
        }
    }

    fn wait_until_ready(&mut self) {
        let deadline = Instant::now() + Duration::from_secs(10);
        loop {
            if let Some(status) = self.server_status() {
                panic!("git-collab-server exited before becoming ready: {status}");
            }

            if let Ok(response) = TcpStream::connect(self.http_addr) {
                drop(response);
                let response = self.get("/");
                if !response.status_line.is_empty() {
                    return;
                }
            }

            if Instant::now() >= deadline {
                panic!("timed out waiting for git-collab-server on {}", self.http_addr);
            }

            thread::sleep(Duration::from_millis(50));
        }
    }

    fn server_status(&mut self) -> Option<String> {
        self.server
            .try_wait()
            .ok()
            .flatten()
            .map(|status| format!("exit status {:?}", status.code()))
    }
}

impl Drop for ServerHarness {
    fn drop(&mut self) {
        let _ = self.server.kill();
        let _ = self.server.wait();
        let _ = &self.root;
    }
}

/// Create an unsigned event commit (plain Event JSON, no signature/pubkey blobs).
/// Returns the commit OID.
pub fn create_unsigned_event(repo: &Repository, event: &Event) -> git2::Oid {
@@ -350,6 +676,10 @@ pub fn git_cmd(dir: &Path, args: &[&str]) {
    git(dir, args);
}

pub fn git_cmd_with_env(dir: &Path, args: &[&str], env: &TestHome) {
    git_with_env(dir, args, env);
}

fn git(dir: &Path, args: &[&str]) {
    let output = Command::new("git")
        .args(args)
@@ -363,3 +693,26 @@ fn git(dir: &Path, args: &[&str]) {
        String::from_utf8_lossy(&output.stderr)
    );
}

fn git_with_env(dir: &Path, args: &[&str], test_home: &TestHome) {
    let mut command = Command::new("git");
    test_home.apply_to_command(&mut command);
    let output = command
        .args(args)
        .current_dir(dir)
        .output()
        .expect("failed to run git");
    assert!(
        output.status.success(),
        "git {:?} failed: {}",
        args,
        String::from_utf8_lossy(&output.stderr)
    );
}

fn pick_loopback_addr() -> SocketAddr {
    let listener = TcpListener::bind("127.0.0.1:0").unwrap();
    let addr = listener.local_addr().unwrap();
    drop(listener);
    addr
}
diff --git a/tests/server_behavior_test.rs b/tests/server_behavior_test.rs
new file mode 100644
index 0000000..6254074
--- /dev/null
+++ b/tests/server_behavior_test.rs
@@ -0,0 +1,102 @@
mod common;

use common::ServerHarness;

#[test]
fn issue_created_locally_and_pushed_renders_in_server_ui() {
    let harness = ServerHarness::new("behavior-issues");

    harness.push_head();

    let issue_id = harness.work_repo().issue_open("My first bug");
    harness.push_collab_refs();

    let list_page = harness.get_ok(&format!("/{}/issues", harness.repo_name()));
    assert!(list_page.body.contains("My first bug"));
    assert!(list_page.body.contains("open"));

    let detail_page = harness.get_ok(&format!("/{}/issues/{}", harness.repo_name(), issue_id));
    assert!(detail_page.body.contains("My first bug"));
    assert!(detail_page.body.contains("Alice"));
    assert!(detail_page.body.contains("open"));
}

#[test]
fn pushed_repository_content_renders_across_repo_http_pages() {
    let harness = ServerHarness::new("behavior-pages");

    let commit_oid = harness.work_repo().commit_file(
        "src/lib.rs",
        "pub fn answer() -> u32 {\n    42\n}\n",
        "add source file",
    );
    let issue_id = harness.work_repo().issue_open("Server issue");
    let patch_id = harness.work_repo().patch_create("Server patch");

    harness.push_head();
    harness.push_collab_refs();

    let repo_list = harness.get_ok("/");
    assert!(repo_list.body.contains(harness.repo_name()));

    let overview = harness.get_ok(&format!("/{}", harness.repo_name()));
    assert!(overview.body.contains("Server issue"));
    assert!(overview.body.contains("Server patch"));
    assert!(overview.body.contains("add source file"));

    let commits = harness.get_ok(&format!("/{}/commits", harness.repo_name()));
    assert!(commits.body.contains("add source file"));
    assert!(commits.body.contains("main"));

    let diff = harness.get_ok(&format!("/{}/diff/{}", harness.repo_name(), commit_oid));
    assert!(diff.body.contains("add source file"));
    assert!(diff.body.contains("src/lib.rs"));
    assert!(diff.body.contains("answer"));

    let tree = harness.get_ok(&format!("/{}/tree", harness.repo_name()));
    assert!(tree.body.contains("src/"));

    let blob = harness.get_ok(&format!("/{}/blob/main/src/lib.rs", harness.repo_name()));
    assert!(blob.body.contains("src/lib.rs"));
    assert!(blob.body.contains("answer"));

    let patches = harness.get_ok(&format!("/{}/patches", harness.repo_name()));
    assert!(patches.body.contains("Server patch"));
    assert!(patches.body.contains("open"));

    let patch_detail = harness.get_ok(&format!("/{}/patches/{}", harness.repo_name(), patch_id));
    assert!(patch_detail.body.contains("Server patch"));
    assert!(patch_detail.body.contains("Alice"));
    assert!(patch_detail.body.contains("main"));

    let issues = harness.get_ok(&format!("/{}/issues", harness.repo_name()));
    assert!(issues.body.contains("Server issue"));
    assert!(issues.body.contains("open"));

    let issue_detail = harness.get_ok(&format!("/{}/issues/{}", harness.repo_name(), issue_id));
    assert!(issue_detail.body.contains("Server issue"));
    assert!(issue_detail.body.contains("Alice"));
    assert!(issue_detail.body.contains("open"));
}

#[test]
fn missing_repository_and_missing_objects_return_not_found() {
    let harness = ServerHarness::new("behavior-not-found");

    harness.push_head();

    let missing_repo = harness.get("/missing-repo");
    assert!(missing_repo.status_line.contains("404"));
    assert!(missing_repo.body.contains("missing-repo"));
    assert!(missing_repo.body.contains("not found"));

    let missing_issue = harness.get(&format!("/{}/issues/missing-issue", harness.repo_name()));
    assert!(missing_issue.status_line.contains("404"));
    assert!(missing_issue.body.contains("missing-issue"));
    assert!(missing_issue.body.contains("not found"));

    let missing_blob = harness.get(&format!("/{}/blob/main/src/missing.rs", harness.repo_name()));
    assert!(missing_blob.status_line.contains("404"));
    assert!(missing_blob.body.contains("src/missing.rs"));
    assert!(missing_blob.body.contains("not found"));
}
diff --git a/tests/sync_test.rs b/tests/sync_test.rs
index 9ac8e59..070a63a 100644
--- a/tests/sync_test.rs
+++ b/tests/sync_test.rs
@@ -7,11 +7,12 @@

mod common;

use std::process::{Command, Output};

use tempfile::TempDir;

use git2::Repository;
use git_collab::dag;
use git_collab::error;
use git_collab::event::{Action, Event, ReviewVerdict};
use git_collab::signing;
use git_collab::state::{self, IssueState, IssueStatus, PatchState};
@@ -19,7 +20,7 @@ use git_collab::sync;

use common::{
    add_comment, alice, bob, close_issue, create_tampered_event, create_unsigned_event, now,
    open_issue, setup_signing_key, test_signing_key,
    open_issue, test_signing_key, ScopedTestConfig,
};

// ---------------------------------------------------------------------------
@@ -30,11 +31,21 @@ struct TestCluster {
    bare_dir: TempDir,
    alice_dir: TempDir,
    bob_dir: TempDir,
    _key_setup: (), // signing key created in default config dir
    _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();

@@ -58,7 +69,6 @@ impl TestCluster {
            config.set_str("user.name", "Alice").unwrap();
            config.set_str("user.email", "alice@example.com").unwrap();
        }
        sync::init(&alice_repo).unwrap();

        let bob_repo =
            Repository::clone(bare_dir.path().to_str().unwrap(), bob_dir.path()).unwrap();
@@ -67,24 +77,12 @@ impl TestCluster {
            config.set_str("user.name", "Bob").unwrap();
            config.set_str("user.email", "bob@example.com").unwrap();
        }
        sync::init(&bob_repo).unwrap();

        // Ensure signing key exists for sync reconciliation
        let config_dir = dirs::config_dir()
            .unwrap_or_else(|| {
                let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
                std::path::PathBuf::from(home).join(".config")
            })
            .join("git-collab");
        if !config_dir.join("signing-key").exists() {
            setup_signing_key(&config_dir);
        }

        TestCluster {
            bare_dir,
            alice_dir,
            bob_dir,
            _key_setup: (),
            _config: config,
        }
    }

@@ -96,10 +94,51 @@ impl TestCluster {
        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()
    }
}

// ---------------------------------------------------------------------------
@@ -125,6 +164,94 @@ fn test_alice_creates_issue_bob_syncs_and_sees_it() {
}

#[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();
@@ -599,14 +726,8 @@ fn test_reconciliation_merge_commit_is_signed() {
    }

    // Verify the merge commit specifically is signed by the syncing user's key
    // (the key stored in the config dir, which sync::sync() loads)
    let config_dir = dirs::config_dir()
        .unwrap_or_else(|| {
            let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
            std::path::PathBuf::from(home).join(".config")
        })
        .join("git-collab");
    let syncing_vk = signing::load_verifying_key(&config_dir).unwrap();
    // (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(),