a73x

f38449fa

Add Ed25519 event signing and sync verification

a73x   2026-03-21 07:22

Every event commit (issues, comments, patches, reviews) is now signed
with the author's Ed25519 key. Sync verifies all incoming signatures
and rejects unsigned or tampered events. Merge commits created during
reconciliation are also signed.

- Add `collab init-key` command for Ed25519 keypair generation
- New `src/signing.rs` module with sign/verify/key management using
  ed25519-dalek (pure Rust, no external GPG/SSH binaries)
- Signatures and public keys embedded in event.json as base64 fields
- Canonical JSON serialization (sorted keys) for deterministic signing
- Atomic ref rejection: if any commit in a ref fails verification,
  the entire ref is skipped during sync
- Private key stored at ~/.config/git-collab/signing-key with 0600 perms
- Warns on world-readable private key file
- 93 tests covering signing, verification, sync rejection, and CLI

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

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