a73x

6e02a0d0

Add auto-sync, search, patch checkout, and patch unread tracking

alex emery   2026-04-12 04:57

- auto-sync: after any local write command succeeds, push to the
  configured remote (default origin). Controlled by collab.autoSync
  git config (default true) with collab.autoSyncRemote override.
  Sync failures warn on stderr but never fail the command — the
  local write already succeeded.
- collab search <query>: case-insensitive full-text search across
  all issues and patches (title, body, comments, reviews, inline
  comments), grouped results with match-field hints.
- collab patch checkout <id>: create a local branch from the patch's
  latest revision commit so reviewers can use their editor, LSP, and
  tests against the code under review. Branch is named collab/<short-id>
  with a numeric suffix if the name is taken.
- patch unread tracking: mirror the existing issue unread system with
  refs/collab/local/seen/patches/{id} seen markers. patch show marks
  as read by storing the current tip; patch list shows "(N new)" when
  events have accrued since the last view.
- patch list and show now surface staleness: "[behind N]" in list,
  "(branch is N commits behind main)" in show, when the patch branch
  has fallen behind its base ref.
- tests disable auto-sync on the test repo so CLI stdout assertions
  aren't polluted by sync output.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

diff --git a/src/cli.rs b/src/cli.rs
index 4f1486f..e60aef4 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -111,6 +111,12 @@ pub enum Commands {
    /// Manage identity aliases
    #[command(subcommand)]
    Identity(IdentityCmd),

    /// Full-text search across all issues and patches
    Search {
        /// Search query (case-insensitive substring match)
        query: String,
    },
}

#[derive(Subcommand)]
@@ -373,6 +379,38 @@ pub enum PatchCmd {
        /// Patch ID (prefix match)
        id: String,
    },
    /// Check out a patch's latest revision as a local branch
    Checkout {
        /// Patch ID (prefix match)
        id: String,
    },
}

impl Commands {
    pub fn is_write(&self) -> bool {
        match self {
            Commands::Issue(cmd) => matches!(
                cmd,
                IssueCmd::Open { .. }
                    | IssueCmd::Comment { .. }
                    | IssueCmd::Close { .. }
                    | IssueCmd::Edit { .. }
                    | IssueCmd::Label { .. }
                    | IssueCmd::Unlabel { .. }
                    | IssueCmd::Assign { .. }
                    | IssueCmd::Unassign { .. }
            ),
            Commands::Patch(cmd) => matches!(
                cmd,
                PatchCmd::Create { .. }
                    | PatchCmd::Comment { .. }
                    | PatchCmd::Review { .. }
                    | PatchCmd::Revise { .. }
                    | PatchCmd::Close { .. }
            ),
            _ => false,
        }
    }
}

#[derive(Subcommand)]
diff --git a/src/lib.rs b/src/lib.rs
index 8cec6a6..29c501f 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -43,7 +43,37 @@ pub fn staleness_warning(repo: &Repository, patch: &state::PatchState) -> Option
    ))
}

fn maybe_auto_sync(repo: &Repository) {
    let enabled = repo
        .config()
        .ok()
        .and_then(|c| c.get_bool("collab.autoSync").ok())
        .unwrap_or(true);

    if !enabled {
        return;
    }

    let remote = repo
        .config()
        .ok()
        .and_then(|c| c.get_string("collab.autoSyncRemote").ok())
        .unwrap_or_else(|| "origin".to_string());

    eprintln!("Auto-syncing with '{}'...", remote);
    match sync::sync(repo, &remote) {
        Ok(()) => {}
        Err(error::Error::PartialSync { succeeded, total }) => {
            eprintln!("warning: auto-sync partially failed ({}/{} refs pushed)", succeeded, total);
        }
        Err(e) => {
            eprintln!("warning: auto-sync failed: {}", e);
        }
    }
}

pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> {
    let is_write = cli.command.is_write();
    match cli.command {
        Commands::Init => sync::init(repo),
        Commands::Issue(cmd) => match cmd {
@@ -205,14 +235,24 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> {
                    println!("{}", output);
                    return Ok(());
                }
                let patches = patch::list(repo, all, archived, limit, offset, sort)?;
                if patches.is_empty() {
                let entries = patch::list(repo, all, archived, limit, offset, sort)?;
                if entries.is_empty() {
                    println!("No patches found.");
                } else {
                    for p in &patches {
                    for e in &entries {
                        let p = &e.patch;
                        let stale = match p.staleness(repo) {
                            Ok((_, behind)) if behind > 0 => format!(" [behind {}]", behind),
                            Ok(_) => String::new(),
                            Err(_) => String::new(),
                        };
                        let unread = match e.unread {
                            Some(n) if n > 0 => format!(" ({} new)", n),
                            _ => String::new(),
                        };
                        println!(
                            "{:.8}  {:6}  {}  (by {})",
                            p.id, p.status, p.title, p.author.name
                            "{:.8}  {:6}  {}  (by {}){}{}",
                            p.id, p.status, p.title, p.author.name, stale, unread
                        );
                    }
                }
@@ -226,7 +266,13 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> {
                }
                let p = patch::show(repo, &id)?;
                let rev_count = p.revisions.len();
                println!("Patch {} [{}] (r{})", &p.id[..8], p.status, rev_count);
                let status_detail = match p.staleness(repo) {
                    Ok((_, behind)) if behind > 0 => {
                        format!(" (branch is {} commits behind {})", behind, p.base_ref)
                    }
                    _ => String::new(),
                };
                println!("Patch {} [{}{}] (r{})", &p.id[..8], p.status, status_detail, rev_count);
                println!("Title: {}", p.title);
                println!("Author: {} <{}>", p.author.name, p.author.email);
                match p.resolve_head(repo) {
@@ -367,6 +413,10 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> {
                println!("Deleted patch {:.8}", full_id);
                Ok(())
            }
            PatchCmd::Checkout { id } => {
                patch::checkout(repo, &id)?;
                Ok(())
            }
        },
        Commands::Status => {
            let project_status = status::compute(repo)?;
@@ -525,5 +575,90 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> {
                Ok(())
            }
        },
        Commands::Search { query } => search(repo, &query),
    }?;

    if is_write {
        maybe_auto_sync(repo);
    }

    Ok(())
}

fn search(repo: &Repository, query: &str) -> Result<(), error::Error> {
    let q = query.to_lowercase();

    let issues = state::list_issues_with_archived(repo)?;
    let mut issue_results: Vec<(&str, &str, &str, String)> = Vec::new();

    for issue in &issues {
        let mut matches = Vec::new();
        if issue.title.to_lowercase().contains(&q) {
            matches.push("title");
        }
        if issue.body.to_lowercase().contains(&q) {
            matches.push("body");
        }
        if issue.comments.iter().any(|c| c.body.to_lowercase().contains(&q)) {
            matches.push("comment");
        }
        if !matches.is_empty() {
            issue_results.push((
                &issue.id,
                issue.status.as_str(),
                &issue.title,
                matches.join(", "),
            ));
        }
    }

    let patches = state::list_patches_with_archived(repo)?;
    let mut patch_results: Vec<(&str, &str, &str, String)> = Vec::new();

    for patch in &patches {
        let mut matches = Vec::new();
        if patch.title.to_lowercase().contains(&q) {
            matches.push("title");
        }
        if patch.body.to_lowercase().contains(&q) {
            matches.push("body");
        }
        if patch.comments.iter().any(|c| c.body.to_lowercase().contains(&q)) {
            matches.push("comment");
        }
        if patch.reviews.iter().any(|r| r.body.to_lowercase().contains(&q)) {
            matches.push("review");
        }
        if patch.inline_comments.iter().any(|ic| ic.body.to_lowercase().contains(&q)) {
            matches.push("inline comment");
        }
        if !matches.is_empty() {
            patch_results.push((
                &patch.id,
                patch.status.as_str(),
                &patch.title,
                matches.join(", "),
            ));
        }
    }

    println!("Issues:");
    if issue_results.is_empty() {
        println!("  (none)");
    } else {
        for (id, status, title, match_field) in &issue_results {
            println!("  {:.8}  {:6}  {}  ({} match)", id, status, title, match_field);
        }
    }
    println!();
    println!("Patches:");
    if patch_results.is_empty() {
        println!("  (none)");
    } else {
        for (id, status, title, match_field) in &patch_results {
            println!("  {:.8}  {:6}  {}  ({} match)", id, status, title, match_field);
        }
    }

    Ok(())
}
diff --git a/src/patch.rs b/src/patch.rs
index e260649..2751362 100644
--- a/src/patch.rs
+++ b/src/patch.rs
@@ -125,6 +125,31 @@ pub fn create(
    Ok(id)
}

pub struct ListEntry {
    pub patch: PatchState,
    pub unread: Option<usize>,
}

/// 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/patches/{}", id);
    let seen_oid = repo.refname_to_id(&seen_ref).ok()?;
    let ref_name = format!("refs/collab/patches/{}", id);
    let tip = repo.refname_to_id(&ref_name).ok()?;

    if seen_oid == tip {
        return Some(0);
    }

    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 list(
    repo: &Repository,
    show_closed: bool,
@@ -132,13 +157,21 @@ pub fn list(
    limit: Option<usize>,
    offset: Option<usize>,
    sort: SortMode,
) -> Result<Vec<PatchState>, crate::error::Error> {
) -> Result<Vec<ListEntry>, crate::error::Error> {
    let patches = if show_archived {
        state::list_patches_with_archived(repo)?
    } else {
        state::list_patches(repo)?
    };
    Ok(cli::filter_sort_paginate(patches, show_closed, sort, offset, limit))
    let filtered = cli::filter_sort_paginate(patches, show_closed, sort, offset, limit);
    let entries = filtered
        .into_iter()
        .map(|patch| {
            let unread = count_unread(repo, &patch.id);
            ListEntry { patch, unread }
        })
        .collect();
    Ok(entries)
}

pub fn list_to_writer(
@@ -150,16 +183,26 @@ pub fn list_to_writer(
    sort: SortMode,
    writer: &mut dyn std::io::Write,
) -> Result<(), crate::error::Error> {
    let patches = list(repo, show_closed, show_archived, limit, offset, sort)?;
    if patches.is_empty() {
    let entries = list(repo, show_closed, show_archived, limit, offset, sort)?;
    if entries.is_empty() {
        writeln!(writer, "No patches found.").ok();
        return Ok(());
    }
    for p in &patches {
    for e in &entries {
        let p = &e.patch;
        let stale = match p.staleness(repo) {
            Ok((_, behind)) if behind > 0 => format!(" [behind {}]", behind),
            Ok(_) => String::new(),
            Err(_) => String::new(),
        };
        let unread = match e.unread {
            Some(n) if n > 0 => format!(" ({} new)", n),
            _ => String::new(),
        };
        writeln!(
            writer,
            "{:.8}  {:6}  {}  (by {})",
            p.id, p.status, p.title, p.author.name
            "{:.8}  {:6}  {}  (by {}){}{}",
            p.id, p.status, p.title, p.author.name, stale, unread
        )
        .ok();
    }
@@ -167,7 +210,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 = list(repo, show_closed, show_archived, None, None, sort)?;
    let entries = list(repo, show_closed, show_archived, None, None, sort)?;
    let patches: Vec<&PatchState> = entries.iter().map(|e| &e.patch).collect();
    Ok(serde_json::to_string_pretty(&patches)?)
}

@@ -179,7 +223,12 @@ pub fn show_json(repo: &Repository, id_prefix: &str) -> Result<String, crate::er

pub fn show(repo: &Repository, id_prefix: &str) -> Result<PatchState, crate::error::Error> {
    let (ref_name, id) = state::resolve_patch_ref(repo, id_prefix)?;
    PatchState::from_ref(repo, &ref_name, &id)
    let patch = PatchState::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/patches/{}", id);
    repo.reference(&seen_ref, tip, true, "mark seen")?;
    Ok(patch)
}

pub fn comment(
@@ -518,6 +567,51 @@ pub fn patch_log_json(patch: &PatchState) -> Result<String, Error> {
    Ok(serde_json::to_string_pretty(&patch.revisions)?)
}

pub fn checkout(repo: &Repository, id_prefix: &str) -> Result<(), crate::error::Error> {
    let (_ref_name, id) = state::resolve_patch_ref(repo, id_prefix)?;
    let patch = show(repo, id_prefix)?;

    let latest_rev = patch
        .revisions
        .last()
        .ok_or_else(|| Error::Cmd("patch has no revisions".to_string()))?;
    let commit_oid = Oid::from_str(&latest_rev.commit)
        .map_err(|e| Error::Cmd(format!("invalid commit OID in revision: {}", e)))?;
    let commit = repo
        .find_commit(commit_oid)
        .map_err(|e| Error::Cmd(format!("commit {} not found: {}", &latest_rev.commit, e)))?;

    let short_id = &id[..std::cmp::min(8, id.len())];
    let branch_name = {
        let candidate = format!("collab/{}", short_id);
        if repo.find_branch(&candidate, git2::BranchType::Local).is_err() {
            candidate
        } else {
            let mut n = 1u32;
            loop {
                let suffixed = format!("collab/{}-{}", short_id, n);
                if repo.find_branch(&suffixed, git2::BranchType::Local).is_err() {
                    break suffixed;
                }
                n += 1;
            }
        }
    };

    repo.branch(&branch_name, &commit, false)?;

    let refname = format!("refs/heads/{}", branch_name);
    let obj = repo.revparse_single(&refname)?;
    repo.checkout_tree(&obj, None)?;
    repo.set_head(&refname)?;

    println!(
        "Checked out patch {} (revision {}) on branch {}",
        short_id, latest_rev.number, branch_name
    );
    Ok(())
}

pub fn delete(repo: &Repository, id_prefix: &str) -> Result<String, crate::error::Error> {
    let (ref_name, id) = state::resolve_patch_ref(repo, id_prefix)?;
    repo.find_reference(&ref_name)?.delete()?;
diff --git a/tests/common/mod.rs b/tests/common/mod.rs
index 8801e5d..2b9756c 100644
--- a/tests/common/mod.rs
+++ b/tests/common/mod.rs
@@ -317,6 +317,8 @@ impl TestRepo {
        git_with_env(dir.path(), &["init", "-b", "main"], &env);
        git_with_env(dir.path(), &["config", "user.name", name], &env);
        git_with_env(dir.path(), &["config", "user.email", email], &env);
        // Disable auto-sync for tests so CLI output isn't polluted by sync attempts
        git_with_env(dir.path(), &["config", "collab.autoSync", "false"], &env);
        git_with_env(dir.path(), &["commit", "--allow-empty", "-m", "initial"], &env);
        env.ensure_signing_key();

diff --git a/tests/sort_test.rs b/tests/sort_test.rs
index 04c979c..74a9722 100644
--- a/tests/sort_test.rs
+++ b/tests/sort_test.rs
@@ -234,10 +234,10 @@ fn test_patch_default_sort_by_recency() {
    // Patch B: created later, never updated
    let (_, _) = create_patch_at(&repo, &alice(), "Beta patch", "2025-06-01T00:00:00Z");

    let patches = git_collab::patch::list(&repo, true, false, None, None, SortMode::Recent).unwrap();
    assert_eq!(patches.len(), 2);
    assert_eq!(patches[0].title, "Alpha patch");
    assert_eq!(patches[1].title, "Beta patch");
    let entries = git_collab::patch::list(&repo, true, false, None, None, SortMode::Recent).unwrap();
    assert_eq!(entries.len(), 2);
    assert_eq!(entries[0].patch.title, "Alpha patch");
    assert_eq!(entries[1].patch.title, "Beta patch");
}

#[test]
@@ -256,10 +256,10 @@ fn test_patch_sort_by_created() {

    let (_, _) = create_patch_at(&repo, &alice(), "Beta patch", "2025-06-01T00:00:00Z");

    let patches = git_collab::patch::list(&repo, true, false, None, None, SortMode::Created).unwrap();
    assert_eq!(patches.len(), 2);
    assert_eq!(patches[0].title, "Beta patch");
    assert_eq!(patches[1].title, "Alpha patch");
    let entries = git_collab::patch::list(&repo, true, false, None, None, SortMode::Created).unwrap();
    assert_eq!(entries.len(), 2);
    assert_eq!(entries[0].patch.title, "Beta patch");
    assert_eq!(entries[1].patch.title, "Alpha patch");
}

#[test]
@@ -271,11 +271,11 @@ fn test_patch_sort_alpha() {
    create_patch_at(&repo, &alice(), "Apple patch", "2025-06-01T00:00:00Z");
    create_patch_at(&repo, &alice(), "Mango patch", "2025-03-01T00:00:00Z");

    let patches = git_collab::patch::list(&repo, true, false, None, None, SortMode::Alpha).unwrap();
    assert_eq!(patches.len(), 3);
    assert_eq!(patches[0].title, "Apple patch");
    assert_eq!(patches[1].title, "Mango patch");
    assert_eq!(patches[2].title, "Zebra patch");
    let entries = git_collab::patch::list(&repo, true, false, None, None, SortMode::Alpha).unwrap();
    assert_eq!(entries.len(), 3);
    assert_eq!(entries[0].patch.title, "Apple patch");
    assert_eq!(entries[1].patch.title, "Mango patch");
    assert_eq!(entries[2].patch.title, "Zebra patch");
}

// ---- CLI integration test ----