a73x

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();