46e52951
Merge branch '001-gpg-event-signing': Ed25519 event signing and sync verification
a73x 2026-03-21 07:24
diff --git a/Cargo.lock b/Cargo.lock index 537c260..ae81def 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -62,7 +62,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ "windows-sys", "windows-sys 0.61.2", ] [[package]] @@ -73,7 +73,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", "windows-sys", "windows-sys 0.61.2", ] [[package]] @@ -104,6 +104,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "base64ct" version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" [[package]] name = "bit-set" version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -259,6 +265,12 @@ dependencies = [ ] [[package]] name = "const-oid" version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" [[package]] name = "convert_case" version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -330,6 +342,33 @@ dependencies = [ ] [[package]] name = "curve25519-dalek" version = "4.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" dependencies = [ "cfg-if", "cpufeatures", "curve25519-dalek-derive", "digest", "fiat-crypto", "rustc_version", "subtle", "zeroize", ] [[package]] name = "curve25519-dalek-derive" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", "syn 2.0.117", ] [[package]] name = "darling" version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -370,6 +409,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" [[package]] name = "der" version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ "const-oid", "zeroize", ] [[package]] name = "deranged" version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -411,6 +460,27 @@ dependencies = [ ] [[package]] name = "dirs" version = "5.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" dependencies = [ "dirs-sys", ] [[package]] name = "dirs-sys" version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" dependencies = [ "libc", "option-ext", "redox_users", "windows-sys 0.48.0", ] [[package]] name = "displaydoc" version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -431,6 +501,31 @@ dependencies = [ ] [[package]] name = "ed25519" version = "2.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" dependencies = [ "pkcs8", "signature", ] [[package]] name = "ed25519-dalek" version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" dependencies = [ "curve25519-dalek", "ed25519", "rand_core", "serde", "sha2", "subtle", "zeroize", ] [[package]] name = "either" version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -449,7 +544,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", "windows-sys", "windows-sys 0.61.2", ] [[package]] @@ -478,6 +573,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" [[package]] name = "fiat-crypto" version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" [[package]] name = "filedescriptor" version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -545,6 +646,17 @@ dependencies = [ [[package]] name = "getrandom" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", "libc", "wasi", ] [[package]] name = "getrandom" version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" @@ -572,10 +684,14 @@ dependencies = [ name = "git-collab" version = "0.1.0" dependencies = [ "base64", "chrono", "clap", "crossterm", "dirs", "ed25519-dalek", "git2", "rand_core", "ratatui", "serde", "serde_json", @@ -893,6 +1009,15 @@ dependencies = [ ] [[package]] name = "libredox" version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" dependencies = [ "libc", ] [[package]] name = "libssh2-sys" version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1015,7 +1140,7 @@ dependencies = [ "libc", "log", "wasi", "windows-sys", "windows-sys 0.61.2", ] [[package]] @@ -1107,6 +1232,12 @@ dependencies = [ ] [[package]] name = "option-ext" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" [[package]] name = "ordered-float" version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1240,6 +1371,16 @@ dependencies = [ ] [[package]] name = "pkcs8" version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" dependencies = [ "der", "spki", ] [[package]] name = "pkg-config" version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1320,6 +1461,9 @@ name = "rand_core" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ "getrandom 0.2.17", ] [[package]] name = "ratatui" @@ -1416,6 +1560,17 @@ dependencies = [ ] [[package]] name = "redox_users" version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" dependencies = [ "getrandom 0.2.17", "libredox", "thiserror 1.0.69", ] [[package]] name = "regex" version = "1.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1463,7 +1618,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", "windows-sys", "windows-sys 0.61.2", ] [[package]] @@ -1582,6 +1737,15 @@ dependencies = [ ] [[package]] name = "signature" version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ "rand_core", ] [[package]] name = "siphasher" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1594,6 +1758,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "spki" version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" dependencies = [ "base64ct", "der", ] [[package]] name = "stable_deref_trait" version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1633,6 +1807,12 @@ dependencies = [ ] [[package]] name = "subtle" version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" version = "1.0.109" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1675,7 +1855,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix", "windows-sys", "windows-sys 0.61.2", ] [[package]] @@ -2174,6 +2354,15 @@ dependencies = [ [[package]] name = "windows-sys" version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" dependencies = [ "windows-targets", ] [[package]] name = "windows-sys" version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" @@ -2182,6 +2371,63 @@ dependencies = [ ] [[package]] name = "windows-targets" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" dependencies = [ "windows_aarch64_gnullvm", "windows_aarch64_msvc", "windows_i686_gnu", "windows_i686_msvc", "windows_x86_64_gnu", "windows_x86_64_gnullvm", "windows_x86_64_msvc", ] [[package]] name = "windows_aarch64_gnullvm" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" [[package]] name = "windows_aarch64_msvc" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" [[package]] name = "windows_i686_gnu" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" [[package]] name = "windows_i686_msvc" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" [[package]] name = "windows_x86_64_gnu" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" [[package]] name = "windows_x86_64_gnullvm" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" [[package]] name = "windows_x86_64_msvc" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" [[package]] name = "wit-bindgen" version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2320,6 +2566,12 @@ dependencies = [ ] [[package]] name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" [[package]] name = "zerotrie" version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" diff --git a/Cargo.toml b/Cargo.toml index 3f520d0..c59d353 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,10 @@ chrono = { version = "0.4", features = ["serde"] } thiserror = "2" ratatui = "0.30.0" crossterm = "0.29.0" ed25519-dalek = { version = "2", features = ["rand_core"] } rand_core = { version = "0.6", features = ["getrandom"] } base64 = "0.22" dirs = "5" [dev-dependencies] tempfile = "3" diff --git a/src/cli.rs b/src/cli.rs index a676fc7..506ce77 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -32,6 +32,14 @@ pub enum Commands { #[arg(default_value = "origin")] remote: String, }, /// Generate an Ed25519 signing keypair #[clap(name = "init-key")] InitKey { /// Overwrite existing key files #[arg(long)] force: bool, }, } #[derive(Subcommand)] @@ -64,6 +72,45 @@ pub enum IssueCmd { #[arg(short, long)] body: String, }, /// Edit an issue's title or body Edit { /// Issue ID (prefix match) id: String, /// New title #[arg(short, long)] title: Option<String>, /// New body #[arg(short, long)] body: Option<String>, }, /// Add a label to an issue Label { /// Issue ID (prefix match) id: String, /// Label to add label: String, }, /// Remove a label from an issue Unlabel { /// Issue ID (prefix match) id: String, /// Label to remove label: String, }, /// Assign an issue to someone Assign { /// Issue ID (prefix match) id: String, /// Name to assign name: String, }, /// Unassign someone from an issue Unassign { /// Issue ID (prefix match) id: String, /// Name to unassign name: String, }, /// Close an issue Close { /// Issue ID (prefix match) @@ -95,6 +142,9 @@ pub enum PatchCmd { /// Head commit to review #[arg(long)] head: String, /// Issue ID this patch fixes (auto-closes on merge) #[arg(long)] fixes: Option<String>, }, /// List patches List { @@ -107,6 +157,11 @@ pub enum PatchCmd { /// Patch ID (prefix match) id: String, }, /// Show diff between base and head Diff { /// Patch ID (prefix match) id: String, }, /// Comment on a patch (use --file and --line for inline comments) Comment { /// Patch ID (prefix match) diff --git a/src/dag.rs b/src/dag.rs index 2cc2507..c56b42b 100644 --- a/src/dag.rs +++ b/src/dag.rs @@ -3,11 +3,17 @@ use git2::{Oid, Repository, Sort}; use crate::error::Error; use crate::event::{Action, Event}; use crate::identity::author_signature; use crate::signing::{sign_event, SignedEvent}; /// Create an orphan commit (no parents) with the given event. /// Returns the new commit OID which also serves as the entity ID. pub fn create_root_event(repo: &Repository, event: &Event) -> Result<Oid, Error> { let json = serde_json::to_vec_pretty(event)?; pub fn create_root_event( repo: &Repository, event: &Event, signing_key: &ed25519_dalek::SigningKey, ) -> Result<Oid, Error> { let signed = sign_event(event, signing_key)?; let json = serde_json::to_vec_pretty(&signed)?; let blob_oid = repo.blob(&json)?; let mut tb = repo.treebuilder(None)?; @@ -23,8 +29,14 @@ pub fn create_root_event(repo: &Repository, event: &Event) -> Result<Oid, Error> } /// Append an event to an existing DAG. The current tip is the parent. pub fn append_event(repo: &Repository, ref_name: &str, event: &Event) -> Result<Oid, Error> { let json = serde_json::to_vec_pretty(event)?; pub fn append_event( repo: &Repository, ref_name: &str, event: &Event, signing_key: &ed25519_dalek::SigningKey, ) -> Result<Oid, Error> { let signed = sign_event(event, signing_key)?; let json = serde_json::to_vec_pretty(&signed)?; let blob_oid = repo.blob(&json)?; let mut tb = repo.treebuilder(None)?; @@ -59,7 +71,13 @@ pub fn walk_events(repo: &Repository, ref_name: &str) -> Result<Vec<(Oid, Event) .get_name("event.json") .ok_or_else(|| git2::Error::from_str("missing event.json in commit tree"))?; let blob = repo.find_blob(entry.id())?; let event: Event = serde_json::from_slice(blob.content())?; let content = blob.content(); // Try SignedEvent first, fall back to plain Event for backward compat let event: Event = if let Ok(signed) = serde_json::from_slice::<SignedEvent>(content) { signed.event } else { serde_json::from_slice(content)? }; events.push((oid, event)); } Ok(events) @@ -76,6 +94,7 @@ pub fn reconcile( local_ref: &str, remote_ref: &str, merge_author: &crate::event::Author, signing_key: &ed25519_dalek::SigningKey, ) -> Result<Oid, Error> { let local_oid = repo.refname_to_id(local_ref)?; let remote_oid = repo.refname_to_id(remote_ref)?; @@ -104,7 +123,8 @@ pub fn reconcile( action: Action::Merge, }; let json = serde_json::to_vec_pretty(&merge_event)?; let signed = sign_event(&merge_event, signing_key)?; let json = serde_json::to_vec_pretty(&signed)?; let blob_oid = repo.blob(&json)?; let mut tb = repo.treebuilder(None)?; tb.insert("event.json", blob_oid, 0o100644)?; @@ -130,6 +150,11 @@ pub fn reconcile( fn commit_message(action: &Action) -> String { match action { Action::IssueOpen { title, .. } => format!("issue: open \"{}\"", title), Action::IssueEdit { .. } => "issue: edit".to_string(), Action::IssueLabel { ref label } => format!("issue: label \"{}\"", label), Action::IssueUnlabel { ref label } => format!("issue: unlabel \"{}\"", label), Action::IssueAssign { ref assignee } => format!("issue: assign \"{}\"", assignee), Action::IssueUnassign { ref assignee } => format!("issue: unassign \"{}\"", assignee), Action::IssueComment { .. } => "issue: comment".to_string(), Action::IssueClose { .. } => "issue: close".to_string(), Action::IssueReopen => "issue: reopen".to_string(), diff --git a/src/error.rs b/src/error.rs index 61b936c..5931733 100644 --- a/src/error.rs +++ b/src/error.rs @@ -13,4 +13,13 @@ pub enum Error { #[error(transparent)] Io(#[from] std::io::Error), #[error("signing error: {0}")] Signing(String), #[error("verification error: {0}")] Verification(String), #[error("no signing key found — run 'collab init-key' to generate one")] KeyNotFound, } diff --git a/src/event.rs b/src/event.rs index c26d86b..289930d 100644 --- a/src/event.rs +++ b/src/event.rs @@ -26,12 +26,30 @@ pub enum Action { IssueClose { reason: Option<String>, }, IssueEdit { title: Option<String>, body: Option<String>, }, IssueLabel { label: String, }, IssueUnlabel { label: String, }, IssueAssign { assignee: String, }, IssueUnassign { assignee: String, }, IssueReopen, PatchCreate { title: String, body: String, base_ref: String, head_commit: String, #[serde(default, skip_serializing_if = "Option::is_none")] fixes: Option<String>, }, PatchRevise { body: Option<String>, diff --git a/src/issue.rs b/src/issue.rs index 2e5335d..fce8fbe 100644 --- a/src/issue.rs +++ b/src/issue.rs @@ -3,9 +3,11 @@ use git2::Repository; use crate::dag; use crate::event::{Action, Event}; use crate::identity::get_author; use crate::state::{self, IssueStatus}; use crate::signing; use crate::state::{self, IssueState, IssueStatus}; pub fn open(repo: &Repository, title: &str, body: &str) -> Result<String, crate::error::Error> { let sk = signing::load_signing_key(&signing::signing_key_dir()?)?; let author = get_author(repo)?; let event = Event { timestamp: chrono::Utc::now().to_rfc3339(), @@ -15,66 +17,157 @@ pub fn open(repo: &Repository, title: &str, body: &str) -> Result<String, crate: body: body.to_string(), }, }; let oid = dag::create_root_event(repo, &event)?; let oid = dag::create_root_event(repo, &event, &sk)?; let id = oid.to_string(); let ref_name = format!("refs/collab/issues/{}", id); repo.reference(&ref_name, oid, false, "issue open")?; Ok(id) } pub fn list(repo: &Repository, show_closed: bool) -> Result<(), crate::error::Error> { pub struct ListEntry { pub issue: IssueState, pub unread: Option<usize>, } pub fn list(repo: &Repository, show_closed: bool) -> Result<Vec<ListEntry>, crate::error::Error> { let issues = state::list_issues(repo)?; let filtered: Vec<_> = issues .iter() let entries = issues .into_iter() .filter(|i| show_closed || i.status == IssueStatus::Open) .map(|issue| { let unread = count_unread(repo, &issue.id); ListEntry { issue, unread } }) .collect(); Ok(entries) } if filtered.is_empty() { println!("No issues found."); return Ok(()); } /// Count events after the last-seen mark. Returns None if never viewed. fn count_unread(repo: &git2::Repository, id: &str) -> Option<usize> { let seen_ref = format!("refs/collab/local/seen/issues/{}", id); let seen_oid = repo.refname_to_id(&seen_ref).ok()?; let ref_name = format!("refs/collab/issues/{}", id); let tip = repo.refname_to_id(&ref_name).ok()?; for issue in &filtered { let status = match issue.status { IssueStatus::Open => "open", IssueStatus::Closed => "closed", }; println!( "{:.8} {:6} {} (by {})", issue.id, status, issue.title, issue.author.name ); if seen_oid == tip { return Some(0); } Ok(()) let mut revwalk = repo.revwalk().ok()?; revwalk .set_sorting(git2::Sort::TOPOLOGICAL) .ok()?; revwalk.push(tip).ok()?; revwalk.hide(seen_oid).ok()?; Some(revwalk.count()) } pub fn show(repo: &Repository, id_prefix: &str) -> Result<(), crate::error::Error> { pub fn show(repo: &Repository, id_prefix: &str) -> Result<IssueState, crate::error::Error> { let (ref_name, id) = state::resolve_issue_ref(repo, id_prefix)?; let issue = state::IssueState::from_ref(repo, &ref_name, &id)?; let issue = IssueState::from_ref(repo, &ref_name, &id)?; // Mark as read: store current tip as seen let tip = repo.refname_to_id(&ref_name)?; let seen_ref = format!("refs/collab/local/seen/issues/{}", id); repo.reference(&seen_ref, tip, true, "mark seen")?; Ok(issue) } let status = match issue.status { IssueStatus::Open => "open", IssueStatus::Closed => "closed", pub fn label(repo: &Repository, id_prefix: &str, label: &str) -> Result<(), crate::error::Error> { let sk = signing::load_signing_key(&signing::signing_key_dir()?)?; let (ref_name, _id) = state::resolve_issue_ref(repo, id_prefix)?; let author = get_author(repo)?; let event = Event { timestamp: chrono::Utc::now().to_rfc3339(), author, action: Action::IssueLabel { label: label.to_string(), }, }; println!("Issue {} [{}]", &issue.id[..8], status); println!("Title: {}", issue.title); println!("Author: {} <{}>", issue.author.name, issue.author.email); println!("Created: {}", issue.created_at); if let Some(ref reason) = issue.close_reason { println!("Closed: {}", reason); } if !issue.body.is_empty() { println!("\n{}", issue.body); } if !issue.comments.is_empty() { println!("\n--- Comments ---"); for c in &issue.comments { println!("\n{} ({}):\n{}", c.author.name, c.timestamp, c.body); } dag::append_event(repo, &ref_name, &event, &sk)?; Ok(()) } pub fn unlabel(repo: &Repository, id_prefix: &str, label: &str) -> Result<(), crate::error::Error> { let sk = signing::load_signing_key(&signing::signing_key_dir()?)?; let (ref_name, _id) = state::resolve_issue_ref(repo, id_prefix)?; let author = get_author(repo)?; let event = Event { timestamp: chrono::Utc::now().to_rfc3339(), author, action: Action::IssueUnlabel { label: label.to_string(), }, }; dag::append_event(repo, &ref_name, &event, &sk)?; Ok(()) } pub fn assign( repo: &Repository, id_prefix: &str, assignee: &str, ) -> Result<(), crate::error::Error> { let sk = signing::load_signing_key(&signing::signing_key_dir()?)?; let (ref_name, _id) = state::resolve_issue_ref(repo, id_prefix)?; let author = get_author(repo)?; let event = Event { timestamp: chrono::Utc::now().to_rfc3339(), author, action: Action::IssueAssign { assignee: assignee.to_string(), }, }; dag::append_event(repo, &ref_name, &event, &sk)?; Ok(()) } pub fn unassign( repo: &Repository, id_prefix: &str, assignee: &str, ) -> Result<(), crate::error::Error> { let sk = signing::load_signing_key(&signing::signing_key_dir()?)?; let (ref_name, _id) = state::resolve_issue_ref(repo, id_prefix)?; let author = get_author(repo)?; let event = Event { timestamp: chrono::Utc::now().to_rfc3339(), author, action: Action::IssueUnassign { assignee: assignee.to_string(), }, }; dag::append_event(repo, &ref_name, &event, &sk)?; Ok(()) } pub fn edit( repo: &Repository, id_prefix: &str, title: Option<&str>, body: Option<&str>, ) -> Result<(), crate::error::Error> { if title.is_none() && body.is_none() { return Err( git2::Error::from_str("at least one of --title or --body must be provided").into(), ); } let sk = signing::load_signing_key(&signing::signing_key_dir()?)?; let (ref_name, _id) = state::resolve_issue_ref(repo, id_prefix)?; let author = get_author(repo)?; let event = Event { timestamp: chrono::Utc::now().to_rfc3339(), author, action: Action::IssueEdit { title: title.map(|s| s.to_string()), body: body.map(|s| s.to_string()), }, }; dag::append_event(repo, &ref_name, &event, &sk)?; Ok(()) } pub fn comment(repo: &Repository, id_prefix: &str, body: &str) -> Result<(), crate::error::Error> { let sk = signing::load_signing_key(&signing::signing_key_dir()?)?; let (ref_name, _id) = state::resolve_issue_ref(repo, id_prefix)?; let author = get_author(repo)?; let event = Event { @@ -84,8 +177,7 @@ pub fn comment(repo: &Repository, id_prefix: &str, body: &str) -> Result<(), cra body: body.to_string(), }, }; dag::append_event(repo, &ref_name, &event)?; println!("Comment added."); dag::append_event(repo, &ref_name, &event, &sk)?; Ok(()) } @@ -94,6 +186,7 @@ pub fn close( id_prefix: &str, reason: Option<&str>, ) -> Result<(), crate::error::Error> { let sk = signing::load_signing_key(&signing::signing_key_dir()?)?; let (ref_name, _id) = state::resolve_issue_ref(repo, id_prefix)?; let author = get_author(repo)?; let event = Event { @@ -103,12 +196,12 @@ pub fn close( reason: reason.map(|s| s.to_string()), }, }; dag::append_event(repo, &ref_name, &event)?; println!("Issue closed."); dag::append_event(repo, &ref_name, &event, &sk)?; Ok(()) } pub fn reopen(repo: &Repository, id_prefix: &str) -> Result<(), crate::error::Error> { let sk = signing::load_signing_key(&signing::signing_key_dir()?)?; let (ref_name, _id) = state::resolve_issue_ref(repo, id_prefix)?; let author = get_author(repo)?; let event = Event { @@ -116,7 +209,6 @@ pub fn reopen(repo: &Repository, id_prefix: &str) -> Result<(), crate::error::Er author, action: Action::IssueReopen, }; dag::append_event(repo, &ref_name, &event)?; println!("Issue reopened."); dag::append_event(repo, &ref_name, &event, &sk)?; Ok(()) } diff --git a/src/lib.rs b/src/lib.rs index 46cbe62..5a806e4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,5 +6,267 @@ pub mod identity; pub mod issue; pub mod patch; pub mod state; pub mod signing; pub mod sync; pub mod tui; use base64::Engine; use cli::{Commands, IssueCmd, PatchCmd}; use event::ReviewVerdict; use git2::Repository; use state::{IssueStatus, PatchStatus}; pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> { match cli.command { Commands::Init => sync::init(repo), Commands::Issue(cmd) => match cmd { IssueCmd::Open { title, body } => { let id = issue::open(repo, &title, &body)?; println!("Opened issue {:.8}", id); Ok(()) } IssueCmd::List { all } => { let entries = issue::list(repo, all)?; if entries.is_empty() { println!("No issues found."); } else { for e in &entries { let i = &e.issue; let status = match i.status { IssueStatus::Open => "open", IssueStatus::Closed => "closed", }; let labels = if i.labels.is_empty() { String::new() } else { format!(" [{}]", i.labels.join(", ")) }; let unread = match e.unread { Some(n) if n > 0 => format!(" ({} new)", n), _ => String::new(), }; println!( "{:.8} {:6} {}{} (by {}){}", i.id, status, i.title, labels, i.author.name, unread ); } } Ok(()) } IssueCmd::Show { id } => { let i = issue::show(repo, &id)?; let status = match i.status { IssueStatus::Open => "open", IssueStatus::Closed => "closed", }; println!("Issue {} [{}]", &i.id[..8], status); println!("Title: {}", i.title); println!("Author: {} <{}>", i.author.name, i.author.email); println!("Created: {}", i.created_at); if !i.labels.is_empty() { println!("Labels: {}", i.labels.join(", ")); } if !i.assignees.is_empty() { println!("Assignees: {}", i.assignees.join(", ")); } if let Some(ref reason) = i.close_reason { println!("Closed: {}", reason); } if !i.body.is_empty() { println!("\n{}", i.body); } if !i.comments.is_empty() { println!("\n--- Comments ---"); for c in &i.comments { println!("\n{} ({}):\n{}", c.author.name, c.timestamp, c.body); } } Ok(()) } IssueCmd::Label { id, label } => { issue::label(repo, &id, &label)?; println!("Label '{}' added.", label); Ok(()) } IssueCmd::Unlabel { id, label } => { issue::unlabel(repo, &id, &label)?; println!("Label '{}' removed.", label); Ok(()) } IssueCmd::Assign { id, name } => { issue::assign(repo, &id, &name)?; println!("Assigned to '{}'.", name); Ok(()) } IssueCmd::Unassign { id, name } => { issue::unassign(repo, &id, &name)?; println!("Unassigned '{}'.", name); Ok(()) } IssueCmd::Edit { id, title, body } => { issue::edit(repo, &id, title.as_deref(), body.as_deref())?; println!("Issue updated."); Ok(()) } IssueCmd::Comment { id, body } => { issue::comment(repo, &id, &body)?; println!("Comment added."); Ok(()) } IssueCmd::Close { id, reason } => { issue::close(repo, &id, reason.as_deref())?; println!("Issue closed."); Ok(()) } IssueCmd::Reopen { id } => { issue::reopen(repo, &id)?; println!("Issue reopened."); Ok(()) } }, Commands::Patch(cmd) => match cmd { PatchCmd::Create { title, body, base, head, fixes, } => { let id = patch::create(repo, &title, &body, &base, &head, fixes.as_deref())?; println!("Created patch {:.8}", id); Ok(()) } PatchCmd::List { all } => { let patches = patch::list(repo, all)?; if patches.is_empty() { println!("No patches found."); } else { for p in &patches { let status = match p.status { PatchStatus::Open => "open", PatchStatus::Closed => "closed", PatchStatus::Merged => "merged", }; println!( "{:.8} {:6} {} (by {})", p.id, status, p.title, p.author.name ); } } Ok(()) } PatchCmd::Show { id } => { let p = patch::show(repo, &id)?; let status = match p.status { PatchStatus::Open => "open", PatchStatus::Closed => "closed", PatchStatus::Merged => "merged", }; println!("Patch {} [{}]", &p.id[..8], status); println!("Title: {}", p.title); println!("Author: {} <{}>", p.author.name, p.author.email); println!("Base: {} Head: {:.8}", p.base_ref, p.head_commit); println!("Created: {}", p.created_at); if let Some(ref fixes) = p.fixes { println!("Fixes: {:.8}", fixes); } if !p.body.is_empty() { println!("\n{}", p.body); } if !p.reviews.is_empty() { println!("\n--- Reviews ---"); for r in &p.reviews { println!( "\n{} ({:?}) - {}:\n{}", r.author.name, r.verdict, r.timestamp, r.body ); } } if !p.inline_comments.is_empty() { println!("\n--- Inline Comments ---"); for c in &p.inline_comments { println!( "\n{} on {}:{} ({}):\n {}", c.author.name, c.file, c.line, c.timestamp, c.body ); } } if !p.comments.is_empty() { println!("\n--- Comments ---"); for c in &p.comments { println!("\n{} ({}):\n{}", c.author.name, c.timestamp, c.body); } } Ok(()) } PatchCmd::Diff { id } => { let diff = patch::diff(repo, &id)?; if diff.is_empty() { println!("No diff available (commits may be identical)."); } else { print!("{}", diff); } Ok(()) } PatchCmd::Comment { id, body, file, line, } => { patch::comment(repo, &id, &body, file.as_deref(), line)?; println!("Comment added."); Ok(()) } PatchCmd::Review { id, verdict, body } => { let v = match verdict.as_str() { "approve" => ReviewVerdict::Approve, "request-changes" => ReviewVerdict::RequestChanges, "comment" => ReviewVerdict::Comment, _ => { return Err(git2::Error::from_str( "verdict must be: approve, request-changes, or comment", ) .into()); } }; patch::review(repo, &id, v, &body)?; println!("Review submitted."); Ok(()) } PatchCmd::Revise { id, head, body } => { patch::revise(repo, &id, &head, body.as_deref())?; println!("Patch revised."); Ok(()) } PatchCmd::Merge { id } => { let p = patch::merge(repo, &id)?; println!("Patch {:.8} merged into {}.", p.id, p.base_ref); Ok(()) } PatchCmd::Close { id, reason } => { patch::close(repo, &id, reason.as_deref())?; println!("Patch closed."); Ok(()) } }, Commands::Dashboard => tui::run(repo), Commands::Sync { remote } => sync::sync(repo, &remote), Commands::InitKey { force } => { let config_dir = signing::signing_key_dir()?; let sk_path = config_dir.join("signing-key"); if sk_path.exists() && !force { return Err(error::Error::Signing( "signing key already exists; use --force to overwrite".to_string(), )); } let vk = signing::generate_keypair(&config_dir)?; let pubkey_b64 = base64::engine::general_purpose::STANDARD.encode(vk.to_bytes()); println!("Signing key generated."); println!("Public key: {}", pubkey_b64); Ok(()) } } } diff --git a/src/main.rs b/src/main.rs index 81f1568..89cecfb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,86 +1,21 @@ mod cli; mod dag; mod error; mod event; mod identity; mod issue; mod patch; mod state; mod sync; mod tui; use clap::Parser; use cli::{Cli, Commands, IssueCmd, PatchCmd}; use event::ReviewVerdict; use git2::Repository; use git_collab::cli::Cli; fn main() { let cli = Cli::parse(); let result = run(cli); if let Err(e) = result { let repo = match Repository::open_from_env() { Ok(r) => r, Err(e) => { eprintln!("error: {}", e); std::process::exit(1); } }; if let Err(e) = git_collab::run(cli, &repo) { eprintln!("error: {}", e); std::process::exit(1); } } fn run(cli: Cli) -> Result<(), error::Error> { let repo = Repository::open_from_env()?; match cli.command { Commands::Init => sync::init(&repo), Commands::Issue(cmd) => match cmd { IssueCmd::Open { title, body } => { let id = issue::open(&repo, &title, &body)?; println!("Opened issue {:.8}", id); Ok(()) } IssueCmd::List { all } => issue::list(&repo, all), IssueCmd::Show { id } => issue::show(&repo, &id), IssueCmd::Comment { id, body } => issue::comment(&repo, &id, &body), IssueCmd::Close { id, reason } => issue::close(&repo, &id, reason.as_deref()), IssueCmd::Reopen { id } => issue::reopen(&repo, &id), }, Commands::Patch(cmd) => match cmd { PatchCmd::Create { title, body, base, head, } => { let id = patch::create(&repo, &title, &body, &base, &head)?; println!("Created patch {:.8}", id); Ok(()) } PatchCmd::List { all } => patch::list(&repo, all), PatchCmd::Show { id } => patch::show(&repo, &id), PatchCmd::Comment { id, body, file, line, } => patch::comment(&repo, &id, &body, file.as_deref(), line), PatchCmd::Review { id, verdict, body } => { let v = match verdict.as_str() { "approve" => ReviewVerdict::Approve, "request-changes" => ReviewVerdict::RequestChanges, "comment" => ReviewVerdict::Comment, _ => { return Err(git2::Error::from_str( "verdict must be: approve, request-changes, or comment", ) .into()); } }; patch::review(&repo, &id, v, &body) } PatchCmd::Revise { id, head, body } => { patch::revise(&repo, &id, &head, body.as_deref()) } PatchCmd::Merge { id } => patch::merge(&repo, &id), PatchCmd::Close { id, reason } => patch::close(&repo, &id, reason.as_deref()), }, Commands::Dashboard => tui::run(&repo), Commands::Sync { remote } => sync::sync(&repo, &remote), } } diff --git a/src/patch.rs b/src/patch.rs index fca1add..2db90af 100644 --- a/src/patch.rs +++ b/src/patch.rs @@ -1,9 +1,11 @@ use git2::Repository; use git2::{DiffFormat, Repository}; use crate::dag; use crate::error::Error; use crate::event::{Action, Event, ReviewVerdict}; use crate::identity::get_author; use crate::state::{self, PatchStatus}; use crate::signing; use crate::state::{self, PatchState, PatchStatus}; pub fn create( repo: &Repository, @@ -11,7 +13,9 @@ pub fn create( body: &str, base_ref: &str, head_commit: &str, fixes: Option<&str>, ) -> Result<String, crate::error::Error> { let sk = signing::load_signing_key(&signing::signing_key_dir()?)?; let author = get_author(repo)?; let event = Event { timestamp: chrono::Utc::now().to_rfc3339(), @@ -21,83 +25,28 @@ pub fn create( body: body.to_string(), base_ref: base_ref.to_string(), head_commit: head_commit.to_string(), fixes: fixes.map(|s| s.to_string()), }, }; let oid = dag::create_root_event(repo, &event)?; let oid = dag::create_root_event(repo, &event, &sk)?; let id = oid.to_string(); let ref_name = format!("refs/collab/patches/{}", id); repo.reference(&ref_name, oid, false, "patch create")?; Ok(id) } pub fn list(repo: &Repository, show_closed: bool) -> Result<(), crate::error::Error> { pub fn list(repo: &Repository, show_closed: bool) -> Result<Vec<PatchState>, crate::error::Error> { let patches = state::list_patches(repo)?; let filtered: Vec<_> = patches .iter() let filtered = patches .into_iter() .filter(|p| show_closed || p.status == PatchStatus::Open) .collect(); if filtered.is_empty() { println!("No patches found."); return Ok(()); } for p in &filtered { let status = match p.status { PatchStatus::Open => "open", PatchStatus::Closed => "closed", PatchStatus::Merged => "merged", }; println!( "{:.8} {:6} {} (by {})", p.id, status, p.title, p.author.name ); } Ok(()) Ok(filtered) } pub fn show(repo: &Repository, id_prefix: &str) -> Result<(), crate::error::Error> { pub fn show(repo: &Repository, id_prefix: &str) -> Result<PatchState, crate::error::Error> { let (ref_name, id) = state::resolve_patch_ref(repo, id_prefix)?; let p = state::PatchState::from_ref(repo, &ref_name, &id)?; let status = match p.status { PatchStatus::Open => "open", PatchStatus::Closed => "closed", PatchStatus::Merged => "merged", }; println!("Patch {} [{}]", &p.id[..8], status); println!("Title: {}", p.title); println!("Author: {} <{}>", p.author.name, p.author.email); println!("Base: {} Head: {:.8}", p.base_ref, p.head_commit); println!("Created: {}", p.created_at); if !p.body.is_empty() { println!("\n{}", p.body); } if !p.reviews.is_empty() { println!("\n--- Reviews ---"); for r in &p.reviews { println!( "\n{} ({:?}) - {}:\n{}", r.author.name, r.verdict, r.timestamp, r.body ); } } if !p.inline_comments.is_empty() { println!("\n--- Inline Comments ---"); for c in &p.inline_comments { println!( "\n{} on {}:{} ({}):\n {}", c.author.name, c.file, c.line, c.timestamp, c.body ); } } if !p.comments.is_empty() { println!("\n--- Comments ---"); for c in &p.comments { println!("\n{} ({}):\n{}", c.author.name, c.timestamp, c.body); } } Ok(()) PatchState::from_ref(repo, &ref_name, &id) } pub fn comment( @@ -107,6 +56,7 @@ pub fn comment( file: Option<&str>, line: Option<u32>, ) -> Result<(), crate::error::Error> { let sk = signing::load_signing_key(&signing::signing_key_dir()?)?; let (ref_name, _id) = state::resolve_patch_ref(repo, id_prefix)?; let author = get_author(repo)?; @@ -132,8 +82,7 @@ pub fn comment( author, action, }; dag::append_event(repo, &ref_name, &event)?; println!("Comment added."); dag::append_event(repo, &ref_name, &event, &sk)?; Ok(()) } @@ -143,6 +92,7 @@ pub fn review( verdict: ReviewVerdict, body: &str, ) -> Result<(), crate::error::Error> { let sk = signing::load_signing_key(&signing::signing_key_dir()?)?; let (ref_name, _id) = state::resolve_patch_ref(repo, id_prefix)?; let author = get_author(repo)?; let event = Event { @@ -153,8 +103,7 @@ pub fn review( body: body.to_string(), }, }; dag::append_event(repo, &ref_name, &event)?; println!("Review submitted."); dag::append_event(repo, &ref_name, &event, &sk)?; Ok(()) } @@ -164,6 +113,7 @@ pub fn revise( head_commit: &str, body: Option<&str>, ) -> Result<(), crate::error::Error> { let sk = signing::load_signing_key(&signing::signing_key_dir()?)?; let (ref_name, _id) = state::resolve_patch_ref(repo, id_prefix)?; let author = get_author(repo)?; let event = Event { @@ -174,14 +124,14 @@ pub fn revise( head_commit: head_commit.to_string(), }, }; dag::append_event(repo, &ref_name, &event)?; println!("Patch revised."); dag::append_event(repo, &ref_name, &event, &sk)?; Ok(()) } pub fn merge(repo: &Repository, id_prefix: &str) -> Result<(), crate::error::Error> { pub fn merge(repo: &Repository, id_prefix: &str) -> Result<PatchState, crate::error::Error> { let sk = signing::load_signing_key(&signing::signing_key_dir()?)?; let (ref_name, id) = state::resolve_patch_ref(repo, id_prefix)?; let p = state::PatchState::from_ref(repo, &ref_name, &id)?; let p = PatchState::from_ref(repo, &ref_name, &id)?; if p.status != PatchStatus::Open { return Err(git2::Error::from_str(&format!( @@ -237,12 +187,80 @@ pub fn merge(repo: &Repository, id_prefix: &str) -> Result<(), crate::error::Err let author = get_author(repo)?; let event = Event { timestamp: chrono::Utc::now().to_rfc3339(), author, author: author.clone(), action: Action::PatchMerge, }; dag::append_event(repo, &ref_name, &event)?; println!("Patch {:.8} merged into {}.", id, p.base_ref); Ok(()) dag::append_event(repo, &ref_name, &event, &sk)?; // Auto-close linked issue if present if let Some(ref fixes_id) = p.fixes { if let Ok((issue_ref, _)) = state::resolve_issue_ref(repo, fixes_id) { let close_event = Event { timestamp: chrono::Utc::now().to_rfc3339(), author, action: Action::IssueClose { reason: Some(format!("Fixed by patch {:.8}", p.id)), }, }; dag::append_event(repo, &issue_ref, &close_event, &sk)?; } } Ok(p) } /// Generate a unified diff between a patch's base branch and head commit. pub fn diff(repo: &Repository, id_prefix: &str) -> Result<String, Error> { let (ref_name, id) = state::resolve_patch_ref(repo, id_prefix)?; let p = PatchState::from_ref(repo, &ref_name, &id)?; generate_diff(repo, &p) } /// Generate a diff string from a patch's base and head. pub fn generate_diff(repo: &Repository, patch: &PatchState) -> Result<String, Error> { let head_obj = repo .revparse_single(&patch.head_commit) .map_err(|e| Error::Cmd(format!("bad head ref: {}", e)))?; let head_commit = head_obj .into_commit() .map_err(|_| Error::Cmd("head ref is not a commit".to_string()))?; let head_tree = head_commit.tree()?; let base_ref = format!("refs/heads/{}", patch.base_ref); let base_tree = if let Ok(base_oid) = repo.refname_to_id(&base_ref) { let base_commit = repo.find_commit(base_oid)?; Some(base_commit.tree()?) } else { None }; let git_diff = repo.diff_tree_to_tree(base_tree.as_ref(), Some(&head_tree), None)?; let mut output = String::new(); let mut lines = 0usize; git_diff.print(DiffFormat::Patch, |_delta, _hunk, line| { if lines >= 5000 { return false; } let prefix = match line.origin() { '+' => "+", '-' => "-", ' ' => " ", _ => "", }; output.push_str(prefix); if let Ok(content) = std::str::from_utf8(line.content()) { output.push_str(content); } lines += 1; true })?; if lines >= 5000 { output.push_str("\n[truncated at 5000 lines]"); } Ok(output) } pub fn close( @@ -250,6 +268,7 @@ pub fn close( id_prefix: &str, reason: Option<&str>, ) -> Result<(), crate::error::Error> { let sk = signing::load_signing_key(&signing::signing_key_dir()?)?; let (ref_name, _id) = state::resolve_patch_ref(repo, id_prefix)?; let author = get_author(repo)?; let event = Event { @@ -259,7 +278,6 @@ pub fn close( reason: reason.map(|s| s.to_string()), }, }; dag::append_event(repo, &ref_name, &event)?; println!("Patch closed."); dag::append_event(repo, &ref_name, &event, &sk)?; Ok(()) } diff --git a/src/signing.rs b/src/signing.rs new file mode 100644 index 0000000..9f41a64 --- /dev/null +++ b/src/signing.rs @@ -0,0 +1,317 @@ use std::fs; use std::path::{Path, PathBuf}; use base64::engine::general_purpose::STANDARD; use base64::Engine; use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; use git2::Oid; use rand_core::OsRng; use serde::{Deserialize, Serialize}; use git2::{Repository, Sort}; use crate::error::Error; use crate::event::Event; /// Return the directory where signing keys are stored. pub fn signing_key_dir() -> Result<PathBuf, Error> { let config = dirs::config_dir().or_else(|| { std::env::var("HOME") .ok() .map(|h| PathBuf::from(h).join(".config")) }); match config { Some(dir) => Ok(dir.join("git-collab")), None => Err(Error::Signing( "cannot determine config directory: HOME is not set".to_string(), )), } } /// Wrapper around Event that adds Ed25519 signature fields. /// Serialized as the event.json blob in git commits. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SignedEvent { #[serde(flatten)] pub event: Event, pub signature: String, pub pubkey: String, } /// Result of verifying an event commit's signature. #[derive(Debug, Clone, PartialEq)] pub enum VerifyStatus { /// Signature verified successfully against the embedded public key. Valid, /// Signature present but verification failed (tampered or wrong key). Invalid, /// No signature or pubkey field in event.json. Missing, } /// Detailed verification result for a single commit. #[derive(Debug, Clone)] pub struct SignatureVerificationResult { pub commit_id: Oid, pub status: VerifyStatus, pub pubkey: Option<String>, pub error: Option<String>, } /// Generate an Ed25519 keypair and store it in `config_dir`. /// /// Creates `config_dir` (with 0o700 permissions on Unix) if it doesn't exist. /// Writes the private key (base64) to `{config_dir}/signing-key` with 0o600 /// permissions and the public key (base64) to `{config_dir}/signing-key.pub`. pub fn generate_keypair(config_dir: &Path) -> Result<VerifyingKey, Error> { // Create config dir if needed if !config_dir.exists() { fs::create_dir_all(config_dir)?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; fs::set_permissions(config_dir, fs::Permissions::from_mode(0o700))?; } } let signing_key = SigningKey::generate(&mut OsRng); let verifying_key = signing_key.verifying_key(); // Write private key let sk_path = config_dir.join("signing-key"); let sk_b64 = STANDARD.encode(signing_key.to_bytes()); fs::write(&sk_path, &sk_b64)?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; fs::set_permissions(&sk_path, fs::Permissions::from_mode(0o600))?; } // Write public key let vk_path = config_dir.join("signing-key.pub"); let vk_b64 = STANDARD.encode(verifying_key.to_bytes()); fs::write(&vk_path, &vk_b64)?; Ok(verifying_key) } /// Load the Ed25519 signing (private) key from `config_dir/signing-key`. pub fn load_signing_key(config_dir: &Path) -> Result<SigningKey, Error> { let path = config_dir.join("signing-key"); if !path.exists() { return Err(Error::KeyNotFound); } #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let mode = fs::metadata(&path)?.permissions().mode() & 0o777; if mode & 0o077 != 0 { eprintln!( "warning: signing key {:?} has permissions {:04o} — should be 0600", path, mode ); } } let b64 = fs::read_to_string(&path)?; let bytes = STANDARD .decode(b64.trim()) .map_err(|e| Error::Signing(format!("invalid signing key base64: {}", e)))?; let key_bytes: [u8; 32] = bytes .try_into() .map_err(|_| Error::Signing("signing key must be 32 bytes".to_string()))?; Ok(SigningKey::from_bytes(&key_bytes)) } /// Load the Ed25519 verifying (public) key from `config_dir/signing-key.pub`. pub fn load_verifying_key(config_dir: &Path) -> Result<VerifyingKey, Error> { let path = config_dir.join("signing-key.pub"); if !path.exists() { return Err(Error::KeyNotFound); } let b64 = fs::read_to_string(&path)?; let bytes = STANDARD .decode(b64.trim()) .map_err(|e| Error::Verification(format!("invalid verifying key base64: {}", e)))?; let key_bytes: [u8; 32] = bytes .try_into() .map_err(|_| Error::Verification("verifying key must be 32 bytes".to_string()))?; VerifyingKey::from_bytes(&key_bytes) .map_err(|e| Error::Verification(format!("invalid verifying key: {}", e))) } /// Serialize an Event to canonical JSON bytes. /// /// Uses `serde_json::Value` as an intermediate step. Since serde_json uses /// BTreeMap-backed Map (no `preserve_order` feature), keys are sorted /// alphabetically, ensuring deterministic output. pub fn canonical_json(event: &Event) -> Result<Vec<u8>, Error> { let value = serde_json::to_value(event)?; let json = serde_json::to_string(&value)?; Ok(json.into_bytes()) } /// Sign an Event with the given signing key, producing a SignedEvent. pub fn sign_event(event: &Event, signing_key: &SigningKey) -> Result<SignedEvent, Error> { let canonical = canonical_json(event)?; let signature = signing_key.sign(&canonical); let verifying_key = signing_key.verifying_key(); Ok(SignedEvent { event: event.clone(), signature: STANDARD.encode(signature.to_bytes()), pubkey: STANDARD.encode(verifying_key.to_bytes()), }) } /// Verify a SignedEvent's signature against its embedded public key. /// /// Returns `Missing` if signature or pubkey fields are empty, /// `Valid` if the signature checks out, `Invalid` otherwise. pub fn verify_signed_event(signed: &SignedEvent) -> Result<VerifyStatus, Error> { if signed.signature.is_empty() || signed.pubkey.is_empty() { return Ok(VerifyStatus::Missing); } let sig_bytes = match STANDARD.decode(&signed.signature) { Ok(b) => b, Err(_) => return Ok(VerifyStatus::Invalid), }; let pubkey_bytes = match STANDARD.decode(&signed.pubkey) { Ok(b) => b, Err(_) => return Ok(VerifyStatus::Invalid), }; let sig_array: [u8; 64] = match sig_bytes.try_into() { Ok(a) => a, Err(_) => return Ok(VerifyStatus::Invalid), }; let key_array: [u8; 32] = match pubkey_bytes.try_into() { Ok(a) => a, Err(_) => return Ok(VerifyStatus::Invalid), }; let signature = Signature::from_bytes(&sig_array); let verifying_key = match VerifyingKey::from_bytes(&key_array) { Ok(vk) => vk, Err(_) => return Ok(VerifyStatus::Invalid), }; let canonical = canonical_json(&signed.event)?; match verifying_key.verify(&canonical, &signature) { Ok(()) => Ok(VerifyStatus::Valid), Err(_) => Ok(VerifyStatus::Invalid), } } /// Walk the DAG for the given ref and verify every event commit's signature. /// /// For each commit, reads `event.json` from the tree: /// - If it deserializes as a `SignedEvent`, calls `verify_signed_event()`. /// - If it only deserializes as a plain `Event` (no signature/pubkey), marks as `Missing`. /// /// Returns one `SignatureVerificationResult` per commit. pub fn verify_ref( repo: &Repository, ref_name: &str, ) -> Result<Vec<SignatureVerificationResult>, Error> { let tip = repo.refname_to_id(ref_name)?; let mut revwalk = repo.revwalk()?; revwalk.set_sorting(Sort::TOPOLOGICAL | Sort::REVERSE)?; revwalk.push(tip)?; let mut results = Vec::new(); for oid_result in revwalk { let oid = oid_result?; let commit = repo.find_commit(oid)?; let tree = commit.tree()?; let entry = tree .get_name("event.json") .ok_or_else(|| git2::Error::from_str("missing event.json in commit tree"))?; let blob = repo.find_blob(entry.id())?; let content = blob.content(); // Try to deserialize as SignedEvent first if let Ok(signed) = serde_json::from_slice::<SignedEvent>(content) { if signed.signature.is_empty() || signed.pubkey.is_empty() { results.push(SignatureVerificationResult { commit_id: oid, status: VerifyStatus::Missing, pubkey: None, error: Some("missing signature".to_string()), }); } else { match verify_signed_event(&signed)? { VerifyStatus::Valid => { results.push(SignatureVerificationResult { commit_id: oid, status: VerifyStatus::Valid, pubkey: Some(signed.pubkey), error: None, }); } VerifyStatus::Invalid => { results.push(SignatureVerificationResult { commit_id: oid, status: VerifyStatus::Invalid, pubkey: Some(signed.pubkey), error: Some("invalid signature".to_string()), }); } VerifyStatus::Missing => { results.push(SignatureVerificationResult { commit_id: oid, status: VerifyStatus::Missing, pubkey: None, error: Some("missing signature".to_string()), }); } } } } else { // Plain Event without signature fields results.push(SignatureVerificationResult { commit_id: oid, status: VerifyStatus::Missing, pubkey: None, error: Some("missing signature".to_string()), }); } } Ok(results) } #[cfg(test)] mod tests { use super::*; use crate::event::{Action, Author}; #[test] fn signed_event_flatten_round_trip() { let event = Event { timestamp: "2026-03-21T00:00:00Z".to_string(), author: Author { name: "Alice".to_string(), email: "alice@example.com".to_string(), }, action: Action::IssueOpen { title: "Test".to_string(), body: "Body".to_string(), }, }; let signed = SignedEvent { event, signature: "dGVzdA==".to_string(), pubkey: "cHVia2V5".to_string(), }; let json = serde_json::to_string_pretty(&signed).unwrap(); let deserialized: SignedEvent = serde_json::from_str(&json).unwrap(); assert_eq!(deserialized.signature, "dGVzdA=="); assert_eq!(deserialized.pubkey, "cHVia2V5"); match deserialized.event.action { Action::IssueOpen { ref title, .. } => assert_eq!(title, "Test"), _ => panic!("Wrong action type after round-trip"), } } } diff --git a/src/state.rs b/src/state.rs index e2a02c0..6445b04 100644 --- a/src/state.rs +++ b/src/state.rs @@ -25,6 +25,8 @@ pub struct IssueState { pub body: String, pub status: IssueStatus, pub close_reason: Option<String>, pub labels: Vec<String>, pub assignees: Vec<String>, pub comments: Vec<Comment>, pub created_at: String, pub author: Author, @@ -63,6 +65,7 @@ pub struct PatchState { pub status: PatchStatus, pub base_ref: String, pub head_commit: String, pub fixes: Option<String>, pub comments: Vec<Comment>, pub inline_comments: Vec<InlineComment>, pub reviews: Vec<Review>, @@ -93,6 +96,8 @@ impl IssueState { body, status: IssueStatus::Open, close_reason: None, labels: Vec::new(), assignees: Vec::new(), comments: Vec::new(), created_at: event.timestamp.clone(), author: event.author.clone(), @@ -117,6 +122,40 @@ impl IssueState { } } } Action::IssueEdit { title, body } => { if let Some(ref mut s) = state { if let Some(t) = title { s.title = t; } if let Some(b) = body { s.body = b; } } } Action::IssueLabel { label } => { if let Some(ref mut s) = state { if !s.labels.contains(&label) { s.labels.push(label); } } } Action::IssueUnlabel { label } => { if let Some(ref mut s) = state { s.labels.retain(|l| l != &label); } } Action::IssueAssign { assignee } => { if let Some(ref mut s) = state { if !s.assignees.contains(&assignee) { s.assignees.push(assignee); } } } Action::IssueUnassign { assignee } => { if let Some(ref mut s) = state { s.assignees.retain(|a| a != &assignee); } } Action::IssueReopen => { if let Some(ref mut s) = state { if status_ts.as_ref().is_none_or(|ts| event.timestamp >= *ts) { @@ -152,6 +191,7 @@ impl PatchState { body, base_ref, head_commit, fixes, } => { state = Some(PatchState { id: id.to_string(), @@ -160,6 +200,7 @@ impl PatchState { status: PatchStatus::Open, base_ref, head_commit, fixes, comments: Vec::new(), inline_comments: Vec::new(), reviews: Vec::new(), @@ -231,67 +272,46 @@ impl PatchState { } } /// List all issue refs and return their materialized state. pub fn list_issues(repo: &Repository) -> Result<Vec<IssueState>, crate::error::Error> { let mut issues = Vec::new(); let refs = repo.references_glob("refs/collab/issues/*")?; for r in refs { let r = r?; let ref_name = r.name().unwrap_or_default().to_string(); let id = ref_name .strip_prefix("refs/collab/issues/") .unwrap_or_default() .to_string(); match IssueState::from_ref(repo, &ref_name, &id) { Ok(state) => issues.push(state), Err(_) => continue, } } Ok(issues) } /// List all patch refs and return their materialized state. pub fn list_patches(repo: &Repository) -> Result<Vec<PatchState>, crate::error::Error> { let mut patches = Vec::new(); let refs = repo.references_glob("refs/collab/patches/*")?; /// Enumerate all collab refs of a given kind, returning (ref_name, id) pairs. fn collab_refs( repo: &Repository, kind: &str, ) -> Result<Vec<(String, String)>, crate::error::Error> { let prefix = format!("refs/collab/{}/", kind); let glob = format!("{}*", prefix); let refs = repo.references_glob(&glob)?; let mut result = Vec::new(); for r in refs { let r = r?; let ref_name = r.name().unwrap_or_default().to_string(); let id = ref_name .strip_prefix("refs/collab/patches/") .strip_prefix(&prefix) .unwrap_or_default() .to_string(); match PatchState::from_ref(repo, &ref_name, &id) { Ok(state) => patches.push(state), Err(_) => continue, } result.push((ref_name, id)); } Ok(patches) Ok(result) } /// Resolve a short ID prefix to the full ref name. Returns (ref_name, id). pub fn resolve_issue_ref( /// Resolve a short ID prefix to a full ref. Returns (ref_name, id). fn resolve_ref( repo: &Repository, kind: &str, singular: &str, prefix: &str, ) -> Result<(String, String), crate::error::Error> { let refs = repo.references_glob("refs/collab/issues/*")?; let mut matches = Vec::new(); for r in refs { let r = r?; let ref_name = r.name().unwrap_or_default().to_string(); let id = ref_name .strip_prefix("refs/collab/issues/") .unwrap_or_default() .to_string(); if id.starts_with(prefix) { matches.push((ref_name, id)); } } let matches: Vec<_> = collab_refs(repo, kind)? .into_iter() .filter(|(_, id)| id.starts_with(prefix)) .collect(); match matches.len() { 0 => Err(git2::Error::from_str(&format!("no issue found matching '{}'", prefix)).into()), 0 => Err( git2::Error::from_str(&format!("no {} found matching '{}'", singular, prefix)).into(), ), 1 => Ok(matches.into_iter().next().unwrap()), _ => Err(git2::Error::from_str(&format!( "ambiguous issue prefix '{}': {} matches", "ambiguous {} prefix '{}': {} matches", singular, prefix, matches.len() )) @@ -299,32 +319,36 @@ pub fn resolve_issue_ref( } } /// Resolve a short ID prefix to the full patch ref name. /// List all issue refs and return their materialized state. pub fn list_issues(repo: &Repository) -> Result<Vec<IssueState>, crate::error::Error> { let items = collab_refs(repo, "issues")? .into_iter() .filter_map(|(ref_name, id)| IssueState::from_ref(repo, &ref_name, &id).ok()) .collect(); Ok(items) } /// List all patch refs and return their materialized state. pub fn list_patches(repo: &Repository) -> Result<Vec<PatchState>, crate::error::Error> { let items = collab_refs(repo, "patches")? .into_iter() .filter_map(|(ref_name, id)| PatchState::from_ref(repo, &ref_name, &id).ok()) .collect(); Ok(items) } /// Resolve a short ID prefix to the full issue ref name. Returns (ref_name, id). pub fn resolve_issue_ref( repo: &Repository, prefix: &str, ) -> Result<(String, String), crate::error::Error> { resolve_ref(repo, "issues", "issue", prefix) } /// Resolve a short ID prefix to the full patch ref name. Returns (ref_name, id). pub fn resolve_patch_ref( repo: &Repository, prefix: &str, ) -> Result<(String, String), crate::error::Error> { let refs = repo.references_glob("refs/collab/patches/*")?; let mut matches = Vec::new(); for r in refs { let r = r?; let ref_name = r.name().unwrap_or_default().to_string(); let id = ref_name .strip_prefix("refs/collab/patches/") .unwrap_or_default() .to_string(); if id.starts_with(prefix) { matches.push((ref_name, id)); } } match matches.len() { 0 => Err(git2::Error::from_str(&format!("no patch found matching '{}'", prefix)).into()), 1 => Ok(matches.into_iter().next().unwrap()), _ => Err(git2::Error::from_str(&format!( "ambiguous patch prefix '{}': {} matches", prefix, matches.len() )) .into()), } resolve_ref(repo, "patches", "patch", prefix) } diff --git a/src/sync.rs b/src/sync.rs index dda19ef..69192e5 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -5,6 +5,7 @@ use git2::Repository; use crate::dag; use crate::error::Error; use crate::identity::get_author; use crate::signing; /// Add collab refspecs to all remotes. pub fn init(repo: &Repository) -> Result<(), Error> { @@ -50,8 +51,9 @@ pub fn sync(repo: &Repository, remote_name: &str) -> Result<(), Error> { // Step 2: Reconcile // Re-open repo to see the fetched refs (git2 caches ref state) let repo = Repository::open(repo.path())?; reconcile_refs(&repo, "issues", &author)?; reconcile_refs(&repo, "patches", &author)?; let sk = signing::load_signing_key(&signing::signing_key_dir()?)?; reconcile_refs(&repo, "issues", &author, &sk)?; reconcile_refs(&repo, "patches", &author, &sk)?; // Step 3: Push collab refs using system git println!("Pushing to '{}'...", remote_name); @@ -109,6 +111,7 @@ fn reconcile_refs( repo: &Repository, kind: &str, author: &crate::event::Author, signing_key: &ed25519_dalek::SigningKey, ) -> Result<(), Error> { let sync_prefix = format!("refs/collab/sync/{}/", kind); let sync_refs: Vec<(String, String)> = { @@ -123,9 +126,38 @@ fn reconcile_refs( }; for (remote_ref, id) in &sync_refs { // Verify all commits on the remote ref before reconciling match signing::verify_ref(repo, remote_ref) { Ok(results) => { let failures: Vec<_> = results .iter() .filter(|r| r.status != signing::VerifyStatus::Valid) .collect(); if !failures.is_empty() { for f in &failures { eprintln!( " Rejecting {} {:.8}: commit {} — {}", kind, id, f.commit_id, f.error.as_deref().unwrap_or("unknown error") ); } continue; } } Err(e) => { eprintln!( " Failed to verify {} {:.8}: {}", kind, id, e ); continue; } } let local_ref = format!("refs/collab/{}/{}", kind, id); if repo.refname_to_id(&local_ref).is_ok() { match dag::reconcile(repo, &local_ref, remote_ref, author) { match dag::reconcile(repo, &local_ref, remote_ref, author, signing_key) { Ok(_) => println!(" Reconciled {} {:.8}", kind, id), Err(e) => eprintln!(" Failed to reconcile {} {:.8}: {}", kind, id, e), } diff --git a/src/tui.rs b/src/tui.rs index 05d05dd..789777e 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -5,11 +5,12 @@ use std::time::Duration; use crossterm::event::{self, Event, KeyCode, KeyModifiers}; use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen}; use crossterm::ExecutableCommand; use git2::{DiffFormat, Repository}; use git2::Repository; use ratatui::prelude::*; use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Tabs, Wrap}; use crate::error::Error; use crate::patch as patch_mod; use crate::state::{self, IssueState, IssueStatus, PatchState, PatchStatus}; #[derive(PartialEq)] @@ -151,68 +152,6 @@ impl App { } } fn generate_diff(repo: &Repository, patch: &PatchState) -> String { let result = (|| -> Result<String, Error> { let head_obj = repo .revparse_single(&patch.head_commit) .map_err(|e| Error::Cmd(format!("bad head ref: {}", e)))?; let head_commit = head_obj .into_commit() .map_err(|_| Error::Cmd("head ref is not a commit".to_string()))?; let head_tree = head_commit.tree()?; let base_ref = format!("refs/heads/{}", patch.base_ref); let base_tree = if let Ok(base_oid) = repo.refname_to_id(&base_ref) { let base_commit = repo.find_commit(base_oid)?; Some(base_commit.tree()?) } else { None }; let diff = repo.diff_tree_to_tree(base_tree.as_ref(), Some(&head_tree), None)?; let mut output = String::new(); let mut lines = 0usize; diff.print(DiffFormat::Patch, |_delta, _hunk, line| { if lines >= 5000 { return false; } let prefix = match line.origin() { '+' => "+", '-' => "-", ' ' => " ", 'H' | 'F' => "", _ => "", }; if !prefix.is_empty() || matches!(line.origin(), 'H' | 'F') { output.push_str(prefix); } if let Ok(content) = std::str::from_utf8(line.content()) { output.push_str(content); } lines += 1; true })?; if lines >= 5000 { output.push_str("\n[truncated at 5000 lines]"); } Ok(output) })(); match result { Ok(diff) => { if diff.is_empty() { "No diff available (commits may be identical)".to_string() } else { diff } } Err(e) => format!("Diff unavailable: {}", e), } } pub fn run(repo: &Repository) -> Result<(), Error> { let issues = state::list_issues(repo)?; let patches = state::list_patches(repo)?; @@ -244,7 +183,13 @@ fn run_loop( if let Some(patch) = visible.get(idx) { if !app.diff_cache.contains_key(&patch.id) { let id = patch.id.clone(); let diff = generate_diff(repo, patch); let diff = match patch_mod::generate_diff(repo, patch) { Ok(d) if d.is_empty() => { "No diff available (commits may be identical)".to_string() } Ok(d) => d, Err(e) => format!("Diff unavailable: {}", e), }; app.diff_cache.insert(id, diff); } } diff --git a/tests/cli_test.rs b/tests/cli_test.rs new file mode 100644 index 0000000..eecd115 --- /dev/null +++ b/tests/cli_test.rs @@ -0,0 +1,865 @@ mod common; use common::TestRepo; // =========================================================================== // Issue commands // =========================================================================== #[test] fn test_issue_open_and_show() { let repo = TestRepo::new("Alice", "alice@example.com"); let out = repo.run_ok(&["issue", "open", "-t", "My first bug"]); assert!(out.starts_with("Opened issue ")); let id = out.trim().strip_prefix("Opened issue ").unwrap(); assert_eq!(id.len(), 8, "should print 8-char short ID"); let out = repo.run_ok(&["issue", "show", id]); assert!(out.contains("My first bug")); assert!(out.contains("[open]")); assert!(out.contains("Alice")); } #[test] fn test_issue_open_with_body() { let repo = TestRepo::new("Alice", "alice@example.com"); let out = repo.run_ok(&["issue", "open", "-t", "Bug", "-b", "Steps to reproduce..."]); let id = out.trim().strip_prefix("Opened issue ").unwrap(); let out = repo.run_ok(&["issue", "show", id]); assert!(out.contains("Steps to reproduce...")); } #[test] fn test_issue_list_filters_closed() { let repo = TestRepo::new("Alice", "alice@example.com"); repo.issue_open("Open bug"); let closed_id = repo.issue_open("Closed bug"); repo.run_ok(&["issue", "close", &closed_id]); let out = repo.run_ok(&["issue", "list"]); assert!(out.contains("Open bug")); assert!(!out.contains("Closed bug")); let out = repo.run_ok(&["issue", "list", "--all"]); assert!(out.contains("Open bug")); assert!(out.contains("Closed bug")); assert!(out.contains("closed")); } #[test] fn test_issue_comment() { let repo = TestRepo::new("Alice", "alice@example.com"); let id = repo.issue_open("Discussion"); let out = repo.run_ok(&["issue", "comment", &id, "-b", "First thought"]); assert!(out.contains("Comment added")); let out = repo.run_ok(&["issue", "show", &id]); assert!(out.contains("First thought")); assert!(out.contains("Comments")); } #[test] fn test_issue_close_with_reason() { let repo = TestRepo::new("Alice", "alice@example.com"); let id = repo.issue_open("Will close"); repo.run_ok(&["issue", "close", &id, "-r", "Duplicate of #42"]); let out = repo.run_ok(&["issue", "show", &id]); assert!(out.contains("[closed]")); assert!(out.contains("Duplicate of #42")); } #[test] fn test_issue_close_without_reason() { let repo = TestRepo::new("Alice", "alice@example.com"); let id = repo.issue_open("Close silently"); let out = repo.run_ok(&["issue", "close", &id]); assert!(out.contains("Issue closed")); let out = repo.run_ok(&["issue", "show", &id]); assert!(out.contains("[closed]")); } #[test] fn test_issue_reopen() { let repo = TestRepo::new("Alice", "alice@example.com"); let id = repo.issue_open("Reopen me"); repo.run_ok(&["issue", "close", &id]); let out = repo.run_ok(&["issue", "reopen", &id]); assert!(out.contains("Issue reopened")); let out = repo.run_ok(&["issue", "show", &id]); assert!(out.contains("[open]")); } #[test] fn test_issue_prefix_resolution() { let repo = TestRepo::new("Alice", "alice@example.com"); let id = repo.issue_open("Prefix test"); // Use first 4 chars as prefix let short = &id[..4]; let out = repo.run_ok(&["issue", "show", short]); assert!(out.contains("Prefix test")); } #[test] fn test_issue_nonexistent_id() { let repo = TestRepo::new("Alice", "alice@example.com"); let err = repo.run_err(&["issue", "show", "deadbeef"]); assert!(err.contains("no issue found")); } #[test] fn test_issue_list_empty() { let repo = TestRepo::new("Alice", "alice@example.com"); let out = repo.run_ok(&["issue", "list"]); assert!(out.contains("No issues found")); } #[test] fn test_multiple_issues_independent() { let repo = TestRepo::new("Alice", "alice@example.com"); let id1 = repo.issue_open("Issue one"); let id2 = repo.issue_open("Issue two"); repo.run_ok(&["issue", "comment", &id1, "-b", "Comment on one"]); repo.run_ok(&["issue", "close", &id2]); let out = repo.run_ok(&["issue", "show", &id1]); assert!(out.contains("[open]")); assert!(out.contains("Comment on one")); let out = repo.run_ok(&["issue", "show", &id2]); assert!(out.contains("[closed]")); assert!(!out.contains("Comment on one")); } // =========================================================================== // Issue edit // =========================================================================== #[test] fn test_issue_edit_title() { let repo = TestRepo::new("Alice", "alice@example.com"); let id = repo.issue_open("Old title"); let out = repo.run_ok(&["issue", "edit", &id, "-t", "New title"]); assert!(out.contains("Issue updated")); let out = repo.run_ok(&["issue", "show", &id]); assert!(out.contains("New title")); assert!(!out.contains("Old title")); } #[test] fn test_issue_edit_body() { let repo = TestRepo::new("Alice", "alice@example.com"); let out = repo.run_ok(&["issue", "open", "-t", "Bug", "-b", "Old body"]); let id = out.trim().strip_prefix("Opened issue ").unwrap(); repo.run_ok(&["issue", "edit", id, "-b", "New body with details"]); let out = repo.run_ok(&["issue", "show", id]); assert!(out.contains("New body with details")); assert!(!out.contains("Old body")); } #[test] fn test_issue_edit_title_and_body() { let repo = TestRepo::new("Alice", "alice@example.com"); let out = repo.run_ok(&["issue", "open", "-t", "Original", "-b", "Original body"]); let id = out.trim().strip_prefix("Opened issue ").unwrap(); repo.run_ok(&["issue", "edit", id, "-t", "Updated", "-b", "Updated body"]); let out = repo.run_ok(&["issue", "show", id]); assert!(out.contains("Updated")); assert!(out.contains("Updated body")); } #[test] fn test_issue_edit_preserves_comments() { let repo = TestRepo::new("Alice", "alice@example.com"); let id = repo.issue_open("Will edit"); repo.run_ok(&["issue", "comment", &id, "-b", "A comment before edit"]); repo.run_ok(&["issue", "edit", &id, "-t", "Edited title"]); let out = repo.run_ok(&["issue", "show", &id]); assert!(out.contains("Edited title")); assert!(out.contains("A comment before edit")); } #[test] fn test_issue_edit_requires_title_or_body() { let repo = TestRepo::new("Alice", "alice@example.com"); let id = repo.issue_open("No change"); let err = repo.run_err(&["issue", "edit", &id]); assert!( err.contains("--title") || err.contains("--body") || err.contains("at least one"), "should require at least --title or --body, got: {}", err ); } // =========================================================================== // Issue labels // =========================================================================== #[test] fn test_issue_label_and_show() { let repo = TestRepo::new("Alice", "alice@example.com"); let id = repo.issue_open("Labeled issue"); let out = repo.run_ok(&["issue", "label", &id, "bug"]); assert!(out.contains("Label") && out.contains("added")); let out = repo.run_ok(&["issue", "show", &id]); assert!(out.contains("bug")); } #[test] fn test_issue_multiple_labels() { let repo = TestRepo::new("Alice", "alice@example.com"); let id = repo.issue_open("Multi-label issue"); repo.run_ok(&["issue", "label", &id, "bug"]); repo.run_ok(&["issue", "label", &id, "priority"]); let out = repo.run_ok(&["issue", "show", &id]); assert!(out.contains("bug")); assert!(out.contains("priority")); } #[test] fn test_issue_unlabel() { let repo = TestRepo::new("Alice", "alice@example.com"); let id = repo.issue_open("Remove label"); repo.run_ok(&["issue", "label", &id, "bug"]); repo.run_ok(&["issue", "label", &id, "wontfix"]); repo.run_ok(&["issue", "unlabel", &id, "bug"]); let out = repo.run_ok(&["issue", "show", &id]); assert!(!out.contains("bug")); assert!(out.contains("wontfix")); } #[test] fn test_issue_label_shown_in_list() { let repo = TestRepo::new("Alice", "alice@example.com"); let id = repo.issue_open("Listed with label"); repo.run_ok(&["issue", "label", &id, "enhancement"]); let out = repo.run_ok(&["issue", "list"]); assert!(out.contains("enhancement")); } // =========================================================================== // Issue assignees // =========================================================================== #[test] fn test_issue_assign_and_show() { let repo = TestRepo::new("Alice", "alice@example.com"); let id = repo.issue_open("Assigned issue"); let out = repo.run_ok(&["issue", "assign", &id, "Bob"]); assert!(out.contains("Assigned")); let out = repo.run_ok(&["issue", "show", &id]); assert!(out.contains("Bob")); assert!(out.contains("Assignee")); } #[test] fn test_issue_unassign() { let repo = TestRepo::new("Alice", "alice@example.com"); let id = repo.issue_open("Unassign test"); repo.run_ok(&["issue", "assign", &id, "Bob"]); repo.run_ok(&["issue", "unassign", &id, "Bob"]); let out = repo.run_ok(&["issue", "show", &id]); assert!(!out.contains("Bob") || !out.contains("Assignee")); } // =========================================================================== // Patch commands // =========================================================================== #[test] fn test_patch_create_and_show() { let repo = TestRepo::new("Alice", "alice@example.com"); repo.commit_file("hello.txt", "hello world", "add hello"); let id = repo.patch_create("Add hello"); let out = repo.run_ok(&["patch", "show", &id]); assert!(out.contains("Add hello")); assert!(out.contains("[open]")); assert!(out.contains("Alice")); } #[test] fn test_patch_list_filters_by_status() { let repo = TestRepo::new("Alice", "alice@example.com"); repo.patch_create("Open patch"); let closed_id = repo.patch_create("Closed patch"); repo.run_ok(&["patch", "close", &closed_id]); let out = repo.run_ok(&["patch", "list"]); assert!(out.contains("Open patch")); assert!(!out.contains("Closed patch")); let out = repo.run_ok(&["patch", "list", "--all"]); assert!(out.contains("Open patch")); assert!(out.contains("Closed patch")); } #[test] fn test_patch_comment() { let repo = TestRepo::new("Alice", "alice@example.com"); let id = repo.patch_create("Review me"); let out = repo.run_ok(&["patch", "comment", &id, "-b", "Looks good overall"]); assert!(out.contains("Comment added")); let out = repo.run_ok(&["patch", "show", &id]); assert!(out.contains("Looks good overall")); assert!(out.contains("Comments")); } #[test] fn test_patch_inline_comment() { let repo = TestRepo::new("Alice", "alice@example.com"); let id = repo.patch_create("Review me"); repo.run_ok(&[ "patch", "comment", &id, "-b", "Use const here", "-f", "src/main.rs", "-l", "42", ]); let out = repo.run_ok(&["patch", "show", &id]); assert!(out.contains("Use const here")); assert!(out.contains("src/main.rs")); assert!(out.contains("42")); assert!(out.contains("Inline Comments")); } #[test] fn test_patch_inline_comment_requires_both_file_and_line() { let repo = TestRepo::new("Alice", "alice@example.com"); let id = repo.patch_create("Review me"); // --file without --line let err = repo.run_err(&["patch", "comment", &id, "-b", "nope", "-f", "foo.rs"]); assert!(err.contains("--file and --line must both be provided")); // --line without --file let err = repo.run_err(&["patch", "comment", &id, "-b", "nope", "-l", "10"]); assert!(err.contains("--file and --line must both be provided")); } #[test] fn test_patch_review_approve() { let repo = TestRepo::new("Alice", "alice@example.com"); let id = repo.patch_create("Feature X"); let out = repo.run_ok(&["patch", "review", &id, "-v", "approve", "-b", "LGTM!"]); assert!(out.contains("Review submitted")); let out = repo.run_ok(&["patch", "show", &id]); assert!(out.contains("LGTM!")); assert!(out.contains("Approve")); assert!(out.contains("Reviews")); } #[test] fn test_patch_review_request_changes() { let repo = TestRepo::new("Alice", "alice@example.com"); let id = repo.patch_create("Feature Y"); repo.run_ok(&[ "patch", "review", &id, "-v", "request-changes", "-b", "Needs error handling", ]); let out = repo.run_ok(&["patch", "show", &id]); assert!(out.contains("RequestChanges")); assert!(out.contains("Needs error handling")); } #[test] fn test_patch_review_invalid_verdict() { let repo = TestRepo::new("Alice", "alice@example.com"); let id = repo.patch_create("Feature Z"); let err = repo.run_err(&["patch", "review", &id, "-v", "yolo", "-b", "whatever"]); assert!(err.contains("verdict must be")); } #[test] fn test_patch_revise() { let repo = TestRepo::new("Alice", "alice@example.com"); let id = repo.patch_create("WIP feature"); let new_head = repo.commit_file("v2.txt", "v2", "version 2"); let out = repo.run_ok(&[ "patch", "revise", &id, "--head", &new_head, "-b", "Updated implementation", ]); assert!(out.contains("Patch revised")); let out = repo.run_ok(&["patch", "show", &id]); assert!(out.contains(&new_head[..8])); assert!(out.contains("Updated implementation")); } #[test] fn test_patch_diff() { let repo = TestRepo::new("Alice", "alice@example.com"); // Create a feature branch with a new file repo.git(&["checkout", "-b", "feature"]); repo.commit_file( "hello.rs", "fn main() {\n println!(\"hello\");\n}\n", "add hello", ); let head = repo.git(&["rev-parse", "HEAD"]).trim().to_string(); repo.git(&["checkout", "main"]); let out = repo.run_ok(&["patch", "create", "-t", "Add hello", "--head", &head]); let id = out.trim().strip_prefix("Created patch ").unwrap(); let out = repo.run_ok(&["patch", "diff", id]); assert!(out.contains("hello.rs"), "should show filename"); assert!(out.contains("fn main()"), "should show added code"); assert!(out.contains("+"), "should show + for additions"); } #[test] fn test_patch_diff_no_changes() { let repo = TestRepo::new("Alice", "alice@example.com"); // Patch pointing at same commit as main — no diff let id = repo.patch_create("No diff patch"); let out = repo.run_ok(&["patch", "diff", &id]); assert!( out.contains("No diff") || out.contains("identical"), "should indicate no diff, got: {}", out ); } #[test] fn test_patch_close() { let repo = TestRepo::new("Alice", "alice@example.com"); let id = repo.patch_create("Will close"); let out = repo.run_ok(&["patch", "close", &id, "-r", "Superseded"]); assert!(out.contains("Patch closed")); let out = repo.run_ok(&["patch", "show", &id]); assert!(out.contains("[closed]")); } #[test] fn test_patch_merge_fast_forward() { let repo = TestRepo::new("Alice", "alice@example.com"); // Create a feature branch ahead of main repo.git(&["checkout", "-b", "feature"]); let head = repo.commit_file("feature.txt", "new feature", "add feature"); repo.git(&["checkout", "main"]); // Create patch pointing at the feature commit let out = repo.run_ok(&["patch", "create", "-t", "Add feature", "--head", &head]); let id = out.trim().strip_prefix("Created patch ").unwrap(); let out = repo.run_ok(&["patch", "merge", id]); assert!(out.contains("merged into main")); // Main should now point at the feature commit let main_head = repo.git(&["rev-parse", "main"]).trim().to_string(); assert_eq!(main_head, head); let out = repo.run_ok(&["patch", "show", id]); assert!(out.contains("[merged]")); } #[test] fn test_patch_cannot_merge_closed() { let repo = TestRepo::new("Alice", "alice@example.com"); repo.git(&["checkout", "-b", "feature"]); let head = repo.commit_file("f.txt", "f", "feature commit"); repo.git(&["checkout", "main"]); let out = repo.run_ok(&["patch", "create", "-t", "Will close", "--head", &head]); let id = out.trim().strip_prefix("Created patch ").unwrap(); repo.run_ok(&["patch", "close", id]); let err = repo.run_err(&["patch", "merge", id]); assert!(err.contains("can only merge open patches")); } #[test] fn test_patch_list_empty() { let repo = TestRepo::new("Alice", "alice@example.com"); let out = repo.run_ok(&["patch", "list"]); assert!(out.contains("No patches found")); } // =========================================================================== // Cross-references (patch --fixes issue) // =========================================================================== #[test] fn test_patch_create_with_fixes() { let repo = TestRepo::new("Alice", "alice@example.com"); let issue_id = repo.issue_open("Login bug"); repo.git(&["checkout", "-b", "fix"]); let head = repo.commit_file("fix.rs", "fixed", "fix login"); repo.git(&["checkout", "main"]); let out = repo.run_ok(&[ "patch", "create", "-t", "Fix login bug", "--head", &head, "--fixes", &issue_id, ]); let patch_id = out.trim().strip_prefix("Created patch ").unwrap(); // Patch show should mention the linked issue let out = repo.run_ok(&["patch", "show", patch_id]); assert!(out.contains("Fixes"), "should show Fixes field"); assert!(out.contains(&issue_id[..8]), "should show linked issue ID"); } #[test] fn test_patch_merge_auto_closes_linked_issue() { let repo = TestRepo::new("Alice", "alice@example.com"); let issue_id = repo.issue_open("Crash on startup"); repo.git(&["checkout", "-b", "fix"]); let head = repo.commit_file("fix.rs", "fixed", "fix crash"); repo.git(&["checkout", "main"]); let out = repo.run_ok(&[ "patch", "create", "-t", "Fix crash", "--head", &head, "--fixes", &issue_id, ]); let patch_id = out.trim().strip_prefix("Created patch ").unwrap(); repo.run_ok(&["patch", "merge", patch_id]); // Issue should now be closed let out = repo.run_ok(&["issue", "show", &issue_id]); assert!(out.contains("[closed]"), "linked issue should be auto-closed on merge"); } // =========================================================================== // Unread tracking // =========================================================================== #[test] fn test_issue_list_shows_unread_after_new_comments() { let repo = TestRepo::new("Alice", "alice@example.com"); let id = repo.issue_open("Unread test"); // View the issue to mark it as read repo.run_ok(&["issue", "show", &id]); // Add comments after viewing repo.run_ok(&["issue", "comment", &id, "-b", "New comment 1"]); repo.run_ok(&["issue", "comment", &id, "-b", "New comment 2"]); // List should show unread count let out = repo.run_ok(&["issue", "list"]); assert!( out.contains("2 new"), "should show 2 new events, got: {}", out ); } #[test] fn test_issue_show_marks_as_read() { let repo = TestRepo::new("Alice", "alice@example.com"); let id = repo.issue_open("Read test"); repo.run_ok(&["issue", "comment", &id, "-b", "A comment"]); // View to mark as read repo.run_ok(&["issue", "show", &id]); // List should show no unread let out = repo.run_ok(&["issue", "list"]); assert!( !out.contains("new"), "should not show 'new' after viewing, got: {}", out ); } #[test] fn test_issue_list_no_unread_for_never_viewed() { let repo = TestRepo::new("Alice", "alice@example.com"); // A brand new issue that's never been shown should not show "new" // (only show unread count after the user has viewed it once) repo.issue_open("Fresh issue"); let out = repo.run_ok(&["issue", "list"]); assert!( !out.contains("new"), "never-viewed issue should not show unread count, got: {}", out ); } // =========================================================================== // Init command // =========================================================================== #[test] fn test_init_no_remotes() { let repo = TestRepo::new("Alice", "alice@example.com"); let out = repo.run_ok(&["init"]); assert!(out.contains("No remotes")); } // =========================================================================== // Full scenario tests // =========================================================================== #[test] fn test_full_issue_lifecycle() { let repo = TestRepo::new("Alice", "alice@example.com"); // Open let id = repo.issue_open("Login page crashes"); // Multiple comments repo.run_ok(&["issue", "comment", &id, "-b", "Stack trace attached"]); repo.run_ok(&["issue", "comment", &id, "-b", "Reproduced on Chrome"]); let out = repo.run_ok(&["issue", "show", &id]); assert!(out.contains("Stack trace attached")); assert!(out.contains("Reproduced on Chrome")); // Close with reason repo.run_ok(&["issue", "close", &id, "-r", "Fixed in commit abc123"]); let out = repo.run_ok(&["issue", "show", &id]); assert!(out.contains("[closed]")); assert!(out.contains("Fixed in commit abc123")); // Reopen repo.run_ok(&["issue", "reopen", &id]); let out = repo.run_ok(&["issue", "show", &id]); assert!(out.contains("[open]")); // Close again (no reason) repo.run_ok(&["issue", "close", &id]); let out = repo.run_ok(&["issue", "list"]); assert!(!out.contains("Login page crashes") || out.contains("No issues")); } #[test] fn test_full_patch_review_cycle() { let repo = TestRepo::new("Alice", "alice@example.com"); // Create feature branch with v1 repo.git(&["checkout", "-b", "feature"]); let v1 = repo.commit_file("feature.rs", "fn hello() {}", "v1 of feature"); repo.git(&["checkout", "main"]); let out = repo.run_ok(&["patch", "create", "-t", "Add hello function", "--head", &v1]); let id = out .trim() .strip_prefix("Created patch ") .unwrap() .to_string(); // Review: request changes repo.run_ok(&[ "patch", "review", &id, "-v", "request-changes", "-b", "Add documentation", ]); // Inline comment on the code repo.run_ok(&[ "patch", "comment", &id, "-b", "Missing doc comment", "-f", "feature.rs", "-l", "1", ]); // General comment repo.run_ok(&["patch", "comment", &id, "-b", "Otherwise looks good"]); // Revise with updated code repo.git(&["checkout", "feature"]); let v2 = repo.commit_file( "feature.rs", "/// Says hello\nfn hello() {}", "v2: add docs", ); repo.git(&["checkout", "main"]); repo.run_ok(&[ "patch", "revise", &id, "--head", &v2, "-b", "Added documentation", ]); // Approve repo.run_ok(&["patch", "review", &id, "-v", "approve", "-b", "LGTM now"]); // Merge repo.run_ok(&["patch", "merge", &id]); // Verify final state let out = repo.run_ok(&["patch", "show", &id]); assert!(out.contains("[merged]")); assert!(out.contains("Added documentation")); assert!(out.contains("LGTM now")); assert!(out.contains("RequestChanges")); assert!(out.contains("Approve")); assert!(out.contains("Missing doc comment")); assert!(out.contains("Otherwise looks good")); assert!(out.contains("Inline Comments")); assert!(out.contains("feature.rs")); } // =========================================================================== // T014: init-key CLI command // =========================================================================== #[test] fn test_init_key_creates_key_files() { // Use a custom HOME so we don't clobber real keys let tmp_home = tempfile::TempDir::new().unwrap(); let config_dir = tmp_home.path().join(".config").join("git-collab"); // Create a repo for the CLI to run in let repo_dir = tempfile::TempDir::new().unwrap(); common::git_cmd(repo_dir.path(), &["init", "-b", "main"]); common::git_cmd(repo_dir.path(), &["config", "user.name", "Alice"]); common::git_cmd(repo_dir.path(), &["config", "user.email", "alice@example.com"]); common::git_cmd(repo_dir.path(), &["commit", "--allow-empty", "-m", "init"]); // Run init-key with overridden HOME let output = std::process::Command::new(env!("CARGO_BIN_EXE_git-collab")) .args(["init-key"]) .current_dir(repo_dir.path()) .env("HOME", tmp_home.path()) .env("XDG_CONFIG_HOME", tmp_home.path().join(".config")) .output() .expect("failed to run git-collab"); let stdout = String::from_utf8(output.stdout).unwrap(); let stderr = String::from_utf8(output.stderr).unwrap(); assert!( output.status.success(), "init-key should succeed: stdout={}, stderr={}", stdout, stderr ); assert!(stdout.contains("Signing key generated"), "should print success message"); assert!(stdout.contains("Public key:"), "should print public key"); // Key files should exist assert!(config_dir.join("signing-key").exists(), "private key file should exist"); assert!(config_dir.join("signing-key.pub").exists(), "public key file should exist"); // Run init-key again without --force: should fail let output = std::process::Command::new(env!("CARGO_BIN_EXE_git-collab")) .args(["init-key"]) .current_dir(repo_dir.path()) .env("HOME", tmp_home.path()) .env("XDG_CONFIG_HOME", tmp_home.path().join(".config")) .output() .expect("failed to run git-collab"); assert!( !output.status.success(), "init-key without --force should fail when key exists" ); let stderr = String::from_utf8(output.stderr).unwrap(); assert!( stderr.contains("already exists") || stderr.contains("--force"), "error should mention --force, got: {}", stderr ); // Run init-key with --force: should succeed let output = std::process::Command::new(env!("CARGO_BIN_EXE_git-collab")) .args(["init-key", "--force"]) .current_dir(repo_dir.path()) .env("HOME", tmp_home.path()) .env("XDG_CONFIG_HOME", tmp_home.path().join(".config")) .output() .expect("failed to run git-collab"); let stdout = String::from_utf8(output.stdout).unwrap(); assert!( output.status.success(), "init-key --force should succeed: {}", String::from_utf8_lossy(&output.stderr) ); assert!(stdout.contains("Signing key generated")); } diff --git a/tests/collab_test.rs b/tests/collab_test.rs index f19b6a9..41453f5 100644 --- a/tests/collab_test.rs +++ b/tests/collab_test.rs @@ -1,92 +1,17 @@ use git2::Repository; use std::path::Path; mod common; use tempfile::TempDir; use git_collab::dag; use git_collab::error::Error; use git_collab::event::{Action, Author, Event, ReviewVerdict}; use git_collab::signing::{self, SignedEvent, VerifyStatus}; use git_collab::state::{self, IssueState, IssueStatus, PatchState, PatchStatus}; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- fn alice() -> Author { Author { name: "Alice".to_string(), email: "alice@example.com".to_string(), } } fn bob() -> Author { Author { name: "Bob".to_string(), email: "bob@example.com".to_string(), } } fn now() -> String { chrono::Utc::now().to_rfc3339() } /// Create a bare repo in a tempdir and configure user identity. fn init_repo(dir: &Path, author: &Author) -> Repository { let repo = Repository::init(dir).expect("init repo"); { let mut config = repo.config().unwrap(); config.set_str("user.name", &author.name).unwrap(); config.set_str("user.email", &author.email).unwrap(); } repo } /// Open an issue directly using DAG primitives (for fine-grained control in tests). fn open_issue(repo: &Repository, author: &Author, title: &str) -> (String, String) { let event = Event { timestamp: now(), author: author.clone(), action: Action::IssueOpen { title: title.to_string(), body: "".to_string(), }, }; let oid = dag::create_root_event(repo, &event).unwrap(); let id = oid.to_string(); let ref_name = format!("refs/collab/issues/{}", id); repo.reference(&ref_name, oid, false, "test open").unwrap(); (ref_name, id) } /// Append a comment event to a ref. fn add_comment(repo: &Repository, ref_name: &str, author: &Author, body: &str) { let event = Event { timestamp: now(), author: author.clone(), action: Action::IssueComment { body: body.to_string(), }, }; dag::append_event(repo, ref_name, &event).unwrap(); } /// Append a close event to a ref. fn close_issue(repo: &Repository, ref_name: &str, author: &Author) { let event = Event { timestamp: now(), author: author.clone(), action: Action::IssueClose { reason: None }, }; dag::append_event(repo, ref_name, &event).unwrap(); } /// Append a reopen event to a ref. fn reopen_issue(repo: &Repository, ref_name: &str, author: &Author) { let event = Event { timestamp: now(), author: author.clone(), action: Action::IssueReopen, }; dag::append_event(repo, ref_name, &event).unwrap(); } use common::{ add_comment, add_review, alice, bob, close_issue, create_patch, init_repo, now, open_issue, reopen_issue, setup_signing_key, test_signing_key, }; // --------------------------------------------------------------------------- // Basic DAG tests @@ -152,74 +77,101 @@ fn test_list_issues_filters_by_status() { } // --------------------------------------------------------------------------- // Issue edit via DAG // --------------------------------------------------------------------------- #[test] fn test_issue_edit_updates_title_and_body() { let tmp = TempDir::new().unwrap(); let repo = init_repo(tmp.path(), &alice()); let (ref_name, id) = open_issue(&repo, &alice(), "Original title"); let sk = test_signing_key(); let event = Event { timestamp: now(), author: alice(), action: Action::IssueEdit { title: Some("New title".to_string()), body: Some("New body".to_string()), }, }; dag::append_event(&repo, &ref_name, &event, &sk).unwrap(); let state = IssueState::from_ref(&repo, &ref_name, &id).unwrap(); assert_eq!(state.title, "New title"); assert_eq!(state.body, "New body"); } #[test] fn test_issue_edit_partial_update() { let tmp = TempDir::new().unwrap(); let repo = init_repo(tmp.path(), &alice()); let (ref_name, id) = open_issue(&repo, &alice(), "Keep this title"); // Edit only body let sk = test_signing_key(); let event = Event { timestamp: now(), author: alice(), action: Action::IssueEdit { title: None, body: Some("Added body".to_string()), }, }; dag::append_event(&repo, &ref_name, &event, &sk).unwrap(); let state = IssueState::from_ref(&repo, &ref_name, &id).unwrap(); assert_eq!(state.title, "Keep this title"); assert_eq!(state.body, "Added body"); } // --------------------------------------------------------------------------- // Multi-user collaboration: concurrent edits → forked DAG → reconcile // --------------------------------------------------------------------------- #[test] fn test_concurrent_comments_create_fork_and_reconcile() { // Simulate two users commenting on the same issue concurrently. // // Starting state: // commit A (IssueOpen) // // Alice adds comment → commit B (parent: A) // Bob adds comment → commit C (parent: A) ← fork! // // Reconciliation creates merge commit M (parents: B, C) // State replay should see all 3 events (open + 2 comments) let tmp = TempDir::new().unwrap(); let repo = init_repo(tmp.path(), &alice()); // Create the issue (commit A) let (ref_name, id) = open_issue(&repo, &alice(), "Concurrent test"); let root_oid = repo.refname_to_id(&ref_name).unwrap(); // Alice comments (commit B) — advances the ref add_comment(&repo, &ref_name, &alice(), "Alice's comment"); let alice_tip = repo.refname_to_id(&ref_name).unwrap(); // Simulate Bob's concurrent comment: reset ref back to root, then append repo.reference(&ref_name, root_oid, true, "simulate bob fork") .unwrap(); add_comment(&repo, &ref_name, &bob(), "Bob's comment"); let bob_tip = repo.refname_to_id(&ref_name).unwrap(); // Now we have two tips diverged from root_oid. // Put Bob's tip in a "remote" ref so we can reconcile. let remote_ref = format!("refs/collab/sync/origin/issues/{}", id); repo.reference(&remote_ref, bob_tip, true, "remote tip") .unwrap(); // Reset local ref to Alice's tip repo.reference(&ref_name, alice_tip, true, "restore alice tip") .unwrap(); // Reconcile let merge_oid = dag::reconcile(&repo, &ref_name, &remote_ref, &alice()).unwrap(); let merge_oid = dag::reconcile(&repo, &ref_name, &remote_ref, &alice(), &test_signing_key()).unwrap(); // Verify merge commit has 2 parents let merge_commit = repo.find_commit(merge_oid).unwrap(); assert_eq!(merge_commit.parent_count(), 2); // Walk events — should see open + both comments + merge let events = dag::walk_events(&repo, &ref_name).unwrap(); let actions: Vec<_> = events.iter().map(|(_, e)| &e.action).collect(); // Must have the open event assert!(actions .iter() .any(|a| matches!(a, Action::IssueOpen { .. }))); // Must have both comments let comments: Vec<_> = actions .iter() .filter(|a| matches!(a, Action::IssueComment { .. })) .collect(); assert_eq!(comments.len(), 2, "both concurrent comments should appear"); // Must have the merge assert!(actions.iter().any(|a| matches!(a, Action::Merge))); // State should show both comments let state = IssueState::from_ref(&repo, &ref_name, &id).unwrap(); assert_eq!(state.comments.len(), 2); assert_eq!(state.status, IssueStatus::Open); @@ -227,61 +179,46 @@ fn test_concurrent_comments_create_fork_and_reconcile() { #[test] fn test_concurrent_close_and_comment() { // Alice closes the issue while Bob comments on it concurrently. // After reconciliation, the issue should be closed (last event wins by topo order) // and Bob's comment should still be visible. let tmp = TempDir::new().unwrap(); let repo = init_repo(tmp.path(), &alice()); let (ref_name, id) = open_issue(&repo, &alice(), "Close vs comment"); let root_oid = repo.refname_to_id(&ref_name).unwrap(); // Alice closes close_issue(&repo, &ref_name, &alice()); let alice_tip = repo.refname_to_id(&ref_name).unwrap(); // Bob comments (from root) repo.reference(&ref_name, root_oid, true, "bob fork") .unwrap(); add_comment(&repo, &ref_name, &bob(), "Wait, I have thoughts"); let bob_tip = repo.refname_to_id(&ref_name).unwrap(); // Set up for reconciliation let remote_ref = format!("refs/collab/sync/origin/issues/{}", id); repo.reference(&remote_ref, bob_tip, true, "remote") .unwrap(); repo.reference(&ref_name, alice_tip, true, "restore") .unwrap(); dag::reconcile(&repo, &ref_name, &remote_ref, &alice()).unwrap(); dag::reconcile(&repo, &ref_name, &remote_ref, &alice(), &test_signing_key()).unwrap(); let state = IssueState::from_ref(&repo, &ref_name, &id).unwrap(); // Both the close and comment should be in the DAG assert_eq!(state.comments.len(), 1); // The close event should have taken effect assert_eq!(state.status, IssueStatus::Closed); } #[test] fn test_concurrent_close_and_reopen() { // Alice closes while Bob reopens from a previously-closed state. // This tests conflicting status transitions. let tmp = TempDir::new().unwrap(); let repo = init_repo(tmp.path(), &alice()); let (ref_name, id) = open_issue(&repo, &alice(), "Status conflict"); // First, close the issue so both users start from "closed" close_issue(&repo, &ref_name, &alice()); let closed_oid = repo.refname_to_id(&ref_name).unwrap(); // Alice comments on the closed issue add_comment(&repo, &ref_name, &alice(), "Staying closed"); let alice_tip = repo.refname_to_id(&ref_name).unwrap(); // Bob reopens from the closed state repo.reference(&ref_name, closed_oid, true, "bob fork") .unwrap(); reopen_issue(&repo, &ref_name, &bob()); @@ -293,11 +230,9 @@ fn test_concurrent_close_and_reopen() { repo.reference(&ref_name, alice_tip, true, "restore") .unwrap(); dag::reconcile(&repo, &ref_name, &remote_ref, &alice()).unwrap(); dag::reconcile(&repo, &ref_name, &remote_ref, &alice(), &test_signing_key()).unwrap(); let _state = IssueState::from_ref(&repo, &ref_name, &id).unwrap(); // Both branches are replayed — the final status depends on topo order. // The important thing is that we don't crash and both events are present. let events = dag::walk_events(&repo, &ref_name).unwrap(); let has_close = events .iter() @@ -311,30 +246,24 @@ fn test_concurrent_close_and_reopen() { #[test] fn test_fast_forward_reconcile() { // If local is behind remote (remote has strictly more events), reconcile // should fast-forward without creating a merge commit. let tmp = TempDir::new().unwrap(); let repo = init_repo(tmp.path(), &alice()); let (ref_name, id) = open_issue(&repo, &alice(), "FF test"); let root_oid = repo.refname_to_id(&ref_name).unwrap(); // Add comment (advances ref) add_comment(&repo, &ref_name, &alice(), "Extra comment"); let ahead_tip = repo.refname_to_id(&ref_name).unwrap(); // Simulate: local is at root, remote is at ahead_tip repo.reference(&ref_name, root_oid, true, "reset local") .unwrap(); let remote_ref = format!("refs/collab/sync/origin/issues/{}", id); repo.reference(&remote_ref, ahead_tip, true, "remote ahead") .unwrap(); let result = dag::reconcile(&repo, &ref_name, &remote_ref, &alice()).unwrap(); let result = dag::reconcile(&repo, &ref_name, &remote_ref, &alice(), &test_signing_key()).unwrap(); assert_eq!(result, ahead_tip, "should fast-forward to remote tip"); // No merge commit — walk should have exactly 2 events let events = dag::walk_events(&repo, &ref_name).unwrap(); assert_eq!(events.len(), 2); assert!(!events @@ -353,13 +282,12 @@ fn test_no_op_when_already_in_sync() { let remote_ref = format!("refs/collab/sync/origin/issues/{}", id); repo.reference(&remote_ref, tip, true, "same tip").unwrap(); let result = dag::reconcile(&repo, &ref_name, &remote_ref, &alice()).unwrap(); let result = dag::reconcile(&repo, &ref_name, &remote_ref, &alice(), &test_signing_key()).unwrap(); assert_eq!(result, tip); } #[test] fn test_local_ahead_no_merge() { // If local has more events than remote, reconcile should be a no-op. let tmp = TempDir::new().unwrap(); let repo = init_repo(tmp.path(), &alice()); @@ -373,7 +301,7 @@ fn test_local_ahead_no_merge() { repo.reference(&remote_ref, root_oid, true, "remote behind") .unwrap(); let result = dag::reconcile(&repo, &ref_name, &remote_ref, &alice()).unwrap(); let result = dag::reconcile(&repo, &ref_name, &remote_ref, &alice(), &test_signing_key()).unwrap(); assert_eq!(result, local_tip, "local should stay ahead"); } @@ -381,36 +309,6 @@ fn test_local_ahead_no_merge() { // Patch collaboration tests // --------------------------------------------------------------------------- fn create_patch(repo: &Repository, author: &Author, title: &str) -> (String, String) { let event = Event { timestamp: now(), author: author.clone(), action: Action::PatchCreate { title: title.to_string(), body: "".to_string(), base_ref: "main".to_string(), head_commit: "abc123".to_string(), }, }; let oid = dag::create_root_event(repo, &event).unwrap(); let id = oid.to_string(); let ref_name = format!("refs/collab/patches/{}", id); repo.reference(&ref_name, oid, false, "test patch").unwrap(); (ref_name, id) } fn add_review(repo: &Repository, ref_name: &str, author: &Author, verdict: ReviewVerdict) { let event = Event { timestamp: now(), author: author.clone(), action: Action::PatchReview { verdict, body: "review comment".to_string(), }, }; dag::append_event(repo, ref_name, &event).unwrap(); } #[test] fn test_patch_review_workflow() { let tmp = TempDir::new().unwrap(); @@ -418,14 +316,13 @@ fn test_patch_review_workflow() { let (ref_name, id) = create_patch(&repo, &alice(), "Add feature X"); // Bob reviews add_review(&repo, &ref_name, &bob(), ReviewVerdict::RequestChanges); let state = PatchState::from_ref(&repo, &ref_name, &id).unwrap(); assert_eq!(state.reviews.len(), 1); assert_eq!(state.reviews[0].verdict, ReviewVerdict::RequestChanges); // Alice revises let sk = test_signing_key(); let event = Event { timestamp: now(), author: alice(), @@ -434,9 +331,8 @@ fn test_patch_review_workflow() { head_commit: "def456".to_string(), }, }; dag::append_event(&repo, &ref_name, &event).unwrap(); dag::append_event(&repo, &ref_name, &event, &sk).unwrap(); // Bob approves add_review(&repo, &ref_name, &bob(), ReviewVerdict::Approve); let state = PatchState::from_ref(&repo, &ref_name, &id).unwrap(); @@ -447,31 +343,27 @@ fn test_patch_review_workflow() { #[test] fn test_concurrent_reviews_on_patch() { // Alice and Bob both review the same patch concurrently. let tmp = TempDir::new().unwrap(); let repo = init_repo(tmp.path(), &alice()); let (ref_name, id) = create_patch(&repo, &alice(), "Concurrent review"); let root_oid = repo.refname_to_id(&ref_name).unwrap(); // Alice approves add_review(&repo, &ref_name, &alice(), ReviewVerdict::Approve); let alice_tip = repo.refname_to_id(&ref_name).unwrap(); // Bob requests changes (from root) repo.reference(&ref_name, root_oid, true, "bob fork") .unwrap(); add_review(&repo, &ref_name, &bob(), ReviewVerdict::RequestChanges); let bob_tip = repo.refname_to_id(&ref_name).unwrap(); // Reconcile let remote_ref = format!("refs/collab/sync/origin/patches/{}", id); repo.reference(&remote_ref, bob_tip, true, "remote") .unwrap(); repo.reference(&ref_name, alice_tip, true, "restore") .unwrap(); dag::reconcile(&repo, &ref_name, &remote_ref, &alice()).unwrap(); dag::reconcile(&repo, &ref_name, &remote_ref, &alice(), &test_signing_key()).unwrap(); let state = PatchState::from_ref(&repo, &ref_name, &id).unwrap(); assert_eq!(state.reviews.len(), 2, "both reviews should be present"); @@ -484,9 +376,6 @@ fn test_concurrent_reviews_on_patch() { #[test] fn test_three_way_fork_sequential_reconcile() { // Three users all comment from the same base. // We reconcile them pairwise: first alice+bob, then result+charlie. let tmp = TempDir::new().unwrap(); let repo = init_repo(tmp.path(), &alice()); @@ -498,33 +387,28 @@ fn test_three_way_fork_sequential_reconcile() { let (ref_name, id) = open_issue(&repo, &alice(), "Three-way"); let root_oid = repo.refname_to_id(&ref_name).unwrap(); // Alice comments add_comment(&repo, &ref_name, &alice(), "Alice here"); let alice_tip = repo.refname_to_id(&ref_name).unwrap(); // Bob comments from root repo.reference(&ref_name, root_oid, true, "bob").unwrap(); add_comment(&repo, &ref_name, &bob(), "Bob here"); let bob_tip = repo.refname_to_id(&ref_name).unwrap(); // Charlie comments from root repo.reference(&ref_name, root_oid, true, "charlie") .unwrap(); add_comment(&repo, &ref_name, &charlie, "Charlie here"); let charlie_tip = repo.refname_to_id(&ref_name).unwrap(); // Reconcile alice + bob repo.reference(&ref_name, alice_tip, true, "alice").unwrap(); let bob_ref = "refs/collab/sync/origin/issues/bob_temp"; repo.reference(bob_ref, bob_tip, true, "bob remote") .unwrap(); dag::reconcile(&repo, &ref_name, bob_ref, &alice()).unwrap(); dag::reconcile(&repo, &ref_name, bob_ref, &alice(), &test_signing_key()).unwrap(); // Reconcile result + charlie let charlie_ref = "refs/collab/sync/origin/issues/charlie_temp"; repo.reference(charlie_ref, charlie_tip, true, "charlie remote") .unwrap(); dag::reconcile(&repo, &ref_name, charlie_ref, &alice()).unwrap(); dag::reconcile(&repo, &ref_name, charlie_ref, &alice(), &test_signing_key()).unwrap(); let state = IssueState::from_ref(&repo, &ref_name, &id).unwrap(); assert_eq!(state.comments.len(), 3, "all three comments must survive"); @@ -599,3 +483,73 @@ fn test_resolve_prefix_match() { assert_eq!(resolved_id, id); assert_eq!(resolved_ref, format!("refs/collab/issues/{}", id)); } // --------------------------------------------------------------------------- // T012: Signed event integration test // --------------------------------------------------------------------------- #[test] fn test_signed_event_in_dag() { let tmp = TempDir::new().unwrap(); let repo = init_repo(tmp.path(), &alice()); // Set up a signing key on disk so issue::open() can find it let config_dir = tmp.path().join("test-config"); setup_signing_key(&config_dir); let sk = signing::load_signing_key(&config_dir).unwrap(); // Create an issue using dag primitives with the signing key let event = Event { timestamp: now(), author: alice(), action: Action::IssueOpen { title: "Signed issue".to_string(), body: "".to_string(), }, }; let oid = dag::create_root_event(&repo, &event, &sk).unwrap(); let id = oid.to_string(); let ref_name = format!("refs/collab/issues/{}", id); repo.reference(&ref_name, oid, false, "test open").unwrap(); // Read the raw blob from the commit and deserialize as SignedEvent let tip = repo.refname_to_id(&ref_name).unwrap(); let commit = repo.find_commit(tip).unwrap(); let tree = commit.tree().unwrap(); let entry = tree.get_name("event.json").unwrap(); let blob = repo.find_blob(entry.id()).unwrap(); let signed: SignedEvent = serde_json::from_slice(blob.content()).unwrap(); // Assert signature and pubkey are present assert!(!signed.signature.is_empty(), "signature should be present"); assert!(!signed.pubkey.is_empty(), "pubkey should be present"); // Verify the signature let status = signing::verify_signed_event(&signed).unwrap(); assert_eq!(status, VerifyStatus::Valid, "signature should verify as valid"); // walk_events should still extract the Event correctly let events = dag::walk_events(&repo, &ref_name).unwrap(); assert_eq!(events.len(), 1); assert!(matches!(events[0].1.action, Action::IssueOpen { .. })); } // --------------------------------------------------------------------------- // T013: Missing signing key error test // --------------------------------------------------------------------------- #[test] fn test_issue_open_without_signing_key_returns_key_not_found() { let tmp = TempDir::new().unwrap(); let _repo = init_repo(tmp.path(), &alice()); // Point to a nonexistent config dir so load_signing_key fails let bad_config = tmp.path().join("nonexistent-config"); let result = signing::load_signing_key(&bad_config); assert!(result.is_err()); match result.unwrap_err() { Error::KeyNotFound => {} // expected other => panic!("expected KeyNotFound error, got: {:?}", other), } } diff --git a/tests/common/mod.rs b/tests/common/mod.rs new file mode 100644 index 0000000..fe2665c --- /dev/null +++ b/tests/common/mod.rs @@ -0,0 +1,318 @@ #![allow(dead_code)] use std::path::Path; use std::process::{Command, Output}; use ed25519_dalek::SigningKey; use git2::Repository; use rand_core::OsRng; use tempfile::TempDir; use git_collab::dag; use git_collab::event::{Action, Author, Event, ReviewVerdict}; use git_collab::signing; // =========================================================================== // Library-level helpers (for collab_test / sync_test) // =========================================================================== pub fn alice() -> Author { Author { name: "Alice".to_string(), email: "alice@example.com".to_string(), } } pub fn bob() -> Author { Author { name: "Bob".to_string(), email: "bob@example.com".to_string(), } } pub fn now() -> String { chrono::Utc::now().to_rfc3339() } /// Generate a test signing key and return it. Does NOT write to disk. pub fn test_signing_key() -> SigningKey { SigningKey::generate(&mut OsRng) } /// Generate a test signing key and write it to a config dir so that /// code using `signing::load_signing_key()` can find it. pub fn setup_signing_key(config_dir: &Path) { git_collab::signing::generate_keypair(config_dir).expect("generate test keypair"); } /// Create a non-bare repo in a directory with user identity configured. pub fn init_repo(dir: &Path, author: &Author) -> Repository { let repo = Repository::init(dir).expect("init repo"); { let mut config = repo.config().unwrap(); config.set_str("user.name", &author.name).unwrap(); config.set_str("user.email", &author.email).unwrap(); } repo } /// Open an issue using DAG primitives. Returns (ref_name, id). pub fn open_issue(repo: &Repository, author: &Author, title: &str) -> (String, String) { let sk = test_signing_key(); let event = Event { timestamp: now(), author: author.clone(), action: Action::IssueOpen { title: title.to_string(), body: "".to_string(), }, }; let oid = dag::create_root_event(repo, &event, &sk).unwrap(); let id = oid.to_string(); let ref_name = format!("refs/collab/issues/{}", id); repo.reference(&ref_name, oid, false, "test open").unwrap(); (ref_name, id) } /// Append a comment event to an issue ref. pub fn add_comment(repo: &Repository, ref_name: &str, author: &Author, body: &str) { let sk = test_signing_key(); let event = Event { timestamp: now(), author: author.clone(), action: Action::IssueComment { body: body.to_string(), }, }; dag::append_event(repo, ref_name, &event, &sk).unwrap(); } /// Append a close event to an issue ref. pub fn close_issue(repo: &Repository, ref_name: &str, author: &Author) { let sk = test_signing_key(); let event = Event { timestamp: now(), author: author.clone(), action: Action::IssueClose { reason: None }, }; dag::append_event(repo, ref_name, &event, &sk).unwrap(); } /// Append a reopen event to an issue ref. pub fn reopen_issue(repo: &Repository, ref_name: &str, author: &Author) { let sk = test_signing_key(); let event = Event { timestamp: now(), author: author.clone(), action: Action::IssueReopen, }; dag::append_event(repo, ref_name, &event, &sk).unwrap(); } /// Create a patch using DAG primitives. Returns (ref_name, id). pub fn create_patch(repo: &Repository, author: &Author, title: &str) -> (String, String) { let sk = test_signing_key(); let event = Event { timestamp: now(), author: author.clone(), action: Action::PatchCreate { title: title.to_string(), body: "".to_string(), base_ref: "main".to_string(), head_commit: "abc123".to_string(), fixes: None, }, }; let oid = dag::create_root_event(repo, &event, &sk).unwrap(); let id = oid.to_string(); let ref_name = format!("refs/collab/patches/{}", id); repo.reference(&ref_name, oid, false, "test patch").unwrap(); (ref_name, id) } /// Append a review event to a patch ref. pub fn add_review(repo: &Repository, ref_name: &str, author: &Author, verdict: ReviewVerdict) { let sk = test_signing_key(); let event = Event { timestamp: now(), author: author.clone(), action: Action::PatchReview { verdict, body: "review comment".to_string(), }, }; dag::append_event(repo, ref_name, &event, &sk).unwrap(); } // =========================================================================== // CLI-level helpers (for cli_test) // =========================================================================== /// A temporary git repository for end-to-end CLI testing. pub struct TestRepo { pub dir: TempDir, } impl TestRepo { /// Create a new repo with user identity and an initial empty commit on `main`. /// Also ensures a signing key exists in the default config dir. pub fn new(name: &str, email: &str) -> Self { let dir = TempDir::new().unwrap(); git(dir.path(), &["init", "-b", "main"]); git(dir.path(), &["config", "user.name", name]); git(dir.path(), &["config", "user.email", email]); git(dir.path(), &["commit", "--allow-empty", "-m", "initial"]); // Ensure signing key exists for CLI operations let config_dir = dirs::config_dir() .unwrap_or_else(|| { let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string()); std::path::PathBuf::from(home).join(".config") }) .join("git-collab"); if !config_dir.join("signing-key").exists() { setup_signing_key(&config_dir); } TestRepo { dir } } /// Run git-collab and return raw output. pub fn run(&self, args: &[&str]) -> Output { Command::new(env!("CARGO_BIN_EXE_git-collab")) .args(args) .current_dir(self.dir.path()) .output() .expect("failed to run git-collab") } /// Run git-collab, assert success, return stdout. pub fn run_ok(&self, args: &[&str]) -> String { let output = self.run(args); let stdout = String::from_utf8(output.stdout).unwrap(); let stderr = String::from_utf8(output.stderr).unwrap(); assert!( output.status.success(), "git-collab {:?} failed (exit {:?}):\nstdout: {}\nstderr: {}", args, output.status.code(), stdout, stderr ); stdout } /// Run git-collab, assert failure, return stderr. pub fn run_err(&self, args: &[&str]) -> String { let output = self.run(args); assert!( !output.status.success(), "expected git-collab {:?} to fail but it succeeded:\nstdout: {}", args, String::from_utf8_lossy(&output.stdout) ); String::from_utf8(output.stderr).unwrap() } /// Open an issue and return the 8-char short ID. pub fn issue_open(&self, title: &str) -> String { let out = self.run_ok(&["issue", "open", "-t", title]); out.trim() .strip_prefix("Opened issue ") .unwrap_or_else(|| panic!("unexpected issue open output: {}", out)) .to_string() } /// Create a patch with HEAD as the head commit. Returns the 8-char short ID. pub fn patch_create(&self, title: &str) -> String { let head = self.git(&["rev-parse", "HEAD"]).trim().to_string(); let out = self.run_ok(&["patch", "create", "-t", title, "--head", &head]); out.trim() .strip_prefix("Created patch ") .unwrap_or_else(|| panic!("unexpected patch create output: {}", out)) .to_string() } /// Run a git command in this repo and return stdout. pub fn git(&self, args: &[&str]) -> String { let output = Command::new("git") .args(args) .current_dir(self.dir.path()) .output() .expect("failed to run git"); assert!( output.status.success(), "git {:?} failed: {}", args, String::from_utf8_lossy(&output.stderr) ); String::from_utf8(output.stdout).unwrap() } /// Create a file, stage it, and commit. Returns the commit OID. pub fn commit_file(&self, path: &str, content: &str, message: &str) -> String { let full_path = self.dir.path().join(path); if let Some(parent) = full_path.parent() { std::fs::create_dir_all(parent).unwrap(); } std::fs::write(&full_path, content).unwrap(); self.git(&["add", path]); self.git(&["commit", "-m", message]); self.git(&["rev-parse", "HEAD"]).trim().to_string() } } /// Create an unsigned event commit (plain Event JSON, no signature fields). /// Returns the commit OID. pub fn create_unsigned_event(repo: &Repository, event: &Event) -> git2::Oid { let json = serde_json::to_vec_pretty(event).unwrap(); let blob_oid = repo.blob(&json).unwrap(); let mut tb = repo.treebuilder(None).unwrap(); tb.insert("event.json", blob_oid, 0o100644).unwrap(); let tree_oid = tb.write().unwrap(); let tree = repo.find_tree(tree_oid).unwrap(); let sig = git2::Signature::now(&event.author.name, &event.author.email).unwrap(); repo.commit(None, &sig, &sig, "unsigned event", &tree, &[]) .unwrap() } /// Create a tampered event commit: sign the event, then modify the body but keep /// the original signature. Returns the commit OID. pub fn create_tampered_event(repo: &Repository, event: &Event) -> git2::Oid { let sk = test_signing_key(); let mut signed = signing::sign_event(event, &sk).unwrap(); // Tamper with the event content while keeping the original signature signed.event.timestamp = "2099-01-01T00:00:00Z".to_string(); let json = serde_json::to_vec_pretty(&signed).unwrap(); let blob_oid = repo.blob(&json).unwrap(); let mut tb = repo.treebuilder(None).unwrap(); tb.insert("event.json", blob_oid, 0o100644).unwrap(); let tree_oid = tb.write().unwrap(); let tree = repo.find_tree(tree_oid).unwrap(); let sig = git2::Signature::now(&event.author.name, &event.author.email).unwrap(); repo.commit(None, &sig, &sig, "tampered event", &tree, &[]) .unwrap() } /// Run a git command in the given directory (public for use in test files). pub fn git_cmd(dir: &Path, args: &[&str]) { git(dir, args); } fn git(dir: &Path, args: &[&str]) { let output = Command::new("git") .args(args) .current_dir(dir) .output() .expect("failed to run git"); assert!( output.status.success(), "git {:?} failed: {}", args, String::from_utf8_lossy(&output.stderr) ); } diff --git a/tests/signing_test.rs b/tests/signing_test.rs new file mode 100644 index 0000000..0d0da0e --- /dev/null +++ b/tests/signing_test.rs @@ -0,0 +1,241 @@ use git_collab::event::{Action, Author, Event}; use git_collab::signing::{ canonical_json, generate_keypair, load_signing_key, load_verifying_key, sign_event, verify_signed_event, SignedEvent, VerifyStatus, }; use tempfile::tempdir; fn make_event() -> Event { Event { timestamp: "2026-03-21T00:00:00Z".to_string(), author: Author { name: "Alice".to_string(), email: "alice@example.com".to_string(), }, action: Action::IssueOpen { title: "Test issue".to_string(), body: "This is a test".to_string(), }, } } // ── T004: Key generation and storage ── #[test] fn generate_keypair_creates_key_files() { let dir = tempdir().unwrap(); let config = dir.path().join("git-collab"); let vk = generate_keypair(&config).unwrap(); assert!(config.join("signing-key").exists()); assert!(config.join("signing-key.pub").exists()); // Verify the public key file content matches the returned key let loaded_vk = load_verifying_key(&config).unwrap(); assert_eq!(vk.to_bytes(), loaded_vk.to_bytes()); } #[cfg(unix)] #[test] fn generate_keypair_sets_private_key_permissions() { use std::os::unix::fs::PermissionsExt; let dir = tempdir().unwrap(); let config = dir.path().join("git-collab"); generate_keypair(&config).unwrap(); let meta = std::fs::metadata(config.join("signing-key")).unwrap(); let mode = meta.permissions().mode() & 0o777; assert_eq!(mode, 0o600, "private key should have 0o600 permissions"); } #[test] fn load_keypair_from_disk() { let dir = tempdir().unwrap(); let config = dir.path().join("git-collab"); generate_keypair(&config).unwrap(); let sk = load_signing_key(&config).unwrap(); let vk = load_verifying_key(&config).unwrap(); // The verifying key derived from loaded signing key should match stored pub key let derived_vk = sk.verifying_key(); assert_eq!(derived_vk.to_bytes(), vk.to_bytes()); } #[test] fn load_signing_key_missing_returns_error() { let dir = tempdir().unwrap(); let config = dir.path().join("nonexistent"); let err = load_signing_key(&config).unwrap_err(); let msg = format!("{}", err); assert!( msg.contains("signing key"), "error should mention signing key: {}", msg ); } #[test] fn load_verifying_key_missing_returns_error() { let dir = tempdir().unwrap(); let config = dir.path().join("nonexistent"); let err = load_verifying_key(&config).unwrap_err(); let msg = format!("{}", err); assert!( msg.contains("signing key"), "error should mention signing key: {}", msg ); } // ── T005: Sign/verify round-trip ── #[test] fn sign_event_produces_nonempty_signature_and_pubkey() { let dir = tempdir().unwrap(); let config = dir.path().join("git-collab"); generate_keypair(&config).unwrap(); let sk = load_signing_key(&config).unwrap(); let event = make_event(); let signed = sign_event(&event, &sk).unwrap(); assert!(!signed.signature.is_empty(), "signature should not be empty"); assert!(!signed.pubkey.is_empty(), "pubkey should not be empty"); // Verify they are valid base64 use base64::engine::general_purpose::STANDARD; use base64::Engine; STANDARD .decode(&signed.signature) .expect("signature should be valid base64"); STANDARD .decode(&signed.pubkey) .expect("pubkey should be valid base64"); } #[test] fn verify_valid_signed_event_returns_valid() { let dir = tempdir().unwrap(); let config = dir.path().join("git-collab"); generate_keypair(&config).unwrap(); let sk = load_signing_key(&config).unwrap(); let event = make_event(); let signed = sign_event(&event, &sk).unwrap(); let status = verify_signed_event(&signed).unwrap(); assert_eq!(status, VerifyStatus::Valid); } #[test] fn verify_tampered_event_returns_invalid() { let dir = tempdir().unwrap(); let config = dir.path().join("git-collab"); generate_keypair(&config).unwrap(); let sk = load_signing_key(&config).unwrap(); let event = make_event(); let mut signed = sign_event(&event, &sk).unwrap(); // Tamper with the event signed.event.author.name = "Mallory".to_string(); let status = verify_signed_event(&signed).unwrap(); assert_eq!(status, VerifyStatus::Invalid); } #[test] fn verify_missing_signature_returns_missing() { let event = make_event(); let signed = SignedEvent { event, signature: String::new(), pubkey: String::new(), }; let status = verify_signed_event(&signed).unwrap(); assert_eq!(status, VerifyStatus::Missing); } // ── T006: Canonical serialization ── #[test] fn canonical_json_deterministic() { let event = make_event(); let bytes1 = canonical_json(&event).unwrap(); let bytes2 = canonical_json(&event).unwrap(); assert_eq!(bytes1, bytes2, "canonical_json should produce identical output"); } #[test] fn signed_event_json_contains_all_fields() { let dir = tempdir().unwrap(); let config = dir.path().join("git-collab"); generate_keypair(&config).unwrap(); let sk = load_signing_key(&config).unwrap(); let event = make_event(); let signed = sign_event(&event, &sk).unwrap(); let json = serde_json::to_string(&signed).unwrap(); let value: serde_json::Value = serde_json::from_str(&json).unwrap(); let obj = value.as_object().unwrap(); // Flattened event fields assert!(obj.contains_key("timestamp"), "missing timestamp"); assert!(obj.contains_key("author"), "missing author"); assert!(obj.contains_key("action") || obj.contains_key("type"), "missing action/type"); // Signature fields assert!(obj.contains_key("signature"), "missing signature"); assert!(obj.contains_key("pubkey"), "missing pubkey"); } #[test] fn signed_event_flatten_round_trip_with_tagged_enum() { let event = Event { timestamp: "2026-03-21T12:00:00Z".to_string(), author: Author { name: "Bob".to_string(), email: "bob@example.com".to_string(), }, action: Action::PatchCreate { title: "Fix bug".to_string(), body: "Fixes #42".to_string(), base_ref: "main".to_string(), head_commit: "abc123".to_string(), fixes: Some("deadbeef".to_string()), }, }; let signed = SignedEvent { event, signature: "dGVzdHNpZw==".to_string(), pubkey: "dGVzdGtleQ==".to_string(), }; let json = serde_json::to_string_pretty(&signed).unwrap(); let deserialized: SignedEvent = serde_json::from_str(&json).unwrap(); assert_eq!(deserialized.signature, signed.signature); assert_eq!(deserialized.pubkey, signed.pubkey); match deserialized.event.action { Action::PatchCreate { ref title, ref fixes, .. } => { assert_eq!(title, "Fix bug"); assert_eq!(fixes.as_deref(), Some("deadbeef")); } _ => panic!("Wrong action type after round-trip"), } } diff --git a/tests/sync_test.rs b/tests/sync_test.rs index 320613b..0c349d7 100644 --- a/tests/sync_test.rs +++ b/tests/sync_test.rs @@ -5,42 +5,31 @@ //! bare_remote <---push/fetch---> alice_repo //! <---push/fetch---> bob_repo mod common; use tempfile::TempDir; use git2::Repository; use git_collab::dag; use git_collab::event::{Action, Author, Event, ReviewVerdict}; use git_collab::event::{Action, Event, ReviewVerdict}; use git_collab::signing; use git_collab::state::{self, IssueState, IssueStatus, PatchState}; use git_collab::sync; use common::{ add_comment, alice, bob, close_issue, create_tampered_event, create_unsigned_event, now, open_issue, setup_signing_key, test_signing_key, }; // --------------------------------------------------------------------------- // Helpers // Test cluster // --------------------------------------------------------------------------- fn alice() -> Author { Author { name: "Alice".to_string(), email: "alice@example.com".to_string(), } } fn bob() -> Author { Author { name: "Bob".to_string(), email: "bob@example.com".to_string(), } } fn now() -> String { chrono::Utc::now().to_rfc3339() } /// Set up the standard test topology: bare remote + two clones. /// Returns (bare_dir, alice_dir, bob_dir) — TempDirs that must be kept alive. struct TestCluster { _bare_dir: TempDir, alice_dir: TempDir, bob_dir: TempDir, _key_setup: (), // signing key created in default config dir } impl TestCluster { @@ -48,8 +37,6 @@ impl TestCluster { let bare_dir = TempDir::new().unwrap(); let bare_repo = Repository::init_bare(bare_dir.path()).unwrap(); // Need at least one ref in the bare repo for clones to work, // so we create a dummy initial commit on refs/heads/main. { let sig = git2::Signature::now("init", "init@test").unwrap(); let tree_oid = bare_repo.treebuilder(None).unwrap().write().unwrap(); @@ -63,7 +50,6 @@ impl TestCluster { let alice_dir = TempDir::new().unwrap(); let bob_dir = TempDir::new().unwrap(); // Clone for Alice let alice_repo = Repository::clone(bare_dir.path().to_str().unwrap(), alice_dir.path()).unwrap(); { @@ -73,7 +59,6 @@ impl TestCluster { } sync::init(&alice_repo).unwrap(); // Clone for Bob let bob_repo = Repository::clone(bare_dir.path().to_str().unwrap(), bob_dir.path()).unwrap(); { @@ -83,10 +68,22 @@ impl TestCluster { } sync::init(&bob_repo).unwrap(); // Ensure signing key exists for sync reconciliation let config_dir = dirs::config_dir() .unwrap_or_else(|| { let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string()); std::path::PathBuf::from(home).join(".config") }) .join("git-collab"); if !config_dir.join("signing-key").exists() { setup_signing_key(&config_dir); } TestCluster { _bare_dir: bare_dir, alice_dir, bob_dir, _key_setup: (), } } @@ -99,42 +96,6 @@ impl TestCluster { } } fn open_issue(repo: &Repository, author: &Author, title: &str) -> (String, String) { let event = Event { timestamp: now(), author: author.clone(), action: Action::IssueOpen { title: title.to_string(), body: "".to_string(), }, }; let oid = dag::create_root_event(repo, &event).unwrap(); let id = oid.to_string(); let ref_name = format!("refs/collab/issues/{}", id); repo.reference(&ref_name, oid, false, "open").unwrap(); (ref_name, id) } fn add_comment(repo: &Repository, ref_name: &str, author: &Author, body: &str) { let event = Event { timestamp: now(), author: author.clone(), action: Action::IssueComment { body: body.to_string(), }, }; dag::append_event(repo, ref_name, &event).unwrap(); } fn close_issue(repo: &Repository, ref_name: &str, author: &Author) { let event = Event { timestamp: now(), author: author.clone(), action: Action::IssueClose { reason: None }, }; dag::append_event(repo, ref_name, &event).unwrap(); } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -145,11 +106,9 @@ fn test_alice_creates_issue_bob_syncs_and_sees_it() { let alice_repo = cluster.alice_repo(); let bob_repo = cluster.bob_repo(); // Alice creates an issue and syncs let (_ref_name, id) = open_issue(&alice_repo, &alice(), "Bug from Alice"); sync::sync(&alice_repo, "origin").unwrap(); // Bob syncs and should see the issue sync::sync(&bob_repo, "origin").unwrap(); let bob_ref = format!("refs/collab/issues/{}", id); @@ -165,17 +124,14 @@ fn test_bob_comments_on_alice_issue_then_sync() { let alice_repo = cluster.alice_repo(); let bob_repo = cluster.bob_repo(); // Alice creates issue, syncs let (alice_ref, id) = open_issue(&alice_repo, &alice(), "Needs discussion"); sync::sync(&alice_repo, "origin").unwrap(); // Bob syncs, sees the issue, adds a comment, syncs sync::sync(&bob_repo, "origin").unwrap(); let bob_ref = format!("refs/collab/issues/{}", id); add_comment(&bob_repo, &bob_ref, &bob(), "I have thoughts on this"); sync::sync(&bob_repo, "origin").unwrap(); // Alice syncs again — should see Bob's comment sync::sync(&alice_repo, "origin").unwrap(); let state = IssueState::from_ref(&alice_repo, &alice_ref, &id).unwrap(); assert_eq!(state.comments.len(), 1); @@ -185,40 +141,28 @@ fn test_bob_comments_on_alice_issue_then_sync() { #[test] fn test_concurrent_comments_sync_convergence() { // Alice and Bob both comment on the same issue without syncing first. // After both sync, they should converge to the same state. let cluster = TestCluster::new(); let alice_repo = cluster.alice_repo(); let bob_repo = cluster.bob_repo(); // Alice creates issue, syncs so Bob can get it let (alice_ref, id) = open_issue(&alice_repo, &alice(), "Concurrent comments"); sync::sync(&alice_repo, "origin").unwrap(); sync::sync(&bob_repo, "origin").unwrap(); // Both comment independently (no sync between) add_comment(&alice_repo, &alice_ref, &alice(), "Alice's take"); let bob_ref = format!("refs/collab/issues/{}", id); add_comment(&bob_repo, &bob_ref, &bob(), "Bob's take"); // Alice syncs first — pushes her comment sync::sync(&alice_repo, "origin").unwrap(); // Bob syncs — fetches Alice's comment, reconciles fork, pushes merge sync::sync(&bob_repo, "origin").unwrap(); // Alice syncs again to get the merge sync::sync(&alice_repo, "origin").unwrap(); // Both should now have the same state let alice_state = IssueState::from_ref(&alice_repo, &alice_ref, &id).unwrap(); let bob_state = IssueState::from_ref(&bob_repo, &bob_ref, &id).unwrap(); assert_eq!(alice_state.comments.len(), 2, "Alice should see 2 comments"); assert_eq!(bob_state.comments.len(), 2, "Bob should see 2 comments"); // Both should have the same comment bodies (order may vary) let mut alice_bodies: Vec<&str> = alice_state .comments .iter() @@ -234,9 +178,6 @@ fn test_concurrent_comments_sync_convergence() { #[test] fn test_both_create_different_issues() { // Alice and Bob each create their own issue without syncing. // After sync, both should see both issues. let cluster = TestCluster::new(); let alice_repo = cluster.alice_repo(); let bob_repo = cluster.bob_repo(); @@ -244,16 +185,10 @@ fn test_both_create_different_issues() { let (_, _alice_issue_id) = open_issue(&alice_repo, &alice(), "Alice's bug"); let (_, _bob_issue_id) = open_issue(&bob_repo, &bob(), "Bob's feature request"); // Alice syncs first sync::sync(&alice_repo, "origin").unwrap(); // Bob syncs — gets Alice's issue, pushes his own sync::sync(&bob_repo, "origin").unwrap(); // Alice syncs again — gets Bob's issue sync::sync(&alice_repo, "origin").unwrap(); // Both repos should have both issues let alice_issues = state::list_issues(&alice_repo).unwrap(); let bob_issues = state::list_issues(&bob_repo).unwrap(); @@ -267,8 +202,6 @@ fn test_both_create_different_issues() { #[test] fn test_alice_closes_while_bob_comments() { // Alice closes an issue while Bob comments on it concurrently. let cluster = TestCluster::new(); let alice_repo = cluster.alice_repo(); let bob_repo = cluster.bob_repo(); @@ -277,37 +210,25 @@ fn test_alice_closes_while_bob_comments() { sync::sync(&alice_repo, "origin").unwrap(); sync::sync(&bob_repo, "origin").unwrap(); // Alice closes close_issue(&alice_repo, &alice_ref, &alice()); // Bob comments let bob_ref = format!("refs/collab/issues/{}", id); add_comment(&bob_repo, &bob_ref, &bob(), "But wait..."); // Alice pushes first sync::sync(&alice_repo, "origin").unwrap(); // Bob syncs — reconciles the fork sync::sync(&bob_repo, "origin").unwrap(); // Alice syncs to get the merge sync::sync(&alice_repo, "origin").unwrap(); // Both should see the comment AND the close let alice_state = IssueState::from_ref(&alice_repo, &alice_ref, &id).unwrap(); let bob_state = IssueState::from_ref(&bob_repo, &bob_ref, &id).unwrap(); assert_eq!(alice_state.comments.len(), 1); assert_eq!(bob_state.comments.len(), 1); // Close happened, so status should be closed // (both close and comment are in DAG; topo replay applies both) assert_eq!(alice_state.status, bob_state.status); } #[test] fn test_sync_idempotent() { // Syncing twice in a row should be a no-op the second time. let cluster = TestCluster::new(); let alice_repo = cluster.alice_repo(); let bob_repo = cluster.bob_repo(); @@ -317,7 +238,6 @@ fn test_sync_idempotent() { sync::sync(&alice_repo, "origin").unwrap(); sync::sync(&bob_repo, "origin").unwrap(); // Sync again immediately — should not fail or duplicate sync::sync(&bob_repo, "origin").unwrap(); let bob_ref = format!("refs/collab/issues/{}", id); @@ -327,24 +247,18 @@ fn test_sync_idempotent() { #[test] fn test_three_user_convergence() { // Three users (Alice, Bob, Charlie) all working on the same issue. // Charlie uses Alice's repo path as a second remote. let cluster = TestCluster::new(); let alice_repo = cluster.alice_repo(); let bob_repo = cluster.bob_repo(); // Alice creates issue, everyone syncs let (alice_ref, id) = open_issue(&alice_repo, &alice(), "Three users"); sync::sync(&alice_repo, "origin").unwrap(); sync::sync(&bob_repo, "origin").unwrap(); // Alice and Bob both comment add_comment(&alice_repo, &alice_ref, &alice(), "Alice's comment"); let bob_ref = format!("refs/collab/issues/{}", id); add_comment(&bob_repo, &bob_ref, &bob(), "Bob's comment"); // Alice syncs, Bob syncs, Alice syncs again (full convergence) sync::sync(&alice_repo, "origin").unwrap(); sync::sync(&bob_repo, "origin").unwrap(); sync::sync(&alice_repo, "origin").unwrap(); @@ -362,7 +276,6 @@ fn test_patch_review_across_repos() { let alice_repo = cluster.alice_repo(); let bob_repo = cluster.bob_repo(); // Alice creates a patch let event = Event { timestamp: now(), author: alice(), @@ -371,19 +284,19 @@ fn test_patch_review_across_repos() { body: "Please review".to_string(), base_ref: "main".to_string(), head_commit: "abc123".to_string(), fixes: None, }, }; let oid = dag::create_root_event(&alice_repo, &event).unwrap(); let sk = test_signing_key(); let oid = dag::create_root_event(&alice_repo, &event, &sk).unwrap(); let id = oid.to_string(); let alice_ref = format!("refs/collab/patches/{}", id); alice_repo .reference(&alice_ref, oid, false, "patch create") .unwrap(); // Alice syncs sync::sync(&alice_repo, "origin").unwrap(); // Bob syncs, reviews the patch sync::sync(&bob_repo, "origin").unwrap(); let bob_ref = format!("refs/collab/patches/{}", id); let review_event = Event { @@ -394,10 +307,9 @@ fn test_patch_review_across_repos() { body: "LGTM!".to_string(), }, }; dag::append_event(&bob_repo, &bob_ref, &review_event).unwrap(); dag::append_event(&bob_repo, &bob_ref, &review_event, &sk).unwrap(); sync::sync(&bob_repo, "origin").unwrap(); // Alice syncs and sees the review sync::sync(&alice_repo, "origin").unwrap(); let state = PatchState::from_ref(&alice_repo, &alice_ref, &id).unwrap(); assert_eq!(state.reviews.len(), 1); @@ -407,13 +319,10 @@ fn test_patch_review_across_repos() { #[test] fn test_concurrent_review_and_revise() { // Bob reviews while Alice revises the patch concurrently. let cluster = TestCluster::new(); let alice_repo = cluster.alice_repo(); let bob_repo = cluster.bob_repo(); // Alice creates patch, syncs let event = Event { timestamp: now(), author: alice(), @@ -422,9 +331,11 @@ fn test_concurrent_review_and_revise() { body: "".to_string(), base_ref: "main".to_string(), head_commit: "v1".to_string(), fixes: None, }, }; let oid = dag::create_root_event(&alice_repo, &event).unwrap(); let sk = test_signing_key(); let oid = dag::create_root_event(&alice_repo, &event, &sk).unwrap(); let id = oid.to_string(); let alice_ref = format!("refs/collab/patches/{}", id); alice_repo @@ -433,7 +344,6 @@ fn test_concurrent_review_and_revise() { sync::sync(&alice_repo, "origin").unwrap(); sync::sync(&bob_repo, "origin").unwrap(); // Alice revises (without syncing) let revise_event = Event { timestamp: now(), author: alice(), @@ -442,9 +352,8 @@ fn test_concurrent_review_and_revise() { head_commit: "v2".to_string(), }, }; dag::append_event(&alice_repo, &alice_ref, &revise_event).unwrap(); dag::append_event(&alice_repo, &alice_ref, &revise_event, &sk).unwrap(); // Bob reviews (without syncing) let bob_ref = format!("refs/collab/patches/{}", id); let review_event = Event { timestamp: now(), @@ -454,49 +363,40 @@ fn test_concurrent_review_and_revise() { body: "Needs work".to_string(), }, }; dag::append_event(&bob_repo, &bob_ref, &review_event).unwrap(); dag::append_event(&bob_repo, &bob_ref, &review_event, &sk).unwrap(); // Both sync sync::sync(&alice_repo, "origin").unwrap(); sync::sync(&bob_repo, "origin").unwrap(); sync::sync(&alice_repo, "origin").unwrap(); // Both should see revise + review let alice_state = PatchState::from_ref(&alice_repo, &alice_ref, &id).unwrap(); let bob_state = PatchState::from_ref(&bob_repo, &bob_ref, &id).unwrap(); assert_eq!(alice_state.reviews.len(), 1); assert_eq!(bob_state.reviews.len(), 1); // The revise should have updated the head_commit assert_eq!(alice_state.head_commit, bob_state.head_commit); } #[test] fn test_multiple_rounds_of_sync() { // Simulate a realistic back-and-forth conversation on an issue. let cluster = TestCluster::new(); let alice_repo = cluster.alice_repo(); let bob_repo = cluster.bob_repo(); // Round 1: Alice opens issue let (alice_ref, id) = open_issue(&alice_repo, &alice(), "Discussion thread"); sync::sync(&alice_repo, "origin").unwrap(); sync::sync(&bob_repo, "origin").unwrap(); let bob_ref = format!("refs/collab/issues/{}", id); // Round 2: Bob comments add_comment(&bob_repo, &bob_ref, &bob(), "First response"); sync::sync(&bob_repo, "origin").unwrap(); sync::sync(&alice_repo, "origin").unwrap(); // Round 3: Alice replies add_comment(&alice_repo, &alice_ref, &alice(), "Thanks for the input"); sync::sync(&alice_repo, "origin").unwrap(); sync::sync(&bob_repo, "origin").unwrap(); // Round 4: Bob closes close_issue(&bob_repo, &bob_ref, &bob()); sync::sync(&bob_repo, "origin").unwrap(); sync::sync(&alice_repo, "origin").unwrap(); @@ -507,3 +407,228 @@ fn test_multiple_rounds_of_sync() { assert_eq!(state.comments[0].body, "First response"); assert_eq!(state.comments[1].body, "Thanks for the input"); } // --------------------------------------------------------------------------- // T022: Signed issue sync succeeds // --------------------------------------------------------------------------- #[test] fn test_signed_issue_sync_succeeds() { let cluster = TestCluster::new(); let alice_repo = cluster.alice_repo(); let bob_repo = cluster.bob_repo(); // Alice creates a signed issue (open_issue uses signing) let (_ref_name, id) = open_issue(&alice_repo, &alice(), "Signed bug report"); sync::sync(&alice_repo, "origin").unwrap(); // Bob syncs — should succeed since all events are signed sync::sync(&bob_repo, "origin").unwrap(); // Verify issue is present on Bob's side let bob_ref = format!("refs/collab/issues/{}", id); let state = IssueState::from_ref(&bob_repo, &bob_ref, &id).unwrap(); assert_eq!(state.title, "Signed bug report"); assert_eq!(state.author.name, "Alice"); assert_eq!(state.status, IssueStatus::Open); // Verify the event is actually signed let results = signing::verify_ref(&bob_repo, &bob_ref).unwrap(); assert_eq!(results.len(), 1); assert_eq!(results[0].status, signing::VerifyStatus::Valid); } // --------------------------------------------------------------------------- // T023: Unsigned event sync is rejected // --------------------------------------------------------------------------- #[test] fn test_unsigned_event_sync_rejected() { // Set up Alice's repo with an unsigned event directly via git2 let alice_dir = TempDir::new().unwrap(); let alice_repo = common::init_repo(alice_dir.path(), &alice()); // Create initial commit so repo is not empty { let sig = git2::Signature::now("Alice", "alice@example.com").unwrap(); let tree_oid = alice_repo.treebuilder(None).unwrap().write().unwrap(); let tree = alice_repo.find_tree(tree_oid).unwrap(); alice_repo .commit(Some("refs/heads/main"), &sig, &sig, "init", &tree, &[]) .unwrap(); } // Create an unsigned event let event = Event { timestamp: now(), author: alice(), action: Action::IssueOpen { title: "Unsigned issue".to_string(), body: "No signature".to_string(), }, }; let oid = create_unsigned_event(&alice_repo, &event); let id = oid.to_string(); let ref_name = format!("refs/collab/issues/{}", id); alice_repo .reference(&ref_name, oid, false, "unsigned issue") .unwrap(); // Verify the ref directly — should show Missing status let results = signing::verify_ref(&alice_repo, &ref_name).unwrap(); assert_eq!(results.len(), 1); assert_eq!(results[0].status, signing::VerifyStatus::Missing); assert_eq!(results[0].commit_id, oid); let error_msg = results[0].error.as_deref().unwrap(); assert!( error_msg.contains("missing signature"), "expected 'missing signature' in error, got: {}", error_msg ); } // --------------------------------------------------------------------------- // T024: Tampered event sync is rejected // --------------------------------------------------------------------------- #[test] fn test_tampered_event_sync_rejected() { // Set up a repo with a tampered event let dir = TempDir::new().unwrap(); let repo = common::init_repo(dir.path(), &alice()); // Create initial commit { let sig = git2::Signature::now("Alice", "alice@example.com").unwrap(); let tree_oid = repo.treebuilder(None).unwrap().write().unwrap(); let tree = repo.find_tree(tree_oid).unwrap(); repo.commit(Some("refs/heads/main"), &sig, &sig, "init", &tree, &[]) .unwrap(); } // Create a tampered event (signed then modified) let event = Event { timestamp: now(), author: alice(), action: Action::IssueOpen { title: "Tampered issue".to_string(), body: "Will be tampered".to_string(), }, }; let oid = create_tampered_event(&repo, &event); let id = oid.to_string(); let ref_name = format!("refs/collab/issues/{}", id); repo.reference(&ref_name, oid, false, "tampered issue") .unwrap(); // Verify the ref — should show Invalid status let results = signing::verify_ref(&repo, &ref_name).unwrap(); assert_eq!(results.len(), 1); assert_eq!(results[0].status, signing::VerifyStatus::Invalid); assert_eq!(results[0].commit_id, oid); let error_msg = results[0].error.as_deref().unwrap(); assert!( error_msg.contains("invalid signature"), "expected 'invalid signature' in error, got: {}", error_msg ); } // --------------------------------------------------------------------------- // T028: Merge commit during reconciliation has valid Ed25519 signature // --------------------------------------------------------------------------- #[test] fn test_reconciliation_merge_commit_is_signed() { let cluster = TestCluster::new(); let alice_repo = cluster.alice_repo(); let bob_repo = cluster.bob_repo(); // Alice creates an issue and syncs it to remote let (alice_ref, id) = open_issue(&alice_repo, &alice(), "Divergent history test"); sync::sync(&alice_repo, "origin").unwrap(); // Bob syncs to get the issue sync::sync(&bob_repo, "origin").unwrap(); let bob_ref = format!("refs/collab/issues/{}", id); // Both add comments — creating divergent history add_comment(&alice_repo, &alice_ref, &alice(), "Alice's divergent comment"); add_comment(&bob_repo, &bob_ref, &bob(), "Bob's divergent comment"); // Bob pushes his comment to remote sync::sync(&bob_repo, "origin").unwrap(); // Alice syncs — this triggers reconciliation (merge commit) because // Alice has a local comment and Bob's comment comes from remote sync::sync(&alice_repo, "origin").unwrap(); // Walk the DAG and find the merge event let events = dag::walk_events(&alice_repo, &alice_ref).unwrap(); let merge_events: Vec<_> = events .iter() .filter(|(_, e)| matches!(e.action, Action::Merge)) .collect(); assert!( !merge_events.is_empty(), "Expected at least one merge event after reconciliation" ); // Verify ALL events on the ref have valid signatures (including the merge) let results = signing::verify_ref(&alice_repo, &alice_ref).unwrap(); assert!( results.len() >= 4, "Expected at least 4 commits (open + 2 comments + merge), got {}", results.len() ); for result in &results { assert_eq!( result.status, signing::VerifyStatus::Valid, "Commit {} has status {:?}, expected Valid. Error: {:?}", result.commit_id, result.status, result.error ); } // Verify the merge commit specifically is signed by the syncing user's key // (the key stored in the config dir, which sync::sync() loads) let config_dir = dirs::config_dir() .unwrap_or_else(|| { let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string()); std::path::PathBuf::from(home).join(".config") }) .join("git-collab"); let syncing_vk = signing::load_verifying_key(&config_dir).unwrap(); let syncing_pubkey = base64::Engine::encode( &base64::engine::general_purpose::STANDARD, syncing_vk.to_bytes(), ); // Find the merge commit and check its pubkey matches the syncing user's key let tip = alice_repo.refname_to_id(&alice_ref).unwrap(); let commit = alice_repo.find_commit(tip).unwrap(); // The tip should be the merge commit (it's the most recent) let tree = commit.tree().unwrap(); let entry = tree.get_name("event.json").unwrap(); let blob = alice_repo.find_blob(entry.id()).unwrap(); let signed: signing::SignedEvent = serde_json::from_slice(blob.content()).unwrap(); assert!( matches!(signed.event.action, Action::Merge), "Expected tip commit to be a Merge event, got {:?}", signed.event.action ); assert_eq!( signed.pubkey, syncing_pubkey, "Merge commit should be signed by the syncing user's key" ); // Verify the signature is cryptographically valid let status = signing::verify_signed_event(&signed).unwrap(); assert_eq!( status, signing::VerifyStatus::Valid, "Merge commit signature must be valid" ); }