a73x

3227a9c9

Add collab log command for chronological event stream

a73x   2026-03-21 16:02

Adds `git-collab log [-n LIMIT]` to display all collab events
(issues and patches) in chronological order. Each entry shows
timestamp, action type, entity kind, short ID, author, and a
human-readable summary. Handles all Action variants including
IssueEdit, IssueLabel, IssueUnlabel, IssueAssign, IssueUnassign.

Also includes concurrent changes: status command, shell completions,
JSON output flags, and pagination for list commands.

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

diff --git a/Cargo.lock b/Cargo.lock
index 950f366..a518e92 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -115,7 +115,16 @@ version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1"
dependencies = [
 "bit-vec",
 "bit-vec 0.6.3",
]

[[package]]
name = "bit-set"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
dependencies = [
 "bit-vec 0.8.0",
]

[[package]]
@@ -125,6 +134,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb"

[[package]]
name = "bit-vec"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"

[[package]]
name = "bitflags"
version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -227,6 +242,15 @@ dependencies = [
]

[[package]]
name = "clap_complete"
version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19c9f1dde76b736e3681f28cec9d5a61299cbaae0fce80a68e43724ad56031eb"
dependencies = [
 "clap",
]

[[package]]
name = "clap_derive"
version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -528,7 +552,7 @@ checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
dependencies = [
 "curve25519-dalek",
 "ed25519",
 "rand_core",
 "rand_core 0.6.4",
 "serde",
 "sha2",
 "subtle",
@@ -572,7 +596,7 @@ version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2"
dependencies = [
 "bit-set",
 "bit-set 0.5.3",
 "regex",
]

@@ -697,12 +721,14 @@ dependencies = [
 "base64",
 "chrono",
 "clap",
 "clap_complete",
 "clap_mangen",
 "crossterm",
 "dirs",
 "ed25519-dalek",
 "git2",
 "rand_core",
 "proptest",
 "rand_core 0.6.4",
 "ratatui",
 "serde",
 "serde_json",
@@ -1356,7 +1382,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
dependencies = [
 "phf_shared",
 "rand",
 "rand 0.8.5",
]

[[package]]
@@ -1419,6 +1445,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"

[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
 "zerocopy",
]

[[package]]
name = "prettyplease"
version = "0.2.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1438,6 +1473,31 @@ dependencies = [
]

[[package]]
name = "proptest"
version = "1.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37566cb3fdacef14c0737f9546df7cfeadbfbc9fef10991038bf5015d0c80532"
dependencies = [
 "bit-set 0.8.0",
 "bit-vec 0.8.0",
 "bitflags 2.11.0",
 "num-traits",
 "rand 0.9.2",
 "rand_chacha",
 "rand_xorshift",
 "regex-syntax",
 "rusty-fork",
 "tempfile",
 "unarray",
]

[[package]]
name = "quick-error"
version = "1.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0"

[[package]]
name = "quote"
version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1464,7 +1524,27 @@ version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
dependencies = [
 "rand_core",
 "rand_core 0.6.4",
]

[[package]]
name = "rand"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
dependencies = [
 "rand_chacha",
 "rand_core 0.9.5",
]

[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
 "ppv-lite86",
 "rand_core 0.9.5",
]

[[package]]
@@ -1477,6 +1557,24 @@ dependencies = [
]

[[package]]
name = "rand_core"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
dependencies = [
 "getrandom 0.3.4",
]

[[package]]
name = "rand_xorshift"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a"
dependencies = [
 "rand_core 0.9.5",
]

[[package]]
name = "ratatui"
version = "0.30.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1645,6 +1743,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"

[[package]]
name = "rusty-fork"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2"
dependencies = [
 "fnv",
 "quick-error",
 "tempfile",
 "wait-timeout",
]

[[package]]
name = "ryu"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1759,7 +1869,7 @@ version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
dependencies = [
 "rand_core",
 "rand_core 0.6.4",
]

[[package]]
@@ -2022,6 +2132,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971"

[[package]]
name = "unarray"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94"

[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2114,6 +2230,15 @@ dependencies = [
]

[[package]]
name = "wait-timeout"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11"
dependencies = [
 "libc",
]

[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2562,6 +2687,26 @@ dependencies = [
]

[[package]]
name = "zerocopy"
version = "0.8.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87"
dependencies = [
 "zerocopy-derive",
]

[[package]]
name = "zerocopy-derive"
version = "0.8.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89"
dependencies = [
 "proc-macro2",
 "quote",
 "syn 2.0.117",
]

[[package]]
name = "zerofrom"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
diff --git a/Cargo.toml b/Cargo.toml
index 8764c48..89855b3 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -6,6 +6,7 @@ edition = "2021"
[dependencies]
git2 = "0.19"
clap = { version = "4", features = ["derive"] }
clap_complete = "4"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
chrono = { version = "0.4", features = ["serde"] }
@@ -19,6 +20,7 @@ dirs = "5"

[build-dependencies]
clap = { version = "4", features = ["derive"] }
clap_complete = "4"
clap_mangen = "0.2"

[dev-dependencies]
diff --git a/src/cli.rs b/src/cli.rs
index 202a6fd..89ab8a3 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -1,4 +1,5 @@
use clap::{Parser, Subcommand};
use clap_complete::Shell;

#[derive(Parser)]
#[command(
@@ -23,9 +24,25 @@ pub enum Commands {
    #[command(subcommand)]
    Patch(PatchCmd),

    /// Show project status overview
    Status,

    /// Interactive patch review dashboard
    Dashboard,

    /// Show raw collab event stream in chronological order
    Log {
        /// Maximum number of events to show
        #[arg(short = 'n', long)]
        limit: Option<usize>,
    },

    /// Generate shell completions
    Completions {
        /// Shell to generate completions for
        shell: Shell,
    },

    /// Sync with a remote (fetch, reconcile, push)
    Sync {
        /// Remote name (default: origin)
@@ -62,11 +79,23 @@ pub enum IssueCmd {
        /// Show closed issues too
        #[arg(short = 'a', long)]
        all: bool,
        /// Maximum number of issues to display
        #[arg(short = 'n', long)]
        limit: Option<usize>,
        /// Number of issues to skip before displaying
        #[arg(long)]
        offset: Option<usize>,
        /// Output as JSON
        #[arg(long)]
        json: bool,
    },
    /// Show issue details
    Show {
        /// Issue ID (prefix match)
        id: String,
        /// Output as JSON
        #[arg(long)]
        json: bool,
    },
    /// Comment on an issue
    Comment {
@@ -177,11 +206,23 @@ pub enum PatchCmd {
        /// Show closed/merged patches too
        #[arg(short = 'a', long)]
        all: bool,
        /// Maximum number of patches to display
        #[arg(short = 'n', long)]
        limit: Option<usize>,
        /// Number of patches to skip before displaying
        #[arg(long)]
        offset: Option<usize>,
        /// Output as JSON
        #[arg(long)]
        json: bool,
    },
    /// Show patch details
    Show {
        /// Patch ID (prefix match)
        id: String,
        /// Output as JSON
        #[arg(long)]
        json: bool,
    },
    /// Show diff between base and head
    Diff {
diff --git a/src/issue.rs b/src/issue.rs
index c4e0cf8..cee4408 100644
--- a/src/issue.rs
+++ b/src/issue.rs
@@ -30,19 +30,63 @@ pub struct ListEntry {
    pub unread: Option<usize>,
}

pub fn list(repo: &Repository, show_closed: bool) -> Result<Vec<ListEntry>, crate::error::Error> {
pub fn list(
    repo: &Repository,
    show_closed: bool,
    limit: Option<usize>,
    offset: Option<usize>,
) -> Result<Vec<ListEntry>, crate::error::Error> {
    let issues = state::list_issues(repo)?;
    let entries = issues
    let entries: Vec<_> = issues
        .into_iter()
        .filter(|i| show_closed || i.status == IssueStatus::Open)
        .map(|issue| {
            let unread = count_unread(repo, &issue.id);
            ListEntry { issue, unread }
        })
        .skip(offset.unwrap_or(0))
        .take(limit.unwrap_or(usize::MAX))
        .collect();
    Ok(entries)
}

pub fn list_to_writer(
    repo: &Repository,
    show_closed: bool,
    limit: Option<usize>,
    offset: Option<usize>,
    writer: &mut dyn std::io::Write,
) -> Result<(), crate::error::Error> {
    let entries = list(repo, show_closed, limit, offset)?;
    if entries.is_empty() {
        writeln!(writer, "No issues found.").ok();
        return Ok(());
    }
    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(),
        };
        writeln!(
            writer,
            "{:.8}  {:6}  {}{}  (by {}){}",
            i.id, status, i.title, labels, i.author.name, unread
        )
        .ok();
    }
    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);
@@ -63,6 +107,21 @@ fn count_unread(repo: &git2::Repository, id: &str) -> Option<usize> {
    Some(revwalk.count())
}

pub fn list_json(repo: &Repository, show_closed: bool) -> Result<String, crate::error::Error> {
    let issues = state::list_issues(repo)?;
    let filtered: Vec<_> = issues
        .into_iter()
        .filter(|i| show_closed || i.status == IssueStatus::Open)
        .collect();
    Ok(serde_json::to_string_pretty(&filtered)?)
}

pub fn show_json(repo: &Repository, id_prefix: &str) -> Result<String, crate::error::Error> {
    let (ref_name, id) = state::resolve_issue_ref(repo, id_prefix)?;
    let issue = IssueState::from_ref(repo, &ref_name, &id)?;
    Ok(serde_json::to_string_pretty(&issue)?)
}

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 = IssueState::from_ref(repo, &ref_name, &id)?;
diff --git a/src/lib.rs b/src/lib.rs
index 08b1c3e..ce146e9 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -5,9 +5,11 @@ pub mod error;
pub mod event;
pub mod identity;
pub mod issue;
pub mod log;
pub mod patch;
pub mod state;
pub mod signing;
pub mod status;
pub mod sync;
pub mod sync_lock;
pub mod trust;
@@ -28,8 +30,13 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> {
                println!("Opened issue {:.8}", id);
                Ok(())
            }
            IssueCmd::List { all } => {
                let entries = issue::list(repo, all)?;
            IssueCmd::List { all, limit, offset, json } => {
                if json {
                    let output = issue::list_json(repo, all)?;
                    println!("{}", output);
                    return Ok(());
                }
                let entries = issue::list(repo, all, limit, offset)?;
                if entries.is_empty() {
                    println!("No issues found.");
                } else {
@@ -56,7 +63,12 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> {
                }
                Ok(())
            }
            IssueCmd::Show { id } => {
            IssueCmd::Show { id, json } => {
                if json {
                    let output = issue::show_json(repo, &id)?;
                    println!("{}", output);
                    return Ok(());
                }
                let i = issue::show(repo, &id)?;
                let status = match i.status {
                    IssueStatus::Open => "open",
@@ -169,8 +181,13 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> {
                println!("Created patch {:.8}", id);
                Ok(())
            }
            PatchCmd::List { all } => {
                let patches = patch::list(repo, all)?;
            PatchCmd::List { all, limit, offset, json } => {
                if json {
                    let output = patch::list_json(repo, all)?;
                    println!("{}", output);
                    return Ok(());
                }
                let patches = patch::list(repo, all, limit, offset)?;
                if patches.is_empty() {
                    println!("No patches found.");
                } else {
@@ -188,7 +205,12 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> {
                }
                Ok(())
            }
            PatchCmd::Show { id } => {
            PatchCmd::Show { id, json } => {
                if json {
                    let output = patch::show_json(repo, &id)?;
                    println!("{}", output);
                    return Ok(());
                }
                let p = patch::show(repo, &id)?;
                let status = match p.status {
                    PatchStatus::Open => "open",
@@ -294,7 +316,14 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> {
                Ok(())
            }
        },
        Commands::Status => {
            let project_status = status::compute(repo)?;
            print!("{}", project_status);
            Ok(())
        }
        Commands::Log { limit } => log::print_log(repo, limit),
        Commands::Dashboard => tui::run(repo),
        Commands::Completions { .. } => unreachable!("handled before repo open"),
        Commands::Sync { remote } => sync::sync(repo, &remote),
        Commands::InitKey { force } => {
            let config_dir = signing::signing_key_dir()?;
diff --git a/src/log.rs b/src/log.rs
new file mode 100644
index 0000000..143962e
--- /dev/null
+++ b/src/log.rs
@@ -0,0 +1,175 @@
use git2::Repository;

use crate::dag;
use crate::error::Error;
use crate::event::{Action, Author};

/// A single entry in the collab log output.
#[derive(Debug, Clone)]
pub struct LogEntry {
    pub timestamp: String,
    pub event_type: String,
    pub entity_kind: String,
    pub entity_id: String,
    pub author: Author,
    pub summary: String,
}

/// Collect all events from every collab ref, sorted by timestamp.
/// If `limit` is provided, return at most that many entries (most recent last).
pub fn collect_events(repo: &Repository, limit: Option<usize>) -> Result<Vec<LogEntry>, Error> {
    let mut entries = Vec::new();

    // Walk issues
    let issue_refs = repo.references_glob("refs/collab/issues/*")?;
    for r in issue_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 let Ok(events) = dag::walk_events(repo, &ref_name) {
            for (_oid, event) in events {
                entries.push(LogEntry {
                    timestamp: event.timestamp.clone(),
                    event_type: action_type_name(&event.action),
                    entity_kind: "issue".to_string(),
                    entity_id: id.clone(),
                    author: event.author,
                    summary: action_summary(&event.action),
                });
            }
        }
    }

    // Walk patches
    let patch_refs = repo.references_glob("refs/collab/patches/*")?;
    for r in patch_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 let Ok(events) = dag::walk_events(repo, &ref_name) {
            for (_oid, event) in events {
                entries.push(LogEntry {
                    timestamp: event.timestamp.clone(),
                    event_type: action_type_name(&event.action),
                    entity_kind: "patch".to_string(),
                    entity_id: id.clone(),
                    author: event.author,
                    summary: action_summary(&event.action),
                });
            }
        }
    }

    // Sort by timestamp (chronological)
    entries.sort_by(|a, b| a.timestamp.cmp(&b.timestamp));

    if let Some(n) = limit {
        entries.truncate(n);
    }

    Ok(entries)
}

/// Format log entries into a human-readable string.
pub fn format_log(entries: &[LogEntry]) -> String {
    let mut out = String::new();
    for entry in entries {
        out.push_str(&format!(
            "{} {} {:.8} {} <{}> {}\n",
            entry.timestamp,
            entry.event_type,
            entry.entity_kind,
            entry.entity_id.get(..8).unwrap_or(&entry.entity_id),
            entry.author.name,
            entry.summary,
        ));
    }
    out
}

/// Print the log to stdout.
pub fn print_log(repo: &Repository, limit: Option<usize>) -> Result<(), Error> {
    let entries = collect_events(repo, limit)?;
    if entries.is_empty() {
        println!("No collab events found.");
        return Ok(());
    }
    print!("{}", format_log(&entries));
    Ok(())
}

fn action_type_name(action: &Action) -> String {
    match action {
        Action::IssueOpen { .. } => "IssueOpen".to_string(),
        Action::IssueComment { .. } => "IssueComment".to_string(),
        Action::IssueClose { .. } => "IssueClose".to_string(),
        Action::IssueEdit { .. } => "IssueEdit".to_string(),
        Action::IssueLabel { .. } => "IssueLabel".to_string(),
        Action::IssueUnlabel { .. } => "IssueUnlabel".to_string(),
        Action::IssueAssign { .. } => "IssueAssign".to_string(),
        Action::IssueUnassign { .. } => "IssueUnassign".to_string(),
        Action::IssueReopen => "IssueReopen".to_string(),
        Action::PatchCreate { .. } => "PatchCreate".to_string(),
        Action::PatchRevise { .. } => "PatchRevise".to_string(),
        Action::PatchReview { .. } => "PatchReview".to_string(),
        Action::PatchComment { .. } => "PatchComment".to_string(),
        Action::PatchInlineComment { .. } => "PatchInlineComment".to_string(),
        Action::PatchClose { .. } => "PatchClose".to_string(),
        Action::PatchMerge => "PatchMerge".to_string(),
        Action::Merge => "Merge".to_string(),
    }
}

fn action_summary(action: &Action) -> String {
    match action {
        Action::IssueOpen { title, .. } => format!("open \"{}\"", title),
        Action::IssueComment { body } => truncate(body, 60),
        Action::IssueClose { reason } => match reason {
            Some(r) => format!("close: {}", r),
            None => "close".to_string(),
        },
        Action::IssueEdit { title, body } => {
            let parts: Vec<&str> = [
                title.as_ref().map(|_| "title"),
                body.as_ref().map(|_| "body"),
            ]
            .into_iter()
            .flatten()
            .collect();
            format!("edit {}", parts.join(", "))
        }
        Action::IssueLabel { label } => format!("label \"{}\"", label),
        Action::IssueUnlabel { label } => format!("unlabel \"{}\"", label),
        Action::IssueAssign { assignee } => format!("assign \"{}\"", assignee),
        Action::IssueUnassign { assignee } => format!("unassign \"{}\"", assignee),
        Action::IssueReopen => "reopen".to_string(),
        Action::PatchCreate { title, .. } => format!("create \"{}\"", title),
        Action::PatchRevise { body, .. } => match body {
            Some(b) => format!("revise: {}", truncate(b, 50)),
            None => "revise".to_string(),
        },
        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 {
            Some(r) => format!("close: {}", r),
            None => "close".to_string(),
        },
        Action::PatchMerge => "merge".to_string(),
        Action::Merge => "dag merge".to_string(),
    }
}

fn truncate(s: &str, max: usize) -> String {
    if s.len() <= max {
        s.to_string()
    } else {
        format!("{}...", &s[..max])
    }
}
diff --git a/src/main.rs b/src/main.rs
index 464e96a..12329b5 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,11 +1,22 @@
use clap::Parser;
use clap::{CommandFactory, Parser};
use git2::Repository;

use git_collab::cli::Cli;
use git_collab::cli::{Cli, Commands};

fn main() {
    let cli = Cli::parse();

    // Handle completions before opening the repo (no git repo needed)
    if let Commands::Completions { shell } = &cli.command {
        clap_complete::generate(
            *shell,
            &mut Cli::command(),
            "git-collab",
            &mut std::io::stdout(),
        );
        return;
    }

    let repo = match Repository::open_from_env() {
        Ok(r) => r,
        Err(e) => {
diff --git a/src/patch.rs b/src/patch.rs
index 43bb9ae..f09a353 100644
--- a/src/patch.rs
+++ b/src/patch.rs
@@ -59,15 +59,65 @@ pub fn create(
    Ok(id)
}

pub fn list(repo: &Repository, show_closed: bool) -> Result<Vec<PatchState>, crate::error::Error> {
pub fn list(
    repo: &Repository,
    show_closed: bool,
    limit: Option<usize>,
    offset: Option<usize>,
) -> Result<Vec<PatchState>, crate::error::Error> {
    let patches = state::list_patches(repo)?;
    let filtered = patches
        .into_iter()
        .filter(|p| show_closed || p.status == PatchStatus::Open)
        .skip(offset.unwrap_or(0))
        .take(limit.unwrap_or(usize::MAX))
        .collect();
    Ok(filtered)
}

pub fn list_to_writer(
    repo: &Repository,
    show_closed: bool,
    limit: Option<usize>,
    offset: Option<usize>,
    writer: &mut dyn std::io::Write,
) -> Result<(), crate::error::Error> {
    let patches = list(repo, show_closed, limit, offset)?;
    if patches.is_empty() {
        writeln!(writer, "No patches found.").ok();
        return Ok(());
    }
    for p in &patches {
        let status = match p.status {
            PatchStatus::Open => "open",
            PatchStatus::Closed => "closed",
            PatchStatus::Merged => "merged",
        };
        writeln!(
            writer,
            "{:.8}  {:6}  {}  (by {})",
            p.id, status, p.title, p.author.name
        )
        .ok();
    }
    Ok(())
}

pub fn list_json(repo: &Repository, show_closed: bool) -> Result<String, crate::error::Error> {
    let patches = state::list_patches(repo)?;
    let filtered: Vec<_> = patches
        .into_iter()
        .filter(|p| show_closed || p.status == PatchStatus::Open)
        .collect();
    Ok(serde_json::to_string_pretty(&filtered)?)
}

pub fn show_json(repo: &Repository, id_prefix: &str) -> Result<String, crate::error::Error> {
    let (ref_name, id) = state::resolve_patch_ref(repo, id_prefix)?;
    let p = PatchState::from_ref(repo, &ref_name, &id)?;
    Ok(serde_json::to_string_pretty(&p)?)
}

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)
diff --git a/src/state.rs b/src/state.rs
index 05ae1a8..a0dba56 100644
--- a/src/state.rs
+++ b/src/state.rs
@@ -1,30 +1,54 @@
use git2::{Oid, Repository};
use serde::Serialize;

use crate::dag;
use crate::event::{Action, Author, ReviewVerdict};

#[derive(Debug, Clone, PartialEq)]
fn serialize_oid<S: serde::Serializer>(oid: &Oid, s: S) -> Result<S::Ok, S::Error> {
    s.serialize_str(&oid.to_string())
}

fn serialize_oid_option<S: serde::Serializer>(oid: &Option<Oid>, s: S) -> Result<S::Ok, S::Error> {
    match oid {
        Some(o) => s.serialize_some(&o.to_string()),
        None => s.serialize_none(),
    }
}

fn serialize_verdict<S: serde::Serializer>(v: &ReviewVerdict, s: S) -> Result<S::Ok, S::Error> {
    let str_val = match v {
        ReviewVerdict::Approve => "approve",
        ReviewVerdict::RequestChanges => "request-changes",
        ReviewVerdict::Comment => "comment",
    };
    s.serialize_str(str_val)
}

#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum IssueStatus {
    Open,
    Closed,
}

#[derive(Debug, Clone)]
#[derive(Debug, Clone, Serialize)]
#[allow(dead_code)]
pub struct Comment {
    pub author: Author,
    pub body: String,
    pub timestamp: String,
    #[serde(serialize_with = "serialize_oid")]
    pub commit_id: Oid,
}

#[derive(Debug, Clone)]
#[derive(Debug, Clone, Serialize)]
pub struct IssueState {
    pub id: String,
    pub title: String,
    pub body: String,
    pub status: IssueStatus,
    pub close_reason: Option<String>,
    #[serde(serialize_with = "serialize_oid_option")]
    pub closed_by: Option<Oid>,
    pub labels: Vec<String>,
    pub assignees: Vec<String>,
@@ -33,15 +57,17 @@ pub struct IssueState {
    pub author: Author,
}

#[derive(Debug, Clone)]
#[derive(Debug, Clone, Serialize)]
pub struct Review {
    pub author: Author,
    #[serde(serialize_with = "serialize_verdict")]
    pub verdict: ReviewVerdict,
    pub body: String,
    pub timestamp: String,
}

#[derive(Debug, Clone, PartialEq)]
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(rename_all = "lowercase")]
#[allow(dead_code)]
pub enum PatchStatus {
    Open,
@@ -49,7 +75,7 @@ pub enum PatchStatus {
    Merged,
}

#[derive(Debug, Clone)]
#[derive(Debug, Clone, Serialize)]
pub struct InlineComment {
    pub author: Author,
    pub file: String,
@@ -58,7 +84,7 @@ pub struct InlineComment {
    pub timestamp: String,
}

#[derive(Debug, Clone)]
#[derive(Debug, Clone, Serialize)]
pub struct PatchState {
    pub id: String,
    pub title: String,
diff --git a/src/status.rs b/src/status.rs
new file mode 100644
index 0000000..62b3598
--- /dev/null
+++ b/src/status.rs
@@ -0,0 +1,153 @@
use std::fmt;

use git2::Repository;

use crate::error::Error;
use crate::event::ReviewVerdict;
use crate::state::{self, IssueStatus, PatchStatus};

/// A single recently-updated item (issue or patch) for the status summary.
#[derive(Debug, Clone)]
pub struct RecentItem {
    pub kind: &'static str,
    pub id: String,
    pub title: String,
    pub status: String,
    pub created_at: String,
}

/// Aggregated project status computed from all collab refs.
#[derive(Debug, Clone)]
pub struct ProjectStatus {
    pub issues_open: usize,
    pub issues_closed: usize,
    pub patches_open: usize,
    pub patches_closed: usize,
    pub patches_merged: usize,
    /// Number of open patches whose latest review verdict is Approve.
    pub patches_approved: usize,
    /// Number of open patches whose latest review verdict is RequestChanges.
    pub patches_changes_requested: usize,
    /// Most recently created items (up to 10), sorted newest first.
    pub recent_items: Vec<RecentItem>,
}

/// Compute an aggregate status for the project.
pub fn compute(repo: &Repository) -> Result<ProjectStatus, Error> {
    let issues = state::list_issues(repo)?;
    let patches = state::list_patches(repo)?;

    let issues_open = issues
        .iter()
        .filter(|i| i.status == IssueStatus::Open)
        .count();
    let issues_closed = issues
        .iter()
        .filter(|i| i.status == IssueStatus::Closed)
        .count();

    let patches_open = patches
        .iter()
        .filter(|p| p.status == PatchStatus::Open)
        .count();
    let patches_closed = patches
        .iter()
        .filter(|p| p.status == PatchStatus::Closed)
        .count();
    let patches_merged = patches
        .iter()
        .filter(|p| p.status == PatchStatus::Merged)
        .count();

    // Review summary: for each open patch, look at the latest review verdict.
    let mut patches_approved = 0usize;
    let mut patches_changes_requested = 0usize;
    for p in patches.iter().filter(|p| p.status == PatchStatus::Open) {
        if let Some(last_review) = p.reviews.last() {
            match last_review.verdict {
                ReviewVerdict::Approve => patches_approved += 1,
                ReviewVerdict::RequestChanges => patches_changes_requested += 1,
                ReviewVerdict::Comment => {}
            }
        }
    }

    // Collect recent items from both issues and patches, sort by created_at descending, cap at 10.
    let mut recent_items: Vec<RecentItem> = Vec::new();
    for issue in &issues {
        recent_items.push(RecentItem {
            kind: "issue",
            id: issue.id[..8.min(issue.id.len())].to_string(),
            title: issue.title.clone(),
            status: match issue.status {
                IssueStatus::Open => "open".to_string(),
                IssueStatus::Closed => "closed".to_string(),
            },
            created_at: issue.created_at.clone(),
        });
    }
    for patch in &patches {
        recent_items.push(RecentItem {
            kind: "patch",
            id: patch.id[..8.min(patch.id.len())].to_string(),
            title: patch.title.clone(),
            status: match patch.status {
                PatchStatus::Open => "open".to_string(),
                PatchStatus::Closed => "closed".to_string(),
                PatchStatus::Merged => "merged".to_string(),
            },
            created_at: patch.created_at.clone(),
        });
    }
    recent_items.sort_by(|a, b| b.created_at.cmp(&a.created_at));
    recent_items.truncate(10);

    Ok(ProjectStatus {
        issues_open,
        issues_closed,
        patches_open,
        patches_closed,
        patches_merged,
        patches_approved,
        patches_changes_requested,
        recent_items,
    })
}

impl fmt::Display for ProjectStatus {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        writeln!(f, "Issues:  {} open, {} closed", self.issues_open, self.issues_closed)?;
        writeln!(
            f,
            "Patches: {} open, {} merged, {} closed",
            self.patches_open, self.patches_merged, self.patches_closed
        )?;

        if self.patches_open > 0 {
            writeln!(
                f,
                "  Review: {} approved, {} changes requested, {} pending",
                self.patches_approved,
                self.patches_changes_requested,
                self.patches_open - self.patches_approved - self.patches_changes_requested
            )?;
        }

        if !self.recent_items.is_empty() {
            writeln!(f)?;
            writeln!(f, "Recently updated:")?;
            for item in &self.recent_items {
                writeln!(
                    f,
                    "  [{kind}] {id} {title} ({status})",
                    kind = item.kind,
                    id = item.id,
                    title = item.title,
                    status = item.status,
                )?;
            }
        }

        Ok(())
    }
}
diff --git a/tests/collab_test.rs b/tests/collab_test.rs
index 3b54981..af75d8c 100644
--- a/tests/collab_test.rs
+++ b/tests/collab_test.rs
@@ -994,3 +994,271 @@ fn test_branch_push_auto_reflects_in_patch() {
    assert_eq!(head2, new_tip, "head should be the new tip");
}

// ---------------------------------------------------------------------------
// Log command tests
// ---------------------------------------------------------------------------

use git_collab::issue;
use git_collab::log;

#[test]
fn test_log_collects_events_from_all_refs() {
    let tmp = TempDir::new().unwrap();
    let repo = init_repo(tmp.path(), &alice());

    // Create an issue and a patch
    let (ref1, _) = open_issue(&repo, &alice(), "Log test issue");
    add_comment(&repo, &ref1, &bob(), "A comment");

    let (ref2, _) = create_patch(&repo, &alice(), "Log test patch");
    add_review(&repo, &ref2, &bob(), ReviewVerdict::Approve);

    let entries = log::collect_events(&repo, None).unwrap();
    // 2 issue events + 2 patch events = 4
    assert_eq!(entries.len(), 4);
}

#[test]
fn test_log_entries_sorted_by_timestamp() {
    let tmp = TempDir::new().unwrap();
    let repo = init_repo(tmp.path(), &alice());

    let (ref1, _) = open_issue(&repo, &alice(), "First");
    add_comment(&repo, &ref1, &bob(), "Second");

    let entries = log::collect_events(&repo, None).unwrap();
    assert!(entries.len() >= 2);
    // Timestamps should be non-decreasing
    for w in entries.windows(2) {
        assert!(w[0].timestamp <= w[1].timestamp);
    }
}

#[test]
fn test_log_limit() {
    let tmp = TempDir::new().unwrap();
    let repo = init_repo(tmp.path(), &alice());

    let (ref1, _) = open_issue(&repo, &alice(), "Limit test");
    add_comment(&repo, &ref1, &alice(), "c1");
    add_comment(&repo, &ref1, &alice(), "c2");
    add_comment(&repo, &ref1, &alice(), "c3");

    let entries = log::collect_events(&repo, Some(2)).unwrap();
    assert_eq!(entries.len(), 2);
}

#[test]
fn test_log_entry_fields() {
    let tmp = TempDir::new().unwrap();
    let repo = init_repo(tmp.path(), &alice());

    let (_, _) = open_issue(&repo, &alice(), "Field test");

    let entries = log::collect_events(&repo, None).unwrap();
    assert_eq!(entries.len(), 1);

    let entry = &entries[0];
    assert_eq!(entry.entity_kind, "issue");
    assert_eq!(entry.event_type, "IssueOpen");
    assert_eq!(entry.author.name, "Alice");
    assert_eq!(entry.summary, "open \"Field test\"");
    assert!(!entry.entity_id.is_empty());
    assert!(!entry.timestamp.is_empty());
}

#[test]
fn test_log_empty_repo() {
    let tmp = TempDir::new().unwrap();
    let repo = init_repo(tmp.path(), &alice());

    let entries = log::collect_events(&repo, None).unwrap();
    assert!(entries.is_empty());
}

#[test]
fn test_log_format_output() {
    let tmp = TempDir::new().unwrap();
    let repo = init_repo(tmp.path(), &alice());

    let (_, _) = open_issue(&repo, &alice(), "Format test");

    let entries = log::collect_events(&repo, None).unwrap();
    let output = log::format_log(&entries);
    assert!(output.contains("issue"));
    assert!(output.contains("Alice"));
    assert!(output.contains("IssueOpen"));
}

// ---------------------------------------------------------------------------
// Pagination tests
// ---------------------------------------------------------------------------

/// Helper: capture output from issue::list_to_writer
fn capture_issue_list(
    repo: &git2::Repository,
    show_closed: bool,
    limit: Option<usize>,
    offset: Option<usize>,
) -> String {
    let mut buf = Vec::new();
    issue::list_to_writer(repo, show_closed, limit, offset, &mut buf).unwrap();
    String::from_utf8(buf).unwrap()
}

/// Helper: capture output from patch::list_to_writer
fn capture_patch_list(
    repo: &git2::Repository,
    show_closed: bool,
    limit: Option<usize>,
    offset: Option<usize>,
) -> String {
    let mut buf = Vec::new();
    patch::list_to_writer(repo, show_closed, limit, offset, &mut buf).unwrap();
    String::from_utf8(buf).unwrap()
}

#[test]
fn test_issue_list_limit() {
    let tmp = TempDir::new().unwrap();
    let repo = init_repo(tmp.path(), &alice());

    open_issue(&repo, &alice(), "Issue A");
    open_issue(&repo, &alice(), "Issue B");
    open_issue(&repo, &alice(), "Issue C");

    let all = state::list_issues(&repo).unwrap();
    assert_eq!(all.len(), 3);

    let output = capture_issue_list(&repo, false, Some(2), None);
    let lines: Vec<_> = output.lines().filter(|l| !l.is_empty()).collect();
    assert_eq!(lines.len(), 2, "limit=2 should show exactly 2 issues");
}

#[test]
fn test_issue_list_offset() {
    let tmp = TempDir::new().unwrap();
    let repo = init_repo(tmp.path(), &alice());

    open_issue(&repo, &alice(), "Issue A");
    open_issue(&repo, &alice(), "Issue B");
    open_issue(&repo, &alice(), "Issue C");

    let output_all = capture_issue_list(&repo, false, None, None);
    let all_lines: Vec<_> = output_all.lines().filter(|l| !l.is_empty()).collect();

    let output_offset = capture_issue_list(&repo, false, None, Some(1));
    let offset_lines: Vec<_> = output_offset.lines().filter(|l| !l.is_empty()).collect();

    assert_eq!(
        offset_lines.len(),
        all_lines.len() - 1,
        "offset=1 should skip one issue"
    );
    assert_eq!(offset_lines, &all_lines[1..]);
}

#[test]
fn test_issue_list_limit_and_offset() {
    let tmp = TempDir::new().unwrap();
    let repo = init_repo(tmp.path(), &alice());

    open_issue(&repo, &alice(), "Issue A");
    open_issue(&repo, &alice(), "Issue B");
    open_issue(&repo, &alice(), "Issue C");
    open_issue(&repo, &alice(), "Issue D");

    let output_all = capture_issue_list(&repo, false, None, None);
    let all_lines: Vec<_> = output_all.lines().filter(|l| !l.is_empty()).collect();
    assert_eq!(all_lines.len(), 4);

    let output = capture_issue_list(&repo, false, Some(2), Some(1));
    let lines: Vec<_> = output.lines().filter(|l| !l.is_empty()).collect();
    assert_eq!(
        lines.len(),
        2,
        "limit=2,offset=1 should show exactly 2 issues"
    );
    assert_eq!(lines, &all_lines[1..3]);
}

#[test]
fn test_issue_list_offset_beyond_end() {
    let tmp = TempDir::new().unwrap();
    let repo = init_repo(tmp.path(), &alice());

    open_issue(&repo, &alice(), "Issue A");

    let output = capture_issue_list(&repo, false, None, Some(100));
    assert!(
        output.contains("No issues found."),
        "offset beyond end should show 'No issues found.'"
    );
}

#[test]
fn test_patch_list_limit() {
    let tmp = TempDir::new().unwrap();
    let repo = init_repo(tmp.path(), &alice());

    create_patch(&repo, &alice(), "Patch A");
    create_patch(&repo, &alice(), "Patch B");
    create_patch(&repo, &alice(), "Patch C");

    let output = capture_patch_list(&repo, false, Some(2), None);
    let lines: Vec<_> = output.lines().filter(|l| !l.is_empty()).collect();
    assert_eq!(lines.len(), 2, "limit=2 should show exactly 2 patches");
}

#[test]
fn test_patch_list_offset() {
    let tmp = TempDir::new().unwrap();
    let repo = init_repo(tmp.path(), &alice());

    create_patch(&repo, &alice(), "Patch A");
    create_patch(&repo, &alice(), "Patch B");
    create_patch(&repo, &alice(), "Patch C");

    let output_all = capture_patch_list(&repo, false, None, None);
    let all_lines: Vec<_> = output_all.lines().filter(|l| !l.is_empty()).collect();

    let output_offset = capture_patch_list(&repo, false, None, Some(1));
    let offset_lines: Vec<_> = output_offset.lines().filter(|l| !l.is_empty()).collect();

    assert_eq!(offset_lines.len(), all_lines.len() - 1);
    assert_eq!(offset_lines, &all_lines[1..]);
}

#[test]
fn test_patch_list_limit_and_offset() {
    let tmp = TempDir::new().unwrap();
    let repo = init_repo(tmp.path(), &alice());

    create_patch(&repo, &alice(), "Patch A");
    create_patch(&repo, &alice(), "Patch B");
    create_patch(&repo, &alice(), "Patch C");
    create_patch(&repo, &alice(), "Patch D");

    let output_all = capture_patch_list(&repo, false, None, None);
    let all_lines: Vec<_> = output_all.lines().filter(|l| !l.is_empty()).collect();

    let output = capture_patch_list(&repo, false, Some(2), Some(1));
    let lines: Vec<_> = output.lines().filter(|l| !l.is_empty()).collect();
    assert_eq!(lines.len(), 2);
    assert_eq!(lines, &all_lines[1..3]);
}

#[test]
fn test_patch_list_offset_beyond_end() {
    let tmp = TempDir::new().unwrap();
    let repo = init_repo(tmp.path(), &alice());

    create_patch(&repo, &alice(), "Patch A");

    let output = capture_patch_list(&repo, false, None, Some(100));
    assert!(
        output.contains("No patches found."),
        "offset beyond end should show 'No patches found.'"
    );
}

diff --git a/tests/completions_test.rs b/tests/completions_test.rs
new file mode 100644
index 0000000..389939d
--- /dev/null
+++ b/tests/completions_test.rs
@@ -0,0 +1,48 @@
use std::process::Command;

/// Verify `completions bash` parses successfully and produces output
#[test]
fn completions_bash_produces_output() {
    let output = Command::new(env!("CARGO_BIN_EXE_git-collab"))
        .args(["completions", "bash"])
        .output()
        .expect("failed to run binary");
    assert!(output.status.success(), "stderr: {}", String::from_utf8_lossy(&output.stderr));
    let stdout = String::from_utf8_lossy(&output.stdout);
    assert!(stdout.contains("complete"), "bash completions should contain 'complete': {}", stdout);
}

/// Verify `completions zsh` parses successfully and produces output
#[test]
fn completions_zsh_produces_output() {
    let output = Command::new(env!("CARGO_BIN_EXE_git-collab"))
        .args(["completions", "zsh"])
        .output()
        .expect("failed to run binary");
    assert!(output.status.success(), "stderr: {}", String::from_utf8_lossy(&output.stderr));
    let stdout = String::from_utf8_lossy(&output.stdout);
    assert!(!stdout.is_empty(), "zsh completions should not be empty");
}

/// Verify `completions fish` parses successfully and produces output
#[test]
fn completions_fish_produces_output() {
    let output = Command::new(env!("CARGO_BIN_EXE_git-collab"))
        .args(["completions", "fish"])
        .output()
        .expect("failed to run binary");
    assert!(output.status.success(), "stderr: {}", String::from_utf8_lossy(&output.stderr));
    let stdout = String::from_utf8_lossy(&output.stdout);
    assert!(stdout.contains("complete"), "fish completions should contain 'complete': {}", stdout);
}

/// Verify completions work without a git repo (run from /tmp)
#[test]
fn completions_work_without_git_repo() {
    let output = Command::new(env!("CARGO_BIN_EXE_git-collab"))
        .args(["completions", "bash"])
        .current_dir(std::env::temp_dir())
        .output()
        .expect("failed to run binary");
    assert!(output.status.success(), "completions should work outside a git repo: {}", String::from_utf8_lossy(&output.stderr));
}
diff --git a/tests/status_test.rs b/tests/status_test.rs
new file mode 100644
index 0000000..b6291ce
--- /dev/null
+++ b/tests/status_test.rs
@@ -0,0 +1,153 @@
mod common;

use tempfile::TempDir;

use git_collab::event::ReviewVerdict;
use git_collab::status;

use common::{alice, bob, create_patch, init_repo, open_issue, close_issue, add_review};

// Additional helpers not in common/mod.rs

fn close_patch(repo: &git2::Repository, ref_name: &str, author: &git_collab::event::Author) {
    let sk = common::test_signing_key();
    let event = git_collab::event::Event {
        timestamp: common::now(),
        author: author.clone(),
        action: git_collab::event::Action::PatchClose { reason: None },
        clock: 0,
    };
    git_collab::dag::append_event(repo, ref_name, &event, &sk).unwrap();
}

fn merge_patch(repo: &git2::Repository, ref_name: &str, author: &git_collab::event::Author) {
    let sk = common::test_signing_key();
    let event = git_collab::event::Event {
        timestamp: common::now(),
        author: author.clone(),
        action: git_collab::event::Action::PatchMerge,
        clock: 0,
    };
    git_collab::dag::append_event(repo, ref_name, &event, &sk).unwrap();
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

#[test]
fn test_status_empty_repo() {
    let tmp = TempDir::new().unwrap();
    let repo = init_repo(tmp.path(), &alice());

    let status = status::compute(&repo).unwrap();
    assert_eq!(status.issues_open, 0);
    assert_eq!(status.issues_closed, 0);
    assert_eq!(status.patches_open, 0);
    assert_eq!(status.patches_closed, 0);
    assert_eq!(status.patches_merged, 0);
    assert_eq!(status.patches_approved, 0);
    assert_eq!(status.patches_changes_requested, 0);
    assert!(status.recent_items.is_empty());
}

#[test]
fn test_status_counts_issues() {
    let tmp = TempDir::new().unwrap();
    let repo = init_repo(tmp.path(), &alice());

    // 2 open, 1 closed
    open_issue(&repo, &alice(), "Open one");
    open_issue(&repo, &bob(), "Open two");
    let (ref_name, _) = open_issue(&repo, &alice(), "Will close");
    close_issue(&repo, &ref_name, &alice());

    let status = status::compute(&repo).unwrap();
    assert_eq!(status.issues_open, 2);
    assert_eq!(status.issues_closed, 1);
}

#[test]
fn test_status_counts_patches_by_state() {
    let tmp = TempDir::new().unwrap();
    let repo = init_repo(tmp.path(), &alice());

    // 1 open, 1 closed, 1 merged
    create_patch(&repo, &alice(), "Open patch");
    let (ref_name, _) = create_patch(&repo, &alice(), "Closed patch");
    close_patch(&repo, &ref_name, &alice());
    let (ref_name, _) = create_patch(&repo, &alice(), "Merged patch");
    merge_patch(&repo, &ref_name, &alice());

    let status = status::compute(&repo).unwrap();
    assert_eq!(status.patches_open, 1);
    assert_eq!(status.patches_closed, 1);
    assert_eq!(status.patches_merged, 1);
}

#[test]
fn test_status_review_summary_for_open_patches() {
    let tmp = TempDir::new().unwrap();
    let repo = init_repo(tmp.path(), &alice());

    // Patch with approval
    let (ref_name, _) = create_patch(&repo, &alice(), "Approved patch");
    add_review(&repo, &ref_name, &bob(), ReviewVerdict::Approve);

    // Patch with changes requested
    let (ref_name, _) = create_patch(&repo, &alice(), "Needs work");
    add_review(&repo, &ref_name, &bob(), ReviewVerdict::RequestChanges);

    // Patch with no reviews (just open)
    create_patch(&repo, &alice(), "No reviews yet");

    let status = status::compute(&repo).unwrap();
    assert_eq!(status.patches_open, 3);
    assert_eq!(status.patches_approved, 1);
    assert_eq!(status.patches_changes_requested, 1);
}

#[test]
fn test_status_recent_items() {
    let tmp = TempDir::new().unwrap();
    let repo = init_repo(tmp.path(), &alice());

    open_issue(&repo, &alice(), "Recent issue");
    create_patch(&repo, &alice(), "Recent patch");

    let status = status::compute(&repo).unwrap();
    assert_eq!(status.recent_items.len(), 2);
    let titles: Vec<&str> = status.recent_items.iter().map(|i| i.title.as_str()).collect();
    assert!(titles.contains(&"Recent issue"));
    assert!(titles.contains(&"Recent patch"));
}

#[test]
fn test_status_recent_items_capped_at_10() {
    let tmp = TempDir::new().unwrap();
    let repo = init_repo(tmp.path(), &alice());

    for i in 0..15 {
        open_issue(&repo, &alice(), &format!("Issue {}", i));
    }

    let status = status::compute(&repo).unwrap();
    assert_eq!(status.recent_items.len(), 10);
}

#[test]
fn test_status_display_format() {
    let tmp = TempDir::new().unwrap();
    let repo = init_repo(tmp.path(), &alice());

    open_issue(&repo, &alice(), "Test issue");
    create_patch(&repo, &alice(), "Test patch");

    let status = status::compute(&repo).unwrap();
    let output = status.to_string();

    // Should contain section headers and counts
    assert!(output.contains("Issues"));
    assert!(output.contains("Patches"));
    assert!(output.contains("1 open"));
}