a73x

46e52951

Merge branch '001-gpg-event-signing': Ed25519 event signing and sync verification

a73x   2026-03-21 07:24


diff --git a/Cargo.lock b/Cargo.lock
index 537c260..ae81def 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -62,7 +62,7 @@ version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
 "windows-sys",
 "windows-sys 0.61.2",
]

[[package]]
@@ -73,7 +73,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
 "anstyle",
 "once_cell_polyfill",
 "windows-sys",
 "windows-sys 0.61.2",
]

[[package]]
@@ -104,6 +104,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"

[[package]]
name = "base64ct"
version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"

[[package]]
name = "bit-set"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -259,6 +265,12 @@ dependencies = [
]

[[package]]
name = "const-oid"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"

[[package]]
name = "convert_case"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -330,6 +342,33 @@ dependencies = [
]

[[package]]
name = "curve25519-dalek"
version = "4.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
dependencies = [
 "cfg-if",
 "cpufeatures",
 "curve25519-dalek-derive",
 "digest",
 "fiat-crypto",
 "rustc_version",
 "subtle",
 "zeroize",
]

[[package]]
name = "curve25519-dalek-derive"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
dependencies = [
 "proc-macro2",
 "quote",
 "syn 2.0.117",
]

[[package]]
name = "darling"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -370,6 +409,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4"

[[package]]
name = "der"
version = "0.7.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
dependencies = [
 "const-oid",
 "zeroize",
]

[[package]]
name = "deranged"
version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -411,6 +460,27 @@ dependencies = [
]

[[package]]
name = "dirs"
version = "5.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225"
dependencies = [
 "dirs-sys",
]

[[package]]
name = "dirs-sys"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c"
dependencies = [
 "libc",
 "option-ext",
 "redox_users",
 "windows-sys 0.48.0",
]

[[package]]
name = "displaydoc"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -431,6 +501,31 @@ dependencies = [
]

[[package]]
name = "ed25519"
version = "2.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
dependencies = [
 "pkcs8",
 "signature",
]

[[package]]
name = "ed25519-dalek"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
dependencies = [
 "curve25519-dalek",
 "ed25519",
 "rand_core",
 "serde",
 "sha2",
 "subtle",
 "zeroize",
]

[[package]]
name = "either"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -449,7 +544,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
 "libc",
 "windows-sys",
 "windows-sys 0.61.2",
]

[[package]]
@@ -478,6 +573,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"

[[package]]
name = "fiat-crypto"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"

[[package]]
name = "filedescriptor"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -545,6 +646,17 @@ dependencies = [

[[package]]
name = "getrandom"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
 "cfg-if",
 "libc",
 "wasi",
]

[[package]]
name = "getrandom"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
@@ -572,10 +684,14 @@ dependencies = [
name = "git-collab"
version = "0.1.0"
dependencies = [
 "base64",
 "chrono",
 "clap",
 "crossterm",
 "dirs",
 "ed25519-dalek",
 "git2",
 "rand_core",
 "ratatui",
 "serde",
 "serde_json",
@@ -893,6 +1009,15 @@ dependencies = [
]

[[package]]
name = "libredox"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a"
dependencies = [
 "libc",
]

[[package]]
name = "libssh2-sys"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1015,7 +1140,7 @@ dependencies = [
 "libc",
 "log",
 "wasi",
 "windows-sys",
 "windows-sys 0.61.2",
]

[[package]]
@@ -1107,6 +1232,12 @@ dependencies = [
]

[[package]]
name = "option-ext"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"

[[package]]
name = "ordered-float"
version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1240,6 +1371,16 @@ dependencies = [
]

[[package]]
name = "pkcs8"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
dependencies = [
 "der",
 "spki",
]

[[package]]
name = "pkg-config"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1320,6 +1461,9 @@ name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
 "getrandom 0.2.17",
]

[[package]]
name = "ratatui"
@@ -1416,6 +1560,17 @@ dependencies = [
]

[[package]]
name = "redox_users"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
dependencies = [
 "getrandom 0.2.17",
 "libredox",
 "thiserror 1.0.69",
]

[[package]]
name = "regex"
version = "1.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1463,7 +1618,7 @@ dependencies = [
 "errno",
 "libc",
 "linux-raw-sys",
 "windows-sys",
 "windows-sys 0.61.2",
]

[[package]]
@@ -1582,6 +1737,15 @@ dependencies = [
]

[[package]]
name = "signature"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
dependencies = [
 "rand_core",
]

[[package]]
name = "siphasher"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1594,6 +1758,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"

[[package]]
name = "spki"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
dependencies = [
 "base64ct",
 "der",
]

[[package]]
name = "stable_deref_trait"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1633,6 +1807,12 @@ dependencies = [
]

[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"

[[package]]
name = "syn"
version = "1.0.109"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1675,7 +1855,7 @@ dependencies = [
 "getrandom 0.4.2",
 "once_cell",
 "rustix",
 "windows-sys",
 "windows-sys 0.61.2",
]

[[package]]
@@ -2174,6 +2354,15 @@ dependencies = [

[[package]]
name = "windows-sys"
version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
dependencies = [
 "windows-targets",
]

[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
@@ -2182,6 +2371,63 @@ dependencies = [
]

[[package]]
name = "windows-targets"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
dependencies = [
 "windows_aarch64_gnullvm",
 "windows_aarch64_msvc",
 "windows_i686_gnu",
 "windows_i686_msvc",
 "windows_x86_64_gnu",
 "windows_x86_64_gnullvm",
 "windows_x86_64_msvc",
]

[[package]]
name = "windows_aarch64_gnullvm"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"

[[package]]
name = "windows_aarch64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"

[[package]]
name = "windows_i686_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"

[[package]]
name = "windows_i686_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"

[[package]]
name = "windows_x86_64_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"

[[package]]
name = "windows_x86_64_gnullvm"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"

[[package]]
name = "windows_x86_64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"

[[package]]
name = "wit-bindgen"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2320,6 +2566,12 @@ dependencies = [
]

[[package]]
name = "zeroize"
version = "1.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"

[[package]]
name = "zerotrie"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
diff --git a/Cargo.toml b/Cargo.toml
index 3f520d0..c59d353 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -12,6 +12,10 @@ chrono = { version = "0.4", features = ["serde"] }
thiserror = "2"
ratatui = "0.30.0"
crossterm = "0.29.0"
ed25519-dalek = { version = "2", features = ["rand_core"] }
rand_core = { version = "0.6", features = ["getrandom"] }
base64 = "0.22"
dirs = "5"

[dev-dependencies]
tempfile = "3"
diff --git a/src/cli.rs b/src/cli.rs
index a676fc7..506ce77 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -32,6 +32,14 @@ pub enum Commands {
        #[arg(default_value = "origin")]
        remote: String,
    },

    /// Generate an Ed25519 signing keypair
    #[clap(name = "init-key")]
    InitKey {
        /// Overwrite existing key files
        #[arg(long)]
        force: bool,
    },
}

#[derive(Subcommand)]
@@ -64,6 +72,45 @@ pub enum IssueCmd {
        #[arg(short, long)]
        body: String,
    },
    /// Edit an issue's title or body
    Edit {
        /// Issue ID (prefix match)
        id: String,
        /// New title
        #[arg(short, long)]
        title: Option<String>,
        /// New body
        #[arg(short, long)]
        body: Option<String>,
    },
    /// Add a label to an issue
    Label {
        /// Issue ID (prefix match)
        id: String,
        /// Label to add
        label: String,
    },
    /// Remove a label from an issue
    Unlabel {
        /// Issue ID (prefix match)
        id: String,
        /// Label to remove
        label: String,
    },
    /// Assign an issue to someone
    Assign {
        /// Issue ID (prefix match)
        id: String,
        /// Name to assign
        name: String,
    },
    /// Unassign someone from an issue
    Unassign {
        /// Issue ID (prefix match)
        id: String,
        /// Name to unassign
        name: String,
    },
    /// Close an issue
    Close {
        /// Issue ID (prefix match)
@@ -95,6 +142,9 @@ pub enum PatchCmd {
        /// Head commit to review
        #[arg(long)]
        head: String,
        /// Issue ID this patch fixes (auto-closes on merge)
        #[arg(long)]
        fixes: Option<String>,
    },
    /// List patches
    List {
@@ -107,6 +157,11 @@ pub enum PatchCmd {
        /// Patch ID (prefix match)
        id: String,
    },
    /// Show diff between base and head
    Diff {
        /// Patch ID (prefix match)
        id: String,
    },
    /// Comment on a patch (use --file and --line for inline comments)
    Comment {
        /// Patch ID (prefix match)
diff --git a/src/dag.rs b/src/dag.rs
index 2cc2507..c56b42b 100644
--- a/src/dag.rs
+++ b/src/dag.rs
@@ -3,11 +3,17 @@ use git2::{Oid, Repository, Sort};
use crate::error::Error;
use crate::event::{Action, Event};
use crate::identity::author_signature;
use crate::signing::{sign_event, SignedEvent};

/// Create an orphan commit (no parents) with the given event.
/// Returns the new commit OID which also serves as the entity ID.
pub fn create_root_event(repo: &Repository, event: &Event) -> Result<Oid, Error> {
    let json = serde_json::to_vec_pretty(event)?;
pub fn create_root_event(
    repo: &Repository,
    event: &Event,
    signing_key: &ed25519_dalek::SigningKey,
) -> Result<Oid, Error> {
    let signed = sign_event(event, signing_key)?;
    let json = serde_json::to_vec_pretty(&signed)?;
    let blob_oid = repo.blob(&json)?;

    let mut tb = repo.treebuilder(None)?;
@@ -23,8 +29,14 @@ pub fn create_root_event(repo: &Repository, event: &Event) -> Result<Oid, Error>
}

/// Append an event to an existing DAG. The current tip is the parent.
pub fn append_event(repo: &Repository, ref_name: &str, event: &Event) -> Result<Oid, Error> {
    let json = serde_json::to_vec_pretty(event)?;
pub fn append_event(
    repo: &Repository,
    ref_name: &str,
    event: &Event,
    signing_key: &ed25519_dalek::SigningKey,
) -> Result<Oid, Error> {
    let signed = sign_event(event, signing_key)?;
    let json = serde_json::to_vec_pretty(&signed)?;
    let blob_oid = repo.blob(&json)?;

    let mut tb = repo.treebuilder(None)?;
@@ -59,7 +71,13 @@ pub fn walk_events(repo: &Repository, ref_name: &str) -> Result<Vec<(Oid, Event)
            .get_name("event.json")
            .ok_or_else(|| git2::Error::from_str("missing event.json in commit tree"))?;
        let blob = repo.find_blob(entry.id())?;
        let event: Event = serde_json::from_slice(blob.content())?;
        let content = blob.content();
        // Try SignedEvent first, fall back to plain Event for backward compat
        let event: Event = if let Ok(signed) = serde_json::from_slice::<SignedEvent>(content) {
            signed.event
        } else {
            serde_json::from_slice(content)?
        };
        events.push((oid, event));
    }
    Ok(events)
@@ -76,6 +94,7 @@ pub fn reconcile(
    local_ref: &str,
    remote_ref: &str,
    merge_author: &crate::event::Author,
    signing_key: &ed25519_dalek::SigningKey,
) -> Result<Oid, Error> {
    let local_oid = repo.refname_to_id(local_ref)?;
    let remote_oid = repo.refname_to_id(remote_ref)?;
@@ -104,7 +123,8 @@ pub fn reconcile(
        action: Action::Merge,
    };

    let json = serde_json::to_vec_pretty(&merge_event)?;
    let signed = sign_event(&merge_event, signing_key)?;
    let json = serde_json::to_vec_pretty(&signed)?;
    let blob_oid = repo.blob(&json)?;
    let mut tb = repo.treebuilder(None)?;
    tb.insert("event.json", blob_oid, 0o100644)?;
@@ -130,6 +150,11 @@ pub fn reconcile(
fn commit_message(action: &Action) -> String {
    match action {
        Action::IssueOpen { title, .. } => format!("issue: open \"{}\"", title),
        Action::IssueEdit { .. } => "issue: edit".to_string(),
        Action::IssueLabel { ref label } => format!("issue: label \"{}\"", label),
        Action::IssueUnlabel { ref label } => format!("issue: unlabel \"{}\"", label),
        Action::IssueAssign { ref assignee } => format!("issue: assign \"{}\"", assignee),
        Action::IssueUnassign { ref assignee } => format!("issue: unassign \"{}\"", assignee),
        Action::IssueComment { .. } => "issue: comment".to_string(),
        Action::IssueClose { .. } => "issue: close".to_string(),
        Action::IssueReopen => "issue: reopen".to_string(),
diff --git a/src/error.rs b/src/error.rs
index 61b936c..5931733 100644
--- a/src/error.rs
+++ b/src/error.rs
@@ -13,4 +13,13 @@ pub enum Error {

    #[error(transparent)]
    Io(#[from] std::io::Error),

    #[error("signing error: {0}")]
    Signing(String),

    #[error("verification error: {0}")]
    Verification(String),

    #[error("no signing key found — run 'collab init-key' to generate one")]
    KeyNotFound,
}
diff --git a/src/event.rs b/src/event.rs
index c26d86b..289930d 100644
--- a/src/event.rs
+++ b/src/event.rs
@@ -26,12 +26,30 @@ pub enum Action {
    IssueClose {
        reason: Option<String>,
    },
    IssueEdit {
        title: Option<String>,
        body: Option<String>,
    },
    IssueLabel {
        label: String,
    },
    IssueUnlabel {
        label: String,
    },
    IssueAssign {
        assignee: String,
    },
    IssueUnassign {
        assignee: String,
    },
    IssueReopen,
    PatchCreate {
        title: String,
        body: String,
        base_ref: String,
        head_commit: String,
        #[serde(default, skip_serializing_if = "Option::is_none")]
        fixes: Option<String>,
    },
    PatchRevise {
        body: Option<String>,
diff --git a/src/issue.rs b/src/issue.rs
index 2e5335d..fce8fbe 100644
--- a/src/issue.rs
+++ b/src/issue.rs
@@ -3,9 +3,11 @@ use git2::Repository;
use crate::dag;
use crate::event::{Action, Event};
use crate::identity::get_author;
use crate::state::{self, IssueStatus};
use crate::signing;
use crate::state::{self, IssueState, IssueStatus};

pub fn open(repo: &Repository, title: &str, body: &str) -> Result<String, crate::error::Error> {
    let sk = signing::load_signing_key(&signing::signing_key_dir()?)?;
    let author = get_author(repo)?;
    let event = Event {
        timestamp: chrono::Utc::now().to_rfc3339(),
@@ -15,66 +17,157 @@ pub fn open(repo: &Repository, title: &str, body: &str) -> Result<String, crate:
            body: body.to_string(),
        },
    };
    let oid = dag::create_root_event(repo, &event)?;
    let oid = dag::create_root_event(repo, &event, &sk)?;
    let id = oid.to_string();
    let ref_name = format!("refs/collab/issues/{}", id);
    repo.reference(&ref_name, oid, false, "issue open")?;
    Ok(id)
}

pub fn list(repo: &Repository, show_closed: bool) -> Result<(), crate::error::Error> {
pub struct ListEntry {
    pub issue: IssueState,
    pub unread: Option<usize>,
}

pub fn list(repo: &Repository, show_closed: bool) -> Result<Vec<ListEntry>, crate::error::Error> {
    let issues = state::list_issues(repo)?;
    let filtered: Vec<_> = issues
        .iter()
    let entries = issues
        .into_iter()
        .filter(|i| show_closed || i.status == IssueStatus::Open)
        .map(|issue| {
            let unread = count_unread(repo, &issue.id);
            ListEntry { issue, unread }
        })
        .collect();
    Ok(entries)
}

    if filtered.is_empty() {
        println!("No issues found.");
        return Ok(());
    }
/// Count events after the last-seen mark. Returns None if never viewed.
fn count_unread(repo: &git2::Repository, id: &str) -> Option<usize> {
    let seen_ref = format!("refs/collab/local/seen/issues/{}", id);
    let seen_oid = repo.refname_to_id(&seen_ref).ok()?;
    let ref_name = format!("refs/collab/issues/{}", id);
    let tip = repo.refname_to_id(&ref_name).ok()?;

    for issue in &filtered {
        let status = match issue.status {
            IssueStatus::Open => "open",
            IssueStatus::Closed => "closed",
        };
        println!(
            "{:.8}  {:6}  {}  (by {})",
            issue.id, status, issue.title, issue.author.name
        );
    if seen_oid == tip {
        return Some(0);
    }
    Ok(())

    let mut revwalk = repo.revwalk().ok()?;
    revwalk
        .set_sorting(git2::Sort::TOPOLOGICAL)
        .ok()?;
    revwalk.push(tip).ok()?;
    revwalk.hide(seen_oid).ok()?;
    Some(revwalk.count())
}

pub fn show(repo: &Repository, id_prefix: &str) -> Result<(), crate::error::Error> {
pub fn show(repo: &Repository, id_prefix: &str) -> Result<IssueState, crate::error::Error> {
    let (ref_name, id) = state::resolve_issue_ref(repo, id_prefix)?;
    let issue = state::IssueState::from_ref(repo, &ref_name, &id)?;
    let issue = IssueState::from_ref(repo, &ref_name, &id)?;
    // Mark as read: store current tip as seen
    let tip = repo.refname_to_id(&ref_name)?;
    let seen_ref = format!("refs/collab/local/seen/issues/{}", id);
    repo.reference(&seen_ref, tip, true, "mark seen")?;
    Ok(issue)
}

    let status = match issue.status {
        IssueStatus::Open => "open",
        IssueStatus::Closed => "closed",
pub fn label(repo: &Repository, id_prefix: &str, label: &str) -> Result<(), crate::error::Error> {
    let sk = signing::load_signing_key(&signing::signing_key_dir()?)?;
    let (ref_name, _id) = state::resolve_issue_ref(repo, id_prefix)?;
    let author = get_author(repo)?;
    let event = Event {
        timestamp: chrono::Utc::now().to_rfc3339(),
        author,
        action: Action::IssueLabel {
            label: label.to_string(),
        },
    };
    println!("Issue {} [{}]", &issue.id[..8], status);
    println!("Title: {}", issue.title);
    println!("Author: {} <{}>", issue.author.name, issue.author.email);
    println!("Created: {}", issue.created_at);
    if let Some(ref reason) = issue.close_reason {
        println!("Closed:  {}", reason);
    }
    if !issue.body.is_empty() {
        println!("\n{}", issue.body);
    }
    if !issue.comments.is_empty() {
        println!("\n--- Comments ---");
        for c in &issue.comments {
            println!("\n{} ({}):\n{}", c.author.name, c.timestamp, c.body);
        }
    dag::append_event(repo, &ref_name, &event, &sk)?;
    Ok(())
}

pub fn unlabel(repo: &Repository, id_prefix: &str, label: &str) -> Result<(), crate::error::Error> {
    let sk = signing::load_signing_key(&signing::signing_key_dir()?)?;
    let (ref_name, _id) = state::resolve_issue_ref(repo, id_prefix)?;
    let author = get_author(repo)?;
    let event = Event {
        timestamp: chrono::Utc::now().to_rfc3339(),
        author,
        action: Action::IssueUnlabel {
            label: label.to_string(),
        },
    };
    dag::append_event(repo, &ref_name, &event, &sk)?;
    Ok(())
}

pub fn assign(
    repo: &Repository,
    id_prefix: &str,
    assignee: &str,
) -> Result<(), crate::error::Error> {
    let sk = signing::load_signing_key(&signing::signing_key_dir()?)?;
    let (ref_name, _id) = state::resolve_issue_ref(repo, id_prefix)?;
    let author = get_author(repo)?;
    let event = Event {
        timestamp: chrono::Utc::now().to_rfc3339(),
        author,
        action: Action::IssueAssign {
            assignee: assignee.to_string(),
        },
    };
    dag::append_event(repo, &ref_name, &event, &sk)?;
    Ok(())
}

pub fn unassign(
    repo: &Repository,
    id_prefix: &str,
    assignee: &str,
) -> Result<(), crate::error::Error> {
    let sk = signing::load_signing_key(&signing::signing_key_dir()?)?;
    let (ref_name, _id) = state::resolve_issue_ref(repo, id_prefix)?;
    let author = get_author(repo)?;
    let event = Event {
        timestamp: chrono::Utc::now().to_rfc3339(),
        author,
        action: Action::IssueUnassign {
            assignee: assignee.to_string(),
        },
    };
    dag::append_event(repo, &ref_name, &event, &sk)?;
    Ok(())
}

pub fn edit(
    repo: &Repository,
    id_prefix: &str,
    title: Option<&str>,
    body: Option<&str>,
) -> Result<(), crate::error::Error> {
    if title.is_none() && body.is_none() {
        return Err(
            git2::Error::from_str("at least one of --title or --body must be provided").into(),
        );
    }
    let sk = signing::load_signing_key(&signing::signing_key_dir()?)?;
    let (ref_name, _id) = state::resolve_issue_ref(repo, id_prefix)?;
    let author = get_author(repo)?;
    let event = Event {
        timestamp: chrono::Utc::now().to_rfc3339(),
        author,
        action: Action::IssueEdit {
            title: title.map(|s| s.to_string()),
            body: body.map(|s| s.to_string()),
        },
    };
    dag::append_event(repo, &ref_name, &event, &sk)?;
    Ok(())
}

pub fn comment(repo: &Repository, id_prefix: &str, body: &str) -> Result<(), crate::error::Error> {
    let sk = signing::load_signing_key(&signing::signing_key_dir()?)?;
    let (ref_name, _id) = state::resolve_issue_ref(repo, id_prefix)?;
    let author = get_author(repo)?;
    let event = Event {
@@ -84,8 +177,7 @@ pub fn comment(repo: &Repository, id_prefix: &str, body: &str) -> Result<(), cra
            body: body.to_string(),
        },
    };
    dag::append_event(repo, &ref_name, &event)?;
    println!("Comment added.");
    dag::append_event(repo, &ref_name, &event, &sk)?;
    Ok(())
}

@@ -94,6 +186,7 @@ pub fn close(
    id_prefix: &str,
    reason: Option<&str>,
) -> Result<(), crate::error::Error> {
    let sk = signing::load_signing_key(&signing::signing_key_dir()?)?;
    let (ref_name, _id) = state::resolve_issue_ref(repo, id_prefix)?;
    let author = get_author(repo)?;
    let event = Event {
@@ -103,12 +196,12 @@ pub fn close(
            reason: reason.map(|s| s.to_string()),
        },
    };
    dag::append_event(repo, &ref_name, &event)?;
    println!("Issue closed.");
    dag::append_event(repo, &ref_name, &event, &sk)?;
    Ok(())
}

pub fn reopen(repo: &Repository, id_prefix: &str) -> Result<(), crate::error::Error> {
    let sk = signing::load_signing_key(&signing::signing_key_dir()?)?;
    let (ref_name, _id) = state::resolve_issue_ref(repo, id_prefix)?;
    let author = get_author(repo)?;
    let event = Event {
@@ -116,7 +209,6 @@ pub fn reopen(repo: &Repository, id_prefix: &str) -> Result<(), crate::error::Er
        author,
        action: Action::IssueReopen,
    };
    dag::append_event(repo, &ref_name, &event)?;
    println!("Issue reopened.");
    dag::append_event(repo, &ref_name, &event, &sk)?;
    Ok(())
}
diff --git a/src/lib.rs b/src/lib.rs
index 46cbe62..5a806e4 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -6,5 +6,267 @@ pub mod identity;
pub mod issue;
pub mod patch;
pub mod state;
pub mod signing;
pub mod sync;
pub mod tui;

use base64::Engine;
use cli::{Commands, IssueCmd, PatchCmd};
use event::ReviewVerdict;
use git2::Repository;
use state::{IssueStatus, PatchStatus};

pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> {
    match cli.command {
        Commands::Init => sync::init(repo),
        Commands::Issue(cmd) => match cmd {
            IssueCmd::Open { title, body } => {
                let id = issue::open(repo, &title, &body)?;
                println!("Opened issue {:.8}", id);
                Ok(())
            }
            IssueCmd::List { all } => {
                let entries = issue::list(repo, all)?;
                if entries.is_empty() {
                    println!("No issues found.");
                } else {
                    for e in &entries {
                        let i = &e.issue;
                        let status = match i.status {
                            IssueStatus::Open => "open",
                            IssueStatus::Closed => "closed",
                        };
                        let labels = if i.labels.is_empty() {
                            String::new()
                        } else {
                            format!(" [{}]", i.labels.join(", "))
                        };
                        let unread = match e.unread {
                            Some(n) if n > 0 => format!(" ({} new)", n),
                            _ => String::new(),
                        };
                        println!(
                            "{:.8}  {:6}  {}{}  (by {}){}",
                            i.id, status, i.title, labels, i.author.name, unread
                        );
                    }
                }
                Ok(())
            }
            IssueCmd::Show { id } => {
                let i = issue::show(repo, &id)?;
                let status = match i.status {
                    IssueStatus::Open => "open",
                    IssueStatus::Closed => "closed",
                };
                println!("Issue {} [{}]", &i.id[..8], status);
                println!("Title: {}", i.title);
                println!("Author: {} <{}>", i.author.name, i.author.email);
                println!("Created: {}", i.created_at);
                if !i.labels.is_empty() {
                    println!("Labels: {}", i.labels.join(", "));
                }
                if !i.assignees.is_empty() {
                    println!("Assignees: {}", i.assignees.join(", "));
                }
                if let Some(ref reason) = i.close_reason {
                    println!("Closed:  {}", reason);
                }
                if !i.body.is_empty() {
                    println!("\n{}", i.body);
                }
                if !i.comments.is_empty() {
                    println!("\n--- Comments ---");
                    for c in &i.comments {
                        println!("\n{} ({}):\n{}", c.author.name, c.timestamp, c.body);
                    }
                }
                Ok(())
            }
            IssueCmd::Label { id, label } => {
                issue::label(repo, &id, &label)?;
                println!("Label '{}' added.", label);
                Ok(())
            }
            IssueCmd::Unlabel { id, label } => {
                issue::unlabel(repo, &id, &label)?;
                println!("Label '{}' removed.", label);
                Ok(())
            }
            IssueCmd::Assign { id, name } => {
                issue::assign(repo, &id, &name)?;
                println!("Assigned to '{}'.", name);
                Ok(())
            }
            IssueCmd::Unassign { id, name } => {
                issue::unassign(repo, &id, &name)?;
                println!("Unassigned '{}'.", name);
                Ok(())
            }
            IssueCmd::Edit { id, title, body } => {
                issue::edit(repo, &id, title.as_deref(), body.as_deref())?;
                println!("Issue updated.");
                Ok(())
            }
            IssueCmd::Comment { id, body } => {
                issue::comment(repo, &id, &body)?;
                println!("Comment added.");
                Ok(())
            }
            IssueCmd::Close { id, reason } => {
                issue::close(repo, &id, reason.as_deref())?;
                println!("Issue closed.");
                Ok(())
            }
            IssueCmd::Reopen { id } => {
                issue::reopen(repo, &id)?;
                println!("Issue reopened.");
                Ok(())
            }
        },
        Commands::Patch(cmd) => match cmd {
            PatchCmd::Create {
                title,
                body,
                base,
                head,
                fixes,
            } => {
                let id = patch::create(repo, &title, &body, &base, &head, fixes.as_deref())?;
                println!("Created patch {:.8}", id);
                Ok(())
            }
            PatchCmd::List { all } => {
                let patches = patch::list(repo, all)?;
                if patches.is_empty() {
                    println!("No patches found.");
                } else {
                    for p in &patches {
                        let status = match p.status {
                            PatchStatus::Open => "open",
                            PatchStatus::Closed => "closed",
                            PatchStatus::Merged => "merged",
                        };
                        println!(
                            "{:.8}  {:6}  {}  (by {})",
                            p.id, status, p.title, p.author.name
                        );
                    }
                }
                Ok(())
            }
            PatchCmd::Show { id } => {
                let p = patch::show(repo, &id)?;
                let status = match p.status {
                    PatchStatus::Open => "open",
                    PatchStatus::Closed => "closed",
                    PatchStatus::Merged => "merged",
                };
                println!("Patch {} [{}]", &p.id[..8], status);
                println!("Title: {}", p.title);
                println!("Author: {} <{}>", p.author.name, p.author.email);
                println!("Base: {}  Head: {:.8}", p.base_ref, p.head_commit);
                println!("Created: {}", p.created_at);
                if let Some(ref fixes) = p.fixes {
                    println!("Fixes: {:.8}", fixes);
                }
                if !p.body.is_empty() {
                    println!("\n{}", p.body);
                }
                if !p.reviews.is_empty() {
                    println!("\n--- Reviews ---");
                    for r in &p.reviews {
                        println!(
                            "\n{} ({:?}) - {}:\n{}",
                            r.author.name, r.verdict, r.timestamp, r.body
                        );
                    }
                }
                if !p.inline_comments.is_empty() {
                    println!("\n--- Inline Comments ---");
                    for c in &p.inline_comments {
                        println!(
                            "\n{} on {}:{} ({}):\n  {}",
                            c.author.name, c.file, c.line, c.timestamp, c.body
                        );
                    }
                }
                if !p.comments.is_empty() {
                    println!("\n--- Comments ---");
                    for c in &p.comments {
                        println!("\n{} ({}):\n{}", c.author.name, c.timestamp, c.body);
                    }
                }
                Ok(())
            }
            PatchCmd::Diff { id } => {
                let diff = patch::diff(repo, &id)?;
                if diff.is_empty() {
                    println!("No diff available (commits may be identical).");
                } else {
                    print!("{}", diff);
                }
                Ok(())
            }
            PatchCmd::Comment {
                id,
                body,
                file,
                line,
            } => {
                patch::comment(repo, &id, &body, file.as_deref(), line)?;
                println!("Comment added.");
                Ok(())
            }
            PatchCmd::Review { id, verdict, body } => {
                let v = match verdict.as_str() {
                    "approve" => ReviewVerdict::Approve,
                    "request-changes" => ReviewVerdict::RequestChanges,
                    "comment" => ReviewVerdict::Comment,
                    _ => {
                        return Err(git2::Error::from_str(
                            "verdict must be: approve, request-changes, or comment",
                        )
                        .into());
                    }
                };
                patch::review(repo, &id, v, &body)?;
                println!("Review submitted.");
                Ok(())
            }
            PatchCmd::Revise { id, head, body } => {
                patch::revise(repo, &id, &head, body.as_deref())?;
                println!("Patch revised.");
                Ok(())
            }
            PatchCmd::Merge { id } => {
                let p = patch::merge(repo, &id)?;
                println!("Patch {:.8} merged into {}.", p.id, p.base_ref);
                Ok(())
            }
            PatchCmd::Close { id, reason } => {
                patch::close(repo, &id, reason.as_deref())?;
                println!("Patch closed.");
                Ok(())
            }
        },
        Commands::Dashboard => tui::run(repo),
        Commands::Sync { remote } => sync::sync(repo, &remote),
        Commands::InitKey { force } => {
            let config_dir = signing::signing_key_dir()?;
            let sk_path = config_dir.join("signing-key");
            if sk_path.exists() && !force {
                return Err(error::Error::Signing(
                    "signing key already exists; use --force to overwrite".to_string(),
                ));
            }

            let vk = signing::generate_keypair(&config_dir)?;
            let pubkey_b64 =
                base64::engine::general_purpose::STANDARD.encode(vk.to_bytes());
            println!("Signing key generated.");
            println!("Public key: {}", pubkey_b64);
            Ok(())
        }
    }
}
diff --git a/src/main.rs b/src/main.rs
index 81f1568..89cecfb 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,86 +1,21 @@
mod cli;
mod dag;
mod error;
mod event;
mod identity;
mod issue;
mod patch;
mod state;
mod sync;
mod tui;

use clap::Parser;
use cli::{Cli, Commands, IssueCmd, PatchCmd};
use event::ReviewVerdict;
use git2::Repository;

use git_collab::cli::Cli;

fn main() {
    let cli = Cli::parse();

    let result = run(cli);
    if let Err(e) = result {
    let repo = match Repository::open_from_env() {
        Ok(r) => r,
        Err(e) => {
            eprintln!("error: {}", e);
            std::process::exit(1);
        }
    };

    if let Err(e) = git_collab::run(cli, &repo) {
        eprintln!("error: {}", e);
        std::process::exit(1);
    }
}

fn run(cli: Cli) -> Result<(), error::Error> {
    let repo = Repository::open_from_env()?;

    match cli.command {
        Commands::Init => sync::init(&repo),
        Commands::Issue(cmd) => match cmd {
            IssueCmd::Open { title, body } => {
                let id = issue::open(&repo, &title, &body)?;
                println!("Opened issue {:.8}", id);
                Ok(())
            }
            IssueCmd::List { all } => issue::list(&repo, all),
            IssueCmd::Show { id } => issue::show(&repo, &id),
            IssueCmd::Comment { id, body } => issue::comment(&repo, &id, &body),
            IssueCmd::Close { id, reason } => issue::close(&repo, &id, reason.as_deref()),
            IssueCmd::Reopen { id } => issue::reopen(&repo, &id),
        },
        Commands::Patch(cmd) => match cmd {
            PatchCmd::Create {
                title,
                body,
                base,
                head,
            } => {
                let id = patch::create(&repo, &title, &body, &base, &head)?;
                println!("Created patch {:.8}", id);
                Ok(())
            }
            PatchCmd::List { all } => patch::list(&repo, all),
            PatchCmd::Show { id } => patch::show(&repo, &id),
            PatchCmd::Comment {
                id,
                body,
                file,
                line,
            } => patch::comment(&repo, &id, &body, file.as_deref(), line),
            PatchCmd::Review { id, verdict, body } => {
                let v = match verdict.as_str() {
                    "approve" => ReviewVerdict::Approve,
                    "request-changes" => ReviewVerdict::RequestChanges,
                    "comment" => ReviewVerdict::Comment,
                    _ => {
                        return Err(git2::Error::from_str(
                            "verdict must be: approve, request-changes, or comment",
                        )
                        .into());
                    }
                };
                patch::review(&repo, &id, v, &body)
            }
            PatchCmd::Revise { id, head, body } => {
                patch::revise(&repo, &id, &head, body.as_deref())
            }
            PatchCmd::Merge { id } => patch::merge(&repo, &id),
            PatchCmd::Close { id, reason } => patch::close(&repo, &id, reason.as_deref()),
        },
        Commands::Dashboard => tui::run(&repo),
        Commands::Sync { remote } => sync::sync(&repo, &remote),
    }
}
diff --git a/src/patch.rs b/src/patch.rs
index fca1add..2db90af 100644
--- a/src/patch.rs
+++ b/src/patch.rs
@@ -1,9 +1,11 @@
use git2::Repository;
use git2::{DiffFormat, Repository};

use crate::dag;
use crate::error::Error;
use crate::event::{Action, Event, ReviewVerdict};
use crate::identity::get_author;
use crate::state::{self, PatchStatus};
use crate::signing;
use crate::state::{self, PatchState, PatchStatus};

pub fn create(
    repo: &Repository,
@@ -11,7 +13,9 @@ pub fn create(
    body: &str,
    base_ref: &str,
    head_commit: &str,
    fixes: Option<&str>,
) -> Result<String, crate::error::Error> {
    let sk = signing::load_signing_key(&signing::signing_key_dir()?)?;
    let author = get_author(repo)?;
    let event = Event {
        timestamp: chrono::Utc::now().to_rfc3339(),
@@ -21,83 +25,28 @@ pub fn create(
            body: body.to_string(),
            base_ref: base_ref.to_string(),
            head_commit: head_commit.to_string(),
            fixes: fixes.map(|s| s.to_string()),
        },
    };
    let oid = dag::create_root_event(repo, &event)?;
    let oid = dag::create_root_event(repo, &event, &sk)?;
    let id = oid.to_string();
    let ref_name = format!("refs/collab/patches/{}", id);
    repo.reference(&ref_name, oid, false, "patch create")?;
    Ok(id)
}

pub fn list(repo: &Repository, show_closed: bool) -> Result<(), crate::error::Error> {
pub fn list(repo: &Repository, show_closed: bool) -> Result<Vec<PatchState>, crate::error::Error> {
    let patches = state::list_patches(repo)?;
    let filtered: Vec<_> = patches
        .iter()
    let filtered = patches
        .into_iter()
        .filter(|p| show_closed || p.status == PatchStatus::Open)
        .collect();

    if filtered.is_empty() {
        println!("No patches found.");
        return Ok(());
    }

    for p in &filtered {
        let status = match p.status {
            PatchStatus::Open => "open",
            PatchStatus::Closed => "closed",
            PatchStatus::Merged => "merged",
        };
        println!(
            "{:.8}  {:6}  {}  (by {})",
            p.id, status, p.title, p.author.name
        );
    }
    Ok(())
    Ok(filtered)
}

pub fn show(repo: &Repository, id_prefix: &str) -> Result<(), crate::error::Error> {
pub fn show(repo: &Repository, id_prefix: &str) -> Result<PatchState, crate::error::Error> {
    let (ref_name, id) = state::resolve_patch_ref(repo, id_prefix)?;
    let p = state::PatchState::from_ref(repo, &ref_name, &id)?;

    let status = match p.status {
        PatchStatus::Open => "open",
        PatchStatus::Closed => "closed",
        PatchStatus::Merged => "merged",
    };
    println!("Patch {} [{}]", &p.id[..8], status);
    println!("Title: {}", p.title);
    println!("Author: {} <{}>", p.author.name, p.author.email);
    println!("Base: {}  Head: {:.8}", p.base_ref, p.head_commit);
    println!("Created: {}", p.created_at);
    if !p.body.is_empty() {
        println!("\n{}", p.body);
    }
    if !p.reviews.is_empty() {
        println!("\n--- Reviews ---");
        for r in &p.reviews {
            println!(
                "\n{} ({:?}) - {}:\n{}",
                r.author.name, r.verdict, r.timestamp, r.body
            );
        }
    }
    if !p.inline_comments.is_empty() {
        println!("\n--- Inline Comments ---");
        for c in &p.inline_comments {
            println!(
                "\n{} on {}:{} ({}):\n  {}",
                c.author.name, c.file, c.line, c.timestamp, c.body
            );
        }
    }
    if !p.comments.is_empty() {
        println!("\n--- Comments ---");
        for c in &p.comments {
            println!("\n{} ({}):\n{}", c.author.name, c.timestamp, c.body);
        }
    }
    Ok(())
    PatchState::from_ref(repo, &ref_name, &id)
}

pub fn comment(
@@ -107,6 +56,7 @@ pub fn comment(
    file: Option<&str>,
    line: Option<u32>,
) -> Result<(), crate::error::Error> {
    let sk = signing::load_signing_key(&signing::signing_key_dir()?)?;
    let (ref_name, _id) = state::resolve_patch_ref(repo, id_prefix)?;
    let author = get_author(repo)?;

@@ -132,8 +82,7 @@ pub fn comment(
        author,
        action,
    };
    dag::append_event(repo, &ref_name, &event)?;
    println!("Comment added.");
    dag::append_event(repo, &ref_name, &event, &sk)?;
    Ok(())
}

@@ -143,6 +92,7 @@ pub fn review(
    verdict: ReviewVerdict,
    body: &str,
) -> Result<(), crate::error::Error> {
    let sk = signing::load_signing_key(&signing::signing_key_dir()?)?;
    let (ref_name, _id) = state::resolve_patch_ref(repo, id_prefix)?;
    let author = get_author(repo)?;
    let event = Event {
@@ -153,8 +103,7 @@ pub fn review(
            body: body.to_string(),
        },
    };
    dag::append_event(repo, &ref_name, &event)?;
    println!("Review submitted.");
    dag::append_event(repo, &ref_name, &event, &sk)?;
    Ok(())
}

@@ -164,6 +113,7 @@ pub fn revise(
    head_commit: &str,
    body: Option<&str>,
) -> Result<(), crate::error::Error> {
    let sk = signing::load_signing_key(&signing::signing_key_dir()?)?;
    let (ref_name, _id) = state::resolve_patch_ref(repo, id_prefix)?;
    let author = get_author(repo)?;
    let event = Event {
@@ -174,14 +124,14 @@ pub fn revise(
            head_commit: head_commit.to_string(),
        },
    };
    dag::append_event(repo, &ref_name, &event)?;
    println!("Patch revised.");
    dag::append_event(repo, &ref_name, &event, &sk)?;
    Ok(())
}

pub fn merge(repo: &Repository, id_prefix: &str) -> Result<(), crate::error::Error> {
pub fn merge(repo: &Repository, id_prefix: &str) -> Result<PatchState, crate::error::Error> {
    let sk = signing::load_signing_key(&signing::signing_key_dir()?)?;
    let (ref_name, id) = state::resolve_patch_ref(repo, id_prefix)?;
    let p = state::PatchState::from_ref(repo, &ref_name, &id)?;
    let p = PatchState::from_ref(repo, &ref_name, &id)?;

    if p.status != PatchStatus::Open {
        return Err(git2::Error::from_str(&format!(
@@ -237,12 +187,80 @@ pub fn merge(repo: &Repository, id_prefix: &str) -> Result<(), crate::error::Err
    let author = get_author(repo)?;
    let event = Event {
        timestamp: chrono::Utc::now().to_rfc3339(),
        author,
        author: author.clone(),
        action: Action::PatchMerge,
    };
    dag::append_event(repo, &ref_name, &event)?;
    println!("Patch {:.8} merged into {}.", id, p.base_ref);
    Ok(())
    dag::append_event(repo, &ref_name, &event, &sk)?;

    // Auto-close linked issue if present
    if let Some(ref fixes_id) = p.fixes {
        if let Ok((issue_ref, _)) = state::resolve_issue_ref(repo, fixes_id) {
            let close_event = Event {
                timestamp: chrono::Utc::now().to_rfc3339(),
                author,
                action: Action::IssueClose {
                    reason: Some(format!("Fixed by patch {:.8}", p.id)),
                },
            };
            dag::append_event(repo, &issue_ref, &close_event, &sk)?;
        }
    }

    Ok(p)
}

/// Generate a unified diff between a patch's base branch and head commit.
pub fn diff(repo: &Repository, id_prefix: &str) -> Result<String, Error> {
    let (ref_name, id) = state::resolve_patch_ref(repo, id_prefix)?;
    let p = PatchState::from_ref(repo, &ref_name, &id)?;
    generate_diff(repo, &p)
}

/// Generate a diff string from a patch's base and head.
pub fn generate_diff(repo: &Repository, patch: &PatchState) -> Result<String, Error> {
    let head_obj = repo
        .revparse_single(&patch.head_commit)
        .map_err(|e| Error::Cmd(format!("bad head ref: {}", e)))?;
    let head_commit = head_obj
        .into_commit()
        .map_err(|_| Error::Cmd("head ref is not a commit".to_string()))?;
    let head_tree = head_commit.tree()?;

    let base_ref = format!("refs/heads/{}", patch.base_ref);
    let base_tree = if let Ok(base_oid) = repo.refname_to_id(&base_ref) {
        let base_commit = repo.find_commit(base_oid)?;
        Some(base_commit.tree()?)
    } else {
        None
    };

    let git_diff = repo.diff_tree_to_tree(base_tree.as_ref(), Some(&head_tree), None)?;

    let mut output = String::new();
    let mut lines = 0usize;
    git_diff.print(DiffFormat::Patch, |_delta, _hunk, line| {
        if lines >= 5000 {
            return false;
        }
        let prefix = match line.origin() {
            '+' => "+",
            '-' => "-",
            ' ' => " ",
            _ => "",
        };
        output.push_str(prefix);
        if let Ok(content) = std::str::from_utf8(line.content()) {
            output.push_str(content);
        }
        lines += 1;
        true
    })?;

    if lines >= 5000 {
        output.push_str("\n[truncated at 5000 lines]");
    }

    Ok(output)
}

pub fn close(
@@ -250,6 +268,7 @@ pub fn close(
    id_prefix: &str,
    reason: Option<&str>,
) -> Result<(), crate::error::Error> {
    let sk = signing::load_signing_key(&signing::signing_key_dir()?)?;
    let (ref_name, _id) = state::resolve_patch_ref(repo, id_prefix)?;
    let author = get_author(repo)?;
    let event = Event {
@@ -259,7 +278,6 @@ pub fn close(
            reason: reason.map(|s| s.to_string()),
        },
    };
    dag::append_event(repo, &ref_name, &event)?;
    println!("Patch closed.");
    dag::append_event(repo, &ref_name, &event, &sk)?;
    Ok(())
}
diff --git a/src/signing.rs b/src/signing.rs
new file mode 100644
index 0000000..9f41a64
--- /dev/null
+++ b/src/signing.rs
@@ -0,0 +1,317 @@
use std::fs;
use std::path::{Path, PathBuf};

use base64::engine::general_purpose::STANDARD;
use base64::Engine;
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
use git2::Oid;
use rand_core::OsRng;
use serde::{Deserialize, Serialize};

use git2::{Repository, Sort};

use crate::error::Error;
use crate::event::Event;

/// Return the directory where signing keys are stored.
pub fn signing_key_dir() -> Result<PathBuf, Error> {
    let config = dirs::config_dir().or_else(|| {
        std::env::var("HOME")
            .ok()
            .map(|h| PathBuf::from(h).join(".config"))
    });
    match config {
        Some(dir) => Ok(dir.join("git-collab")),
        None => Err(Error::Signing(
            "cannot determine config directory: HOME is not set".to_string(),
        )),
    }
}

/// Wrapper around Event that adds Ed25519 signature fields.
/// Serialized as the event.json blob in git commits.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SignedEvent {
    #[serde(flatten)]
    pub event: Event,
    pub signature: String,
    pub pubkey: String,
}

/// Result of verifying an event commit's signature.
#[derive(Debug, Clone, PartialEq)]
pub enum VerifyStatus {
    /// Signature verified successfully against the embedded public key.
    Valid,
    /// Signature present but verification failed (tampered or wrong key).
    Invalid,
    /// No signature or pubkey field in event.json.
    Missing,
}

/// Detailed verification result for a single commit.
#[derive(Debug, Clone)]
pub struct SignatureVerificationResult {
    pub commit_id: Oid,
    pub status: VerifyStatus,
    pub pubkey: Option<String>,
    pub error: Option<String>,
}

/// Generate an Ed25519 keypair and store it in `config_dir`.
///
/// Creates `config_dir` (with 0o700 permissions on Unix) if it doesn't exist.
/// Writes the private key (base64) to `{config_dir}/signing-key` with 0o600
/// permissions and the public key (base64) to `{config_dir}/signing-key.pub`.
pub fn generate_keypair(config_dir: &Path) -> Result<VerifyingKey, Error> {
    // Create config dir if needed
    if !config_dir.exists() {
        fs::create_dir_all(config_dir)?;
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            fs::set_permissions(config_dir, fs::Permissions::from_mode(0o700))?;
        }
    }

    let signing_key = SigningKey::generate(&mut OsRng);
    let verifying_key = signing_key.verifying_key();

    // Write private key
    let sk_path = config_dir.join("signing-key");
    let sk_b64 = STANDARD.encode(signing_key.to_bytes());
    fs::write(&sk_path, &sk_b64)?;
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        fs::set_permissions(&sk_path, fs::Permissions::from_mode(0o600))?;
    }

    // Write public key
    let vk_path = config_dir.join("signing-key.pub");
    let vk_b64 = STANDARD.encode(verifying_key.to_bytes());
    fs::write(&vk_path, &vk_b64)?;

    Ok(verifying_key)
}

/// Load the Ed25519 signing (private) key from `config_dir/signing-key`.
pub fn load_signing_key(config_dir: &Path) -> Result<SigningKey, Error> {
    let path = config_dir.join("signing-key");
    if !path.exists() {
        return Err(Error::KeyNotFound);
    }
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        let mode = fs::metadata(&path)?.permissions().mode() & 0o777;
        if mode & 0o077 != 0 {
            eprintln!(
                "warning: signing key {:?} has permissions {:04o} — should be 0600",
                path, mode
            );
        }
    }
    let b64 = fs::read_to_string(&path)?;
    let bytes = STANDARD
        .decode(b64.trim())
        .map_err(|e| Error::Signing(format!("invalid signing key base64: {}", e)))?;
    let key_bytes: [u8; 32] = bytes
        .try_into()
        .map_err(|_| Error::Signing("signing key must be 32 bytes".to_string()))?;
    Ok(SigningKey::from_bytes(&key_bytes))
}

/// Load the Ed25519 verifying (public) key from `config_dir/signing-key.pub`.
pub fn load_verifying_key(config_dir: &Path) -> Result<VerifyingKey, Error> {
    let path = config_dir.join("signing-key.pub");
    if !path.exists() {
        return Err(Error::KeyNotFound);
    }
    let b64 = fs::read_to_string(&path)?;
    let bytes = STANDARD
        .decode(b64.trim())
        .map_err(|e| Error::Verification(format!("invalid verifying key base64: {}", e)))?;
    let key_bytes: [u8; 32] = bytes
        .try_into()
        .map_err(|_| Error::Verification("verifying key must be 32 bytes".to_string()))?;
    VerifyingKey::from_bytes(&key_bytes)
        .map_err(|e| Error::Verification(format!("invalid verifying key: {}", e)))
}

/// Serialize an Event to canonical JSON bytes.
///
/// Uses `serde_json::Value` as an intermediate step. Since serde_json uses
/// BTreeMap-backed Map (no `preserve_order` feature), keys are sorted
/// alphabetically, ensuring deterministic output.
pub fn canonical_json(event: &Event) -> Result<Vec<u8>, Error> {
    let value = serde_json::to_value(event)?;
    let json = serde_json::to_string(&value)?;
    Ok(json.into_bytes())
}

/// Sign an Event with the given signing key, producing a SignedEvent.
pub fn sign_event(event: &Event, signing_key: &SigningKey) -> Result<SignedEvent, Error> {
    let canonical = canonical_json(event)?;
    let signature = signing_key.sign(&canonical);
    let verifying_key = signing_key.verifying_key();

    Ok(SignedEvent {
        event: event.clone(),
        signature: STANDARD.encode(signature.to_bytes()),
        pubkey: STANDARD.encode(verifying_key.to_bytes()),
    })
}

/// Verify a SignedEvent's signature against its embedded public key.
///
/// Returns `Missing` if signature or pubkey fields are empty,
/// `Valid` if the signature checks out, `Invalid` otherwise.
pub fn verify_signed_event(signed: &SignedEvent) -> Result<VerifyStatus, Error> {
    if signed.signature.is_empty() || signed.pubkey.is_empty() {
        return Ok(VerifyStatus::Missing);
    }

    let sig_bytes = match STANDARD.decode(&signed.signature) {
        Ok(b) => b,
        Err(_) => return Ok(VerifyStatus::Invalid),
    };
    let pubkey_bytes = match STANDARD.decode(&signed.pubkey) {
        Ok(b) => b,
        Err(_) => return Ok(VerifyStatus::Invalid),
    };

    let sig_array: [u8; 64] = match sig_bytes.try_into() {
        Ok(a) => a,
        Err(_) => return Ok(VerifyStatus::Invalid),
    };
    let key_array: [u8; 32] = match pubkey_bytes.try_into() {
        Ok(a) => a,
        Err(_) => return Ok(VerifyStatus::Invalid),
    };

    let signature = Signature::from_bytes(&sig_array);
    let verifying_key = match VerifyingKey::from_bytes(&key_array) {
        Ok(vk) => vk,
        Err(_) => return Ok(VerifyStatus::Invalid),
    };

    let canonical = canonical_json(&signed.event)?;

    match verifying_key.verify(&canonical, &signature) {
        Ok(()) => Ok(VerifyStatus::Valid),
        Err(_) => Ok(VerifyStatus::Invalid),
    }
}

/// Walk the DAG for the given ref and verify every event commit's signature.
///
/// For each commit, reads `event.json` from the tree:
/// - If it deserializes as a `SignedEvent`, calls `verify_signed_event()`.
/// - If it only deserializes as a plain `Event` (no signature/pubkey), marks as `Missing`.
///
/// Returns one `SignatureVerificationResult` per commit.
pub fn verify_ref(
    repo: &Repository,
    ref_name: &str,
) -> Result<Vec<SignatureVerificationResult>, Error> {
    let tip = repo.refname_to_id(ref_name)?;
    let mut revwalk = repo.revwalk()?;
    revwalk.set_sorting(Sort::TOPOLOGICAL | Sort::REVERSE)?;
    revwalk.push(tip)?;

    let mut results = Vec::new();
    for oid_result in revwalk {
        let oid = oid_result?;
        let commit = repo.find_commit(oid)?;
        let tree = commit.tree()?;
        let entry = tree
            .get_name("event.json")
            .ok_or_else(|| git2::Error::from_str("missing event.json in commit tree"))?;
        let blob = repo.find_blob(entry.id())?;
        let content = blob.content();

        // Try to deserialize as SignedEvent first
        if let Ok(signed) = serde_json::from_slice::<SignedEvent>(content) {
            if signed.signature.is_empty() || signed.pubkey.is_empty() {
                results.push(SignatureVerificationResult {
                    commit_id: oid,
                    status: VerifyStatus::Missing,
                    pubkey: None,
                    error: Some("missing signature".to_string()),
                });
            } else {
                match verify_signed_event(&signed)? {
                    VerifyStatus::Valid => {
                        results.push(SignatureVerificationResult {
                            commit_id: oid,
                            status: VerifyStatus::Valid,
                            pubkey: Some(signed.pubkey),
                            error: None,
                        });
                    }
                    VerifyStatus::Invalid => {
                        results.push(SignatureVerificationResult {
                            commit_id: oid,
                            status: VerifyStatus::Invalid,
                            pubkey: Some(signed.pubkey),
                            error: Some("invalid signature".to_string()),
                        });
                    }
                    VerifyStatus::Missing => {
                        results.push(SignatureVerificationResult {
                            commit_id: oid,
                            status: VerifyStatus::Missing,
                            pubkey: None,
                            error: Some("missing signature".to_string()),
                        });
                    }
                }
            }
        } else {
            // Plain Event without signature fields
            results.push(SignatureVerificationResult {
                commit_id: oid,
                status: VerifyStatus::Missing,
                pubkey: None,
                error: Some("missing signature".to_string()),
            });
        }
    }

    Ok(results)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::event::{Action, Author};

    #[test]
    fn signed_event_flatten_round_trip() {
        let event = Event {
            timestamp: "2026-03-21T00:00:00Z".to_string(),
            author: Author {
                name: "Alice".to_string(),
                email: "alice@example.com".to_string(),
            },
            action: Action::IssueOpen {
                title: "Test".to_string(),
                body: "Body".to_string(),
            },
        };
        let signed = SignedEvent {
            event,
            signature: "dGVzdA==".to_string(),
            pubkey: "cHVia2V5".to_string(),
        };
        let json = serde_json::to_string_pretty(&signed).unwrap();
        let deserialized: SignedEvent = serde_json::from_str(&json).unwrap();
        assert_eq!(deserialized.signature, "dGVzdA==");
        assert_eq!(deserialized.pubkey, "cHVia2V5");
        match deserialized.event.action {
            Action::IssueOpen { ref title, .. } => assert_eq!(title, "Test"),
            _ => panic!("Wrong action type after round-trip"),
        }
    }
}
diff --git a/src/state.rs b/src/state.rs
index e2a02c0..6445b04 100644
--- a/src/state.rs
+++ b/src/state.rs
@@ -25,6 +25,8 @@ pub struct IssueState {
    pub body: String,
    pub status: IssueStatus,
    pub close_reason: Option<String>,
    pub labels: Vec<String>,
    pub assignees: Vec<String>,
    pub comments: Vec<Comment>,
    pub created_at: String,
    pub author: Author,
@@ -63,6 +65,7 @@ pub struct PatchState {
    pub status: PatchStatus,
    pub base_ref: String,
    pub head_commit: String,
    pub fixes: Option<String>,
    pub comments: Vec<Comment>,
    pub inline_comments: Vec<InlineComment>,
    pub reviews: Vec<Review>,
@@ -93,6 +96,8 @@ impl IssueState {
                        body,
                        status: IssueStatus::Open,
                        close_reason: None,
                        labels: Vec::new(),
                        assignees: Vec::new(),
                        comments: Vec::new(),
                        created_at: event.timestamp.clone(),
                        author: event.author.clone(),
@@ -117,6 +122,40 @@ impl IssueState {
                        }
                    }
                }
                Action::IssueEdit { title, body } => {
                    if let Some(ref mut s) = state {
                        if let Some(t) = title {
                            s.title = t;
                        }
                        if let Some(b) = body {
                            s.body = b;
                        }
                    }
                }
                Action::IssueLabel { label } => {
                    if let Some(ref mut s) = state {
                        if !s.labels.contains(&label) {
                            s.labels.push(label);
                        }
                    }
                }
                Action::IssueUnlabel { label } => {
                    if let Some(ref mut s) = state {
                        s.labels.retain(|l| l != &label);
                    }
                }
                Action::IssueAssign { assignee } => {
                    if let Some(ref mut s) = state {
                        if !s.assignees.contains(&assignee) {
                            s.assignees.push(assignee);
                        }
                    }
                }
                Action::IssueUnassign { assignee } => {
                    if let Some(ref mut s) = state {
                        s.assignees.retain(|a| a != &assignee);
                    }
                }
                Action::IssueReopen => {
                    if let Some(ref mut s) = state {
                        if status_ts.as_ref().is_none_or(|ts| event.timestamp >= *ts) {
@@ -152,6 +191,7 @@ impl PatchState {
                    body,
                    base_ref,
                    head_commit,
                    fixes,
                } => {
                    state = Some(PatchState {
                        id: id.to_string(),
@@ -160,6 +200,7 @@ impl PatchState {
                        status: PatchStatus::Open,
                        base_ref,
                        head_commit,
                        fixes,
                        comments: Vec::new(),
                        inline_comments: Vec::new(),
                        reviews: Vec::new(),
@@ -231,67 +272,46 @@ impl PatchState {
    }
}

/// List all issue refs and return their materialized state.
pub fn list_issues(repo: &Repository) -> Result<Vec<IssueState>, crate::error::Error> {
    let mut issues = Vec::new();
    let refs = repo.references_glob("refs/collab/issues/*")?;
    for r in refs {
        let r = r?;
        let ref_name = r.name().unwrap_or_default().to_string();
        let id = ref_name
            .strip_prefix("refs/collab/issues/")
            .unwrap_or_default()
            .to_string();
        match IssueState::from_ref(repo, &ref_name, &id) {
            Ok(state) => issues.push(state),
            Err(_) => continue,
        }
    }
    Ok(issues)
}

/// List all patch refs and return their materialized state.
pub fn list_patches(repo: &Repository) -> Result<Vec<PatchState>, crate::error::Error> {
    let mut patches = Vec::new();
    let refs = repo.references_glob("refs/collab/patches/*")?;
/// Enumerate all collab refs of a given kind, returning (ref_name, id) pairs.
fn collab_refs(
    repo: &Repository,
    kind: &str,
) -> Result<Vec<(String, String)>, crate::error::Error> {
    let prefix = format!("refs/collab/{}/", kind);
    let glob = format!("{}*", prefix);
    let refs = repo.references_glob(&glob)?;
    let mut result = Vec::new();
    for r in refs {
        let r = r?;
        let ref_name = r.name().unwrap_or_default().to_string();
        let id = ref_name
            .strip_prefix("refs/collab/patches/")
            .strip_prefix(&prefix)
            .unwrap_or_default()
            .to_string();
        match PatchState::from_ref(repo, &ref_name, &id) {
            Ok(state) => patches.push(state),
            Err(_) => continue,
        }
        result.push((ref_name, id));
    }
    Ok(patches)
    Ok(result)
}

/// Resolve a short ID prefix to the full ref name. Returns (ref_name, id).
pub fn resolve_issue_ref(
/// Resolve a short ID prefix to a full ref. Returns (ref_name, id).
fn resolve_ref(
    repo: &Repository,
    kind: &str,
    singular: &str,
    prefix: &str,
) -> Result<(String, String), crate::error::Error> {
    let refs = repo.references_glob("refs/collab/issues/*")?;
    let mut matches = Vec::new();
    for r in refs {
        let r = r?;
        let ref_name = r.name().unwrap_or_default().to_string();
        let id = ref_name
            .strip_prefix("refs/collab/issues/")
            .unwrap_or_default()
            .to_string();
        if id.starts_with(prefix) {
            matches.push((ref_name, id));
        }
    }
    let matches: Vec<_> = collab_refs(repo, kind)?
        .into_iter()
        .filter(|(_, id)| id.starts_with(prefix))
        .collect();
    match matches.len() {
        0 => Err(git2::Error::from_str(&format!("no issue found matching '{}'", prefix)).into()),
        0 => Err(
            git2::Error::from_str(&format!("no {} found matching '{}'", singular, prefix)).into(),
        ),
        1 => Ok(matches.into_iter().next().unwrap()),
        _ => Err(git2::Error::from_str(&format!(
            "ambiguous issue prefix '{}': {} matches",
            "ambiguous {} prefix '{}': {} matches",
            singular,
            prefix,
            matches.len()
        ))
@@ -299,32 +319,36 @@ pub fn resolve_issue_ref(
    }
}

/// Resolve a short ID prefix to the full patch ref name.
/// List all issue refs and return their materialized state.
pub fn list_issues(repo: &Repository) -> Result<Vec<IssueState>, crate::error::Error> {
    let items = collab_refs(repo, "issues")?
        .into_iter()
        .filter_map(|(ref_name, id)| IssueState::from_ref(repo, &ref_name, &id).ok())
        .collect();
    Ok(items)
}

/// List all patch refs and return their materialized state.
pub fn list_patches(repo: &Repository) -> Result<Vec<PatchState>, crate::error::Error> {
    let items = collab_refs(repo, "patches")?
        .into_iter()
        .filter_map(|(ref_name, id)| PatchState::from_ref(repo, &ref_name, &id).ok())
        .collect();
    Ok(items)
}

/// Resolve a short ID prefix to the full issue ref name. Returns (ref_name, id).
pub fn resolve_issue_ref(
    repo: &Repository,
    prefix: &str,
) -> Result<(String, String), crate::error::Error> {
    resolve_ref(repo, "issues", "issue", prefix)
}

/// Resolve a short ID prefix to the full patch ref name. Returns (ref_name, id).
pub fn resolve_patch_ref(
    repo: &Repository,
    prefix: &str,
) -> Result<(String, String), crate::error::Error> {
    let refs = repo.references_glob("refs/collab/patches/*")?;
    let mut matches = Vec::new();
    for r in refs {
        let r = r?;
        let ref_name = r.name().unwrap_or_default().to_string();
        let id = ref_name
            .strip_prefix("refs/collab/patches/")
            .unwrap_or_default()
            .to_string();
        if id.starts_with(prefix) {
            matches.push((ref_name, id));
        }
    }
    match matches.len() {
        0 => Err(git2::Error::from_str(&format!("no patch found matching '{}'", prefix)).into()),
        1 => Ok(matches.into_iter().next().unwrap()),
        _ => Err(git2::Error::from_str(&format!(
            "ambiguous patch prefix '{}': {} matches",
            prefix,
            matches.len()
        ))
        .into()),
    }
    resolve_ref(repo, "patches", "patch", prefix)
}
diff --git a/src/sync.rs b/src/sync.rs
index dda19ef..69192e5 100644
--- a/src/sync.rs
+++ b/src/sync.rs
@@ -5,6 +5,7 @@ use git2::Repository;
use crate::dag;
use crate::error::Error;
use crate::identity::get_author;
use crate::signing;

/// Add collab refspecs to all remotes.
pub fn init(repo: &Repository) -> Result<(), Error> {
@@ -50,8 +51,9 @@ pub fn sync(repo: &Repository, remote_name: &str) -> Result<(), Error> {
    // Step 2: Reconcile
    // Re-open repo to see the fetched refs (git2 caches ref state)
    let repo = Repository::open(repo.path())?;
    reconcile_refs(&repo, "issues", &author)?;
    reconcile_refs(&repo, "patches", &author)?;
    let sk = signing::load_signing_key(&signing::signing_key_dir()?)?;
    reconcile_refs(&repo, "issues", &author, &sk)?;
    reconcile_refs(&repo, "patches", &author, &sk)?;

    // Step 3: Push collab refs using system git
    println!("Pushing to '{}'...", remote_name);
@@ -109,6 +111,7 @@ fn reconcile_refs(
    repo: &Repository,
    kind: &str,
    author: &crate::event::Author,
    signing_key: &ed25519_dalek::SigningKey,
) -> Result<(), Error> {
    let sync_prefix = format!("refs/collab/sync/{}/", kind);
    let sync_refs: Vec<(String, String)> = {
@@ -123,9 +126,38 @@ fn reconcile_refs(
    };

    for (remote_ref, id) in &sync_refs {
        // Verify all commits on the remote ref before reconciling
        match signing::verify_ref(repo, remote_ref) {
            Ok(results) => {
                let failures: Vec<_> = results
                    .iter()
                    .filter(|r| r.status != signing::VerifyStatus::Valid)
                    .collect();
                if !failures.is_empty() {
                    for f in &failures {
                        eprintln!(
                            "  Rejecting {} {:.8}: commit {} — {}",
                            kind,
                            id,
                            f.commit_id,
                            f.error.as_deref().unwrap_or("unknown error")
                        );
                    }
                    continue;
                }
            }
            Err(e) => {
                eprintln!(
                    "  Failed to verify {} {:.8}: {}",
                    kind, id, e
                );
                continue;
            }
        }

        let local_ref = format!("refs/collab/{}/{}", kind, id);
        if repo.refname_to_id(&local_ref).is_ok() {
            match dag::reconcile(repo, &local_ref, remote_ref, author) {
            match dag::reconcile(repo, &local_ref, remote_ref, author, signing_key) {
                Ok(_) => println!("  Reconciled {} {:.8}", kind, id),
                Err(e) => eprintln!("  Failed to reconcile {} {:.8}: {}", kind, id, e),
            }
diff --git a/src/tui.rs b/src/tui.rs
index 05d05dd..789777e 100644
--- a/src/tui.rs
+++ b/src/tui.rs
@@ -5,11 +5,12 @@ use std::time::Duration;
use crossterm::event::{self, Event, KeyCode, KeyModifiers};
use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen};
use crossterm::ExecutableCommand;
use git2::{DiffFormat, Repository};
use git2::Repository;
use ratatui::prelude::*;
use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Tabs, Wrap};

use crate::error::Error;
use crate::patch as patch_mod;
use crate::state::{self, IssueState, IssueStatus, PatchState, PatchStatus};

#[derive(PartialEq)]
@@ -151,68 +152,6 @@ impl App {
    }
}

fn generate_diff(repo: &Repository, patch: &PatchState) -> String {
    let result = (|| -> Result<String, Error> {
        let head_obj = repo
            .revparse_single(&patch.head_commit)
            .map_err(|e| Error::Cmd(format!("bad head ref: {}", e)))?;
        let head_commit = head_obj
            .into_commit()
            .map_err(|_| Error::Cmd("head ref is not a commit".to_string()))?;
        let head_tree = head_commit.tree()?;

        let base_ref = format!("refs/heads/{}", patch.base_ref);
        let base_tree = if let Ok(base_oid) = repo.refname_to_id(&base_ref) {
            let base_commit = repo.find_commit(base_oid)?;
            Some(base_commit.tree()?)
        } else {
            None
        };

        let diff = repo.diff_tree_to_tree(base_tree.as_ref(), Some(&head_tree), None)?;

        let mut output = String::new();
        let mut lines = 0usize;
        diff.print(DiffFormat::Patch, |_delta, _hunk, line| {
            if lines >= 5000 {
                return false;
            }
            let prefix = match line.origin() {
                '+' => "+",
                '-' => "-",
                ' ' => " ",
                'H' | 'F' => "",
                _ => "",
            };
            if !prefix.is_empty() || matches!(line.origin(), 'H' | 'F') {
                output.push_str(prefix);
            }
            if let Ok(content) = std::str::from_utf8(line.content()) {
                output.push_str(content);
            }
            lines += 1;
            true
        })?;

        if lines >= 5000 {
            output.push_str("\n[truncated at 5000 lines]");
        }

        Ok(output)
    })();

    match result {
        Ok(diff) => {
            if diff.is_empty() {
                "No diff available (commits may be identical)".to_string()
            } else {
                diff
            }
        }
        Err(e) => format!("Diff unavailable: {}", e),
    }
}

pub fn run(repo: &Repository) -> Result<(), Error> {
    let issues = state::list_issues(repo)?;
    let patches = state::list_patches(repo)?;
@@ -244,7 +183,13 @@ fn run_loop(
                if let Some(patch) = visible.get(idx) {
                    if !app.diff_cache.contains_key(&patch.id) {
                        let id = patch.id.clone();
                        let diff = generate_diff(repo, patch);
                        let diff = match patch_mod::generate_diff(repo, patch) {
                            Ok(d) if d.is_empty() => {
                                "No diff available (commits may be identical)".to_string()
                            }
                            Ok(d) => d,
                            Err(e) => format!("Diff unavailable: {}", e),
                        };
                        app.diff_cache.insert(id, diff);
                    }
                }
diff --git a/tests/cli_test.rs b/tests/cli_test.rs
new file mode 100644
index 0000000..eecd115
--- /dev/null
+++ b/tests/cli_test.rs
@@ -0,0 +1,865 @@
mod common;

use common::TestRepo;

// ===========================================================================
// Issue commands
// ===========================================================================

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

    let out = repo.run_ok(&["issue", "open", "-t", "My first bug"]);
    assert!(out.starts_with("Opened issue "));
    let id = out.trim().strip_prefix("Opened issue ").unwrap();
    assert_eq!(id.len(), 8, "should print 8-char short ID");

    let out = repo.run_ok(&["issue", "show", id]);
    assert!(out.contains("My first bug"));
    assert!(out.contains("[open]"));
    assert!(out.contains("Alice"));
}

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

    let out = repo.run_ok(&["issue", "open", "-t", "Bug", "-b", "Steps to reproduce..."]);
    let id = out.trim().strip_prefix("Opened issue ").unwrap();

    let out = repo.run_ok(&["issue", "show", id]);
    assert!(out.contains("Steps to reproduce..."));
}

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

    repo.issue_open("Open bug");
    let closed_id = repo.issue_open("Closed bug");
    repo.run_ok(&["issue", "close", &closed_id]);

    let out = repo.run_ok(&["issue", "list"]);
    assert!(out.contains("Open bug"));
    assert!(!out.contains("Closed bug"));

    let out = repo.run_ok(&["issue", "list", "--all"]);
    assert!(out.contains("Open bug"));
    assert!(out.contains("Closed bug"));
    assert!(out.contains("closed"));
}

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

    let out = repo.run_ok(&["issue", "comment", &id, "-b", "First thought"]);
    assert!(out.contains("Comment added"));

    let out = repo.run_ok(&["issue", "show", &id]);
    assert!(out.contains("First thought"));
    assert!(out.contains("Comments"));
}

#[test]
fn test_issue_close_with_reason() {
    let repo = TestRepo::new("Alice", "alice@example.com");
    let id = repo.issue_open("Will close");

    repo.run_ok(&["issue", "close", &id, "-r", "Duplicate of #42"]);

    let out = repo.run_ok(&["issue", "show", &id]);
    assert!(out.contains("[closed]"));
    assert!(out.contains("Duplicate of #42"));
}

#[test]
fn test_issue_close_without_reason() {
    let repo = TestRepo::new("Alice", "alice@example.com");
    let id = repo.issue_open("Close silently");

    let out = repo.run_ok(&["issue", "close", &id]);
    assert!(out.contains("Issue closed"));

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

#[test]
fn test_issue_reopen() {
    let repo = TestRepo::new("Alice", "alice@example.com");
    let id = repo.issue_open("Reopen me");
    repo.run_ok(&["issue", "close", &id]);

    let out = repo.run_ok(&["issue", "reopen", &id]);
    assert!(out.contains("Issue reopened"));

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

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

    // Use first 4 chars as prefix
    let short = &id[..4];
    let out = repo.run_ok(&["issue", "show", short]);
    assert!(out.contains("Prefix test"));
}

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

    let err = repo.run_err(&["issue", "show", "deadbeef"]);
    assert!(err.contains("no issue found"));
}

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

    let out = repo.run_ok(&["issue", "list"]);
    assert!(out.contains("No issues found"));
}

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

    let id1 = repo.issue_open("Issue one");
    let id2 = repo.issue_open("Issue two");

    repo.run_ok(&["issue", "comment", &id1, "-b", "Comment on one"]);
    repo.run_ok(&["issue", "close", &id2]);

    let out = repo.run_ok(&["issue", "show", &id1]);
    assert!(out.contains("[open]"));
    assert!(out.contains("Comment on one"));

    let out = repo.run_ok(&["issue", "show", &id2]);
    assert!(out.contains("[closed]"));
    assert!(!out.contains("Comment on one"));
}

// ===========================================================================
// Issue edit
// ===========================================================================

#[test]
fn test_issue_edit_title() {
    let repo = TestRepo::new("Alice", "alice@example.com");
    let id = repo.issue_open("Old title");

    let out = repo.run_ok(&["issue", "edit", &id, "-t", "New title"]);
    assert!(out.contains("Issue updated"));

    let out = repo.run_ok(&["issue", "show", &id]);
    assert!(out.contains("New title"));
    assert!(!out.contains("Old title"));
}

#[test]
fn test_issue_edit_body() {
    let repo = TestRepo::new("Alice", "alice@example.com");
    let out = repo.run_ok(&["issue", "open", "-t", "Bug", "-b", "Old body"]);
    let id = out.trim().strip_prefix("Opened issue ").unwrap();

    repo.run_ok(&["issue", "edit", id, "-b", "New body with details"]);

    let out = repo.run_ok(&["issue", "show", id]);
    assert!(out.contains("New body with details"));
    assert!(!out.contains("Old body"));
}

#[test]
fn test_issue_edit_title_and_body() {
    let repo = TestRepo::new("Alice", "alice@example.com");
    let out = repo.run_ok(&["issue", "open", "-t", "Original", "-b", "Original body"]);
    let id = out.trim().strip_prefix("Opened issue ").unwrap();

    repo.run_ok(&["issue", "edit", id, "-t", "Updated", "-b", "Updated body"]);

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

#[test]
fn test_issue_edit_preserves_comments() {
    let repo = TestRepo::new("Alice", "alice@example.com");
    let id = repo.issue_open("Will edit");

    repo.run_ok(&["issue", "comment", &id, "-b", "A comment before edit"]);
    repo.run_ok(&["issue", "edit", &id, "-t", "Edited title"]);

    let out = repo.run_ok(&["issue", "show", &id]);
    assert!(out.contains("Edited title"));
    assert!(out.contains("A comment before edit"));
}

#[test]
fn test_issue_edit_requires_title_or_body() {
    let repo = TestRepo::new("Alice", "alice@example.com");
    let id = repo.issue_open("No change");

    let err = repo.run_err(&["issue", "edit", &id]);
    assert!(
        err.contains("--title") || err.contains("--body") || err.contains("at least one"),
        "should require at least --title or --body, got: {}",
        err
    );
}

// ===========================================================================
// Issue labels
// ===========================================================================

#[test]
fn test_issue_label_and_show() {
    let repo = TestRepo::new("Alice", "alice@example.com");
    let id = repo.issue_open("Labeled issue");

    let out = repo.run_ok(&["issue", "label", &id, "bug"]);
    assert!(out.contains("Label") && out.contains("added"));

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

#[test]
fn test_issue_multiple_labels() {
    let repo = TestRepo::new("Alice", "alice@example.com");
    let id = repo.issue_open("Multi-label issue");

    repo.run_ok(&["issue", "label", &id, "bug"]);
    repo.run_ok(&["issue", "label", &id, "priority"]);

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

#[test]
fn test_issue_unlabel() {
    let repo = TestRepo::new("Alice", "alice@example.com");
    let id = repo.issue_open("Remove label");

    repo.run_ok(&["issue", "label", &id, "bug"]);
    repo.run_ok(&["issue", "label", &id, "wontfix"]);
    repo.run_ok(&["issue", "unlabel", &id, "bug"]);

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

#[test]
fn test_issue_label_shown_in_list() {
    let repo = TestRepo::new("Alice", "alice@example.com");
    let id = repo.issue_open("Listed with label");

    repo.run_ok(&["issue", "label", &id, "enhancement"]);

    let out = repo.run_ok(&["issue", "list"]);
    assert!(out.contains("enhancement"));
}

// ===========================================================================
// Issue assignees
// ===========================================================================

#[test]
fn test_issue_assign_and_show() {
    let repo = TestRepo::new("Alice", "alice@example.com");
    let id = repo.issue_open("Assigned issue");

    let out = repo.run_ok(&["issue", "assign", &id, "Bob"]);
    assert!(out.contains("Assigned"));

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

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

    repo.run_ok(&["issue", "assign", &id, "Bob"]);
    repo.run_ok(&["issue", "unassign", &id, "Bob"]);

    let out = repo.run_ok(&["issue", "show", &id]);
    assert!(!out.contains("Bob") || !out.contains("Assignee"));
}

// ===========================================================================
// Patch commands
// ===========================================================================

#[test]
fn test_patch_create_and_show() {
    let repo = TestRepo::new("Alice", "alice@example.com");
    repo.commit_file("hello.txt", "hello world", "add hello");

    let id = repo.patch_create("Add hello");

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

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

    repo.patch_create("Open patch");
    let closed_id = repo.patch_create("Closed patch");
    repo.run_ok(&["patch", "close", &closed_id]);

    let out = repo.run_ok(&["patch", "list"]);
    assert!(out.contains("Open patch"));
    assert!(!out.contains("Closed patch"));

    let out = repo.run_ok(&["patch", "list", "--all"]);
    assert!(out.contains("Open patch"));
    assert!(out.contains("Closed patch"));
}

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

    let out = repo.run_ok(&["patch", "comment", &id, "-b", "Looks good overall"]);
    assert!(out.contains("Comment added"));

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

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

    repo.run_ok(&[
        "patch",
        "comment",
        &id,
        "-b",
        "Use const here",
        "-f",
        "src/main.rs",
        "-l",
        "42",
    ]);

    let out = repo.run_ok(&["patch", "show", &id]);
    assert!(out.contains("Use const here"));
    assert!(out.contains("src/main.rs"));
    assert!(out.contains("42"));
    assert!(out.contains("Inline Comments"));
}

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

    // --file without --line
    let err = repo.run_err(&["patch", "comment", &id, "-b", "nope", "-f", "foo.rs"]);
    assert!(err.contains("--file and --line must both be provided"));

    // --line without --file
    let err = repo.run_err(&["patch", "comment", &id, "-b", "nope", "-l", "10"]);
    assert!(err.contains("--file and --line must both be provided"));
}

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

    let out = repo.run_ok(&["patch", "review", &id, "-v", "approve", "-b", "LGTM!"]);
    assert!(out.contains("Review submitted"));

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

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

    repo.run_ok(&[
        "patch",
        "review",
        &id,
        "-v",
        "request-changes",
        "-b",
        "Needs error handling",
    ]);

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

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

    let err = repo.run_err(&["patch", "review", &id, "-v", "yolo", "-b", "whatever"]);
    assert!(err.contains("verdict must be"));
}

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

    let new_head = repo.commit_file("v2.txt", "v2", "version 2");
    let out = repo.run_ok(&[
        "patch",
        "revise",
        &id,
        "--head",
        &new_head,
        "-b",
        "Updated implementation",
    ]);
    assert!(out.contains("Patch revised"));

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

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

    // Create a feature branch with a new file
    repo.git(&["checkout", "-b", "feature"]);
    repo.commit_file(
        "hello.rs",
        "fn main() {\n    println!(\"hello\");\n}\n",
        "add hello",
    );
    let head = repo.git(&["rev-parse", "HEAD"]).trim().to_string();
    repo.git(&["checkout", "main"]);

    let out = repo.run_ok(&["patch", "create", "-t", "Add hello", "--head", &head]);
    let id = out.trim().strip_prefix("Created patch ").unwrap();

    let out = repo.run_ok(&["patch", "diff", id]);
    assert!(out.contains("hello.rs"), "should show filename");
    assert!(out.contains("fn main()"), "should show added code");
    assert!(out.contains("+"), "should show + for additions");
}

#[test]
fn test_patch_diff_no_changes() {
    let repo = TestRepo::new("Alice", "alice@example.com");
    // Patch pointing at same commit as main — no diff
    let id = repo.patch_create("No diff patch");

    let out = repo.run_ok(&["patch", "diff", &id]);
    assert!(
        out.contains("No diff") || out.contains("identical"),
        "should indicate no diff, got: {}",
        out
    );
}

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

    let out = repo.run_ok(&["patch", "close", &id, "-r", "Superseded"]);
    assert!(out.contains("Patch closed"));

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

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

    // Create a feature branch ahead of main
    repo.git(&["checkout", "-b", "feature"]);
    let head = repo.commit_file("feature.txt", "new feature", "add feature");
    repo.git(&["checkout", "main"]);

    // Create patch pointing at the feature commit
    let out = repo.run_ok(&["patch", "create", "-t", "Add feature", "--head", &head]);
    let id = out.trim().strip_prefix("Created patch ").unwrap();

    let out = repo.run_ok(&["patch", "merge", id]);
    assert!(out.contains("merged into main"));

    // Main should now point at the feature commit
    let main_head = repo.git(&["rev-parse", "main"]).trim().to_string();
    assert_eq!(main_head, head);

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

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

    repo.git(&["checkout", "-b", "feature"]);
    let head = repo.commit_file("f.txt", "f", "feature commit");
    repo.git(&["checkout", "main"]);

    let out = repo.run_ok(&["patch", "create", "-t", "Will close", "--head", &head]);
    let id = out.trim().strip_prefix("Created patch ").unwrap();

    repo.run_ok(&["patch", "close", id]);

    let err = repo.run_err(&["patch", "merge", id]);
    assert!(err.contains("can only merge open patches"));
}

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

    let out = repo.run_ok(&["patch", "list"]);
    assert!(out.contains("No patches found"));
}

// ===========================================================================
// Cross-references (patch --fixes issue)
// ===========================================================================

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

    let issue_id = repo.issue_open("Login bug");

    repo.git(&["checkout", "-b", "fix"]);
    let head = repo.commit_file("fix.rs", "fixed", "fix login");
    repo.git(&["checkout", "main"]);

    let out = repo.run_ok(&[
        "patch", "create",
        "-t", "Fix login bug",
        "--head", &head,
        "--fixes", &issue_id,
    ]);
    let patch_id = out.trim().strip_prefix("Created patch ").unwrap();

    // Patch show should mention the linked issue
    let out = repo.run_ok(&["patch", "show", patch_id]);
    assert!(out.contains("Fixes"), "should show Fixes field");
    assert!(out.contains(&issue_id[..8]), "should show linked issue ID");
}

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

    let issue_id = repo.issue_open("Crash on startup");

    repo.git(&["checkout", "-b", "fix"]);
    let head = repo.commit_file("fix.rs", "fixed", "fix crash");
    repo.git(&["checkout", "main"]);

    let out = repo.run_ok(&[
        "patch", "create",
        "-t", "Fix crash",
        "--head", &head,
        "--fixes", &issue_id,
    ]);
    let patch_id = out.trim().strip_prefix("Created patch ").unwrap();

    repo.run_ok(&["patch", "merge", patch_id]);

    // Issue should now be closed
    let out = repo.run_ok(&["issue", "show", &issue_id]);
    assert!(out.contains("[closed]"), "linked issue should be auto-closed on merge");
}

// ===========================================================================
// Unread tracking
// ===========================================================================

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

    // View the issue to mark it as read
    repo.run_ok(&["issue", "show", &id]);

    // Add comments after viewing
    repo.run_ok(&["issue", "comment", &id, "-b", "New comment 1"]);
    repo.run_ok(&["issue", "comment", &id, "-b", "New comment 2"]);

    // List should show unread count
    let out = repo.run_ok(&["issue", "list"]);
    assert!(
        out.contains("2 new"),
        "should show 2 new events, got: {}",
        out
    );
}

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

    repo.run_ok(&["issue", "comment", &id, "-b", "A comment"]);

    // View to mark as read
    repo.run_ok(&["issue", "show", &id]);

    // List should show no unread
    let out = repo.run_ok(&["issue", "list"]);
    assert!(
        !out.contains("new"),
        "should not show 'new' after viewing, got: {}",
        out
    );
}

#[test]
fn test_issue_list_no_unread_for_never_viewed() {
    let repo = TestRepo::new("Alice", "alice@example.com");
    // A brand new issue that's never been shown should not show "new"
    // (only show unread count after the user has viewed it once)
    repo.issue_open("Fresh issue");

    let out = repo.run_ok(&["issue", "list"]);
    assert!(
        !out.contains("new"),
        "never-viewed issue should not show unread count, got: {}",
        out
    );
}

// ===========================================================================
// Init command
// ===========================================================================

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

    let out = repo.run_ok(&["init"]);
    assert!(out.contains("No remotes"));
}

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

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

    // Open
    let id = repo.issue_open("Login page crashes");

    // Multiple comments
    repo.run_ok(&["issue", "comment", &id, "-b", "Stack trace attached"]);
    repo.run_ok(&["issue", "comment", &id, "-b", "Reproduced on Chrome"]);

    let out = repo.run_ok(&["issue", "show", &id]);
    assert!(out.contains("Stack trace attached"));
    assert!(out.contains("Reproduced on Chrome"));

    // Close with reason
    repo.run_ok(&["issue", "close", &id, "-r", "Fixed in commit abc123"]);
    let out = repo.run_ok(&["issue", "show", &id]);
    assert!(out.contains("[closed]"));
    assert!(out.contains("Fixed in commit abc123"));

    // Reopen
    repo.run_ok(&["issue", "reopen", &id]);
    let out = repo.run_ok(&["issue", "show", &id]);
    assert!(out.contains("[open]"));

    // Close again (no reason)
    repo.run_ok(&["issue", "close", &id]);
    let out = repo.run_ok(&["issue", "list"]);
    assert!(!out.contains("Login page crashes") || out.contains("No issues"));
}

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

    // Create feature branch with v1
    repo.git(&["checkout", "-b", "feature"]);
    let v1 = repo.commit_file("feature.rs", "fn hello() {}", "v1 of feature");
    repo.git(&["checkout", "main"]);

    let out = repo.run_ok(&["patch", "create", "-t", "Add hello function", "--head", &v1]);
    let id = out
        .trim()
        .strip_prefix("Created patch ")
        .unwrap()
        .to_string();

    // Review: request changes
    repo.run_ok(&[
        "patch",
        "review",
        &id,
        "-v",
        "request-changes",
        "-b",
        "Add documentation",
    ]);

    // Inline comment on the code
    repo.run_ok(&[
        "patch",
        "comment",
        &id,
        "-b",
        "Missing doc comment",
        "-f",
        "feature.rs",
        "-l",
        "1",
    ]);

    // General comment
    repo.run_ok(&["patch", "comment", &id, "-b", "Otherwise looks good"]);

    // Revise with updated code
    repo.git(&["checkout", "feature"]);
    let v2 = repo.commit_file(
        "feature.rs",
        "/// Says hello\nfn hello() {}",
        "v2: add docs",
    );
    repo.git(&["checkout", "main"]);

    repo.run_ok(&[
        "patch",
        "revise",
        &id,
        "--head",
        &v2,
        "-b",
        "Added documentation",
    ]);

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

    // Merge
    repo.run_ok(&["patch", "merge", &id]);

    // Verify final state
    let out = repo.run_ok(&["patch", "show", &id]);
    assert!(out.contains("[merged]"));
    assert!(out.contains("Added documentation"));
    assert!(out.contains("LGTM now"));
    assert!(out.contains("RequestChanges"));
    assert!(out.contains("Approve"));
    assert!(out.contains("Missing doc comment"));
    assert!(out.contains("Otherwise looks good"));
    assert!(out.contains("Inline Comments"));
    assert!(out.contains("feature.rs"));
}

// ===========================================================================
// T014: init-key CLI command
// ===========================================================================

#[test]
fn test_init_key_creates_key_files() {
    // Use a custom HOME so we don't clobber real keys
    let tmp_home = tempfile::TempDir::new().unwrap();
    let config_dir = tmp_home.path().join(".config").join("git-collab");

    // Create a repo for the CLI to run in
    let repo_dir = tempfile::TempDir::new().unwrap();
    common::git_cmd(repo_dir.path(), &["init", "-b", "main"]);
    common::git_cmd(repo_dir.path(), &["config", "user.name", "Alice"]);
    common::git_cmd(repo_dir.path(), &["config", "user.email", "alice@example.com"]);
    common::git_cmd(repo_dir.path(), &["commit", "--allow-empty", "-m", "init"]);

    // Run init-key with overridden HOME
    let output = std::process::Command::new(env!("CARGO_BIN_EXE_git-collab"))
        .args(["init-key"])
        .current_dir(repo_dir.path())
        .env("HOME", tmp_home.path())
        .env("XDG_CONFIG_HOME", tmp_home.path().join(".config"))
        .output()
        .expect("failed to run git-collab");

    let stdout = String::from_utf8(output.stdout).unwrap();
    let stderr = String::from_utf8(output.stderr).unwrap();
    assert!(
        output.status.success(),
        "init-key should succeed: stdout={}, stderr={}",
        stdout,
        stderr
    );
    assert!(stdout.contains("Signing key generated"), "should print success message");
    assert!(stdout.contains("Public key:"), "should print public key");

    // Key files should exist
    assert!(config_dir.join("signing-key").exists(), "private key file should exist");
    assert!(config_dir.join("signing-key.pub").exists(), "public key file should exist");

    // Run init-key again without --force: should fail
    let output = std::process::Command::new(env!("CARGO_BIN_EXE_git-collab"))
        .args(["init-key"])
        .current_dir(repo_dir.path())
        .env("HOME", tmp_home.path())
        .env("XDG_CONFIG_HOME", tmp_home.path().join(".config"))
        .output()
        .expect("failed to run git-collab");
    assert!(
        !output.status.success(),
        "init-key without --force should fail when key exists"
    );
    let stderr = String::from_utf8(output.stderr).unwrap();
    assert!(
        stderr.contains("already exists") || stderr.contains("--force"),
        "error should mention --force, got: {}",
        stderr
    );

    // Run init-key with --force: should succeed
    let output = std::process::Command::new(env!("CARGO_BIN_EXE_git-collab"))
        .args(["init-key", "--force"])
        .current_dir(repo_dir.path())
        .env("HOME", tmp_home.path())
        .env("XDG_CONFIG_HOME", tmp_home.path().join(".config"))
        .output()
        .expect("failed to run git-collab");
    let stdout = String::from_utf8(output.stdout).unwrap();
    assert!(
        output.status.success(),
        "init-key --force should succeed: {}",
        String::from_utf8_lossy(&output.stderr)
    );
    assert!(stdout.contains("Signing key generated"));
}
diff --git a/tests/collab_test.rs b/tests/collab_test.rs
index f19b6a9..41453f5 100644
--- a/tests/collab_test.rs
+++ b/tests/collab_test.rs
@@ -1,92 +1,17 @@
use git2::Repository;
use std::path::Path;
mod common;

use tempfile::TempDir;

use git_collab::dag;
use git_collab::error::Error;
use git_collab::event::{Action, Author, Event, ReviewVerdict};
use git_collab::signing::{self, SignedEvent, VerifyStatus};
use git_collab::state::{self, IssueState, IssueStatus, PatchState, PatchStatus};

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

fn alice() -> Author {
    Author {
        name: "Alice".to_string(),
        email: "alice@example.com".to_string(),
    }
}

fn bob() -> Author {
    Author {
        name: "Bob".to_string(),
        email: "bob@example.com".to_string(),
    }
}

fn now() -> String {
    chrono::Utc::now().to_rfc3339()
}

/// Create a bare repo in a tempdir and configure user identity.
fn init_repo(dir: &Path, author: &Author) -> Repository {
    let repo = Repository::init(dir).expect("init repo");
    {
        let mut config = repo.config().unwrap();
        config.set_str("user.name", &author.name).unwrap();
        config.set_str("user.email", &author.email).unwrap();
    }
    repo
}

/// Open an issue directly using DAG primitives (for fine-grained control in tests).
fn open_issue(repo: &Repository, author: &Author, title: &str) -> (String, String) {
    let event = Event {
        timestamp: now(),
        author: author.clone(),
        action: Action::IssueOpen {
            title: title.to_string(),
            body: "".to_string(),
        },
    };
    let oid = dag::create_root_event(repo, &event).unwrap();
    let id = oid.to_string();
    let ref_name = format!("refs/collab/issues/{}", id);
    repo.reference(&ref_name, oid, false, "test open").unwrap();
    (ref_name, id)
}

/// Append a comment event to a ref.
fn add_comment(repo: &Repository, ref_name: &str, author: &Author, body: &str) {
    let event = Event {
        timestamp: now(),
        author: author.clone(),
        action: Action::IssueComment {
            body: body.to_string(),
        },
    };
    dag::append_event(repo, ref_name, &event).unwrap();
}

/// Append a close event to a ref.
fn close_issue(repo: &Repository, ref_name: &str, author: &Author) {
    let event = Event {
        timestamp: now(),
        author: author.clone(),
        action: Action::IssueClose { reason: None },
    };
    dag::append_event(repo, ref_name, &event).unwrap();
}

/// Append a reopen event to a ref.
fn reopen_issue(repo: &Repository, ref_name: &str, author: &Author) {
    let event = Event {
        timestamp: now(),
        author: author.clone(),
        action: Action::IssueReopen,
    };
    dag::append_event(repo, ref_name, &event).unwrap();
}
use common::{
    add_comment, add_review, alice, bob, close_issue, create_patch, init_repo, now, open_issue,
    reopen_issue, setup_signing_key, test_signing_key,
};

// ---------------------------------------------------------------------------
// Basic DAG tests
@@ -152,74 +77,101 @@ fn test_list_issues_filters_by_status() {
}

// ---------------------------------------------------------------------------
// Issue edit via DAG
// ---------------------------------------------------------------------------

#[test]
fn test_issue_edit_updates_title_and_body() {
    let tmp = TempDir::new().unwrap();
    let repo = init_repo(tmp.path(), &alice());

    let (ref_name, id) = open_issue(&repo, &alice(), "Original title");

    let sk = test_signing_key();
    let event = Event {
        timestamp: now(),
        author: alice(),
        action: Action::IssueEdit {
            title: Some("New title".to_string()),
            body: Some("New body".to_string()),
        },
    };
    dag::append_event(&repo, &ref_name, &event, &sk).unwrap();

    let state = IssueState::from_ref(&repo, &ref_name, &id).unwrap();
    assert_eq!(state.title, "New title");
    assert_eq!(state.body, "New body");
}

#[test]
fn test_issue_edit_partial_update() {
    let tmp = TempDir::new().unwrap();
    let repo = init_repo(tmp.path(), &alice());

    let (ref_name, id) = open_issue(&repo, &alice(), "Keep this title");

    // Edit only body
    let sk = test_signing_key();
    let event = Event {
        timestamp: now(),
        author: alice(),
        action: Action::IssueEdit {
            title: None,
            body: Some("Added body".to_string()),
        },
    };
    dag::append_event(&repo, &ref_name, &event, &sk).unwrap();

    let state = IssueState::from_ref(&repo, &ref_name, &id).unwrap();
    assert_eq!(state.title, "Keep this title");
    assert_eq!(state.body, "Added body");
}

// ---------------------------------------------------------------------------
// Multi-user collaboration: concurrent edits → forked DAG → reconcile
// ---------------------------------------------------------------------------

#[test]
fn test_concurrent_comments_create_fork_and_reconcile() {
    // Simulate two users commenting on the same issue concurrently.
    //
    // Starting state:
    //   commit A (IssueOpen)
    //
    // Alice adds comment → commit B (parent: A)
    // Bob adds comment   → commit C (parent: A)  ← fork!
    //
    // Reconciliation creates merge commit M (parents: B, C)
    // State replay should see all 3 events (open + 2 comments)

    let tmp = TempDir::new().unwrap();
    let repo = init_repo(tmp.path(), &alice());

    // Create the issue (commit A)
    let (ref_name, id) = open_issue(&repo, &alice(), "Concurrent test");
    let root_oid = repo.refname_to_id(&ref_name).unwrap();

    // Alice comments (commit B) — advances the ref
    add_comment(&repo, &ref_name, &alice(), "Alice's comment");
    let alice_tip = repo.refname_to_id(&ref_name).unwrap();

    // Simulate Bob's concurrent comment: reset ref back to root, then append
    repo.reference(&ref_name, root_oid, true, "simulate bob fork")
        .unwrap();
    add_comment(&repo, &ref_name, &bob(), "Bob's comment");
    let bob_tip = repo.refname_to_id(&ref_name).unwrap();

    // Now we have two tips diverged from root_oid.
    // Put Bob's tip in a "remote" ref so we can reconcile.
    let remote_ref = format!("refs/collab/sync/origin/issues/{}", id);
    repo.reference(&remote_ref, bob_tip, true, "remote tip")
        .unwrap();

    // Reset local ref to Alice's tip
    repo.reference(&ref_name, alice_tip, true, "restore alice tip")
        .unwrap();

    // Reconcile
    let merge_oid = dag::reconcile(&repo, &ref_name, &remote_ref, &alice()).unwrap();
    let merge_oid = dag::reconcile(&repo, &ref_name, &remote_ref, &alice(), &test_signing_key()).unwrap();

    // Verify merge commit has 2 parents
    let merge_commit = repo.find_commit(merge_oid).unwrap();
    assert_eq!(merge_commit.parent_count(), 2);

    // Walk events — should see open + both comments + merge
    let events = dag::walk_events(&repo, &ref_name).unwrap();
    let actions: Vec<_> = events.iter().map(|(_, e)| &e.action).collect();

    // Must have the open event
    assert!(actions
        .iter()
        .any(|a| matches!(a, Action::IssueOpen { .. })));
    // Must have both comments
    let comments: Vec<_> = actions
        .iter()
        .filter(|a| matches!(a, Action::IssueComment { .. }))
        .collect();
    assert_eq!(comments.len(), 2, "both concurrent comments should appear");
    // Must have the merge
    assert!(actions.iter().any(|a| matches!(a, Action::Merge)));

    // State should show both comments
    let state = IssueState::from_ref(&repo, &ref_name, &id).unwrap();
    assert_eq!(state.comments.len(), 2);
    assert_eq!(state.status, IssueStatus::Open);
@@ -227,61 +179,46 @@ fn test_concurrent_comments_create_fork_and_reconcile() {

#[test]
fn test_concurrent_close_and_comment() {
    // Alice closes the issue while Bob comments on it concurrently.
    // After reconciliation, the issue should be closed (last event wins by topo order)
    // and Bob's comment should still be visible.

    let tmp = TempDir::new().unwrap();
    let repo = init_repo(tmp.path(), &alice());

    let (ref_name, id) = open_issue(&repo, &alice(), "Close vs comment");
    let root_oid = repo.refname_to_id(&ref_name).unwrap();

    // Alice closes
    close_issue(&repo, &ref_name, &alice());
    let alice_tip = repo.refname_to_id(&ref_name).unwrap();

    // Bob comments (from root)
    repo.reference(&ref_name, root_oid, true, "bob fork")
        .unwrap();
    add_comment(&repo, &ref_name, &bob(), "Wait, I have thoughts");
    let bob_tip = repo.refname_to_id(&ref_name).unwrap();

    // Set up for reconciliation
    let remote_ref = format!("refs/collab/sync/origin/issues/{}", id);
    repo.reference(&remote_ref, bob_tip, true, "remote")
        .unwrap();
    repo.reference(&ref_name, alice_tip, true, "restore")
        .unwrap();

    dag::reconcile(&repo, &ref_name, &remote_ref, &alice()).unwrap();
    dag::reconcile(&repo, &ref_name, &remote_ref, &alice(), &test_signing_key()).unwrap();

    let state = IssueState::from_ref(&repo, &ref_name, &id).unwrap();
    // Both the close and comment should be in the DAG
    assert_eq!(state.comments.len(), 1);
    // The close event should have taken effect
    assert_eq!(state.status, IssueStatus::Closed);
}

#[test]
fn test_concurrent_close_and_reopen() {
    // Alice closes while Bob reopens from a previously-closed state.
    // This tests conflicting status transitions.

    let tmp = TempDir::new().unwrap();
    let repo = init_repo(tmp.path(), &alice());

    let (ref_name, id) = open_issue(&repo, &alice(), "Status conflict");

    // First, close the issue so both users start from "closed"
    close_issue(&repo, &ref_name, &alice());
    let closed_oid = repo.refname_to_id(&ref_name).unwrap();

    // Alice comments on the closed issue
    add_comment(&repo, &ref_name, &alice(), "Staying closed");
    let alice_tip = repo.refname_to_id(&ref_name).unwrap();

    // Bob reopens from the closed state
    repo.reference(&ref_name, closed_oid, true, "bob fork")
        .unwrap();
    reopen_issue(&repo, &ref_name, &bob());
@@ -293,11 +230,9 @@ fn test_concurrent_close_and_reopen() {
    repo.reference(&ref_name, alice_tip, true, "restore")
        .unwrap();

    dag::reconcile(&repo, &ref_name, &remote_ref, &alice()).unwrap();
    dag::reconcile(&repo, &ref_name, &remote_ref, &alice(), &test_signing_key()).unwrap();

    let _state = IssueState::from_ref(&repo, &ref_name, &id).unwrap();
    // Both branches are replayed — the final status depends on topo order.
    // The important thing is that we don't crash and both events are present.
    let events = dag::walk_events(&repo, &ref_name).unwrap();
    let has_close = events
        .iter()
@@ -311,30 +246,24 @@ fn test_concurrent_close_and_reopen() {

#[test]
fn test_fast_forward_reconcile() {
    // If local is behind remote (remote has strictly more events), reconcile
    // should fast-forward without creating a merge commit.

    let tmp = TempDir::new().unwrap();
    let repo = init_repo(tmp.path(), &alice());

    let (ref_name, id) = open_issue(&repo, &alice(), "FF test");
    let root_oid = repo.refname_to_id(&ref_name).unwrap();

    // Add comment (advances ref)
    add_comment(&repo, &ref_name, &alice(), "Extra comment");
    let ahead_tip = repo.refname_to_id(&ref_name).unwrap();

    // Simulate: local is at root, remote is at ahead_tip
    repo.reference(&ref_name, root_oid, true, "reset local")
        .unwrap();
    let remote_ref = format!("refs/collab/sync/origin/issues/{}", id);
    repo.reference(&remote_ref, ahead_tip, true, "remote ahead")
        .unwrap();

    let result = dag::reconcile(&repo, &ref_name, &remote_ref, &alice()).unwrap();
    let result = dag::reconcile(&repo, &ref_name, &remote_ref, &alice(), &test_signing_key()).unwrap();
    assert_eq!(result, ahead_tip, "should fast-forward to remote tip");

    // No merge commit — walk should have exactly 2 events
    let events = dag::walk_events(&repo, &ref_name).unwrap();
    assert_eq!(events.len(), 2);
    assert!(!events
@@ -353,13 +282,12 @@ fn test_no_op_when_already_in_sync() {
    let remote_ref = format!("refs/collab/sync/origin/issues/{}", id);
    repo.reference(&remote_ref, tip, true, "same tip").unwrap();

    let result = dag::reconcile(&repo, &ref_name, &remote_ref, &alice()).unwrap();
    let result = dag::reconcile(&repo, &ref_name, &remote_ref, &alice(), &test_signing_key()).unwrap();
    assert_eq!(result, tip);
}

#[test]
fn test_local_ahead_no_merge() {
    // If local has more events than remote, reconcile should be a no-op.
    let tmp = TempDir::new().unwrap();
    let repo = init_repo(tmp.path(), &alice());

@@ -373,7 +301,7 @@ fn test_local_ahead_no_merge() {
    repo.reference(&remote_ref, root_oid, true, "remote behind")
        .unwrap();

    let result = dag::reconcile(&repo, &ref_name, &remote_ref, &alice()).unwrap();
    let result = dag::reconcile(&repo, &ref_name, &remote_ref, &alice(), &test_signing_key()).unwrap();
    assert_eq!(result, local_tip, "local should stay ahead");
}

@@ -381,36 +309,6 @@ fn test_local_ahead_no_merge() {
// Patch collaboration tests
// ---------------------------------------------------------------------------

fn create_patch(repo: &Repository, author: &Author, title: &str) -> (String, String) {
    let event = Event {
        timestamp: now(),
        author: author.clone(),
        action: Action::PatchCreate {
            title: title.to_string(),
            body: "".to_string(),
            base_ref: "main".to_string(),
            head_commit: "abc123".to_string(),
        },
    };
    let oid = dag::create_root_event(repo, &event).unwrap();
    let id = oid.to_string();
    let ref_name = format!("refs/collab/patches/{}", id);
    repo.reference(&ref_name, oid, false, "test patch").unwrap();
    (ref_name, id)
}

fn add_review(repo: &Repository, ref_name: &str, author: &Author, verdict: ReviewVerdict) {
    let event = Event {
        timestamp: now(),
        author: author.clone(),
        action: Action::PatchReview {
            verdict,
            body: "review comment".to_string(),
        },
    };
    dag::append_event(repo, ref_name, &event).unwrap();
}

#[test]
fn test_patch_review_workflow() {
    let tmp = TempDir::new().unwrap();
@@ -418,14 +316,13 @@ fn test_patch_review_workflow() {

    let (ref_name, id) = create_patch(&repo, &alice(), "Add feature X");

    // Bob reviews
    add_review(&repo, &ref_name, &bob(), ReviewVerdict::RequestChanges);

    let state = PatchState::from_ref(&repo, &ref_name, &id).unwrap();
    assert_eq!(state.reviews.len(), 1);
    assert_eq!(state.reviews[0].verdict, ReviewVerdict::RequestChanges);

    // Alice revises
    let sk = test_signing_key();
    let event = Event {
        timestamp: now(),
        author: alice(),
@@ -434,9 +331,8 @@ fn test_patch_review_workflow() {
            head_commit: "def456".to_string(),
        },
    };
    dag::append_event(&repo, &ref_name, &event).unwrap();
    dag::append_event(&repo, &ref_name, &event, &sk).unwrap();

    // Bob approves
    add_review(&repo, &ref_name, &bob(), ReviewVerdict::Approve);

    let state = PatchState::from_ref(&repo, &ref_name, &id).unwrap();
@@ -447,31 +343,27 @@ fn test_patch_review_workflow() {

#[test]
fn test_concurrent_reviews_on_patch() {
    // Alice and Bob both review the same patch concurrently.
    let tmp = TempDir::new().unwrap();
    let repo = init_repo(tmp.path(), &alice());

    let (ref_name, id) = create_patch(&repo, &alice(), "Concurrent review");
    let root_oid = repo.refname_to_id(&ref_name).unwrap();

    // Alice approves
    add_review(&repo, &ref_name, &alice(), ReviewVerdict::Approve);
    let alice_tip = repo.refname_to_id(&ref_name).unwrap();

    // Bob requests changes (from root)
    repo.reference(&ref_name, root_oid, true, "bob fork")
        .unwrap();
    add_review(&repo, &ref_name, &bob(), ReviewVerdict::RequestChanges);
    let bob_tip = repo.refname_to_id(&ref_name).unwrap();

    // Reconcile
    let remote_ref = format!("refs/collab/sync/origin/patches/{}", id);
    repo.reference(&remote_ref, bob_tip, true, "remote")
        .unwrap();
    repo.reference(&ref_name, alice_tip, true, "restore")
        .unwrap();

    dag::reconcile(&repo, &ref_name, &remote_ref, &alice()).unwrap();
    dag::reconcile(&repo, &ref_name, &remote_ref, &alice(), &test_signing_key()).unwrap();

    let state = PatchState::from_ref(&repo, &ref_name, &id).unwrap();
    assert_eq!(state.reviews.len(), 2, "both reviews should be present");
@@ -484,9 +376,6 @@ fn test_concurrent_reviews_on_patch() {

#[test]
fn test_three_way_fork_sequential_reconcile() {
    // Three users all comment from the same base.
    // We reconcile them pairwise: first alice+bob, then result+charlie.

    let tmp = TempDir::new().unwrap();
    let repo = init_repo(tmp.path(), &alice());

@@ -498,33 +387,28 @@ fn test_three_way_fork_sequential_reconcile() {
    let (ref_name, id) = open_issue(&repo, &alice(), "Three-way");
    let root_oid = repo.refname_to_id(&ref_name).unwrap();

    // Alice comments
    add_comment(&repo, &ref_name, &alice(), "Alice here");
    let alice_tip = repo.refname_to_id(&ref_name).unwrap();

    // Bob comments from root
    repo.reference(&ref_name, root_oid, true, "bob").unwrap();
    add_comment(&repo, &ref_name, &bob(), "Bob here");
    let bob_tip = repo.refname_to_id(&ref_name).unwrap();

    // Charlie comments from root
    repo.reference(&ref_name, root_oid, true, "charlie")
        .unwrap();
    add_comment(&repo, &ref_name, &charlie, "Charlie here");
    let charlie_tip = repo.refname_to_id(&ref_name).unwrap();

    // Reconcile alice + bob
    repo.reference(&ref_name, alice_tip, true, "alice").unwrap();
    let bob_ref = "refs/collab/sync/origin/issues/bob_temp";
    repo.reference(bob_ref, bob_tip, true, "bob remote")
        .unwrap();
    dag::reconcile(&repo, &ref_name, bob_ref, &alice()).unwrap();
    dag::reconcile(&repo, &ref_name, bob_ref, &alice(), &test_signing_key()).unwrap();

    // Reconcile result + charlie
    let charlie_ref = "refs/collab/sync/origin/issues/charlie_temp";
    repo.reference(charlie_ref, charlie_tip, true, "charlie remote")
        .unwrap();
    dag::reconcile(&repo, &ref_name, charlie_ref, &alice()).unwrap();
    dag::reconcile(&repo, &ref_name, charlie_ref, &alice(), &test_signing_key()).unwrap();

    let state = IssueState::from_ref(&repo, &ref_name, &id).unwrap();
    assert_eq!(state.comments.len(), 3, "all three comments must survive");
@@ -599,3 +483,73 @@ fn test_resolve_prefix_match() {
    assert_eq!(resolved_id, id);
    assert_eq!(resolved_ref, format!("refs/collab/issues/{}", id));
}

// ---------------------------------------------------------------------------
// T012: Signed event integration test
// ---------------------------------------------------------------------------

#[test]
fn test_signed_event_in_dag() {
    let tmp = TempDir::new().unwrap();
    let repo = init_repo(tmp.path(), &alice());

    // Set up a signing key on disk so issue::open() can find it
    let config_dir = tmp.path().join("test-config");
    setup_signing_key(&config_dir);
    let sk = signing::load_signing_key(&config_dir).unwrap();

    // Create an issue using dag primitives with the signing key
    let event = Event {
        timestamp: now(),
        author: alice(),
        action: Action::IssueOpen {
            title: "Signed issue".to_string(),
            body: "".to_string(),
        },
    };
    let oid = dag::create_root_event(&repo, &event, &sk).unwrap();
    let id = oid.to_string();
    let ref_name = format!("refs/collab/issues/{}", id);
    repo.reference(&ref_name, oid, false, "test open").unwrap();

    // Read the raw blob from the commit and deserialize as SignedEvent
    let tip = repo.refname_to_id(&ref_name).unwrap();
    let commit = repo.find_commit(tip).unwrap();
    let tree = commit.tree().unwrap();
    let entry = tree.get_name("event.json").unwrap();
    let blob = repo.find_blob(entry.id()).unwrap();
    let signed: SignedEvent = serde_json::from_slice(blob.content()).unwrap();

    // Assert signature and pubkey are present
    assert!(!signed.signature.is_empty(), "signature should be present");
    assert!(!signed.pubkey.is_empty(), "pubkey should be present");

    // Verify the signature
    let status = signing::verify_signed_event(&signed).unwrap();
    assert_eq!(status, VerifyStatus::Valid, "signature should verify as valid");

    // walk_events should still extract the Event correctly
    let events = dag::walk_events(&repo, &ref_name).unwrap();
    assert_eq!(events.len(), 1);
    assert!(matches!(events[0].1.action, Action::IssueOpen { .. }));
}

// ---------------------------------------------------------------------------
// T013: Missing signing key error test
// ---------------------------------------------------------------------------

#[test]
fn test_issue_open_without_signing_key_returns_key_not_found() {
    let tmp = TempDir::new().unwrap();
    let _repo = init_repo(tmp.path(), &alice());

    // Point to a nonexistent config dir so load_signing_key fails
    let bad_config = tmp.path().join("nonexistent-config");
    let result = signing::load_signing_key(&bad_config);

    assert!(result.is_err());
    match result.unwrap_err() {
        Error::KeyNotFound => {} // expected
        other => panic!("expected KeyNotFound error, got: {:?}", other),
    }
}
diff --git a/tests/common/mod.rs b/tests/common/mod.rs
new file mode 100644
index 0000000..fe2665c
--- /dev/null
+++ b/tests/common/mod.rs
@@ -0,0 +1,318 @@
#![allow(dead_code)]

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

use ed25519_dalek::SigningKey;
use git2::Repository;
use rand_core::OsRng;
use tempfile::TempDir;

use git_collab::dag;
use git_collab::event::{Action, Author, Event, ReviewVerdict};
use git_collab::signing;

// ===========================================================================
// Library-level helpers (for collab_test / sync_test)
// ===========================================================================

pub fn alice() -> Author {
    Author {
        name: "Alice".to_string(),
        email: "alice@example.com".to_string(),
    }
}

pub fn bob() -> Author {
    Author {
        name: "Bob".to_string(),
        email: "bob@example.com".to_string(),
    }
}

pub fn now() -> String {
    chrono::Utc::now().to_rfc3339()
}

/// Generate a test signing key and return it. Does NOT write to disk.
pub fn test_signing_key() -> SigningKey {
    SigningKey::generate(&mut OsRng)
}

/// Generate a test signing key and write it to a config dir so that
/// code using `signing::load_signing_key()` can find it.
pub fn setup_signing_key(config_dir: &Path) {
    git_collab::signing::generate_keypair(config_dir).expect("generate test keypair");
}

/// Create a non-bare repo in a directory with user identity configured.
pub fn init_repo(dir: &Path, author: &Author) -> Repository {
    let repo = Repository::init(dir).expect("init repo");
    {
        let mut config = repo.config().unwrap();
        config.set_str("user.name", &author.name).unwrap();
        config.set_str("user.email", &author.email).unwrap();
    }
    repo
}

/// Open an issue using DAG primitives. Returns (ref_name, id).
pub fn open_issue(repo: &Repository, author: &Author, title: &str) -> (String, String) {
    let sk = test_signing_key();
    let event = Event {
        timestamp: now(),
        author: author.clone(),
        action: Action::IssueOpen {
            title: title.to_string(),
            body: "".to_string(),
        },
    };
    let oid = dag::create_root_event(repo, &event, &sk).unwrap();
    let id = oid.to_string();
    let ref_name = format!("refs/collab/issues/{}", id);
    repo.reference(&ref_name, oid, false, "test open").unwrap();
    (ref_name, id)
}

/// Append a comment event to an issue ref.
pub fn add_comment(repo: &Repository, ref_name: &str, author: &Author, body: &str) {
    let sk = test_signing_key();
    let event = Event {
        timestamp: now(),
        author: author.clone(),
        action: Action::IssueComment {
            body: body.to_string(),
        },
    };
    dag::append_event(repo, ref_name, &event, &sk).unwrap();
}

/// Append a close event to an issue ref.
pub fn close_issue(repo: &Repository, ref_name: &str, author: &Author) {
    let sk = test_signing_key();
    let event = Event {
        timestamp: now(),
        author: author.clone(),
        action: Action::IssueClose { reason: None },
    };
    dag::append_event(repo, ref_name, &event, &sk).unwrap();
}

/// Append a reopen event to an issue ref.
pub fn reopen_issue(repo: &Repository, ref_name: &str, author: &Author) {
    let sk = test_signing_key();
    let event = Event {
        timestamp: now(),
        author: author.clone(),
        action: Action::IssueReopen,
    };
    dag::append_event(repo, ref_name, &event, &sk).unwrap();
}

/// Create a patch using DAG primitives. Returns (ref_name, id).
pub fn create_patch(repo: &Repository, author: &Author, title: &str) -> (String, String) {
    let sk = test_signing_key();
    let event = Event {
        timestamp: now(),
        author: author.clone(),
        action: Action::PatchCreate {
            title: title.to_string(),
            body: "".to_string(),
            base_ref: "main".to_string(),
            head_commit: "abc123".to_string(),
            fixes: None,
        },
    };
    let oid = dag::create_root_event(repo, &event, &sk).unwrap();
    let id = oid.to_string();
    let ref_name = format!("refs/collab/patches/{}", id);
    repo.reference(&ref_name, oid, false, "test patch").unwrap();
    (ref_name, id)
}

/// Append a review event to a patch ref.
pub fn add_review(repo: &Repository, ref_name: &str, author: &Author, verdict: ReviewVerdict) {
    let sk = test_signing_key();
    let event = Event {
        timestamp: now(),
        author: author.clone(),
        action: Action::PatchReview {
            verdict,
            body: "review comment".to_string(),
        },
    };
    dag::append_event(repo, ref_name, &event, &sk).unwrap();
}

// ===========================================================================
// CLI-level helpers (for cli_test)
// ===========================================================================

/// A temporary git repository for end-to-end CLI testing.
pub struct TestRepo {
    pub dir: TempDir,
}

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

        TestRepo { dir }
    }

    /// Run git-collab and return raw output.
    pub fn run(&self, args: &[&str]) -> Output {
        Command::new(env!("CARGO_BIN_EXE_git-collab"))
            .args(args)
            .current_dir(self.dir.path())
            .output()
            .expect("failed to run git-collab")
    }

    /// Run git-collab, assert success, return stdout.
    pub fn run_ok(&self, args: &[&str]) -> String {
        let output = self.run(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
    }

    /// Run git-collab, assert failure, return stderr.
    pub fn run_err(&self, args: &[&str]) -> String {
        let output = self.run(args);
        assert!(
            !output.status.success(),
            "expected git-collab {:?} to fail but it succeeded:\nstdout: {}",
            args,
            String::from_utf8_lossy(&output.stdout)
        );
        String::from_utf8(output.stderr).unwrap()
    }

    /// 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]);
        out.trim()
            .strip_prefix("Opened issue ")
            .unwrap_or_else(|| panic!("unexpected issue open output: {}", out))
            .to_string()
    }

    /// Create a patch with HEAD as the head commit. Returns the 8-char short ID.
    pub fn patch_create(&self, title: &str) -> String {
        let head = self.git(&["rev-parse", "HEAD"]).trim().to_string();
        let out = self.run_ok(&["patch", "create", "-t", title, "--head", &head]);
        out.trim()
            .strip_prefix("Created patch ")
            .unwrap_or_else(|| panic!("unexpected patch create output: {}", out))
            .to_string()
    }

    /// Run a git command in this repo and return stdout.
    pub fn git(&self, args: &[&str]) -> String {
        let output = Command::new("git")
            .args(args)
            .current_dir(self.dir.path())
            .output()
            .expect("failed to run git");
        assert!(
            output.status.success(),
            "git {:?} failed: {}",
            args,
            String::from_utf8_lossy(&output.stderr)
        );
        String::from_utf8(output.stdout).unwrap()
    }

    /// Create a file, stage it, and commit. Returns the commit OID.
    pub fn commit_file(&self, path: &str, content: &str, message: &str) -> String {
        let full_path = self.dir.path().join(path);
        if let Some(parent) = full_path.parent() {
            std::fs::create_dir_all(parent).unwrap();
        }
        std::fs::write(&full_path, content).unwrap();
        self.git(&["add", path]);
        self.git(&["commit", "-m", message]);
        self.git(&["rev-parse", "HEAD"]).trim().to_string()
    }
}

/// Create an unsigned event commit (plain Event JSON, no signature fields).
/// Returns the commit OID.
pub fn create_unsigned_event(repo: &Repository, event: &Event) -> git2::Oid {
    let json = serde_json::to_vec_pretty(event).unwrap();
    let blob_oid = repo.blob(&json).unwrap();

    let mut tb = repo.treebuilder(None).unwrap();
    tb.insert("event.json", blob_oid, 0o100644).unwrap();
    let tree_oid = tb.write().unwrap();
    let tree = repo.find_tree(tree_oid).unwrap();

    let sig = git2::Signature::now(&event.author.name, &event.author.email).unwrap();
    repo.commit(None, &sig, &sig, "unsigned event", &tree, &[])
        .unwrap()
}

/// Create a tampered event commit: sign the event, then modify the body but keep
/// the original signature. Returns the commit OID.
pub fn create_tampered_event(repo: &Repository, event: &Event) -> git2::Oid {
    let sk = test_signing_key();
    let mut signed = signing::sign_event(event, &sk).unwrap();
    // Tamper with the event content while keeping the original signature
    signed.event.timestamp = "2099-01-01T00:00:00Z".to_string();
    let json = serde_json::to_vec_pretty(&signed).unwrap();
    let blob_oid = repo.blob(&json).unwrap();

    let mut tb = repo.treebuilder(None).unwrap();
    tb.insert("event.json", blob_oid, 0o100644).unwrap();
    let tree_oid = tb.write().unwrap();
    let tree = repo.find_tree(tree_oid).unwrap();

    let sig = git2::Signature::now(&event.author.name, &event.author.email).unwrap();
    repo.commit(None, &sig, &sig, "tampered event", &tree, &[])
        .unwrap()
}

/// Run a git command in the given directory (public for use in test files).
pub fn git_cmd(dir: &Path, args: &[&str]) {
    git(dir, args);
}

fn git(dir: &Path, args: &[&str]) {
    let output = Command::new("git")
        .args(args)
        .current_dir(dir)
        .output()
        .expect("failed to run git");
    assert!(
        output.status.success(),
        "git {:?} failed: {}",
        args,
        String::from_utf8_lossy(&output.stderr)
    );
}
diff --git a/tests/signing_test.rs b/tests/signing_test.rs
new file mode 100644
index 0000000..0d0da0e
--- /dev/null
+++ b/tests/signing_test.rs
@@ -0,0 +1,241 @@
use git_collab::event::{Action, Author, Event};
use git_collab::signing::{
    canonical_json, generate_keypair, load_signing_key, load_verifying_key, sign_event,
    verify_signed_event, SignedEvent, VerifyStatus,
};
use tempfile::tempdir;

fn make_event() -> Event {
    Event {
        timestamp: "2026-03-21T00:00:00Z".to_string(),
        author: Author {
            name: "Alice".to_string(),
            email: "alice@example.com".to_string(),
        },
        action: Action::IssueOpen {
            title: "Test issue".to_string(),
            body: "This is a test".to_string(),
        },
    }
}

// ── T004: Key generation and storage ──

#[test]
fn generate_keypair_creates_key_files() {
    let dir = tempdir().unwrap();
    let config = dir.path().join("git-collab");

    let vk = generate_keypair(&config).unwrap();
    assert!(config.join("signing-key").exists());
    assert!(config.join("signing-key.pub").exists());

    // Verify the public key file content matches the returned key
    let loaded_vk = load_verifying_key(&config).unwrap();
    assert_eq!(vk.to_bytes(), loaded_vk.to_bytes());
}

#[cfg(unix)]
#[test]
fn generate_keypair_sets_private_key_permissions() {
    use std::os::unix::fs::PermissionsExt;

    let dir = tempdir().unwrap();
    let config = dir.path().join("git-collab");

    generate_keypair(&config).unwrap();

    let meta = std::fs::metadata(config.join("signing-key")).unwrap();
    let mode = meta.permissions().mode() & 0o777;
    assert_eq!(mode, 0o600, "private key should have 0o600 permissions");
}

#[test]
fn load_keypair_from_disk() {
    let dir = tempdir().unwrap();
    let config = dir.path().join("git-collab");

    generate_keypair(&config).unwrap();

    let sk = load_signing_key(&config).unwrap();
    let vk = load_verifying_key(&config).unwrap();

    // The verifying key derived from loaded signing key should match stored pub key
    let derived_vk = sk.verifying_key();
    assert_eq!(derived_vk.to_bytes(), vk.to_bytes());
}

#[test]
fn load_signing_key_missing_returns_error() {
    let dir = tempdir().unwrap();
    let config = dir.path().join("nonexistent");

    let err = load_signing_key(&config).unwrap_err();
    let msg = format!("{}", err);
    assert!(
        msg.contains("signing key"),
        "error should mention signing key: {}",
        msg
    );
}

#[test]
fn load_verifying_key_missing_returns_error() {
    let dir = tempdir().unwrap();
    let config = dir.path().join("nonexistent");

    let err = load_verifying_key(&config).unwrap_err();
    let msg = format!("{}", err);
    assert!(
        msg.contains("signing key"),
        "error should mention signing key: {}",
        msg
    );
}

// ── T005: Sign/verify round-trip ──

#[test]
fn sign_event_produces_nonempty_signature_and_pubkey() {
    let dir = tempdir().unwrap();
    let config = dir.path().join("git-collab");
    generate_keypair(&config).unwrap();
    let sk = load_signing_key(&config).unwrap();

    let event = make_event();
    let signed = sign_event(&event, &sk).unwrap();

    assert!(!signed.signature.is_empty(), "signature should not be empty");
    assert!(!signed.pubkey.is_empty(), "pubkey should not be empty");

    // Verify they are valid base64
    use base64::engine::general_purpose::STANDARD;
    use base64::Engine;
    STANDARD
        .decode(&signed.signature)
        .expect("signature should be valid base64");
    STANDARD
        .decode(&signed.pubkey)
        .expect("pubkey should be valid base64");
}

#[test]
fn verify_valid_signed_event_returns_valid() {
    let dir = tempdir().unwrap();
    let config = dir.path().join("git-collab");
    generate_keypair(&config).unwrap();
    let sk = load_signing_key(&config).unwrap();

    let event = make_event();
    let signed = sign_event(&event, &sk).unwrap();

    let status = verify_signed_event(&signed).unwrap();
    assert_eq!(status, VerifyStatus::Valid);
}

#[test]
fn verify_tampered_event_returns_invalid() {
    let dir = tempdir().unwrap();
    let config = dir.path().join("git-collab");
    generate_keypair(&config).unwrap();
    let sk = load_signing_key(&config).unwrap();

    let event = make_event();
    let mut signed = sign_event(&event, &sk).unwrap();

    // Tamper with the event
    signed.event.author.name = "Mallory".to_string();

    let status = verify_signed_event(&signed).unwrap();
    assert_eq!(status, VerifyStatus::Invalid);
}

#[test]
fn verify_missing_signature_returns_missing() {
    let event = make_event();
    let signed = SignedEvent {
        event,
        signature: String::new(),
        pubkey: String::new(),
    };

    let status = verify_signed_event(&signed).unwrap();
    assert_eq!(status, VerifyStatus::Missing);
}

// ── T006: Canonical serialization ──

#[test]
fn canonical_json_deterministic() {
    let event = make_event();

    let bytes1 = canonical_json(&event).unwrap();
    let bytes2 = canonical_json(&event).unwrap();

    assert_eq!(bytes1, bytes2, "canonical_json should produce identical output");
}

#[test]
fn signed_event_json_contains_all_fields() {
    let dir = tempdir().unwrap();
    let config = dir.path().join("git-collab");
    generate_keypair(&config).unwrap();
    let sk = load_signing_key(&config).unwrap();

    let event = make_event();
    let signed = sign_event(&event, &sk).unwrap();

    let json = serde_json::to_string(&signed).unwrap();
    let value: serde_json::Value = serde_json::from_str(&json).unwrap();
    let obj = value.as_object().unwrap();

    // Flattened event fields
    assert!(obj.contains_key("timestamp"), "missing timestamp");
    assert!(obj.contains_key("author"), "missing author");
    assert!(obj.contains_key("action") || obj.contains_key("type"), "missing action/type");

    // Signature fields
    assert!(obj.contains_key("signature"), "missing signature");
    assert!(obj.contains_key("pubkey"), "missing pubkey");
}

#[test]
fn signed_event_flatten_round_trip_with_tagged_enum() {
    let event = Event {
        timestamp: "2026-03-21T12:00:00Z".to_string(),
        author: Author {
            name: "Bob".to_string(),
            email: "bob@example.com".to_string(),
        },
        action: Action::PatchCreate {
            title: "Fix bug".to_string(),
            body: "Fixes #42".to_string(),
            base_ref: "main".to_string(),
            head_commit: "abc123".to_string(),
            fixes: Some("deadbeef".to_string()),
        },
    };

    let signed = SignedEvent {
        event,
        signature: "dGVzdHNpZw==".to_string(),
        pubkey: "dGVzdGtleQ==".to_string(),
    };

    let json = serde_json::to_string_pretty(&signed).unwrap();
    let deserialized: SignedEvent = serde_json::from_str(&json).unwrap();

    assert_eq!(deserialized.signature, signed.signature);
    assert_eq!(deserialized.pubkey, signed.pubkey);
    match deserialized.event.action {
        Action::PatchCreate {
            ref title,
            ref fixes,
            ..
        } => {
            assert_eq!(title, "Fix bug");
            assert_eq!(fixes.as_deref(), Some("deadbeef"));
        }
        _ => panic!("Wrong action type after round-trip"),
    }
}
diff --git a/tests/sync_test.rs b/tests/sync_test.rs
index 320613b..0c349d7 100644
--- a/tests/sync_test.rs
+++ b/tests/sync_test.rs
@@ -5,42 +5,31 @@
//!   bare_remote  <---push/fetch--->  alice_repo
//!                <---push/fetch--->  bob_repo

mod common;

use tempfile::TempDir;

use git2::Repository;
use git_collab::dag;
use git_collab::event::{Action, Author, Event, ReviewVerdict};
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, setup_signing_key, test_signing_key,
};

// ---------------------------------------------------------------------------
// Helpers
// Test cluster
// ---------------------------------------------------------------------------

fn alice() -> Author {
    Author {
        name: "Alice".to_string(),
        email: "alice@example.com".to_string(),
    }
}

fn bob() -> Author {
    Author {
        name: "Bob".to_string(),
        email: "bob@example.com".to_string(),
    }
}

fn now() -> String {
    chrono::Utc::now().to_rfc3339()
}

/// Set up the standard test topology: bare remote + two clones.
/// Returns (bare_dir, alice_dir, bob_dir) — TempDirs that must be kept alive.
struct TestCluster {
    _bare_dir: TempDir,
    alice_dir: TempDir,
    bob_dir: TempDir,
    _key_setup: (), // signing key created in default config dir
}

impl TestCluster {
@@ -48,8 +37,6 @@ impl TestCluster {
        let bare_dir = TempDir::new().unwrap();
        let bare_repo = Repository::init_bare(bare_dir.path()).unwrap();

        // Need at least one ref in the bare repo for clones to work,
        // so we create a dummy initial commit on refs/heads/main.
        {
            let sig = git2::Signature::now("init", "init@test").unwrap();
            let tree_oid = bare_repo.treebuilder(None).unwrap().write().unwrap();
@@ -63,7 +50,6 @@ impl TestCluster {
        let alice_dir = TempDir::new().unwrap();
        let bob_dir = TempDir::new().unwrap();

        // Clone for Alice
        let alice_repo =
            Repository::clone(bare_dir.path().to_str().unwrap(), alice_dir.path()).unwrap();
        {
@@ -73,7 +59,6 @@ impl TestCluster {
        }
        sync::init(&alice_repo).unwrap();

        // Clone for Bob
        let bob_repo =
            Repository::clone(bare_dir.path().to_str().unwrap(), bob_dir.path()).unwrap();
        {
@@ -83,10 +68,22 @@ impl TestCluster {
        }
        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: bare_dir,
            alice_dir,
            bob_dir,
            _key_setup: (),
        }
    }

@@ -99,42 +96,6 @@ impl TestCluster {
    }
}

fn open_issue(repo: &Repository, author: &Author, title: &str) -> (String, String) {
    let event = Event {
        timestamp: now(),
        author: author.clone(),
        action: Action::IssueOpen {
            title: title.to_string(),
            body: "".to_string(),
        },
    };
    let oid = dag::create_root_event(repo, &event).unwrap();
    let id = oid.to_string();
    let ref_name = format!("refs/collab/issues/{}", id);
    repo.reference(&ref_name, oid, false, "open").unwrap();
    (ref_name, id)
}

fn add_comment(repo: &Repository, ref_name: &str, author: &Author, body: &str) {
    let event = Event {
        timestamp: now(),
        author: author.clone(),
        action: Action::IssueComment {
            body: body.to_string(),
        },
    };
    dag::append_event(repo, ref_name, &event).unwrap();
}

fn close_issue(repo: &Repository, ref_name: &str, author: &Author) {
    let event = Event {
        timestamp: now(),
        author: author.clone(),
        action: Action::IssueClose { reason: None },
    };
    dag::append_event(repo, ref_name, &event).unwrap();
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
@@ -145,11 +106,9 @@ fn test_alice_creates_issue_bob_syncs_and_sees_it() {
    let alice_repo = cluster.alice_repo();
    let bob_repo = cluster.bob_repo();

    // Alice creates an issue and syncs
    let (_ref_name, id) = open_issue(&alice_repo, &alice(), "Bug from Alice");
    sync::sync(&alice_repo, "origin").unwrap();

    // Bob syncs and should see the issue
    sync::sync(&bob_repo, "origin").unwrap();

    let bob_ref = format!("refs/collab/issues/{}", id);
@@ -165,17 +124,14 @@ fn test_bob_comments_on_alice_issue_then_sync() {
    let alice_repo = cluster.alice_repo();
    let bob_repo = cluster.bob_repo();

    // Alice creates issue, syncs
    let (alice_ref, id) = open_issue(&alice_repo, &alice(), "Needs discussion");
    sync::sync(&alice_repo, "origin").unwrap();

    // Bob syncs, sees the issue, adds a comment, syncs
    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();

    // Alice syncs again — should see Bob's comment
    sync::sync(&alice_repo, "origin").unwrap();
    let state = IssueState::from_ref(&alice_repo, &alice_ref, &id).unwrap();
    assert_eq!(state.comments.len(), 1);
@@ -185,40 +141,28 @@ fn test_bob_comments_on_alice_issue_then_sync() {

#[test]
fn test_concurrent_comments_sync_convergence() {
    // Alice and Bob both comment on the same issue without syncing first.
    // After both sync, they should converge to the same state.

    let cluster = TestCluster::new();
    let alice_repo = cluster.alice_repo();
    let bob_repo = cluster.bob_repo();

    // Alice creates issue, syncs so Bob can get it
    let (alice_ref, id) = open_issue(&alice_repo, &alice(), "Concurrent comments");
    sync::sync(&alice_repo, "origin").unwrap();
    sync::sync(&bob_repo, "origin").unwrap();

    // Both comment independently (no sync between)
    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");

    // Alice syncs first — pushes her comment
    sync::sync(&alice_repo, "origin").unwrap();

    // Bob syncs — fetches Alice's comment, reconciles fork, pushes merge
    sync::sync(&bob_repo, "origin").unwrap();

    // Alice syncs again to get the merge
    sync::sync(&alice_repo, "origin").unwrap();

    // Both should now have the same state
    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");

    // Both should have the same comment bodies (order may vary)
    let mut alice_bodies: Vec<&str> = alice_state
        .comments
        .iter()
@@ -234,9 +178,6 @@ fn test_concurrent_comments_sync_convergence() {

#[test]
fn test_both_create_different_issues() {
    // Alice and Bob each create their own issue without syncing.
    // After sync, both should see both issues.

    let cluster = TestCluster::new();
    let alice_repo = cluster.alice_repo();
    let bob_repo = cluster.bob_repo();
@@ -244,16 +185,10 @@ fn test_both_create_different_issues() {
    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");

    // Alice syncs first
    sync::sync(&alice_repo, "origin").unwrap();

    // Bob syncs — gets Alice's issue, pushes his own
    sync::sync(&bob_repo, "origin").unwrap();

    // Alice syncs again — gets Bob's issue
    sync::sync(&alice_repo, "origin").unwrap();

    // Both repos should have both issues
    let alice_issues = state::list_issues(&alice_repo).unwrap();
    let bob_issues = state::list_issues(&bob_repo).unwrap();

@@ -267,8 +202,6 @@ fn test_both_create_different_issues() {

#[test]
fn test_alice_closes_while_bob_comments() {
    // Alice closes an issue while Bob comments on it concurrently.

    let cluster = TestCluster::new();
    let alice_repo = cluster.alice_repo();
    let bob_repo = cluster.bob_repo();
@@ -277,37 +210,25 @@ fn test_alice_closes_while_bob_comments() {
    sync::sync(&alice_repo, "origin").unwrap();
    sync::sync(&bob_repo, "origin").unwrap();

    // Alice closes
    close_issue(&alice_repo, &alice_ref, &alice());

    // Bob comments
    let bob_ref = format!("refs/collab/issues/{}", id);
    add_comment(&bob_repo, &bob_ref, &bob(), "But wait...");

    // Alice pushes first
    sync::sync(&alice_repo, "origin").unwrap();

    // Bob syncs — reconciles the fork
    sync::sync(&bob_repo, "origin").unwrap();

    // Alice syncs to get the merge
    sync::sync(&alice_repo, "origin").unwrap();

    // Both should see the comment AND the close
    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);
    // Close happened, so status should be closed
    // (both close and comment are in DAG; topo replay applies both)
    assert_eq!(alice_state.status, bob_state.status);
}

#[test]
fn test_sync_idempotent() {
    // Syncing twice in a row should be a no-op the second time.

    let cluster = TestCluster::new();
    let alice_repo = cluster.alice_repo();
    let bob_repo = cluster.bob_repo();
@@ -317,7 +238,6 @@ fn test_sync_idempotent() {
    sync::sync(&alice_repo, "origin").unwrap();

    sync::sync(&bob_repo, "origin").unwrap();
    // Sync again immediately — should not fail or duplicate
    sync::sync(&bob_repo, "origin").unwrap();

    let bob_ref = format!("refs/collab/issues/{}", id);
@@ -327,24 +247,18 @@ fn test_sync_idempotent() {

#[test]
fn test_three_user_convergence() {
    // Three users (Alice, Bob, Charlie) all working on the same issue.
    // Charlie uses Alice's repo path as a second remote.

    let cluster = TestCluster::new();
    let alice_repo = cluster.alice_repo();
    let bob_repo = cluster.bob_repo();

    // Alice creates issue, everyone syncs
    let (alice_ref, id) = open_issue(&alice_repo, &alice(), "Three users");
    sync::sync(&alice_repo, "origin").unwrap();
    sync::sync(&bob_repo, "origin").unwrap();

    // Alice and Bob both comment
    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");

    // Alice syncs, Bob syncs, Alice syncs again (full convergence)
    sync::sync(&alice_repo, "origin").unwrap();
    sync::sync(&bob_repo, "origin").unwrap();
    sync::sync(&alice_repo, "origin").unwrap();
@@ -362,7 +276,6 @@ fn test_patch_review_across_repos() {
    let alice_repo = cluster.alice_repo();
    let bob_repo = cluster.bob_repo();

    // Alice creates a patch
    let event = Event {
        timestamp: now(),
        author: alice(),
@@ -371,19 +284,19 @@ fn test_patch_review_across_repos() {
            body: "Please review".to_string(),
            base_ref: "main".to_string(),
            head_commit: "abc123".to_string(),
            fixes: None,
        },
    };
    let oid = dag::create_root_event(&alice_repo, &event).unwrap();
    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();

    // Alice syncs
    sync::sync(&alice_repo, "origin").unwrap();

    // Bob syncs, reviews the patch
    sync::sync(&bob_repo, "origin").unwrap();
    let bob_ref = format!("refs/collab/patches/{}", id);
    let review_event = Event {
@@ -394,10 +307,9 @@ fn test_patch_review_across_repos() {
            body: "LGTM!".to_string(),
        },
    };
    dag::append_event(&bob_repo, &bob_ref, &review_event).unwrap();
    dag::append_event(&bob_repo, &bob_ref, &review_event, &sk).unwrap();
    sync::sync(&bob_repo, "origin").unwrap();

    // Alice syncs and sees the review
    sync::sync(&alice_repo, "origin").unwrap();
    let state = PatchState::from_ref(&alice_repo, &alice_ref, &id).unwrap();
    assert_eq!(state.reviews.len(), 1);
@@ -407,13 +319,10 @@ fn test_patch_review_across_repos() {

#[test]
fn test_concurrent_review_and_revise() {
    // Bob reviews while Alice revises the patch concurrently.

    let cluster = TestCluster::new();
    let alice_repo = cluster.alice_repo();
    let bob_repo = cluster.bob_repo();

    // Alice creates patch, syncs
    let event = Event {
        timestamp: now(),
        author: alice(),
@@ -422,9 +331,11 @@ fn test_concurrent_review_and_revise() {
            body: "".to_string(),
            base_ref: "main".to_string(),
            head_commit: "v1".to_string(),
            fixes: None,
        },
    };
    let oid = dag::create_root_event(&alice_repo, &event).unwrap();
    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
@@ -433,7 +344,6 @@ fn test_concurrent_review_and_revise() {
    sync::sync(&alice_repo, "origin").unwrap();
    sync::sync(&bob_repo, "origin").unwrap();

    // Alice revises (without syncing)
    let revise_event = Event {
        timestamp: now(),
        author: alice(),
@@ -442,9 +352,8 @@ fn test_concurrent_review_and_revise() {
            head_commit: "v2".to_string(),
        },
    };
    dag::append_event(&alice_repo, &alice_ref, &revise_event).unwrap();
    dag::append_event(&alice_repo, &alice_ref, &revise_event, &sk).unwrap();

    // Bob reviews (without syncing)
    let bob_ref = format!("refs/collab/patches/{}", id);
    let review_event = Event {
        timestamp: now(),
@@ -454,49 +363,40 @@ fn test_concurrent_review_and_revise() {
            body: "Needs work".to_string(),
        },
    };
    dag::append_event(&bob_repo, &bob_ref, &review_event).unwrap();
    dag::append_event(&bob_repo, &bob_ref, &review_event, &sk).unwrap();

    // Both sync
    sync::sync(&alice_repo, "origin").unwrap();
    sync::sync(&bob_repo, "origin").unwrap();
    sync::sync(&alice_repo, "origin").unwrap();

    // Both should see revise + review
    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);
    // The revise should have updated the head_commit
    assert_eq!(alice_state.head_commit, bob_state.head_commit);
}

#[test]
fn test_multiple_rounds_of_sync() {
    // Simulate a realistic back-and-forth conversation on an issue.

    let cluster = TestCluster::new();
    let alice_repo = cluster.alice_repo();
    let bob_repo = cluster.bob_repo();

    // Round 1: Alice opens issue
    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);

    // Round 2: Bob comments
    add_comment(&bob_repo, &bob_ref, &bob(), "First response");
    sync::sync(&bob_repo, "origin").unwrap();
    sync::sync(&alice_repo, "origin").unwrap();

    // Round 3: Alice replies
    add_comment(&alice_repo, &alice_ref, &alice(), "Thanks for the input");
    sync::sync(&alice_repo, "origin").unwrap();
    sync::sync(&bob_repo, "origin").unwrap();

    // Round 4: Bob closes
    close_issue(&bob_repo, &bob_ref, &bob());
    sync::sync(&bob_repo, "origin").unwrap();
    sync::sync(&alice_repo, "origin").unwrap();
@@ -507,3 +407,228 @@ fn test_multiple_rounds_of_sync() {
    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 initial commit so repo is not empty
    {
        let sig = git2::Signature::now("Alice", "alice@example.com").unwrap();
        let tree_oid = alice_repo.treebuilder(None).unwrap().write().unwrap();
        let tree = alice_repo.find_tree(tree_oid).unwrap();
        alice_repo
            .commit(Some("refs/heads/main"), &sig, &sig, "init", &tree, &[])
            .unwrap();
    }

    // Create an unsigned event
    let event = Event {
        timestamp: now(),
        author: alice(),
        action: Action::IssueOpen {
            title: "Unsigned issue".to_string(),
            body: "No signature".to_string(),
        },
    };
    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 initial commit
    {
        let sig = git2::Signature::now("Alice", "alice@example.com").unwrap();
        let tree_oid = repo.treebuilder(None).unwrap().write().unwrap();
        let tree = repo.find_tree(tree_oid).unwrap();
        repo.commit(Some("refs/heads/main"), &sig, &sig, "init", &tree, &[])
            .unwrap();
    }

    // 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(),
        },
    };
    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 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();
    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();
    let entry = tree.get_name("event.json").unwrap();
    let blob = alice_repo.find_blob(entry.id()).unwrap();
    let signed: signing::SignedEvent = serde_json::from_slice(blob.content()).unwrap();

    assert!(
        matches!(signed.event.action, Action::Merge),
        "Expected tip commit to be a Merge event, got {:?}",
        signed.event.action
    );
    assert_eq!(
        signed.pubkey, syncing_pubkey,
        "Merge commit should be signed by the syncing user's key"
    );

    // Verify the signature is cryptographically valid
    let status = signing::verify_signed_event(&signed).unwrap();
    assert_eq!(
        status,
        signing::VerifyStatus::Valid,
        "Merge commit signature must be valid"
    );
}