2b6f69e8
Simplify codebase: extract helpers, deduplicate across modules
a73x 2026-03-22 09:58
dag.rs:
- Extract sign_and_build_tree() — eliminates 4x blob-creation boilerplate
- Extract load_event_from_commit() — eliminates 2x event-reading boilerplate
- max_clock is now a one-liner
state.rs:
- Unify collab_refs/collab_archive_refs into shared refs_under() helper
patch.rs:
- Extract resolve_base_tree() — dedup between generate_diff and generate_diff_at_revision
- list_json now reuses list() instead of duplicating archived conditional
issue.rs:
- Extract load_issues() helper — dedup archived conditional in list/list_json
tests:
- crdt_test.rs: use common alice/bob/init_repo/test_signing_key instead of local redefs
- sort_test.rs: use common init_repo/test_signing_key instead of local redefs
Also fix remaining {:?} -> {} for ReviewVerdict Display in log.rs and tui/widgets.rs.
Fixes: da854a5b, 75254fb0, 33943aca, f6fd326d, 2da1931e
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
diff --git a/src/dag.rs b/src/dag.rs index 57f2df7..063d825 100644 --- a/src/dag.rs +++ b/src/dag.rs @@ -29,11 +29,26 @@ fn build_event_tree( Ok(tree_oid) } /// Read the clock value from the tip commit of a DAG. /// Since clocks are monotonically increasing, the tip always has the max clock. /// Returns 0 if the event has clock 0 (pre-migration). pub fn max_clock(repo: &Repository, tip: Oid) -> Result<u64, Error> { let commit = repo.find_commit(tip)?; /// Sign an event, serialize it, and build the event tree. Returns the tree OID. fn sign_and_build_tree( repo: &Repository, event: &Event, signing_key: &ed25519_dalek::SigningKey, ) -> Result<Oid, Error> { let detached = sign_event(event, signing_key)?; let event_json = serde_json::to_vec_pretty(event)?; let event_blob = repo.blob(&event_json)?; let sig_blob = repo.blob(detached.signature.as_bytes())?; let pubkey_blob = repo.blob(detached.pubkey.as_bytes())?; let manifest_blob = repo.blob(MANIFEST_JSON)?; build_event_tree(repo, event_blob, sig_blob, pubkey_blob, manifest_blob) } /// Load and deserialize the event from a commit's tree. fn load_event_from_commit(repo: &Repository, oid: Oid) -> Result<Event, Error> { let commit = repo.find_commit(oid)?; let tree = commit.tree()?; let entry = tree .get_name("event.json") @@ -54,8 +69,14 @@ pub fn max_clock(repo: &Repository, tip: Oid) -> Result<u64, Error> { }); } let event: Event = serde_json::from_slice(content)?; Ok(event.clock) Ok(serde_json::from_slice(content)?) } /// Read the clock value from the tip commit of a DAG. /// Since clocks are monotonically increasing, the tip always has the max clock. /// Returns 0 if the event has clock 0 (pre-migration). pub fn max_clock(repo: &Repository, tip: Oid) -> Result<u64, Error> { Ok(load_event_from_commit(repo, tip)?.clock) } /// Create an orphan commit (no parents) with the given event. @@ -69,15 +90,7 @@ pub fn create_root_event( let mut event = event.clone(); event.clock = 1; let detached = sign_event(&event, signing_key)?; let event_json = serde_json::to_vec_pretty(&event)?; let event_blob = repo.blob(&event_json)?; let sig_blob = repo.blob(detached.signature.as_bytes())?; let pubkey_blob = repo.blob(detached.pubkey.as_bytes())?; let manifest_blob = repo.blob(MANIFEST_JSON)?; let tree_oid = build_event_tree(repo, event_blob, sig_blob, pubkey_blob, manifest_blob)?; let tree_oid = sign_and_build_tree(repo, &event, signing_key)?; let tree = repo.find_tree(tree_oid)?; let sig = author_signature(&event.author)?; @@ -101,15 +114,7 @@ pub fn append_event( let mut event = event.clone(); event.clock = current_max + 1; let detached = sign_event(&event, signing_key)?; let event_json = serde_json::to_vec_pretty(&event)?; let event_blob = repo.blob(&event_json)?; let sig_blob = repo.blob(detached.signature.as_bytes())?; let pubkey_blob = repo.blob(detached.pubkey.as_bytes())?; let manifest_blob = repo.blob(MANIFEST_JSON)?; let tree_oid = build_event_tree(repo, event_blob, sig_blob, pubkey_blob, manifest_blob)?; let tree_oid = sign_and_build_tree(repo, &event, signing_key)?; let tree = repo.find_tree(tree_oid)?; let sig = author_signature(&event.author)?; @@ -168,31 +173,7 @@ pub fn walk_events(repo: &Repository, ref_name: &str) -> Result<Vec<(Oid, Event) let mut events = 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"))?; // Verify the entry points to a blob, not a tree or commit if entry.kind() != Some(ObjectType::Blob) { return Err(Error::Git(git2::Error::from_str( "event.json entry is not a blob", ))); } let blob = repo.find_blob(entry.id())?; // Check blob size before attempting deserialization let content = blob.content(); if content.len() > MAX_EVENT_BLOB_SIZE { return Err(Error::PayloadTooLarge { actual: content.len(), limit: MAX_EVENT_BLOB_SIZE, }); } let event: Event = serde_json::from_slice(content)?; let event = load_event_from_commit(repo, oid)?; events.push((oid, event)); } Ok(events) @@ -256,14 +237,7 @@ pub fn reconcile( clock: merge_clock, }; let detached = sign_event(&merge_event, signing_key)?; let event_json = serde_json::to_vec_pretty(&merge_event)?; let event_blob = repo.blob(&event_json)?; let sig_blob = repo.blob(detached.signature.as_bytes())?; let pubkey_blob = repo.blob(detached.pubkey.as_bytes())?; let manifest_blob = repo.blob(MANIFEST_JSON)?; let tree_oid = build_event_tree(repo, event_blob, sig_blob, pubkey_blob, manifest_blob)?; let tree_oid = sign_and_build_tree(repo, &merge_event, signing_key)?; let tree = repo.find_tree(tree_oid)?; let sig = author_signature(merge_author)?; @@ -311,14 +285,7 @@ pub fn migrate_clocks( clock = migrated.clock; // respect existing clocks } let detached = sign_event(&migrated, signing_key)?; let event_json = serde_json::to_vec_pretty(&migrated)?; let event_blob = repo.blob(&event_json)?; let sig_blob = repo.blob(detached.signature.as_bytes())?; let pubkey_blob = repo.blob(detached.pubkey.as_bytes())?; let manifest_blob = repo.blob(MANIFEST_JSON)?; let tree_oid = build_event_tree(repo, event_blob, sig_blob, pubkey_blob, manifest_blob)?; let tree_oid = sign_and_build_tree(repo, &migrated, signing_key)?; let tree = repo.find_tree(tree_oid)?; let sig = author_signature(&migrated.author)?; diff --git a/src/issue.rs b/src/issue.rs index 0385825..39e3d2b 100644 --- a/src/issue.rs +++ b/src/issue.rs @@ -30,6 +30,14 @@ pub struct ListEntry { pub unread: Option<usize>, } fn load_issues(repo: &Repository, show_archived: bool) -> Result<Vec<state::IssueState>, crate::error::Error> { if show_archived { state::list_issues_with_archived(repo) } else { state::list_issues(repo) } } pub fn list( repo: &Repository, show_closed: bool, @@ -38,11 +46,7 @@ pub fn list( offset: Option<usize>, sort: SortMode, ) -> Result<Vec<ListEntry>, crate::error::Error> { let issues = if show_archived { state::list_issues_with_archived(repo)? } else { state::list_issues(repo)? }; let issues = load_issues(repo, show_archived)?; let filtered = cli::filter_sort_paginate(issues, show_closed, sort, offset, limit); let entries = filtered .into_iter() @@ -111,11 +115,7 @@ fn count_unread(repo: &git2::Repository, id: &str) -> Option<usize> { } pub fn list_json(repo: &Repository, show_closed: bool, show_archived: bool, sort: SortMode) -> Result<String, crate::error::Error> { let issues = if show_archived { state::list_issues_with_archived(repo)? } else { state::list_issues(repo)? }; let issues = load_issues(repo, show_archived)?; let filtered = cli::filter_sort_paginate(issues, show_closed, sort, None, None); Ok(serde_json::to_string_pretty(&filtered)?) } diff --git a/src/log.rs b/src/log.rs index b9ad607..d92f0cf 100644 --- a/src/log.rs +++ b/src/log.rs @@ -154,7 +154,7 @@ fn action_summary(action: &Action) -> String { Some(b) => format!("revision: {}", truncate(b, 50)), None => "revision".to_string(), }, Action::PatchReview { verdict, .. } => format!("review: {:?}", verdict), Action::PatchReview { verdict, .. } => format!("review: {}", verdict), Action::PatchComment { body } => truncate(body, 60), Action::PatchInlineComment { file, line, .. } => format!("comment on {}:{}", file, line), Action::PatchClose { reason } => match reason { diff --git a/src/patch.rs b/src/patch.rs index a75752a..e260649 100644 --- a/src/patch.rs +++ b/src/patch.rs @@ -167,13 +167,8 @@ pub fn list_to_writer( } pub fn list_json(repo: &Repository, show_closed: bool, show_archived: bool, sort: SortMode) -> Result<String, crate::error::Error> { let patches = if show_archived { state::list_patches_with_archived(repo)? } else { state::list_patches(repo)? }; let filtered = cli::filter_sort_paginate(patches, show_closed, sort, None, None); Ok(serde_json::to_string_pretty(&filtered)?) let patches = list(repo, show_closed, show_archived, None, None, sort)?; Ok(serde_json::to_string_pretty(&patches)?) } pub fn show_json(repo: &Repository, id_prefix: &str) -> Result<String, crate::error::Error> { @@ -369,25 +364,29 @@ pub fn diff( } } /// Resolve the base tree for diffing: find the merge-base between the base branch /// and the given head OID, falling back to the base branch tip if no merge-base exists. fn resolve_base_tree<'a>( repo: &'a Repository, base_branch: &str, head_oid: Oid, ) -> Result<Option<git2::Tree<'a>>, Error> { let base_ref = format!("refs/heads/{}", base_branch); let base_oid = match repo.refname_to_id(&base_ref) { Ok(oid) => oid, Err(_) => return Ok(None), }; let tree_source = repo.merge_base(base_oid, head_oid).unwrap_or(base_oid); Ok(Some(repo.find_commit(tree_source)?.tree()?)) } /// Generate a diff string from a patch's base and head using three-dot (merge-base) diff. pub fn generate_diff(repo: &Repository, patch: &state::PatchState) -> Result<String, Error> { let head_oid = patch.resolve_head(repo)?; let head_commit = repo.find_commit(head_oid) .map_err(|e| Error::Cmd(format!("bad head ref: {}", e)))?; 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) { if let Ok(merge_base_oid) = repo.merge_base(base_oid, head_oid) { let merge_base_commit = repo.find_commit(merge_base_oid)?; Some(merge_base_commit.tree()?) } else { let base_commit = repo.find_commit(base_oid)?; Some(base_commit.tree()?) } } else { None }; let base_tree = resolve_base_tree(repo, &patch.base_ref, head_oid)?; let git_diff = repo.diff_tree_to_tree(base_tree.as_ref(), Some(&head_tree), None)?; format_diff(&git_diff) @@ -402,22 +401,9 @@ fn generate_diff_at_revision( let revision = patch.revisions.iter().find(|r| r.number == rev_number) .ok_or_else(|| Error::Cmd(format!("revision {} not found", rev_number)))?; let tree_oid = Oid::from_str(&revision.tree)?; let head_tree = repo.find_tree(tree_oid)?; let base_ref = format!("refs/heads/{}", patch.base_ref); let head_tree = repo.find_tree(Oid::from_str(&revision.tree)?)?; let commit_oid = Oid::from_str(&revision.commit)?; let base_tree = if let Ok(base_oid) = repo.refname_to_id(&base_ref) { if let Ok(merge_base_oid) = repo.merge_base(base_oid, commit_oid) { let merge_base_commit = repo.find_commit(merge_base_oid)?; Some(merge_base_commit.tree()?) } else { let base_commit = repo.find_commit(base_oid)?; Some(base_commit.tree()?) } } else { None }; let base_tree = resolve_base_tree(repo, &patch.base_ref, commit_oid)?; let git_diff = repo.diff_tree_to_tree(base_tree.as_ref(), Some(&head_tree), None)?; format_diff(&git_diff) diff --git a/src/state.rs b/src/state.rs index a08289a..9b0132e 100644 --- a/src/state.rs +++ b/src/state.rs @@ -541,12 +541,11 @@ impl PatchState { } } /// Enumerate all collab refs of a given kind, returning (ref_name, id) pairs. fn collab_refs( /// Enumerate collab refs under a given prefix, returning (ref_name, id) pairs. fn refs_under( repo: &Repository, kind: &str, prefix: &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(); @@ -554,7 +553,7 @@ fn collab_refs( let r = r?; let ref_name = r.name().unwrap_or_default().to_string(); let id = ref_name .strip_prefix(&prefix) .strip_prefix(prefix) .unwrap_or_default() .to_string(); result.push((ref_name, id)); @@ -562,25 +561,18 @@ fn collab_refs( Ok(result) } /// Enumerate all collab refs of a given kind under the archive namespace. fn collab_refs( repo: &Repository, kind: &str, ) -> Result<Vec<(String, String)>, crate::error::Error> { refs_under(repo, &format!("refs/collab/{}/", kind)) } fn collab_archive_refs( repo: &Repository, kind: &str, ) -> Result<Vec<(String, String)>, crate::error::Error> { let prefix = format!("refs/collab/archive/{}/", 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(&prefix) .unwrap_or_default() .to_string(); result.push((ref_name, id)); } Ok(result) refs_under(repo, &format!("refs/collab/archive/{}/", kind)) } /// Resolve a short ID prefix to a full ref. Searches both active and archive namespaces. diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 2d2300a..ccb92a6 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -496,7 +496,7 @@ mod tests { }; let detail = format_event_detail(&oid, &event); assert!(detail.contains("Patch Review")); assert!(detail.contains("Approve")); assert!(detail.contains("approve")); assert!(detail.contains("Looks good!")); } diff --git a/src/tui/widgets.rs b/src/tui/widgets.rs index fcfdc69..170ebff 100644 --- a/src/tui/widgets.rs +++ b/src/tui/widgets.rs @@ -78,7 +78,7 @@ pub(crate) fn format_event_detail(oid: &Oid, event: &crate::event::Event) -> Str } } Action::PatchReview { verdict, body, .. } => { detail.push_str(&format!("\nVerdict: {:?}\n", verdict)); detail.push_str(&format!("\nVerdict: {}\n", verdict)); if !body.is_empty() { detail.push_str(&format!("\n{}\n", body)); } diff --git a/tests/crdt_test.rs b/tests/crdt_test.rs index 4853366..d618350 100644 --- a/tests/crdt_test.rs +++ b/tests/crdt_test.rs @@ -1,42 +1,14 @@ mod common; use common::{alice, bob, init_repo, test_signing_key}; use ed25519_dalek::SigningKey; use git2::{Oid, Repository}; use rand_core::OsRng; use tempfile::TempDir; use git_collab::dag; use git_collab::event::{Action, Author, Event}; use git_collab::event::{Action, Event}; use git_collab::state::{IssueState, IssueStatus, PatchState, PatchStatus}; 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 test_sk() -> SigningKey { SigningKey::generate(&mut OsRng) } fn init_repo(dir: &std::path::Path) -> Repository { let repo = Repository::init(dir).unwrap(); { let mut config = repo.config().unwrap(); config.set_str("user.name", "Test").unwrap(); config.set_str("user.email", "test@test.com").unwrap(); } repo } fn read_event_clock(repo: &Repository, oid: Oid) -> u64 { let commit = repo.find_commit(oid).unwrap(); let tree = commit.tree().unwrap(); @@ -51,8 +23,8 @@ fn read_event_clock(repo: &Repository, oid: Oid) -> u64 { #[test] fn create_root_event_sets_clock_to_1() { let dir = TempDir::new().unwrap(); let repo = init_repo(dir.path()); let sk = test_sk(); let repo = init_repo(dir.path(), &alice()); let sk = test_signing_key(); let event = Event { timestamp: "2026-01-01T00:00:00Z".to_string(), @@ -72,8 +44,8 @@ fn create_root_event_sets_clock_to_1() { #[test] fn append_event_increments_clock() { let dir = TempDir::new().unwrap(); let repo = init_repo(dir.path()); let sk = test_sk(); let repo = init_repo(dir.path(), &alice()); let sk = test_signing_key(); let open_event = Event { timestamp: "2026-01-01T00:00:00Z".to_string(), @@ -118,8 +90,8 @@ fn append_event_increments_clock() { #[test] fn max_clock_returns_highest_clock_in_dag() { let dir = TempDir::new().unwrap(); let repo = init_repo(dir.path()); let sk = test_sk(); let repo = init_repo(dir.path(), &alice()); let sk = test_signing_key(); let event = Event { timestamp: "2026-01-01T00:00:00Z".to_string(), @@ -155,8 +127,8 @@ fn max_clock_returns_highest_clock_in_dag() { #[test] fn reconcile_merge_clock_is_max_of_both_branches_plus_one() { let dir = TempDir::new().unwrap(); let repo = init_repo(dir.path()); let sk = test_sk(); let repo = init_repo(dir.path(), &alice()); let sk = test_signing_key(); // Create root event (clock=1) let open = Event { @@ -221,8 +193,8 @@ fn concurrent_issue_close_reopen_higher_clock_wins() { // the OID tiebreaker should produce a deterministic result. // But when clocks differ, the higher clock always wins. let dir = TempDir::new().unwrap(); let repo = init_repo(dir.path()); let sk = test_sk(); let repo = init_repo(dir.path(), &alice()); let sk = test_signing_key(); // Create root issue (clock=1) let open = Event { @@ -280,8 +252,8 @@ fn concurrent_issue_close_reopen_higher_clock_wins() { fn concurrent_issue_same_clock_oid_breaks_tie() { // When two status changes have the same clock, higher OID (lexicographic) wins. let dir = TempDir::new().unwrap(); let repo = init_repo(dir.path()); let sk = test_sk(); let repo = init_repo(dir.path(), &alice()); let sk = test_signing_key(); // Create root issue (clock=1) let open = Event { @@ -338,17 +310,12 @@ fn concurrent_issue_same_clock_oid_breaks_tie() { #[test] fn concurrent_patch_close_merge_higher_clock_wins() { let dir = TempDir::new().unwrap(); let repo = init_repo(dir.path()); let sk = test_sk(); let repo = init_repo(dir.path(), &alice()); let sk = test_signing_key(); // Need a branch for the patch let sig = git2::Signature::now("Test", "test@test.com").unwrap(); let tree_oid = repo.treebuilder(None).unwrap().write().unwrap(); let tree = repo.find_tree(tree_oid).unwrap(); let initial = repo .commit(Some("refs/heads/main"), &sig, &sig, "initial", &tree, &[]) .unwrap(); let initial_commit = repo.find_commit(initial).unwrap(); let main_oid = repo.refname_to_id("refs/heads/main").unwrap(); let initial_commit = repo.find_commit(main_oid).unwrap(); repo.branch("test-branch", &initial_commit, false).unwrap(); // Create root patch (clock=1) @@ -414,8 +381,8 @@ fn concurrent_patch_close_merge_higher_clock_wins() { #[test] fn migrate_clocks_assigns_sequential_clocks() { let dir = TempDir::new().unwrap(); let repo = init_repo(dir.path()); let sk = test_sk(); let repo = init_repo(dir.path(), &alice()); let sk = test_signing_key(); // Create a DAG with events that have clock=0 (simulating pre-migration data) // We need to create commits directly to simulate old format @@ -463,8 +430,8 @@ fn migrate_clocks_assigns_sequential_clocks() { fn migrate_clocks_on_zero_clock_events() { // Build a DAG with clock=0 events by writing directly (bypassing DAG functions) let dir = TempDir::new().unwrap(); let repo = init_repo(dir.path()); let sk = test_sk(); let repo = init_repo(dir.path(), &alice()); let sk = test_signing_key(); let event1 = Event { timestamp: "2026-01-01T00:00:00Z".to_string(), @@ -533,8 +500,8 @@ fn clock_field_survives_serialization_round_trip() { #[test] fn clock_field_in_dag_round_trip() { let dir = TempDir::new().unwrap(); let repo = init_repo(dir.path()); let sk = test_sk(); let repo = init_repo(dir.path(), &alice()); let sk = test_signing_key(); let event = Event { timestamp: "2026-01-01T00:00:00Z".to_string(), diff --git a/tests/sort_test.rs b/tests/sort_test.rs index 81c0c93..04c979c 100644 --- a/tests/sort_test.rs +++ b/tests/sort_test.rs @@ -1,9 +1,7 @@ mod common; use common::{alice, bob, TestRepo}; use ed25519_dalek::SigningKey; use common::{alice, bob, init_repo, test_signing_key, TestRepo}; use git2::Repository; use rand_core::OsRng; use tempfile::TempDir; use git_collab::cli::SortMode; @@ -11,20 +9,6 @@ use git_collab::dag; use git_collab::event::{Action, Author, Event}; use git_collab::state::{self, IssueState, PatchState}; fn test_signing_key() -> SigningKey { SigningKey::generate(&mut OsRng) } fn init_repo(dir: &std::path::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 } /// Create an issue with a specific timestamp. Returns (ref_name, id). fn open_issue_at( repo: &Repository,