a73x

a6375981

Add archive for closed issues/patches and relates-to for issues

a73x   2026-03-21 17:12

Auto-archive closed/merged items by moving refs from refs/collab/{issues,patches}/
to refs/collab/archive/{issues,patches}/. List commands show only active items by
default; --archived flag includes archived. Resolve (show/comment/etc) searches
both active and archive namespaces. Sync fetches and pushes archive refs.

Also adds --relates-to on issue open for linking issues, and removes the CLI
reopen subcommand (IssueReopen event kept for backward compat).

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

diff --git a/benches/core_ops.rs b/benches/core_ops.rs
index 29f5f6d..ef69a7f 100644
--- a/benches/core_ops.rs
+++ b/benches/core_ops.rs
@@ -47,6 +47,7 @@ fn setup_issues(n: usize) -> (Repository, TempDir) {
            action: Action::IssueOpen {
                title: format!("Issue {}", i),
                body: format!("Body for issue {}", i),
                relates_to: None,
            },
            clock: 0,
        };
@@ -99,6 +100,7 @@ fn setup_issue_with_comments(n: usize) -> (Repository, String, String, TempDir) 
        action: Action::IssueOpen {
            title: "Big issue".to_string(),
            body: "An issue with many comments".to_string(),
            relates_to: None,
        },
        clock: 0,
    };
diff --git a/src/cli.rs b/src/cli.rs
index bdcb2a2..5c9acff 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -91,12 +91,18 @@ pub enum IssueCmd {
        /// Issue body
        #[arg(short, long, default_value = "")]
        body: String,
        /// Related issue ID
        #[arg(long)]
        relates_to: Option<String>,
    },
    /// List issues
    List {
        /// Show closed issues too
        #[arg(short = 'a', long)]
        all: bool,
        /// Include archived issues
        #[arg(long)]
        archived: bool,
        /// Maximum number of issues to display
        #[arg(short = 'n', long)]
        limit: Option<usize>,
@@ -173,11 +179,6 @@ pub enum IssueCmd {
        #[arg(short, long)]
        reason: Option<String>,
    },
    /// Reopen a closed issue
    Reopen {
        /// Issue ID (prefix match)
        id: String,
    },
    /// Delete an issue (removes the local collab ref)
    Delete {
        /// Issue ID (prefix match)
@@ -242,6 +243,9 @@ pub enum PatchCmd {
        /// Show closed/merged patches too
        #[arg(short = 'a', long)]
        all: bool,
        /// Include archived patches
        #[arg(long)]
        archived: bool,
        /// Maximum number of patches to display
        #[arg(short = 'n', long)]
        limit: Option<usize>,
diff --git a/src/event.rs b/src/event.rs
index d3e5a49..fbcd2d6 100644
--- a/src/event.rs
+++ b/src/event.rs
@@ -22,6 +22,8 @@ pub enum Action {
    IssueOpen {
        title: String,
        body: String,
        #[serde(default, skip_serializing_if = "Option::is_none")]
        relates_to: Option<String>,
    },
    #[serde(rename = "issue.comment")]
    IssueComment {
diff --git a/src/issue.rs b/src/issue.rs
index 9b440cb..b732f7f 100644
--- a/src/issue.rs
+++ b/src/issue.rs
@@ -7,7 +7,12 @@ use crate::identity::get_author;
use crate::signing;
use crate::state::{self, IssueState, IssueStatus};

pub fn open(repo: &Repository, title: &str, body: &str) -> Result<String, crate::error::Error> {
pub fn open(
    repo: &Repository,
    title: &str,
    body: &str,
    relates_to: Option<&str>,
) -> Result<String, crate::error::Error> {
    let sk = signing::load_signing_key(&signing::signing_key_dir()?)?;
    let author = get_author(repo)?;
    let event = Event {
@@ -16,6 +21,7 @@ pub fn open(repo: &Repository, title: &str, body: &str) -> Result<String, crate:
        action: Action::IssueOpen {
            title: title.to_string(),
            body: body.to_string(),
            relates_to: relates_to.map(|s| s.to_string()),
        },
        clock: 0,
    };
@@ -34,11 +40,16 @@ pub struct ListEntry {
pub fn list(
    repo: &Repository,
    show_closed: bool,
    show_archived: bool,
    limit: Option<usize>,
    offset: Option<usize>,
    sort: SortMode,
) -> Result<Vec<ListEntry>, crate::error::Error> {
    let issues = state::list_issues(repo)?;
    let issues = if show_archived {
        state::list_issues_with_archived(repo)?
    } else {
        state::list_issues(repo)?
    };
    let mut entries: Vec<_> = issues
        .into_iter()
        .filter(|i| show_closed || i.status == IssueStatus::Open)
@@ -63,12 +74,13 @@ pub fn list(
pub fn list_to_writer(
    repo: &Repository,
    show_closed: bool,
    show_archived: bool,
    limit: Option<usize>,
    offset: Option<usize>,
    sort: SortMode,
    writer: &mut dyn std::io::Write,
) -> Result<(), crate::error::Error> {
    let entries = list(repo, show_closed, limit, offset, sort)?;
    let entries = list(repo, show_closed, show_archived, limit, offset, sort)?;
    if entries.is_empty() {
        writeln!(writer, "No issues found.").ok();
        return Ok(());
@@ -118,8 +130,12 @@ fn count_unread(repo: &git2::Repository, id: &str) -> Option<usize> {
    Some(revwalk.count())
}

pub fn list_json(repo: &Repository, show_closed: bool, sort: SortMode) -> Result<String, crate::error::Error> {
    let issues = state::list_issues(repo)?;
pub fn list_json(repo: &Repository, show_closed: bool, show_archived: bool, sort: SortMode) -> Result<String, crate::error::Error> {
    let issues = if show_archived {
        state::list_issues_with_archived(repo)?
    } else {
        state::list_issues(repo)?
    };
    let mut filtered: Vec<_> = issues
        .into_iter()
        .filter(|i| show_closed || i.status == IssueStatus::Open)
@@ -269,7 +285,7 @@ pub fn close(
    reason: Option<&str>,
) -> Result<(), crate::error::Error> {
    let sk = signing::load_signing_key(&signing::signing_key_dir()?)?;
    let (ref_name, _id) = state::resolve_issue_ref(repo, id_prefix)?;
    let (ref_name, id) = state::resolve_issue_ref(repo, id_prefix)?;
    let author = get_author(repo)?;
    let event = Event {
        timestamp: chrono::Utc::now().to_rfc3339(),
@@ -280,6 +296,10 @@ pub fn close(
        clock: 0,
    };
    dag::append_event(repo, &ref_name, &event, &sk)?;
    // Archive the ref (move to refs/collab/archive/issues/)
    if ref_name.starts_with("refs/collab/issues/") {
        state::archive_issue_ref(repo, &id)?;
    }
    Ok(())
}

diff --git a/src/lib.rs b/src/lib.rs
index db934ed..822c0ac 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -26,18 +26,18 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> {
    match cli.command {
        Commands::Init => sync::init(repo),
        Commands::Issue(cmd) => match cmd {
            IssueCmd::Open { title, body } => {
                let id = issue::open(repo, &title, &body)?;
            IssueCmd::Open { title, body, relates_to } => {
                let id = issue::open(repo, &title, &body, relates_to.as_deref())?;
                println!("Opened issue {:.8}", id);
                Ok(())
            }
            IssueCmd::List { all, limit, offset, json, sort } => {
            IssueCmd::List { all, archived, limit, offset, json, sort } => {
                if json {
                    let output = issue::list_json(repo, all, sort)?;
                    let output = issue::list_json(repo, all, archived, sort)?;
                    println!("{}", output);
                    return Ok(());
                }
                let entries = issue::list(repo, all, limit, offset, sort)?;
                let entries = issue::list(repo, all, archived, limit, offset, sort)?;
                if entries.is_empty() {
                    println!("No issues found.");
                } else {
@@ -85,6 +85,9 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> {
                if !i.assignees.is_empty() {
                    println!("Assignees: {}", i.assignees.join(", "));
                }
                if let Some(ref relates_to) = i.relates_to {
                    println!("Relates-to: {:.8}", relates_to);
                }
                if let Some(ref reason) = i.close_reason {
                    println!("Closed:  {}", reason);
                }
@@ -134,11 +137,6 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> {
                println!("Issue closed.");
                Ok(())
            }
            IssueCmd::Reopen { id } => {
                issue::reopen(repo, &id)?;
                println!("Issue reopened.");
                Ok(())
            }
            IssueCmd::Delete { id } => {
                let full_id = issue::delete(repo, &id)?;
                println!("Deleted issue {:.8}", full_id);
@@ -187,13 +185,13 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> {
                println!("Created patch {:.8}", id);
                Ok(())
            }
            PatchCmd::List { all, limit, offset, json, sort } => {
            PatchCmd::List { all, archived, limit, offset, json, sort } => {
                if json {
                    let output = patch::list_json(repo, all, sort)?;
                    let output = patch::list_json(repo, all, archived, sort)?;
                    println!("{}", output);
                    return Ok(());
                }
                let patches = patch::list(repo, all, limit, offset, sort)?;
                let patches = patch::list(repo, all, archived, limit, offset, sort)?;
                if patches.is_empty() {
                    println!("No patches found.");
                } else {
diff --git a/src/patch.rs b/src/patch.rs
index 1dd2e43..7b70e49 100644
--- a/src/patch.rs
+++ b/src/patch.rs
@@ -63,11 +63,16 @@ pub fn create(
pub fn list(
    repo: &Repository,
    show_closed: bool,
    show_archived: bool,
    limit: Option<usize>,
    offset: Option<usize>,
    sort: SortMode,
) -> Result<Vec<PatchState>, crate::error::Error> {
    let patches = state::list_patches(repo)?;
    let patches = if show_archived {
        state::list_patches_with_archived(repo)?
    } else {
        state::list_patches(repo)?
    };
    let mut filtered: Vec<_> = patches
        .into_iter()
        .filter(|p| show_closed || p.status == PatchStatus::Open)
@@ -88,12 +93,13 @@ pub fn list(
pub fn list_to_writer(
    repo: &Repository,
    show_closed: bool,
    show_archived: bool,
    limit: Option<usize>,
    offset: Option<usize>,
    sort: SortMode,
    writer: &mut dyn std::io::Write,
) -> Result<(), crate::error::Error> {
    let patches = list(repo, show_closed, limit, offset, sort)?;
    let patches = list(repo, show_closed, show_archived, limit, offset, sort)?;
    if patches.is_empty() {
        writeln!(writer, "No patches found.").ok();
        return Ok(());
@@ -114,8 +120,12 @@ pub fn list_to_writer(
    Ok(())
}

pub fn list_json(repo: &Repository, show_closed: bool, sort: SortMode) -> Result<String, crate::error::Error> {
    let patches = state::list_patches(repo)?;
pub fn list_json(repo: &Repository, show_closed: bool, show_archived: bool, sort: SortMode) -> Result<String, crate::error::Error> {
    let patches = if show_archived {
        state::list_patches_with_archived(repo)?
    } else {
        state::list_patches(repo)?
    };
    let mut filtered: Vec<_> = patches
        .into_iter()
        .filter(|p| show_closed || p.status == PatchStatus::Open)
@@ -281,9 +291,14 @@ pub fn merge(repo: &Repository, id_prefix: &str) -> Result<PatchState, crate::er
    };
    dag::append_event(repo, &ref_name, &event, &sk)?;

    // Archive the patch ref
    if ref_name.starts_with("refs/collab/patches/") {
        state::archive_patch_ref(repo, &id)?;
    }

    // Auto-close linked issue if present
    if let Some(ref fixes_id) = p.fixes {
        if let Ok((issue_ref, _)) = state::resolve_issue_ref(repo, fixes_id) {
        if let Ok((issue_ref, issue_id)) = state::resolve_issue_ref(repo, fixes_id) {
            let close_event = Event {
                timestamp: chrono::Utc::now().to_rfc3339(),
                author,
@@ -293,6 +308,10 @@ pub fn merge(repo: &Repository, id_prefix: &str) -> Result<PatchState, crate::er
                clock: 0,
            };
            dag::append_event(repo, &issue_ref, &close_event, &sk)?;
            // Archive the issue ref
            if issue_ref.starts_with("refs/collab/issues/") {
                state::archive_issue_ref(repo, &issue_id)?;
            }
        }
    }

@@ -367,7 +386,7 @@ pub fn close(
    reason: Option<&str>,
) -> Result<(), crate::error::Error> {
    let sk = signing::load_signing_key(&signing::signing_key_dir()?)?;
    let (ref_name, _id) = state::resolve_patch_ref(repo, id_prefix)?;
    let (ref_name, id) = state::resolve_patch_ref(repo, id_prefix)?;
    let author = get_author(repo)?;
    let event = Event {
        timestamp: chrono::Utc::now().to_rfc3339(),
@@ -378,6 +397,10 @@ pub fn close(
        clock: 0,
    };
    dag::append_event(repo, &ref_name, &event, &sk)?;
    // Archive the ref (move to refs/collab/archive/patches/)
    if ref_name.starts_with("refs/collab/patches/") {
        state::archive_patch_ref(repo, &id)?;
    }
    Ok(())
}

diff --git a/src/signing.rs b/src/signing.rs
index 76a2790..883f1ff 100644
--- a/src/signing.rs
+++ b/src/signing.rs
@@ -321,6 +321,7 @@ mod tests {
            action: Action::IssueOpen {
                title: "Test".to_string(),
                body: "Body".to_string(),
                relates_to: None,
            },
            clock: 0,
        };
diff --git a/src/state.rs b/src/state.rs
index 6d3b907..0378c93 100644
--- a/src/state.rs
+++ b/src/state.rs
@@ -87,6 +87,8 @@ pub struct IssueState {
    #[serde(default)]
    pub last_updated: String,
    pub author: Author,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub relates_to: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -174,7 +176,7 @@ impl IssueState {
                max_timestamp = event.timestamp.clone();
            }
            match event.action {
                Action::IssueOpen { title, body } => {
                Action::IssueOpen { title, body, relates_to } => {
                    state = Some(IssueState {
                        id: id.to_string(),
                        title,
@@ -188,6 +190,7 @@ impl IssueState {
                        created_at: event.timestamp.clone(),
                        last_updated: String::new(),
                        author: event.author.clone(),
                        relates_to,
                    });
                }
                Action::IssueComment { body } => {
@@ -447,17 +450,46 @@ fn collab_refs(
    Ok(result)
}

/// Resolve a short ID prefix to a full ref. Returns (ref_name, id).
/// Enumerate all collab refs of a given kind under the archive namespace.
fn collab_archive_refs(
    repo: &Repository,
    kind: &str,
) -> Result<Vec<(String, String)>, crate::error::Error> {
    let prefix = format!("refs/collab/archive/{}/", kind);
    let glob = format!("{}*", prefix);
    let refs = repo.references_glob(&glob)?;
    let mut result = Vec::new();
    for r in refs {
        let r = r?;
        let ref_name = r.name().unwrap_or_default().to_string();
        let id = ref_name
            .strip_prefix(&prefix)
            .unwrap_or_default()
            .to_string();
        result.push((ref_name, id));
    }
    Ok(result)
}

/// Resolve a short ID prefix to a full ref. Searches both active and archive namespaces.
/// Returns (ref_name, id).
fn resolve_ref(
    repo: &Repository,
    kind: &str,
    singular: &str,
    prefix: &str,
) -> Result<(String, String), crate::error::Error> {
    let matches: Vec<_> = collab_refs(repo, kind)?
    let mut matches: Vec<_> = collab_refs(repo, kind)?
        .into_iter()
        .filter(|(_, id)| id.starts_with(prefix))
        .collect();
    // Also search archive namespace
    let archive_matches: Vec<_> = collab_archive_refs(repo, kind)?
        .into_iter()
        .filter(|(_, id)| id.starts_with(prefix))
        .collect();
    matches.extend(archive_matches);

    match matches.len() {
        0 => Err(
            git2::Error::from_str(&format!("no {} found matching '{}'", singular, prefix)).into(),
@@ -491,6 +523,54 @@ pub fn list_patches(repo: &Repository) -> Result<Vec<PatchState>, crate::error::
    Ok(items)
}

/// List all issue refs (active + archived) and return their materialized state.
pub fn list_issues_with_archived(repo: &Repository) -> Result<Vec<IssueState>, crate::error::Error> {
    let mut items: Vec<_> = collab_refs(repo, "issues")?
        .into_iter()
        .filter_map(|(ref_name, id)| IssueState::from_ref(repo, &ref_name, &id).ok())
        .collect();
    let archived: Vec<_> = collab_archive_refs(repo, "issues")?
        .into_iter()
        .filter_map(|(ref_name, id)| IssueState::from_ref(repo, &ref_name, &id).ok())
        .collect();
    items.extend(archived);
    Ok(items)
}

/// List all patch refs (active + archived) and return their materialized state.
pub fn list_patches_with_archived(repo: &Repository) -> Result<Vec<PatchState>, crate::error::Error> {
    let mut items: Vec<_> = collab_refs(repo, "patches")?
        .into_iter()
        .filter_map(|(ref_name, id)| PatchState::from_ref(repo, &ref_name, &id).ok())
        .collect();
    let archived: Vec<_> = collab_archive_refs(repo, "patches")?
        .into_iter()
        .filter_map(|(ref_name, id)| PatchState::from_ref(repo, &ref_name, &id).ok())
        .collect();
    items.extend(archived);
    Ok(items)
}

/// Move an issue ref from active to archive namespace.
pub fn archive_issue_ref(repo: &Repository, id: &str) -> Result<(), crate::error::Error> {
    let old_ref = format!("refs/collab/issues/{}", id);
    let oid = repo.refname_to_id(&old_ref)?;
    let new_ref = format!("refs/collab/archive/issues/{}", id);
    repo.reference(&new_ref, oid, false, "archive issue")?;
    repo.find_reference(&old_ref)?.delete()?;
    Ok(())
}

/// Move a patch ref from active to archive namespace.
pub fn archive_patch_ref(repo: &Repository, id: &str) -> Result<(), crate::error::Error> {
    let old_ref = format!("refs/collab/patches/{}", id);
    let oid = repo.refname_to_id(&old_ref)?;
    let new_ref = format!("refs/collab/archive/patches/{}", id);
    repo.reference(&new_ref, oid, false, "archive patch")?;
    repo.find_reference(&old_ref)?.delete()?;
    Ok(())
}

/// Resolve a short ID prefix to the full issue ref name. Returns (ref_name, id).
pub fn resolve_issue_ref(
    repo: &Repository,
diff --git a/src/sync.rs b/src/sync.rs
index 98a8d1d..da0c0ed 100644
--- a/src/sync.rs
+++ b/src/sync.rs
@@ -182,7 +182,12 @@ fn push_ref(workdir: &Path, remote_name: &str, ref_name: &str) -> RefPushResult 
/// Collect all collab ref names that should be pushed.
fn collect_push_refs(repo: &Repository) -> Result<Vec<String>, Error> {
    let mut refs = Vec::new();
    for pattern in &["refs/collab/issues/*", "refs/collab/patches/*"] {
    for pattern in &[
        "refs/collab/issues/*",
        "refs/collab/patches/*",
        "refs/collab/archive/issues/*",
        "refs/collab/archive/patches/*",
    ] {
        let iter = repo.references_glob(pattern)?;
        for r in iter {
            let r = r?;
@@ -294,6 +299,8 @@ pub fn sync(repo: &Repository, remote_name: &str) -> Result<(), Error> {
            remote_name,
            "+refs/collab/issues/*:refs/collab/sync/issues/*",
            "+refs/collab/patches/*:refs/collab/sync/patches/*",
            "+refs/collab/archive/issues/*:refs/collab/sync/archive/issues/*",
            "+refs/collab/archive/patches/*:refs/collab/sync/archive/patches/*",
        ])
        .current_dir(&workdir)
        .status()
diff --git a/src/tui/events.rs b/src/tui/events.rs
index ff02163..3171aaf 100644
--- a/src/tui/events.rs
+++ b/src/tui/events.rs
@@ -85,7 +85,7 @@ pub(crate) fn run_loop(
                            KeyCode::Esc => {
                                // Submit with title only, no body
                                let title = app.create_title.clone();
                                match issue_mod::open(repo, &title, "") {
                                match issue_mod::open(repo, &title, "", None) {
                                    Ok(id) => {
                                        app.reload(repo);
                                        app.status_msg =
@@ -103,7 +103,7 @@ pub(crate) fn run_loop(
                            KeyCode::Enter => {
                                let title = app.create_title.clone();
                                let body = app.input_buf.clone();
                                match issue_mod::open(repo, &title, &body) {
                                match issue_mod::open(repo, &title, &body, None) {
                                    Ok(id) => {
                                        app.reload(repo);
                                        app.status_msg =
diff --git a/src/tui/mod.rs b/src/tui/mod.rs
index 1fdf39a..5ea36ad 100644
--- a/src/tui/mod.rs
+++ b/src/tui/mod.rs
@@ -66,6 +66,7 @@ mod tests {
            created_at: String::new(),
            last_updated: String::new(),
            author: make_author(),
            relates_to: None,
        }
    }

@@ -253,6 +254,7 @@ mod tests {
                created_at: "2026-01-01T00:00:00Z".to_string(),
                last_updated: "2026-01-01T00:00:00Z".to_string(),
                author: test_author(),
                relates_to: None,
            })
            .collect()
    }
@@ -330,6 +332,7 @@ mod tests {
                    action: Action::IssueOpen {
                        title: "Test Issue".to_string(),
                        body: "This is the body".to_string(),
                        relates_to: None,
                    },
                    clock: 0,
                },
@@ -369,6 +372,7 @@ mod tests {
        let action = Action::IssueOpen {
            title: "t".to_string(),
            body: "b".to_string(),
            relates_to: None,
        };
        assert_eq!(action_type_label(&action), "Issue Open");
    }
@@ -439,6 +443,7 @@ mod tests {
            action: Action::IssueOpen {
                title: "My Issue".to_string(),
                body: "Description here".to_string(),
                relates_to: None,
            },
            clock: 0,
        };
diff --git a/src/tui/widgets.rs b/src/tui/widgets.rs
index e786584..f7d32ef 100644
--- a/src/tui/widgets.rs
+++ b/src/tui/widgets.rs
@@ -40,7 +40,7 @@ pub(crate) fn format_event_detail(oid: &Oid, event: &crate::event::Event) -> Str

    // Action-specific payload
    match &event.action {
        Action::IssueOpen { title, body } => {
        Action::IssueOpen { title, body, .. } => {
            detail.push_str(&format!("\nTitle: {}\n", title));
            if !body.is_empty() {
                detail.push_str(&format!("\n{}\n", body));
diff --git a/tests/adversarial_test.rs b/tests/adversarial_test.rs
index 91b0638..c56105d 100644
--- a/tests/adversarial_test.rs
+++ b/tests/adversarial_test.rs
@@ -80,6 +80,7 @@ fn valid_event_json() -> String {
        action: Action::IssueOpen {
            title: "Test".to_string(),
            body: "Body".to_string(),
            relates_to: None,
        },
        clock: 1,
    })
@@ -274,6 +275,7 @@ fn dag_with_one_corrupted_commit_in_middle() {
        action: Action::IssueOpen {
            title: "Test".to_string(),
            body: "Body".to_string(),
            relates_to: None,
        },
        clock: 0,
    };
@@ -353,6 +355,7 @@ fn blob_at_limit_succeeds() {
        action: Action::IssueOpen {
            title: "Test".to_string(),
            body: body_padding,
            relates_to: None,
        },
        clock: 1,
    };
@@ -464,6 +467,7 @@ fn malformed_remote_event_no_local_state_change() {
        action: Action::IssueOpen {
            title: "Original".to_string(),
            body: "Body".to_string(),
            relates_to: None,
        },
        clock: 0,
    };
@@ -527,7 +531,7 @@ fn arb_author() -> impl Strategy<Value = Author> {
/// Generate an arbitrary Action variant
fn arb_action() -> impl Strategy<Value = Action> {
    prop_oneof![
        (".*", ".*").prop_map(|(title, body)| Action::IssueOpen { title, body }),
        (".*", ".*").prop_map(|(title, body)| Action::IssueOpen { title, body, relates_to: None }),
        ".*".prop_map(|body| Action::IssueComment { body }),
        proptest::option::of(".*").prop_map(|reason| Action::IssueClose { reason }),
        Just(Action::IssueReopen),
diff --git a/tests/archive_test.rs b/tests/archive_test.rs
new file mode 100644
index 0000000..c70962f
--- /dev/null
+++ b/tests/archive_test.rs
@@ -0,0 +1,230 @@
mod common;

use tempfile::TempDir;

use git_collab::dag;
use git_collab::event::{Action, Event};
use git_collab::state::{self, IssueState, IssueStatus, PatchState, PatchStatus};

use common::{alice, close_issue, create_patch, init_repo, now, open_issue, test_signing_key};

// ---------------------------------------------------------------------------
// Archive on close: issues
// ---------------------------------------------------------------------------

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

    let (ref_name, id) = open_issue(&repo, &alice(), "To be archived");

    // Close + archive
    close_issue(&repo, &ref_name, &alice());
    state::archive_issue_ref(&repo, &id).unwrap();

    // Old ref should be gone
    assert!(repo.refname_to_id(&ref_name).is_err());

    // New archive ref should exist
    let archive_ref = format!("refs/collab/archive/issues/{}", id);
    assert!(repo.refname_to_id(&archive_ref).is_ok());

    // Should still be able to materialize state from archive ref
    let issue = IssueState::from_ref(&repo, &archive_ref, &id).unwrap();
    assert_eq!(issue.status, IssueStatus::Closed);
    assert_eq!(issue.title, "To be archived");
}

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

    let (_ref1, _id1) = open_issue(&repo, &alice(), "Active issue");
    let (ref2, id2) = open_issue(&repo, &alice(), "Archived issue");
    close_issue(&repo, &ref2, &alice());
    state::archive_issue_ref(&repo, &id2).unwrap();

    // Default list should only show active
    let issues = state::list_issues(&repo).unwrap();
    assert_eq!(issues.len(), 1);
    assert_eq!(issues[0].title, "Active issue");
}

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

    let (_ref1, _id1) = open_issue(&repo, &alice(), "Active issue");
    let (ref2, id2) = open_issue(&repo, &alice(), "Archived issue");
    close_issue(&repo, &ref2, &alice());
    state::archive_issue_ref(&repo, &id2).unwrap();

    // With archived flag should show both
    let issues = state::list_issues_with_archived(&repo).unwrap();
    assert_eq!(issues.len(), 2);
}

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

    let (ref_name, id) = open_issue(&repo, &alice(), "Will be archived");
    close_issue(&repo, &ref_name, &alice());
    state::archive_issue_ref(&repo, &id).unwrap();

    // resolve_issue_ref should find it in archive
    let (resolved_ref, resolved_id) = state::resolve_issue_ref(&repo, &id[..8]).unwrap();
    assert_eq!(resolved_id, id);
    assert!(resolved_ref.contains("archive"));
}

// ---------------------------------------------------------------------------
// Archive on close: patches
// ---------------------------------------------------------------------------

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

    let (ref_name, id) = create_patch(&repo, &alice(), "To be archived");

    // Close + archive
    let sk = test_signing_key();
    let event = Event {
        timestamp: now(),
        author: alice(),
        action: Action::PatchClose { reason: None },
        clock: 0,
    };
    dag::append_event(&repo, &ref_name, &event, &sk).unwrap();
    state::archive_patch_ref(&repo, &id).unwrap();

    // Old ref should be gone
    assert!(repo.refname_to_id(&ref_name).is_err());

    // New archive ref should exist
    let archive_ref = format!("refs/collab/archive/patches/{}", id);
    assert!(repo.refname_to_id(&archive_ref).is_ok());

    // Should still be able to materialize state from archive ref
    let patch = PatchState::from_ref(&repo, &archive_ref, &id).unwrap();
    assert_eq!(patch.status, PatchStatus::Closed);
}

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

    let (_ref1, _id1) = create_patch(&repo, &alice(), "Active patch");
    let (ref2, id2) = create_patch(&repo, &alice(), "Archived patch");

    let sk = test_signing_key();
    let event = Event {
        timestamp: now(),
        author: alice(),
        action: Action::PatchClose { reason: None },
        clock: 0,
    };
    dag::append_event(&repo, &ref2, &event, &sk).unwrap();
    state::archive_patch_ref(&repo, &id2).unwrap();

    let patches = state::list_patches(&repo).unwrap();
    assert_eq!(patches.len(), 1);
    assert_eq!(patches[0].title, "Active patch");
}

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

    let (_ref1, _id1) = create_patch(&repo, &alice(), "Active patch");
    let (ref2, id2) = create_patch(&repo, &alice(), "Archived patch");

    let sk = test_signing_key();
    let event = Event {
        timestamp: now(),
        author: alice(),
        action: Action::PatchClose { reason: None },
        clock: 0,
    };
    dag::append_event(&repo, &ref2, &event, &sk).unwrap();
    state::archive_patch_ref(&repo, &id2).unwrap();

    let patches = state::list_patches_with_archived(&repo).unwrap();
    assert_eq!(patches.len(), 2);
}

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

    let (ref_name, id) = create_patch(&repo, &alice(), "Will archive");

    let sk = test_signing_key();
    let event = Event {
        timestamp: now(),
        author: alice(),
        action: Action::PatchClose { reason: None },
        clock: 0,
    };
    dag::append_event(&repo, &ref_name, &event, &sk).unwrap();
    state::archive_patch_ref(&repo, &id).unwrap();

    let (resolved_ref, resolved_id) = state::resolve_patch_ref(&repo, &id[..8]).unwrap();
    assert_eq!(resolved_id, id);
    assert!(resolved_ref.contains("archive"));
}

// ---------------------------------------------------------------------------
// --relates-to on issue open
// ---------------------------------------------------------------------------

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

    // First create an issue to relate to
    let (_ref1, id1) = open_issue(&repo, &alice(), "Parent issue");

    // Open an issue with relates_to
    let sk = test_signing_key();
    let event = Event {
        timestamp: now(),
        author: alice(),
        action: Action::IssueOpen {
            title: "Child issue".to_string(),
            body: "".to_string(),
            relates_to: Some(id1.clone()),
        },
        clock: 0,
    };
    let oid = dag::create_root_event(&repo, &event, &sk).unwrap();
    let id = oid.to_string();
    let ref_name = format!("refs/collab/issues/{}", id);
    repo.reference(&ref_name, oid, false, "test open with relates_to")
        .unwrap();

    let issue = IssueState::from_ref(&repo, &ref_name, &id).unwrap();
    assert_eq!(issue.title, "Child issue");
    assert_eq!(issue.relates_to.as_deref(), Some(id1.as_str()));
}

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

    let (ref_name, id) = open_issue(&repo, &alice(), "Solo issue");

    let issue = IssueState::from_ref(&repo, &ref_name, &id).unwrap();
    assert_eq!(issue.relates_to, None);
}
diff --git a/tests/cli_test.rs b/tests/cli_test.rs
index 2ea1c65..4495397 100644
--- a/tests/cli_test.rs
+++ b/tests/cli_test.rs
@@ -44,7 +44,8 @@ fn test_issue_list_filters_closed() {
    assert!(out.contains("Open bug"));
    assert!(!out.contains("Closed bug"));

    let out = repo.run_ok(&["issue", "list", "--all"]);
    // Closed issues are archived, so --all alone won't show them; need --archived
    let out = repo.run_ok(&["issue", "list", "--all", "--archived"]);
    assert!(out.contains("Open bug"));
    assert!(out.contains("Closed bug"));
    assert!(out.contains("closed"));
@@ -88,19 +89,6 @@ fn test_issue_close_without_reason() {
}

#[test]
fn test_issue_reopen() {
    let repo = TestRepo::new("Alice", "alice@example.com");
    let id = repo.issue_open("Reopen me");
    repo.run_ok(&["issue", "close", &id]);

    let out = repo.run_ok(&["issue", "reopen", &id]);
    assert!(out.contains("Issue reopened"));

    let out = repo.run_ok(&["issue", "show", &id]);
    assert!(out.contains("[open]"));
}

#[test]
fn test_issue_prefix_resolution() {
    let repo = TestRepo::new("Alice", "alice@example.com");
    let id = repo.issue_open("Prefix test");
@@ -327,7 +315,8 @@ fn test_patch_list_filters_by_status() {
    assert!(out.contains("Open patch"));
    assert!(!out.contains("Closed patch"));

    let out = repo.run_ok(&["patch", "list", "--all"]);
    // Closed patches are archived, so --all alone won't show them; need --archived
    let out = repo.run_ok(&["patch", "list", "--all", "--archived"]);
    assert!(out.contains("Open patch"));
    assert!(out.contains("Closed patch"));
}
@@ -692,21 +681,19 @@ fn test_full_issue_lifecycle() {
    assert!(out.contains("Stack trace attached"));
    assert!(out.contains("Reproduced on Chrome"));

    // Close with reason
    // Close with reason (this archives the issue)
    repo.run_ok(&["issue", "close", &id, "-r", "Fixed in commit abc123"]);
    let out = repo.run_ok(&["issue", "show", &id]);
    assert!(out.contains("[closed]"));
    assert!(out.contains("Fixed in commit abc123"));

    // Reopen
    repo.run_ok(&["issue", "reopen", &id]);
    let out = repo.run_ok(&["issue", "show", &id]);
    assert!(out.contains("[open]"));

    // Close again (no reason)
    repo.run_ok(&["issue", "close", &id]);
    // Issue should be archived and not in default list
    let out = repo.run_ok(&["issue", "list"]);
    assert!(!out.contains("Login page crashes") || out.contains("No issues"));

    // But visible with --archived
    let out = repo.run_ok(&["issue", "list", "--all", "--archived"]);
    assert!(out.contains("Login page crashes"));
}

#[test]
diff --git a/tests/collab_test.rs b/tests/collab_test.rs
index 4e47759..9233a7d 100644
--- a/tests/collab_test.rs
+++ b/tests/collab_test.rs
@@ -510,6 +510,7 @@ fn test_signed_event_in_dag() {
        action: Action::IssueOpen {
            title: "Signed issue".to_string(),
            body: "".to_string(),
            relates_to: None,
        },
        clock: 0,
    };
@@ -934,9 +935,9 @@ fn test_merge_branch_based_patch() {
    // Merge the patch
    patch::merge(&repo, &id).unwrap();

    // Check that the patch is now merged
    let patch_ref = format!("refs/collab/patches/{}", id);
    let state = PatchState::from_ref(&repo, &patch_ref, &id).unwrap();
    // Check that the patch is now merged (archived after merge)
    let archive_ref = format!("refs/collab/archive/patches/{}", id);
    let state = PatchState::from_ref(&repo, &archive_ref, &id).unwrap();
    assert_eq!(state.status, PatchStatus::Merged);

    // Check that the base branch now has the feature commit
@@ -1106,7 +1107,7 @@ fn capture_issue_list(
    offset: Option<usize>,
) -> String {
    let mut buf = Vec::new();
    issue::list_to_writer(repo, show_closed, limit, offset, git_collab::cli::SortMode::Recent, &mut buf).unwrap();
    issue::list_to_writer(repo, show_closed, false, limit, offset, git_collab::cli::SortMode::Recent, &mut buf).unwrap();
    String::from_utf8(buf).unwrap()
}

@@ -1118,7 +1119,7 @@ fn capture_patch_list(
    offset: Option<usize>,
) -> String {
    let mut buf = Vec::new();
    patch::list_to_writer(repo, show_closed, limit, offset, git_collab::cli::SortMode::Recent, &mut buf).unwrap();
    patch::list_to_writer(repo, show_closed, false, limit, offset, git_collab::cli::SortMode::Recent, &mut buf).unwrap();
    String::from_utf8(buf).unwrap()
}

@@ -1331,7 +1332,7 @@ fn test_issue_list_json_output() {
    open_issue(&repo, &alice(), "Issue one");
    open_issue(&repo, &bob(), "Issue two");

    let json_str = git_collab::issue::list_json(&repo, false, git_collab::cli::SortMode::Recent).unwrap();
    let json_str = git_collab::issue::list_json(&repo, false, false, git_collab::cli::SortMode::Recent).unwrap();
    let value: serde_json::Value = serde_json::from_str(&json_str).unwrap();
    let arr = value.as_array().unwrap();
    assert_eq!(arr.len(), 2);
@@ -1351,12 +1352,12 @@ fn test_issue_list_json_filters_closed() {
    close_issue(&repo, &ref2, &alice());

    // Without --all, only open issues
    let json_str = git_collab::issue::list_json(&repo, false, git_collab::cli::SortMode::Recent).unwrap();
    let json_str = git_collab::issue::list_json(&repo, false, false, git_collab::cli::SortMode::Recent).unwrap();
    let value: serde_json::Value = serde_json::from_str(&json_str).unwrap();
    assert_eq!(value.as_array().unwrap().len(), 1);

    // With --all, both
    let json_str = git_collab::issue::list_json(&repo, true, git_collab::cli::SortMode::Recent).unwrap();
    let json_str = git_collab::issue::list_json(&repo, true, false, git_collab::cli::SortMode::Recent).unwrap();
    let value: serde_json::Value = serde_json::from_str(&json_str).unwrap();
    assert_eq!(value.as_array().unwrap().len(), 2);
}
@@ -1383,7 +1384,7 @@ fn test_patch_list_json_output() {
    create_patch(&repo, &alice(), "Patch one");
    create_patch(&repo, &bob(), "Patch two");

    let json_str = git_collab::patch::list_json(&repo, false, git_collab::cli::SortMode::Recent).unwrap();
    let json_str = git_collab::patch::list_json(&repo, false, false, git_collab::cli::SortMode::Recent).unwrap();
    let value: serde_json::Value = serde_json::from_str(&json_str).unwrap();
    let arr = value.as_array().unwrap();
    assert_eq!(arr.len(), 2);
diff --git a/tests/common/mod.rs b/tests/common/mod.rs
index cdfcf0c..3d87e7e 100644
--- a/tests/common/mod.rs
+++ b/tests/common/mod.rs
@@ -65,6 +65,7 @@ pub fn open_issue(repo: &Repository, author: &Author, title: &str) -> (String, S
        action: Action::IssueOpen {
            title: title.to_string(),
            body: "".to_string(),
            relates_to: None,
        },
        clock: 0,
    };
diff --git a/tests/crdt_test.rs b/tests/crdt_test.rs
index 4c64b91..426ef11 100644
--- a/tests/crdt_test.rs
+++ b/tests/crdt_test.rs
@@ -60,6 +60,7 @@ fn create_root_event_sets_clock_to_1() {
        action: Action::IssueOpen {
            title: "test".to_string(),
            body: "body".to_string(),
            relates_to: None,
        },
        clock: 0, // caller passes 0, DAG should override to 1
    };
@@ -80,6 +81,7 @@ fn append_event_increments_clock() {
        action: Action::IssueOpen {
            title: "test".to_string(),
            body: "body".to_string(),
            relates_to: None,
        },
        clock: 0,
    };
@@ -125,6 +127,7 @@ fn max_clock_returns_highest_clock_in_dag() {
        action: Action::IssueOpen {
            title: "test".to_string(),
            body: "body".to_string(),
            relates_to: None,
        },
        clock: 0,
    };
@@ -162,6 +165,7 @@ fn reconcile_merge_clock_is_max_of_both_branches_plus_one() {
        action: Action::IssueOpen {
            title: "test".to_string(),
            body: "body".to_string(),
            relates_to: None,
        },
        clock: 0,
    };
@@ -227,6 +231,7 @@ fn concurrent_issue_close_reopen_higher_clock_wins() {
        action: Action::IssueOpen {
            title: "test".to_string(),
            body: "body".to_string(),
            relates_to: None,
        },
        clock: 0,
    };
@@ -285,6 +290,7 @@ fn concurrent_issue_same_clock_oid_breaks_tie() {
        action: Action::IssueOpen {
            title: "test".to_string(),
            body: "body".to_string(),
            relates_to: None,
        },
        clock: 0,
    };
@@ -416,6 +422,7 @@ fn migrate_clocks_assigns_sequential_clocks() {
        action: Action::IssueOpen {
            title: "test".to_string(),
            body: "body".to_string(),
            relates_to: None,
        },
        clock: 0,
    };
@@ -462,6 +469,7 @@ fn migrate_clocks_on_zero_clock_events() {
        action: Action::IssueOpen {
            title: "test".to_string(),
            body: "body".to_string(),
            relates_to: None,
        },
        clock: 0,
    };
@@ -507,6 +515,7 @@ fn clock_field_survives_serialization_round_trip() {
        action: Action::IssueOpen {
            title: "test".to_string(),
            body: "body".to_string(),
            relates_to: None,
        },
        clock: 42,
    };
@@ -530,6 +539,7 @@ fn clock_field_in_dag_round_trip() {
        action: Action::IssueOpen {
            title: "test".to_string(),
            body: "body".to_string(),
            relates_to: None,
        },
        clock: 0, // Caller passes 0
    };
diff --git a/tests/signing_test.rs b/tests/signing_test.rs
index 89eb86e..6e440b9 100644
--- a/tests/signing_test.rs
+++ b/tests/signing_test.rs
@@ -15,6 +15,7 @@ fn make_event() -> Event {
        action: Action::IssueOpen {
            title: "Test issue".to_string(),
            body: "This is a test".to_string(),
            relates_to: None,
        },
        clock: 0,
    }
diff --git a/tests/sort_test.rs b/tests/sort_test.rs
index 160b56d..29f148b 100644
--- a/tests/sort_test.rs
+++ b/tests/sort_test.rs
@@ -39,6 +39,7 @@ fn open_issue_at(
        action: Action::IssueOpen {
            title: title.to_string(),
            body: "".to_string(),
            relates_to: None,
        },
        clock: 0,
    };
@@ -159,7 +160,7 @@ fn test_issue_default_sort_by_recency() {
    assert_eq!(issues.len(), 2);

    // Default sort = recent: issue A (last_updated=2025-12) should come first
    let entries = git_collab::issue::list(&repo, true, None, None, SortMode::Recent).unwrap();
    let entries = git_collab::issue::list(&repo, true, false, None, None, SortMode::Recent).unwrap();
    assert_eq!(entries.len(), 2);
    assert_eq!(entries[0].issue.title, "Alpha issue");
    assert_eq!(entries[1].issue.title, "Beta issue");
@@ -184,7 +185,7 @@ fn test_issue_sort_by_created() {
    let (_, _) = open_issue_at(&repo, &alice(), "Beta issue", "2025-06-01T00:00:00Z");

    // Sort by created: B (2025-06) comes first (descending)
    let entries = git_collab::issue::list(&repo, true, None, None, SortMode::Created).unwrap();
    let entries = git_collab::issue::list(&repo, true, false, None, None, SortMode::Created).unwrap();
    assert_eq!(entries.len(), 2);
    assert_eq!(entries[0].issue.title, "Beta issue");
    assert_eq!(entries[1].issue.title, "Alpha issue");
@@ -199,7 +200,7 @@ fn test_issue_sort_alpha() {
    open_issue_at(&repo, &alice(), "Apple issue", "2025-06-01T00:00:00Z");
    open_issue_at(&repo, &alice(), "Mango issue", "2025-03-01T00:00:00Z");

    let entries = git_collab::issue::list(&repo, true, None, None, SortMode::Alpha).unwrap();
    let entries = git_collab::issue::list(&repo, true, false, None, None, SortMode::Alpha).unwrap();
    assert_eq!(entries.len(), 3);
    assert_eq!(entries[0].issue.title, "Apple issue");
    assert_eq!(entries[1].issue.title, "Mango issue");
@@ -246,7 +247,7 @@ 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, None, None, SortMode::Recent).unwrap();
    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");
@@ -268,7 +269,7 @@ 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, None, None, SortMode::Created).unwrap();
    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");
@@ -283,7 +284,7 @@ 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, None, None, SortMode::Alpha).unwrap();
    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");
diff --git a/tests/sync_test.rs b/tests/sync_test.rs
index cbcd567..ba2715c 100644
--- a/tests/sync_test.rs
+++ b/tests/sync_test.rs
@@ -474,6 +474,7 @@ fn test_unsigned_event_sync_rejected() {
        action: Action::IssueOpen {
            title: "Unsigned issue".to_string(),
            body: "No signature".to_string(),
            relates_to: None,
        },
        clock: 0,
    };
@@ -522,6 +523,7 @@ fn test_tampered_event_sync_rejected() {
        action: Action::IssueOpen {
            title: "Tampered issue".to_string(),
            body: "Will be tampered".to_string(),
            relates_to: None,
        },
        clock: 0,
    };