1fdc1306
Add patch merge, error handling, conflict resolution, and test cleanup
a73x 2026-03-20 18:54
- Add `patch merge` command that fast-forwards or merge-commits the patch head into the base branch, then records a PatchMerge event - Replace all .expect() panics with thiserror error enum (error::Error) wrapping git2::Error and serde_json::Error - Implement timestamp-wins conflict resolution for concurrent status transitions (close vs reopen on same entity) - Add PatchMerge action variant and wire PatchStatus::Merged - Clean up unused imports and variables in test files - Propagate crate::error::Error through all public API functions Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
diff --git a/Cargo.lock b/Cargo.lock index d5a2cb5..c850add 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -258,6 +258,7 @@ dependencies = [ "serde", "serde_json", "tempfile", "thiserror", ] [[package]] @@ -776,6 +777,26 @@ dependencies = [ ] [[package]] name = "thiserror" version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", "syn", ] [[package]] name = "tinystr" version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" diff --git a/Cargo.toml b/Cargo.toml index c2d0704..6c23c40 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ clap = { version = "4", features = ["derive"] } serde = { version = "1", features = ["derive"] } serde_json = "1" chrono = { version = "0.4", features = ["serde"] } thiserror = "2" [dev-dependencies] tempfile = "3" diff --git a/src/cli.rs b/src/cli.rs index c99da78..e097e87 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -126,6 +126,11 @@ pub enum PatchCmd { #[arg(short, long)] body: Option<String>, }, /// Merge a patch into its base branch Merge { /// Patch ID (prefix match) id: String, }, /// Close a patch Close { /// Patch ID (prefix match) diff --git a/src/dag.rs b/src/dag.rs index e9d4233..0255be7 100644 --- a/src/dag.rs +++ b/src/dag.rs @@ -1,12 +1,13 @@ use git2::{Oid, Repository, Sort}; use crate::error::Error; use crate::event::{Action, Event}; use crate::identity::author_signature; /// 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, git2::Error> { let json = serde_json::to_vec_pretty(event).expect("event serialization failed"); pub fn create_root_event(repo: &Repository, event: &Event) -> Result<Oid, Error> { let json = serde_json::to_vec_pretty(event)?; let blob_oid = repo.blob(&json)?; let mut tb = repo.treebuilder(None)?; @@ -22,8 +23,8 @@ pub fn create_root_event(repo: &Repository, event: &Event) -> Result<Oid, git2:: } /// 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, git2::Error> { let json = serde_json::to_vec_pretty(event).expect("event serialization failed"); pub fn append_event(repo: &Repository, ref_name: &str, event: &Event) -> Result<Oid, Error> { let json = serde_json::to_vec_pretty(event)?; let blob_oid = repo.blob(&json)?; let mut tb = repo.treebuilder(None)?; @@ -43,7 +44,7 @@ pub fn append_event(repo: &Repository, ref_name: &str, event: &Event) -> Result< /// Walk the DAG from the given ref in topological order (oldest first). /// Returns (commit_oid, event) pairs. pub fn walk_events(repo: &Repository, ref_name: &str) -> Result<Vec<(Oid, Event)>, git2::Error> { pub fn walk_events(repo: &Repository, ref_name: &str) -> Result<Vec<(Oid, Event)>, Error> { let tip = repo.refname_to_id(ref_name)?; let mut revwalk = repo.revwalk()?; revwalk.set_sorting(Sort::TOPOLOGICAL | Sort::REVERSE)?; @@ -58,8 +59,7 @@ 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()).expect("invalid event.json in commit"); let event: Event = serde_json::from_slice(blob.content())?; events.push((oid, event)); } Ok(events) @@ -76,7 +76,7 @@ pub fn reconcile( local_ref: &str, remote_ref: &str, merge_author: &crate::event::Author, ) -> Result<Oid, git2::Error> { ) -> Result<Oid, Error> { let local_oid = repo.refname_to_id(local_ref)?; let remote_oid = repo.refname_to_id(remote_ref)?; @@ -104,7 +104,7 @@ pub fn reconcile( action: Action::Merge, }; let json = serde_json::to_vec_pretty(&merge_event).expect("event serialization failed"); let json = serde_json::to_vec_pretty(&merge_event)?; let blob_oid = repo.blob(&json)?; let mut tb = repo.treebuilder(None)?; tb.insert("event.json", blob_oid, 0o100644)?; @@ -138,6 +138,7 @@ fn commit_message(action: &Action) -> String { Action::PatchReview { verdict, .. } => format!("patch: review ({:?})", verdict), Action::PatchComment { .. } => "patch: comment".to_string(), Action::PatchClose { .. } => "patch: close".to_string(), Action::PatchMerge => "patch: merge".to_string(), Action::Merge => "collab: merge".to_string(), } } diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..82a949c --- /dev/null +++ b/src/error.rs @@ -0,0 +1,10 @@ use thiserror::Error; #[derive(Debug, Error)] pub enum Error { #[error(transparent)] Git(#[from] git2::Error), #[error(transparent)] Json(#[from] serde_json::Error), } diff --git a/src/event.rs b/src/event.rs index f7ba3a3..26f3b31 100644 --- a/src/event.rs +++ b/src/event.rs @@ -47,6 +47,7 @@ pub enum Action { PatchClose { reason: Option<String>, }, PatchMerge, Merge, } diff --git a/src/issue.rs b/src/issue.rs index ccf68cf..e9fb09e 100644 --- a/src/issue.rs +++ b/src/issue.rs @@ -5,7 +5,7 @@ use crate::event::{Action, Event}; use crate::identity::get_author; use crate::state::{self, IssueStatus}; pub fn open(repo: &Repository, title: &str, body: &str) -> Result<String, git2::Error> { pub fn open(repo: &Repository, title: &str, body: &str) -> Result<String, crate::error::Error> { let author = get_author(repo)?; let event = Event { timestamp: chrono::Utc::now().to_rfc3339(), @@ -22,7 +22,7 @@ pub fn open(repo: &Repository, title: &str, body: &str) -> Result<String, git2:: Ok(id) } pub fn list(repo: &Repository, show_closed: bool) -> Result<(), git2::Error> { pub fn list(repo: &Repository, show_closed: bool) -> Result<(), crate::error::Error> { let issues = state::list_issues(repo)?; let filtered: Vec<_> = issues .iter() @@ -47,7 +47,7 @@ pub fn list(repo: &Repository, show_closed: bool) -> Result<(), git2::Error> { Ok(()) } pub fn show(repo: &Repository, id_prefix: &str) -> Result<(), git2::Error> { pub fn show(repo: &Repository, id_prefix: &str) -> Result<(), crate::error::Error> { let (ref_name, id) = state::resolve_issue_ref(repo, id_prefix)?; let issue = state::IssueState::from_ref(repo, &ref_name, &id)?; @@ -71,7 +71,7 @@ pub fn show(repo: &Repository, id_prefix: &str) -> Result<(), git2::Error> { Ok(()) } pub fn comment(repo: &Repository, id_prefix: &str, body: &str) -> Result<(), git2::Error> { pub fn comment(repo: &Repository, id_prefix: &str, body: &str) -> Result<(), crate::error::Error> { let (ref_name, _id) = state::resolve_issue_ref(repo, id_prefix)?; let author = get_author(repo)?; let event = Event { @@ -86,7 +86,11 @@ pub fn comment(repo: &Repository, id_prefix: &str, body: &str) -> Result<(), git Ok(()) } pub fn close(repo: &Repository, id_prefix: &str, reason: Option<&str>) -> Result<(), git2::Error> { pub fn close( repo: &Repository, id_prefix: &str, reason: Option<&str>, ) -> Result<(), crate::error::Error> { let (ref_name, _id) = state::resolve_issue_ref(repo, id_prefix)?; let author = get_author(repo)?; let event = Event { @@ -101,7 +105,7 @@ pub fn close(repo: &Repository, id_prefix: &str, reason: Option<&str>) -> Result Ok(()) } pub fn reopen(repo: &Repository, id_prefix: &str) -> Result<(), git2::Error> { pub fn reopen(repo: &Repository, id_prefix: &str) -> Result<(), crate::error::Error> { let (ref_name, _id) = state::resolve_issue_ref(repo, id_prefix)?; let author = get_author(repo)?; let event = Event { diff --git a/src/lib.rs b/src/lib.rs index 0e99bd2..3dd124d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,6 @@ pub mod cli; pub mod dag; pub mod error; pub mod event; pub mod identity; pub mod issue; diff --git a/src/main.rs b/src/main.rs index 8ba155a..29d73c6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,6 @@ mod cli; mod dag; mod error; mod event; mod identity; mod issue; @@ -22,7 +23,7 @@ fn main() { } } fn run(cli: Cli) -> Result<(), git2::Error> { fn run(cli: Cli) -> Result<(), error::Error> { let repo = Repository::open_from_env()?; match cli.command { @@ -60,7 +61,8 @@ fn run(cli: Cli) -> Result<(), git2::Error> { _ => { return Err(git2::Error::from_str( "verdict must be: approve, request-changes, or comment", )); ) .into()); } }; patch::review(&repo, &id, v, &body) @@ -68,6 +70,7 @@ fn run(cli: Cli) -> Result<(), git2::Error> { 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::Sync { remote } => sync::sync(&repo, &remote), diff --git a/src/patch.rs b/src/patch.rs index 1f47c95..eceee5e 100644 --- a/src/patch.rs +++ b/src/patch.rs @@ -11,7 +11,7 @@ pub fn create( body: &str, base_ref: &str, head_commit: &str, ) -> Result<String, git2::Error> { ) -> Result<String, crate::error::Error> { let author = get_author(repo)?; let event = Event { timestamp: chrono::Utc::now().to_rfc3339(), @@ -30,7 +30,7 @@ pub fn create( Ok(id) } pub fn list(repo: &Repository, show_closed: bool) -> Result<(), git2::Error> { pub fn list(repo: &Repository, show_closed: bool) -> Result<(), crate::error::Error> { let patches = state::list_patches(repo)?; let filtered: Vec<_> = patches .iter() @@ -56,7 +56,7 @@ pub fn list(repo: &Repository, show_closed: bool) -> Result<(), git2::Error> { Ok(()) } pub fn show(repo: &Repository, id_prefix: &str) -> Result<(), git2::Error> { pub fn show(repo: &Repository, id_prefix: &str) -> Result<(), crate::error::Error> { let (ref_name, id) = state::resolve_patch_ref(repo, id_prefix)?; let p = state::PatchState::from_ref(repo, &ref_name, &id)?; @@ -96,7 +96,7 @@ pub fn review( id_prefix: &str, verdict: ReviewVerdict, body: &str, ) -> Result<(), git2::Error> { ) -> Result<(), crate::error::Error> { let (ref_name, _id) = state::resolve_patch_ref(repo, id_prefix)?; let author = get_author(repo)?; let event = Event { @@ -117,7 +117,7 @@ pub fn revise( id_prefix: &str, head_commit: &str, body: Option<&str>, ) -> Result<(), git2::Error> { ) -> Result<(), crate::error::Error> { let (ref_name, _id) = state::resolve_patch_ref(repo, id_prefix)?; let author = get_author(repo)?; let event = Event { @@ -133,7 +133,73 @@ pub fn revise( Ok(()) } pub fn close(repo: &Repository, id_prefix: &str, reason: Option<&str>) -> Result<(), git2::Error> { pub fn merge(repo: &Repository, id_prefix: &str) -> Result<(), crate::error::Error> { let (ref_name, id) = state::resolve_patch_ref(repo, id_prefix)?; let p = state::PatchState::from_ref(repo, &ref_name, &id)?; if p.status != PatchStatus::Open { return Err(git2::Error::from_str(&format!( "patch is {:?}, can only merge open patches", p.status )) .into()); } // Resolve the head commit and the base branch let head_oid = git2::Oid::from_str(&p.head_commit) .map_err(|_| git2::Error::from_str("invalid head commit OID in patch"))?; let head_commit = repo.find_commit(head_oid)?; let base_ref = format!("refs/heads/{}", p.base_ref); let base_oid = repo.refname_to_id(&base_ref)?; // Fast-forward: the base must be an ancestor of head if repo.graph_descendant_of(head_oid, base_oid)? { repo.reference(&base_ref, head_oid, true, "collab: merge patch")?; } else if head_oid == base_oid { // Already at the same point, nothing to do } else { // Not a fast-forward — create a merge commit on the base branch let base_commit = repo.find_commit(base_oid)?; let author = get_author(repo)?; let sig = crate::identity::author_signature(&author)?; let mut index = repo.merge_commits(&base_commit, &head_commit, None)?; if index.has_conflicts() { return Err(git2::Error::from_str( "merge has conflicts; resolve manually then revise the patch", ) .into()); } let tree_oid = index.write_tree_to(repo)?; let tree = repo.find_tree(tree_oid)?; let msg = format!("Merge patch {:.8}: {}", id, p.title); repo.commit( Some(&base_ref), &sig, &sig, &msg, &tree, &[&base_commit, &head_commit], )?; } // Record the merge event in the patch DAG let author = get_author(repo)?; let event = Event { timestamp: chrono::Utc::now().to_rfc3339(), author, action: Action::PatchMerge, }; dag::append_event(repo, &ref_name, &event)?; println!("Patch {:.8} merged into {}.", id, p.base_ref); Ok(()) } pub fn close( repo: &Repository, id_prefix: &str, reason: Option<&str>, ) -> Result<(), crate::error::Error> { let (ref_name, _id) = state::resolve_patch_ref(repo, id_prefix)?; let author = get_author(repo)?; let event = Event { diff --git a/src/state.rs b/src/state.rs index f047f04..c70a6d4 100644 --- a/src/state.rs +++ b/src/state.rs @@ -60,10 +60,19 @@ pub struct PatchState { } impl IssueState { pub fn from_ref(repo: &Repository, ref_name: &str, id: &str) -> Result<Self, git2::Error> { pub fn from_ref( repo: &Repository, ref_name: &str, id: &str, ) -> Result<Self, crate::error::Error> { let events = dag::walk_events(repo, ref_name)?; let mut state: Option<IssueState> = None; // Track the timestamp of the latest status-changing event so that // concurrent close/reopen conflicts resolve deterministically: // the event with the later timestamp wins. let mut status_ts: Option<String> = None; for (oid, event) in events { match event.action { Action::IssueOpen { title, body } => { @@ -89,12 +98,18 @@ impl IssueState { } Action::IssueClose { .. } => { if let Some(ref mut s) = state { s.status = IssueStatus::Closed; if status_ts.as_ref().is_none_or(|ts| event.timestamp >= *ts) { s.status = IssueStatus::Closed; status_ts = Some(event.timestamp.clone()); } } } Action::IssueReopen => { if let Some(ref mut s) = state { s.status = IssueStatus::Open; if status_ts.as_ref().is_none_or(|ts| event.timestamp >= *ts) { s.status = IssueStatus::Open; status_ts = Some(event.timestamp.clone()); } } } Action::Merge => {} @@ -102,15 +117,21 @@ impl IssueState { } } state.ok_or_else(|| git2::Error::from_str("no IssueOpen event found in DAG")) state.ok_or_else(|| git2::Error::from_str("no IssueOpen event found in DAG").into()) } } impl PatchState { pub fn from_ref(repo: &Repository, ref_name: &str, id: &str) -> Result<Self, git2::Error> { pub fn from_ref( repo: &Repository, ref_name: &str, id: &str, ) -> Result<Self, crate::error::Error> { let events = dag::walk_events(repo, ref_name)?; let mut state: Option<PatchState> = None; let mut status_ts: Option<String> = None; for (oid, event) in events { match event.action { Action::PatchCreate { @@ -162,7 +183,18 @@ impl PatchState { } Action::PatchClose { .. } => { if let Some(ref mut s) = state { s.status = PatchStatus::Closed; if status_ts.as_ref().is_none_or(|ts| event.timestamp >= *ts) { s.status = PatchStatus::Closed; status_ts = Some(event.timestamp.clone()); } } } Action::PatchMerge => { if let Some(ref mut s) = state { if status_ts.as_ref().is_none_or(|ts| event.timestamp >= *ts) { s.status = PatchStatus::Merged; status_ts = Some(event.timestamp.clone()); } } } Action::Merge => {} @@ -170,12 +202,12 @@ impl PatchState { } } state.ok_or_else(|| git2::Error::from_str("no PatchCreate event found in DAG")) state.ok_or_else(|| git2::Error::from_str("no PatchCreate event found in DAG").into()) } } /// List all issue refs and return their materialized state. pub fn list_issues(repo: &Repository) -> Result<Vec<IssueState>, git2::Error> { 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 { @@ -194,7 +226,7 @@ pub fn list_issues(repo: &Repository) -> Result<Vec<IssueState>, git2::Error> { } /// List all patch refs and return their materialized state. pub fn list_patches(repo: &Repository) -> Result<Vec<PatchState>, git2::Error> { 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/*")?; for r in refs { @@ -213,7 +245,10 @@ pub fn list_patches(repo: &Repository) -> Result<Vec<PatchState>, git2::Error> { } /// Resolve a short ID prefix to the full ref name. Returns (ref_name, id). pub fn resolve_issue_ref(repo: &Repository, prefix: &str) -> Result<(String, String), git2::Error> { pub fn resolve_issue_ref( repo: &Repository, 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 { @@ -228,21 +263,22 @@ pub fn resolve_issue_ref(repo: &Repository, prefix: &str) -> Result<(String, Str } } match matches.len() { 0 => Err(git2::Error::from_str(&format!( "no issue found matching '{}'", prefix ))), 0 => Err(git2::Error::from_str(&format!("no issue found matching '{}'", prefix)).into()), 1 => Ok(matches.into_iter().next().unwrap()), _ => Err(git2::Error::from_str(&format!( "ambiguous issue prefix '{}': {} matches", prefix, matches.len() ))), )) .into()), } } /// Resolve a short ID prefix to the full patch ref name. pub fn resolve_patch_ref(repo: &Repository, prefix: &str) -> Result<(String, String), git2::Error> { 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 { @@ -257,15 +293,13 @@ pub fn resolve_patch_ref(repo: &Repository, prefix: &str) -> Result<(String, Str } } match matches.len() { 0 => Err(git2::Error::from_str(&format!( "no patch found matching '{}'", prefix ))), 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()), } } diff --git a/src/sync.rs b/src/sync.rs index b51d0c8..3c6e0f6 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -4,7 +4,7 @@ use crate::dag; use crate::identity::get_author; /// Add collab refspecs to all remotes. pub fn init(repo: &Repository) -> Result<(), git2::Error> { pub fn init(repo: &Repository) -> Result<(), crate::error::Error> { let remotes = repo.remotes()?; if remotes.is_empty() { println!("No remotes configured."); @@ -21,7 +21,7 @@ pub fn init(repo: &Repository) -> Result<(), git2::Error> { } /// Sync with a specific remote: fetch, reconcile, push. pub fn sync(repo: &Repository, remote_name: &str) -> Result<(), git2::Error> { pub fn sync(repo: &Repository, remote_name: &str) -> Result<(), crate::error::Error> { let author = get_author(repo)?; // Step 1: Connect to remote and discover collab refs @@ -99,7 +99,7 @@ pub fn sync(repo: &Repository, remote_name: &str) -> Result<(), git2::Error> { fn list_remote_collab_refs( repo: &Repository, remote_name: &str, ) -> Result<Vec<(String, Oid)>, git2::Error> { ) -> Result<Vec<(String, Oid)>, crate::error::Error> { let mut remote = repo.find_remote(remote_name)?; remote.connect(Direction::Fetch)?; @@ -123,7 +123,7 @@ fn reconcile_refs( remote_name: &str, kind: &str, author: &crate::event::Author, ) -> Result<(), git2::Error> { ) -> Result<(), crate::error::Error> { let sync_prefix = format!("refs/collab/sync/{}/{}/", remote_name, kind); let sync_refs: Vec<(String, String)> = { let refs = repo.references_glob(&format!("{}*", sync_prefix))?; diff --git a/tests/collab_test.rs b/tests/collab_test.rs index c7d31c9..f19b6a9 100644 --- a/tests/collab_test.rs +++ b/tests/collab_test.rs @@ -1,4 +1,4 @@ use git2::{Repository, Sort}; use git2::Repository; use std::path::Path; use tempfile::TempDir; @@ -136,7 +136,7 @@ fn test_list_issues_filters_by_status() { let tmp = TempDir::new().unwrap(); let repo = init_repo(tmp.path(), &alice()); let (ref1, _) = open_issue(&repo, &alice(), "Open issue"); let (_ref1, _) = open_issue(&repo, &alice(), "Open issue"); let (ref2, _) = open_issue(&repo, &alice(), "Closed issue"); close_issue(&repo, &ref2, &alice()); @@ -295,7 +295,7 @@ fn test_concurrent_close_and_reopen() { dag::reconcile(&repo, &ref_name, &remote_ref, &alice()).unwrap(); let state = IssueState::from_ref(&repo, &ref_name, &id).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(); diff --git a/tests/sync_test.rs b/tests/sync_test.rs index 44cba29..320613b 100644 --- a/tests/sync_test.rs +++ b/tests/sync_test.rs @@ -5,13 +5,12 @@ //! bare_remote <---push/fetch---> alice_repo //! <---push/fetch---> bob_repo use std::path::Path; use tempfile::TempDir; use git2::Repository; use git_collab::dag; use git_collab::event::{Action, Author, Event, ReviewVerdict}; use git_collab::state::{self, IssueState, IssueStatus, PatchState, PatchStatus}; use git_collab::state::{self, IssueState, IssueStatus, PatchState}; use git_collab::sync; // --------------------------------------------------------------------------- @@ -242,8 +241,8 @@ fn test_both_create_different_issues() { let alice_repo = cluster.alice_repo(); let bob_repo = cluster.bob_repo(); 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"); 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();