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