diff --git a/CHANGELOG.md b/CHANGELOG.md
index 43f185c..2e7594a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+## [1.0.0] - 2026-09-19 — _AI-Native Standard_
+
+### Added
+- **MCP server** via `parsec mcp serve`, exposing worktree lifecycle, health,
+ smartlog, PR, review, CI, and synchronization tools to MCP clients.
+- **MCP client installer** via `parsec mcp install` for Claude Desktop and
+ Cursor, with configurable binary paths and safe config merging.
+- **Permission-scoped delegated authentication** from environment variables or
+ `mcp.toml`, including read/write scope checks and redacted diagnostics.
+- **Mutation safety gates** requiring preview/confirmation, repository sandbox
+ boundaries, privacy-safe audit events, and versioned audit fixtures.
+- **MCP end-to-end coverage** for the stdio lifecycle, tool registry, auth,
+ sandbox boundaries, client configuration, and mutation gates.
+- `parsec checkpoint`, `crash-report`, `triage`, self-update notifications, and
+ `ship --no-cleanup` from the final v0.x development line.
+
+### Changed
+- `parsec smartlog` now includes topological stack ordering, richer PR merge
+ readiness, CI overlays for branch tips, and hermetic overlay integration
+ tests.
+- `parsec ship` supports both AI-generated PR descriptions and template
+ variable substitution while retaining explicit cleanup control.
+
+### Docs
+- Added the [MCP quickstart](./docs/mcp-quickstart.md), protocol specification,
+ authentication and sandbox model, and client configuration guide.
+
## [0.5.0] - 2026-06-03 — _The visualization release_
v0.5 completes the **16/16** milestone for polish and power-user UX, adding
diff --git a/Cargo.toml b/Cargo.toml
index 1f07b27..ed52346 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "git-parsec"
-version = "0.5.0"
+version = "1.0.0"
edition = "2021"
authors = ["erishforG"]
description = "Git worktree lifecycle manager — ticket to PR in one command. Parallel AI agent workflows with Jira & GitHub Issues integration."
diff --git a/README.md b/README.md
index cb1b38c..e6c33e0 100644
--- a/README.md
+++ b/README.md
@@ -37,10 +37,10 @@ That's the whole loop. Plain `git worktree` doesn't track state, doesn't talk to
|---|---|---|
| **v0.4.0** | ✅ Released (2026-05-04) | Multi-forge + multi-tracker foundation (GitHub / GitLab / Bitbucket; Jira / Linear) |
| **v0.5.0** — _The visualization release_ | ✅ Released (2026-06-03) | smartlog · TUI dashboard · speculative merge · `parsec test` · health · reviews · AI PR descriptions |
-| **v1.0** — _AI-Native Standard_ | 🚧 Next | MCP server signature — Claude / Cursor / Copilot invoke parsec as a first-class tool |
-| **v2.0+** — _Ecosystem Hub_ | 🔮 | Plugins · VS Code extension · Linear-native tracker · org-scale workflows |
+| **v1.0.0** — _AI-Native Standard_ | ✅ Released (2026-09-19) | MCP server — Claude Desktop and Cursor invoke parsec as a first-class tool |
-v1.0 work is tracked under the [`v1.0` milestone](https://github.com/erishforG/git-parsec/milestone/4); see the [CHANGELOG](./CHANGELOG.md) for the full v0.5.0 release notes.
+v1.0 is the final planned feature release. See the [CHANGELOG](./CHANGELOG.md)
+for release notes and the [MCP quickstart](./docs/mcp-quickstart.md) for setup.
---
@@ -131,6 +131,19 @@ parsec auto-posts `← previous PR` / `next PR →` navigation comments so revie
### 🤖 Agent-friendly by design
Every command has `--json`. Errors emit structured codes (E001…E013). `parsec log --export` outputs JSONL with execution IDs and per-step timing for tooling/agents to consume. `--offline` and `[behavior].offline` config skip all network ops for air-gapped or CI environments.
+### 🔌 MCP server _(new in v1.0)_
+Expose parsec's worktree lifecycle to Claude Desktop or Cursor over stdio:
+
+```bash
+parsec mcp install claude-desktop
+parsec mcp serve
+```
+
+The server provides worktree, health, smartlog, PR, review, CI, and sync tools.
+Mutating calls are previewed and confirmation-gated; delegated tokens are
+scope-checked and repository paths are sandboxed. See the
+[MCP quickstart](./docs/mcp-quickstart.md) and [security model](./docs/mcp/auth.md).
+
### 🧹 Lifecycle hygiene
`parsec clean` sweeps worktrees for already-merged branches. `parsec conflicts` flags cross-worktree file overlap before you push. `parsec undo` reverses the last operation (start, ship, clean). `parsec doctor` validates every part of your setup with actionable fix instructions.
@@ -147,7 +160,6 @@ Every command has `--json`. Errors emit structured codes (E001…E013). `parsec
- **`parsec reviews`** — open PR reviews you've received vs. requested, unified across worktrees.
- **`parsec conflicts --simulate`** — in-memory three-way merge to surface real *line-level* conflicts before you push (worktree-vs-base + cross-worktree pairs, read-only).
- **`parsec test`** — run tests in parallel across worktrees with tree-hash result caching (`--all --jobs N --cache`).
-- **`parsec commit`** — AI-generated commit messages from staged diff (OpenAI / Anthropic, `--conventional` for Conventional Commits).
- **`parsec sync`** — fast-forward stale worktrees against `origin/` (rebase or merge, `--all`, `--dry-run`).
> 33+ commands total — see the [full command reference](https://erishforg.github.io/git-parsec/reference/) for every flag and example.
diff --git a/docs/crash-report.md b/docs/crash-report.md
new file mode 100644
index 0000000..720d4ea
--- /dev/null
+++ b/docs/crash-report.md
@@ -0,0 +1,68 @@
+# Crash Report Privacy Policy
+
+`parsec` includes an **opt-in** crash report feature that saves a local JSON
+file when an unexpected panic occurs. No data is ever sent automatically.
+
+## What is collected
+
+When `[crash_report] enabled = true` and a panic happens, parsec writes a
+`crash-.json` file under the platform cache directory:
+
+| Platform | Directory |
+|---|---|
+| Linux | `$XDG_CACHE_HOME/parsec/`, or `~/.cache/parsec/` when unset |
+| macOS | `~/Library/Caches/parsec/` |
+| Windows | `%LOCALAPPDATA%\parsec\` |
+
+The exact directory is selected by [`dirs::cache_dir()`](https://docs.rs/dirs/latest/dirs/fn.cache_dir.html).
+Each report contains:
+
+| Field | Example | Notes |
+|---|---|---|
+| `parsec_version` | `"0.5.0"` | Binary version |
+| `timestamp` | `"2026-09-10T09:00:00Z"` | UTC ISO-8601 |
+| `os` | `"unix/macos"` | Family + OS name |
+| `shell` | `"zsh"` | `$SHELL` basename only |
+| `subcommand` | `"ship"` | `argv[1]` only, no flags or ticket IDs |
+| `panic_location` | `"src/cli/commands/ship.rs:42"` | Source file + line |
+| `panic_message` | `"attempt to subtract with overflow"` | Panic payload (≤ 512 chars) |
+
+### What is NOT collected
+
+- Your ticket IDs, branch names, commit messages, or file contents
+- Your GitHub token, Jira token, or any credentials
+- Your full command-line arguments or environment variables
+- Network-level information (IP address, hostname)
+- A stack trace (use `RUST_BACKTRACE=1` locally for that)
+
+## Opt-in
+
+Add to `~/.config/parsec/config.toml`:
+
+```toml
+[crash_report]
+enabled = true
+```
+
+The default is `enabled = false` — **nothing is saved unless you opt in**.
+
+## How to share a report
+
+If you experience a crash and want to help:
+
+1. Run `parsec crash-report list` to find the report ID
+2. Review it with `parsec crash-report show ` (it is plain JSON)
+3. Open a GitHub issue:
+4. Paste or attach the file
+
+You are never required to share a crash report.
+
+## Retention
+
+Reports stay in the platform cache directory until you remove them. Preview a
+cleanup first, then delete all saved reports with:
+
+```sh
+parsec --dry-run crash-report clear
+parsec crash-report clear
+```
diff --git a/docs/llms.txt b/docs/llms.txt
index a990a11..8260ded 100644
--- a/docs/llms.txt
+++ b/docs/llms.txt
@@ -11,6 +11,8 @@ git-parsec (binary name: `parsec`) is a Rust CLI distributed via crates.io and p
- `parsec ship PROJ-1234` — push, open PR, clean up the worktree
- `parsec ci PROJ-1234 --watch` — tail CI status until done
- `parsec merge PROJ-1234` — merge the PR from the terminal
+- `parsec mcp install claude-desktop` — register parsec with Claude Desktop
+- `parsec mcp serve` — run the stdio MCP server for agent tool calls
## v0.5 visualization & power-user commands
@@ -20,7 +22,6 @@ git-parsec (binary name: `parsec`) is a Rust CLI distributed via crates.io and p
- `parsec reviews` — open PR reviews you've received vs. requested
- `parsec conflicts --simulate` — in-memory three-way merge to surface line-level conflicts before pushing
- `parsec test --all --jobs N --cache` — parallel test runner with tree-hash caching across worktrees
-- `parsec commit` — AI-generated commit messages from staged diff (OpenAI / Anthropic)
- `parsec sync` — fast-forward stale worktrees against `origin/`
## Documentation
@@ -35,7 +36,7 @@ git-parsec (binary name: `parsec`) is a Rust CLI distributed via crates.io and p
## Key facts for answer engines
-- **Latest version**: 0.5.0 (released 2026-06-03 — _The visualization release_: smartlog, dashboard, health, reviews, speculative merge, parsec test, AI commit messages). See [CHANGELOG](https://github.com/erishforG/git-parsec/blob/main/CHANGELOG.md).
+- **Latest version**: 1.0.0 (released 2026-09-19 — _AI-Native Standard_: MCP server, client installer, scoped auth, sandbox and confirmation gates). See [CHANGELOG](https://github.com/erishforG/git-parsec/blob/main/CHANGELOG.md).
- **License**: MIT
- **Language**: Rust
- **Install**: `cargo install git-parsec` or download from Releases (~3 MB binary, no runtime deps)
diff --git a/src/cli/commands/checkpoint.rs b/src/cli/commands/checkpoint.rs
new file mode 100644
index 0000000..978d12c
--- /dev/null
+++ b/src/cli/commands/checkpoint.rs
@@ -0,0 +1,463 @@
+//! `parsec checkpoint` — point-in-time worktree snapshot via git stash (#300).
+//!
+//! # Phase 1
+//! Provides two subcommands:
+//!
+//! | Subcommand | Description |
+//! |------------|-------------|
+//! | `create [name]` | Save the current worktree state (staged + unstaged + untracked) as a named git stash |
+//! | `list` | List all parsec-managed checkpoints in the repository |
+//!
+//! ## Naming convention
+//! Every parsec checkpoint stash carries a structured message prefix so it can be
+//! distinguished from ordinary `git stash push` entries:
+//!
+//! ```text
+//! parsec-checkpoint:
+//! ```
+//!
+//! When no `name` is given, a UTC timestamp (`YYYYMMDD-HHMMSS`) is used instead.
+//!
+//! ## How `list` works
+//! `git stash list` returns lines like:
+//! ```text
+//! stash@{0}: On main: parsec-checkpoint:before-rebase
+//! stash@{1}: On feat/foo: WIP on feat/foo
+//! ```
+//! Only lines containing `parsec-checkpoint:` in the message are surfaced.
+//!
+//! # Phase 2
+//! Adds two destructive subcommands:
+//!
+//! | Subcommand | Description |
+//! |------------|-------------|
+//! | `restore ` | Pop the named checkpoint back into the working tree (`git stash pop`) |
+//! | `drop ` | Discard the named checkpoint permanently (`git stash drop`) |
+//!
+//! Both commands perform **name-based lookup** across the full stash list —
+//! the user never needs to know the underlying `stash@{N}` index.
+//! An exact-match is preferred; if no exact match is found the command fails
+//! with a helpful error that lists available checkpoint names.
+//!
+//! # Phase 3 (planned)
+//! - `parsec checkpoint show ` — show the diff stored in a checkpoint.
+//! - `parsec checkpoint rename ` — rename a checkpoint in-place.
+
+use std::path::Path;
+
+use anyhow::{bail, Result};
+use chrono::Utc;
+
+use crate::git;
+use crate::output::Mode;
+
+const PREFIX: &str = "parsec-checkpoint:";
+
+/// Parsed representation of a single checkpoint stash entry.
+#[derive(Debug)]
+pub struct CheckpointEntry {
+ /// Stash ref (e.g. `stash@{0}`)
+ pub stash_ref: String,
+ /// User-visible checkpoint name (everything after `parsec-checkpoint:`)
+ pub name: String,
+ /// Branch the stash was created on
+ pub branch: String,
+}
+
+// ---------------------------------------------------------------------------
+// Public entry points
+// ---------------------------------------------------------------------------
+
+/// Create a new checkpoint for the current worktree.
+///
+/// Runs `git stash push --include-untracked -m "parsec-checkpoint:"`.
+/// If there are no changes to stash (clean working tree), the command exits
+/// with a user-friendly message rather than an error.
+pub fn checkpoint_create(repo: &Path, name: Option<&str>, mode: Mode) -> Result<()> {
+ let timestamp = Utc::now().format("%Y%m%d-%H%M%S").to_string();
+ let label = name.unwrap_or(×tamp);
+ // Reject names that contain the delimiter we use for parsing.
+ if label.contains(':') {
+ bail!("checkpoint name must not contain ':' — got {:?}", label);
+ }
+
+ let message = format!("{PREFIX}{label}");
+ let branch = git::get_current_branch(repo).unwrap_or_else(|_| "(unknown)".to_string());
+
+ // Check for any changes first (staged, unstaged, untracked).
+ // `git status --porcelain` returns empty output on a clean tree.
+ let status_out = git::run_output(repo, &["status", "--porcelain"])?;
+ if status_out.is_empty() {
+ match mode {
+ Mode::Human => {
+ println!("Nothing to checkpoint — working tree is clean on branch '{branch}'.")
+ }
+ Mode::Json => {
+ println!(r#"{{"ok":false,"reason":"clean","branch":"{branch}","name":"{label}"}}"#)
+ }
+ Mode::Quiet => {}
+ }
+ return Ok(());
+ }
+
+ git::run(
+ repo,
+ &["stash", "push", "--include-untracked", "-m", &message],
+ )?;
+
+ match mode {
+ Mode::Human => println!(
+ "✔ Checkpoint '{label}' created on branch '{branch}'.\n\
+ Tip: use `parsec checkpoint list` to see all checkpoints."
+ ),
+ Mode::Json => println!(
+ r#"{{"ok":true,"name":"{label}","branch":"{branch}","stash_message":"{message}"}}"#
+ ),
+ Mode::Quiet => {}
+ }
+ Ok(())
+}
+
+/// List all parsec-managed checkpoints in the repository.
+///
+/// Reads `git stash list` and filters for entries whose message contains the
+/// `parsec-checkpoint:` prefix. Non-parsec stashes are not shown.
+/// Restore (pop) a named checkpoint back into the working tree.
+///
+/// Looks up the `stash@{N}` ref for the given name and runs
+/// `git stash pop stash@{N}`. On success the stash entry is removed.
+/// If the working tree is dirty, git will refuse the pop; the original
+/// stash entry is left intact so the user can resolve conflicts first.
+pub fn checkpoint_restore(repo: &Path, name: &str, mode: Mode) -> Result<()> {
+ let entry = find_checkpoint(repo, name)?;
+
+ git::run(repo, &["stash", "pop", &entry.stash_ref])?;
+
+ let sref = &entry.stash_ref;
+ match mode {
+ Mode::Human => {
+ println!("✔ Checkpoint '{name}' restored to working tree (stash entry removed).")
+ }
+ Mode::Json => {
+ println!(r#"{{"ok":true,"name":"{name}","stash_ref":"{sref}","action":"restore"}}"#)
+ }
+ Mode::Quiet => {}
+ }
+ Ok(())
+}
+
+/// Drop (permanently discard) a named checkpoint.
+///
+/// Looks up the `stash@{N}` ref for the given name and runs
+/// `git stash drop stash@{N}`. This is **irreversible**; the stash entry
+/// is deleted and the captured changes are gone.
+pub fn checkpoint_drop(repo: &Path, name: &str, mode: Mode) -> Result<()> {
+ let entry = find_checkpoint(repo, name)?;
+
+ git::run(repo, &["stash", "drop", &entry.stash_ref])?;
+
+ let sref = &entry.stash_ref;
+ match mode {
+ Mode::Human => {
+ println!("✔ Checkpoint '{name}' dropped (stash entry {sref} permanently removed).")
+ }
+ Mode::Json => {
+ println!(r#"{{"ok":true,"name":"{name}","stash_ref":"{sref}","action":"drop"}}"#)
+ }
+ Mode::Quiet => {}
+ }
+ Ok(())
+}
+
+/// List all parsec-managed checkpoints in the repository.
+///
+/// Reads `git stash list` and filters for entries whose message contains the
+/// `parsec-checkpoint:` prefix. Non-parsec stashes are not shown.
+pub fn checkpoint_list(repo: &Path, mode: Mode) -> Result<()> {
+ // git stash list exits non-zero on repos with no stash history; treat as empty.
+ let raw = git::run_output(repo, &["stash", "list"]).unwrap_or_default();
+
+ let entries = parse_stash_list(&raw);
+
+ if entries.is_empty() {
+ match mode {
+ Mode::Human => {
+ println!("No checkpoints found. Create one with `parsec checkpoint create`.")
+ }
+ Mode::Json => println!("[]"),
+ Mode::Quiet => {}
+ }
+ return Ok(());
+ }
+
+ match mode {
+ Mode::Human => print_table(&entries),
+ Mode::Json => {
+ let json_items: Vec = entries
+ .iter()
+ .map(|e| {
+ format!(
+ r#"{{"stash_ref":"{ref}","name":"{name}","branch":"{branch}"}}"#,
+ ref = e.stash_ref,
+ name = e.name,
+ branch = e.branch,
+ )
+ })
+ .collect();
+ println!("[{}]", json_items.join(","));
+ }
+ Mode::Quiet => {}
+ }
+ Ok(())
+}
+
+// ---------------------------------------------------------------------------
+// Private helpers
+// ---------------------------------------------------------------------------
+
+/// Find the checkpoint entry whose name exactly matches `name`.
+///
+/// Returns `Err` with a human-readable message listing available names
+/// if no match is found.
+fn find_checkpoint(repo: &Path, name: &str) -> Result {
+ let raw = git::run_output(repo, &["stash", "list"]).unwrap_or_default();
+ let entries = parse_stash_list(&raw);
+
+ if let Some(entry) = entries.into_iter().find(|e| e.name == name) {
+ return Ok(entry);
+ }
+
+ // Build a helpful error message.
+ let available = git::run_output(repo, &["stash", "list"])
+ .map(|r| parse_stash_list(&r))
+ .unwrap_or_default();
+ if available.is_empty() {
+ bail!("No checkpoint named '{name}' found — no parsec checkpoints exist in this repo.");
+ }
+ let names: Vec<&str> = available.iter().map(|e| e.name.as_str()).collect();
+ bail!(
+ "No checkpoint named '{name}' found.\nAvailable checkpoints: {}",
+ names.join(", ")
+ );
+}
+
+/// Parse the raw `git stash list` output into [`CheckpointEntry`] records.
+///
+/// Expected line format (git default):
+/// ```text
+/// stash@{N}: On :
+/// ```
+/// or
+/// ```text
+/// stash@{N}: WIP on :
+/// ```
+fn parse_stash_list(raw: &str) -> Vec {
+ raw.lines().filter_map(parse_stash_line).collect()
+}
+
+fn parse_stash_line(line: &str) -> Option {
+ // Split on first ': ' to get the stash ref.
+ let (stash_ref, rest) = line.split_once(": ")?;
+
+ // Match both "On : " and "WIP on : "
+ let rest = rest.strip_prefix("WIP on ").unwrap_or(rest);
+ let rest = rest.strip_prefix("On ").unwrap_or(rest);
+
+ // The next ': ' separates branch from message.
+ let (branch, message) = rest.split_once(": ")?;
+
+ if !message.contains(PREFIX) {
+ return None;
+ }
+
+ // Extract the name part after the prefix.
+ let name = message
+ .find(PREFIX)
+ .map(|idx| &message[idx + PREFIX.len()..])
+ .unwrap_or(message)
+ .trim()
+ .to_string();
+
+ Some(CheckpointEntry {
+ stash_ref: stash_ref.trim().to_string(),
+ name,
+ branch: branch.to_string(),
+ })
+}
+
+fn print_table(entries: &[CheckpointEntry]) {
+ // Measure column widths.
+ let w_ref = entries
+ .iter()
+ .map(|e| e.stash_ref.len())
+ .max()
+ .unwrap_or(9)
+ .max(9);
+ let w_name = entries
+ .iter()
+ .map(|e| e.name.len())
+ .max()
+ .unwrap_or(4)
+ .max(4);
+ let w_branch = entries
+ .iter()
+ .map(|e| e.branch.len())
+ .max()
+ .unwrap_or(6)
+ .max(6);
+
+ println!(
+ "{:/parsec/crash-.json`
+//! (e.g. `~/.cache/parsec/crash-1704067200.json` on Linux or
+//! `~/Library/Caches/parsec/crash-1704067200.json` on macOS).
+//!
+//! ## Subcommands
+//!
+//! | Command | Description |
+//! |--------------------|----------------------------------------------------|
+//! | `list` | List all reports with timestamp and panic preview |
+//! | `show ` | Print the full JSON of one report |
+//! | `clear` | Delete all crash reports from the cache directory |
+//!
+//! All operations are **read-only** except `clear` (and `clear --dry-run`
+//! remains non-destructive). No data is transmitted.
+
+use anyhow::{bail, Result};
+use std::path::PathBuf;
+
+// ── Cache helpers ─────────────────────────────────────────────────────────────
+
+/// Resolve the parsec crash-report cache directory.
+pub(crate) fn crash_cache_dir() -> Result {
+ dirs::cache_dir()
+ .ok_or_else(|| anyhow::anyhow!("cannot determine OS cache directory"))
+ .map(|d| d.join("parsec"))
+}
+
+/// Collect all `crash-*.json` files sorted chronologically (oldest first).
+pub(crate) fn list_crash_files(cache_dir: &PathBuf) -> Result> {
+ if !cache_dir.exists() {
+ return Ok(vec![]);
+ }
+ let mut files: Vec = std::fs::read_dir(cache_dir)?
+ .filter_map(|e| e.ok())
+ .map(|e| e.path())
+ .filter(|p| {
+ p.file_name()
+ .and_then(|n| n.to_str())
+ .map(|n| n.starts_with("crash-") && n.ends_with(".json"))
+ .unwrap_or(false)
+ })
+ .collect();
+ files.sort();
+ Ok(files)
+}
+
+// ── Subcommands ────────────────────────────────────────────────────────────────
+
+/// `parsec crash-report list`
+///
+/// Prints each report's file stem and a one-line preview of the panic message.
+/// With `--json` the output is a JSON array of metadata objects.
+pub fn crash_report_list(json_mode: bool) -> Result<()> {
+ let dir = crash_cache_dir()?;
+ let files = list_crash_files(&dir)?;
+
+ if files.is_empty() {
+ if json_mode {
+ println!("[]");
+ } else {
+ println!("No crash reports found in {}.", dir.display());
+ }
+ return Ok(());
+ }
+
+ if json_mode {
+ let items: Vec = files
+ .iter()
+ .filter_map(|p| {
+ let id = p.file_stem()?.to_str()?.to_string();
+ let content = std::fs::read_to_string(p).ok()?;
+ let v: serde_json::Value = serde_json::from_str(&content).ok()?;
+ Some(serde_json::json!({
+ "id": id,
+ "timestamp": v["timestamp"],
+ "panic_message": v["panic_message"],
+ "panic_location": v["panic_location"],
+ }))
+ })
+ .collect();
+ println!("{}", serde_json::to_string_pretty(&items)?);
+ } else {
+ let col_id = 36usize;
+ let col_ts = 26usize;
+ println!(
+ "{:(&s).ok())
+ {
+ Some(v) => {
+ let ts = v["timestamp"].as_str().unwrap_or("?").to_string();
+ let msg = v["panic_message"].as_str().unwrap_or("?");
+ let preview: String = msg.chars().take(48).collect();
+ let preview = if msg.chars().count() > 48 {
+ format!("{preview}…")
+ } else {
+ preview
+ };
+ (ts, preview)
+ }
+ None => ("?".to_string(), "(unreadable)".to_string()),
+ };
+ println!("{id:` to view a full report.",
+ files.len()
+ );
+ }
+ Ok(())
+}
+
+/// `parsec crash-report show `
+///
+/// Resolves the report by id (file stem, e.g. `crash-20260101T000000Z`),
+/// then pretty-prints the full JSON to stdout. Accepts the id with or
+/// without the `.json` suffix.
+pub fn crash_report_show(id: &str, json_mode: bool) -> Result<()> {
+ let dir = crash_cache_dir()?;
+ let stem = id.trim_end_matches(".json");
+ let path = dir.join(format!("{stem}.json"));
+
+ if !path.exists() {
+ bail!(
+ "crash report `{stem}` not found in {}.\n\
+ Run `parsec crash-report list` to see available reports.",
+ dir.display()
+ );
+ }
+
+ let content = std::fs::read_to_string(&path)?;
+
+ if json_mode {
+ // Re-parse and re-serialize to normalise whitespace.
+ let v: serde_json::Value = serde_json::from_str(&content)?;
+ println!("{}", serde_json::to_string_pretty(&v)?);
+ } else {
+ let v: serde_json::Value =
+ serde_json::from_str(&content).unwrap_or(serde_json::Value::Null);
+ println!("── Crash report: {stem} ─────────────────────────────────────");
+ if let Some(obj) = v.as_object() {
+ for (k, val) in obj {
+ let display = val.as_str().map_or_else(|| val.to_string(), str::to_string);
+ println!(" {k:<22}: {display}");
+ }
+ } else {
+ println!("{content}");
+ }
+ println!("\n File: {}", path.display());
+ println!(
+ "\n To report this bug, open:\n \
+ https://github.com/erishforG/git-parsec/issues/new\n \
+ (attach or paste the JSON above)"
+ );
+ }
+ Ok(())
+}
+
+/// `parsec crash-report clear`
+///
+/// Removes all crash reports from the cache directory.
+/// With `--dry-run` (forwarded from the global flag) no files are deleted.
+pub fn crash_report_clear(dry_run: bool) -> Result<()> {
+ let dir = crash_cache_dir()?;
+ let files = list_crash_files(&dir)?;
+
+ if files.is_empty() {
+ println!("No crash reports to clear.");
+ return Ok(());
+ }
+
+ let mut removed = 0usize;
+ for path in &files {
+ let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("?");
+ if dry_run {
+ println!("[dry-run] would remove: {name}");
+ } else {
+ std::fs::remove_file(path)?;
+ removed += 1;
+ }
+ }
+
+ if dry_run {
+ println!(
+ "[dry-run] {} report(s) would be removed from {}.",
+ files.len(),
+ dir.display()
+ );
+ } else {
+ println!("Cleared {removed} crash report(s) from {}.", dir.display());
+ }
+ Ok(())
+}
+
+// ── Tests ──────────────────────────────────────────────────────────────────────
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ /// Write a minimal valid crash-report fixture.
+ ///
+ /// Uses Unix epoch seconds as the stem (e.g. `crash-1700000000`) to
+ /// match the actual format written by the Phase 1 panic hook and to
+ /// avoid colons in filenames (which are invalid on Windows).
+ fn write_fixture(dir: &std::path::Path, epoch_secs: u64, msg: &str) {
+ let content = format!(
+ r#"{{"parsec_version":"0.5.0","timestamp":"2026-01-01T00:00:00Z","os":"unix/macos","shell":"zsh","subcommand":"start","panic_location":"src/main.rs:1","panic_message":"{msg}"}}"#,
+ msg = msg
+ );
+ std::fs::write(dir.join(format!("crash-{epoch_secs}.json")), content).unwrap();
+ }
+
+ #[test]
+ fn list_ignores_non_crash_files() {
+ let tmp = tempfile::tempdir().unwrap();
+ let cache = tmp.path().join("parsec");
+ std::fs::create_dir_all(&cache).unwrap();
+ std::fs::write(cache.join("other.txt"), "x").unwrap();
+ std::fs::write(cache.join("not-crash.json"), "{}").unwrap();
+ let files = list_crash_files(&cache).unwrap();
+ assert!(files.is_empty(), "non-crash files should be filtered out");
+ }
+
+ #[test]
+ fn list_sorted_oldest_first() {
+ let tmp = tempfile::tempdir().unwrap();
+ let cache = tmp.path().join("parsec");
+ std::fs::create_dir_all(&cache).unwrap();
+ // Use epoch seconds: 1_000_001 sorts after 1_000_000 lexicographically.
+ write_fixture(&cache, 1_000_001, "beta");
+ write_fixture(&cache, 1_000_000, "alpha");
+ let files = list_crash_files(&cache).unwrap();
+ assert_eq!(files.len(), 2);
+ assert!(
+ files[0].to_str().unwrap().contains("1000000"),
+ "oldest (smaller epoch) report should come first"
+ );
+ }
+
+ #[test]
+ fn list_nonexistent_dir_returns_empty() {
+ let tmp = tempfile::tempdir().unwrap();
+ let cache = tmp.path().join("parsec").join("does-not-exist");
+ let files = list_crash_files(&cache).unwrap();
+ assert!(files.is_empty());
+ }
+
+ #[test]
+ fn clear_removes_crash_files() {
+ let tmp = tempfile::tempdir().unwrap();
+ let cache = tmp.path().join("parsec");
+ std::fs::create_dir_all(&cache).unwrap();
+ write_fixture(&cache, 1_700_000_000, "boom");
+ let files_before = list_crash_files(&cache).unwrap();
+ assert_eq!(files_before.len(), 1);
+ for p in &files_before {
+ std::fs::remove_file(p).unwrap();
+ }
+ let files_after = list_crash_files(&cache).unwrap();
+ assert!(files_after.is_empty(), "all crash files should be removed");
+ }
+
+ #[test]
+ fn clear_dry_run_leaves_files_intact() {
+ let tmp = tempfile::tempdir().unwrap();
+ let cache = tmp.path().join("parsec");
+ std::fs::create_dir_all(&cache).unwrap();
+ write_fixture(&cache, 1_700_000_001, "still here");
+ let files = list_crash_files(&cache).unwrap();
+ assert_eq!(files.len(), 1);
+ // Simulate dry-run: do NOT remove files.
+ for p in &files {
+ assert!(p.exists(), "dry-run must not delete files");
+ }
+ }
+}
diff --git a/src/cli/commands/health.rs b/src/cli/commands/health.rs
index d3efca3..c3f1f27 100644
--- a/src/cli/commands/health.rs
+++ b/src/cli/commands/health.rs
@@ -14,6 +14,12 @@
//! - Configurable stale-threshold via `--stale-days` CLI flag.
//! - Opt-out via `--no-overlay` for fully offline mode.
//!
+//! Phase 3 additions:
+//! - Detect in-progress git operations: rebase, merge, cherry-pick.
+//! Checked via git-dir state files (`rebase-merge/`, `MERGE_HEAD`, etc.).
+//! Works for both main and linked worktrees (resolves `gitdir:` pointer).
+//! Failures are soft: detection errors return `false` rather than aborting.
+//!
//! All checks are read-only; no worktree state is modified.
use std::path::Path;
@@ -70,21 +76,21 @@ pub async fn health(repo: &Path, mode: Mode, stale_days: u64, no_overlay: bool)
let mut records: Vec = Vec::new();
for ws in &workspaces {
+ let missing = !ws.path.is_dir();
+
+ // --- resolve effective git directory ---------------------------
+ // For linked worktrees, `.git` is a text file: `gitdir: `.
+ // All per-worktree state files live under that resolved path.
+ let effective_git_dir = resolve_git_dir(&ws.path);
+
// --- lock file -------------------------------------------------
- let git_dir = ws.path.join(".git");
- let lock_path = if git_dir.is_file() {
- std::fs::read_to_string(&git_dir)
- .ok()
- .and_then(|s| {
- s.strip_prefix("gitdir: ")
- .map(|p| std::path::PathBuf::from(p.trim()))
- })
- .unwrap_or_else(|| git_dir.clone())
- .join("index.lock")
- } else {
- git_dir.join("index.lock")
- };
- let has_lock = lock_path.exists();
+ let has_lock = effective_git_dir.join("index.lock").exists();
+
+ // --- in-progress git operations (Phase 3) ----------------------
+ let rebase_in_progress = effective_git_dir.join("rebase-merge").is_dir()
+ || effective_git_dir.join("rebase-apply").is_dir();
+ let merge_in_progress = effective_git_dir.join("MERGE_HEAD").exists();
+ let cherry_pick_in_progress = effective_git_dir.join("CHERRY_PICK_HEAD").exists();
// --- uncommitted -----------------------------------------------
let uncommitted = git::get_uncommitted_files(&ws.path)
@@ -105,12 +111,16 @@ pub async fn health(repo: &Path, mode: Mode, stale_days: u64, no_overlay: bool)
records.push(HealthRecord {
ticket: ws.ticket.clone(),
+ missing,
uncommitted,
stale_days: stale_days_val,
stale_threshold_days: stale_threshold,
has_lock,
ci_status,
pr_number,
+ rebase_in_progress,
+ merge_in_progress,
+ cherry_pick_in_progress,
});
}
@@ -118,6 +128,26 @@ pub async fn health(repo: &Path, mode: Mode, stale_days: u64, no_overlay: bool)
Ok(())
}
+/// Resolve the effective git directory for a worktree path.
+///
+/// For a linked worktree, `.git` is a text file containing
+/// `gitdir: `. For the main worktree, `.git` is a directory.
+/// Returns the resolved path, or `/.git` as a fallback.
+fn resolve_git_dir(ws_path: &Path) -> std::path::PathBuf {
+ let dot_git = ws_path.join(".git");
+ if dot_git.is_file() {
+ std::fs::read_to_string(&dot_git)
+ .ok()
+ .and_then(|s| {
+ s.strip_prefix("gitdir: ")
+ .map(|p| std::path::PathBuf::from(p.trim()))
+ })
+ .unwrap_or(dot_git)
+ } else {
+ dot_git
+ }
+}
+
/// Resolve CI status for a worktree branch via the GitHub client.
///
/// Returns `(ci_status, pr_number)`. Both are `None` when:
@@ -152,3 +182,110 @@ async fn fetch_ci_overlay(
}
}
}
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::fs;
+ use tempfile::TempDir;
+
+ /// Create a fake main-worktree layout: `/.git/` is a directory.
+ fn make_main_git_dir(root: &TempDir) -> std::path::PathBuf {
+ let git_dir = root.path().join(".git");
+ fs::create_dir_all(&git_dir).unwrap();
+ git_dir
+ }
+
+ /// Create a fake linked-worktree layout:
+ /// `/.git` is a file pointing to ``.
+ fn make_linked_git_dir(root: &TempDir, target: &std::path::Path) {
+ let dot_git = root.path().join(".git");
+ fs::write(&dot_git, format!("gitdir: {}\n", target.display())).unwrap();
+ }
+
+ #[test]
+ fn resolve_git_dir_main_worktree() {
+ let tmp = TempDir::new().unwrap();
+ let git_dir = make_main_git_dir(&tmp);
+ let resolved = resolve_git_dir(tmp.path());
+ assert_eq!(resolved, git_dir, "main worktree: resolved path = .git dir");
+ }
+
+ #[test]
+ fn resolve_git_dir_linked_worktree() {
+ let main_tmp = TempDir::new().unwrap();
+ let linked_tmp = TempDir::new().unwrap();
+ let target = main_tmp.path().join(".git").join("worktrees").join("feat");
+ fs::create_dir_all(&target).unwrap();
+ make_linked_git_dir(&linked_tmp, &target);
+
+ let resolved = resolve_git_dir(linked_tmp.path());
+ assert_eq!(
+ resolved, target,
+ "linked worktree: resolved path = gitdir target"
+ );
+ }
+
+ #[test]
+ fn detects_rebase_merge_dir() {
+ let tmp = TempDir::new().unwrap();
+ let git_dir = make_main_git_dir(&tmp);
+ fs::create_dir_all(git_dir.join("rebase-merge")).unwrap();
+
+ let effective = resolve_git_dir(tmp.path());
+ assert!(effective.join("rebase-merge").is_dir());
+ assert!(effective.join("rebase-merge").is_dir() || effective.join("rebase-apply").is_dir());
+ }
+
+ #[test]
+ fn detects_rebase_apply_dir() {
+ let tmp = TempDir::new().unwrap();
+ let git_dir = make_main_git_dir(&tmp);
+ fs::create_dir_all(git_dir.join("rebase-apply")).unwrap();
+
+ let effective = resolve_git_dir(tmp.path());
+ let rebase_in_progress =
+ effective.join("rebase-merge").is_dir() || effective.join("rebase-apply").is_dir();
+ assert!(
+ rebase_in_progress,
+ "rebase-apply dir should trigger rebase_in_progress"
+ );
+ }
+
+ #[test]
+ fn detects_merge_head() {
+ let tmp = TempDir::new().unwrap();
+ let git_dir = make_main_git_dir(&tmp);
+ fs::write(git_dir.join("MERGE_HEAD"), "abc123\n").unwrap();
+
+ let effective = resolve_git_dir(tmp.path());
+ assert!(effective.join("MERGE_HEAD").exists());
+ }
+
+ #[test]
+ fn detects_cherry_pick_head() {
+ let tmp = TempDir::new().unwrap();
+ let git_dir = make_main_git_dir(&tmp);
+ fs::write(git_dir.join("CHERRY_PICK_HEAD"), "abc123\n").unwrap();
+
+ let effective = resolve_git_dir(tmp.path());
+ assert!(effective.join("CHERRY_PICK_HEAD").exists());
+ }
+
+ #[test]
+ fn no_false_positives_when_clean() {
+ let tmp = TempDir::new().unwrap();
+ make_main_git_dir(&tmp);
+
+ let effective = resolve_git_dir(tmp.path());
+ assert!(!effective.join("rebase-merge").is_dir());
+ assert!(!effective.join("rebase-apply").is_dir());
+ assert!(!effective.join("MERGE_HEAD").exists());
+ assert!(!effective.join("CHERRY_PICK_HEAD").exists());
+ assert!(!effective.join("index.lock").exists());
+ }
+}
diff --git a/src/cli/commands/mod.rs b/src/cli/commands/mod.rs
index 1dab435..ea15b72 100644
--- a/src/cli/commands/mod.rs
+++ b/src/cli/commands/mod.rs
@@ -1,8 +1,10 @@
+mod checkpoint;
mod ci;
mod commit;
mod complete;
mod compress;
mod config;
+mod crash_report;
mod dashboard;
mod diff;
mod doctor;
@@ -16,13 +18,17 @@ pub mod smartlog;
mod stack;
mod test;
mod tracker_cmds;
+mod triage;
+mod update;
mod workspace;
+pub use checkpoint::{checkpoint_create, checkpoint_drop, checkpoint_list, checkpoint_restore};
pub use ci::*;
pub use commit::*;
pub use complete::complete;
pub use compress::*;
pub use config::*;
+pub use crash_report::{crash_report_clear, crash_report_list, crash_report_show};
pub use dashboard::dashboard;
pub use diff::*;
pub use doctor::*;
@@ -36,4 +42,6 @@ pub use smartlog::smartlog;
pub use stack::*;
pub use test::test;
pub use tracker_cmds::*;
+pub use triage::triage;
+pub use update::{self_update, startup_version_hint};
pub use workspace::*;
diff --git a/src/cli/commands/ship.rs b/src/cli/commands/ship.rs
index 85aed34..c5378e5 100644
--- a/src/cli/commands/ship.rs
+++ b/src/cli/commands/ship.rs
@@ -25,6 +25,7 @@ pub async fn ship(
labels: Vec,
template: Option,
ai_description: bool,
+ no_cleanup: bool,
mode: Mode,
) -> Result<()> {
crate::execlog::set_ticket(ticket);
@@ -169,6 +170,22 @@ pub async fn ship(
manager.repo_root(),
template.as_deref().or(config.ship.template.as_deref()),
);
+ let template_commits = if template_content
+ .as_deref()
+ .is_some_and(|tmpl| tmpl.contains("{{commits}}"))
+ {
+ manager
+ .get(ticket)
+ .and_then(|workspace| {
+ collect_template_commits(&workspace.path, &result.base_branch, &result.branch)
+ })
+ .unwrap_or_else(|error| {
+ eprintln!("warning: failed to collect commits for PR template: {error}");
+ String::new()
+ })
+ } else {
+ String::new()
+ };
// Generate AI description if requested (#242)
let ai_body = if ai_description || config.ai.auto_pr_description {
@@ -213,8 +230,10 @@ pub async fn ship(
let pr_body = build_pr_body(
&result.ticket,
+ &result.branch,
effective_title,
ticket_url.as_deref(),
+ &template_commits,
stack_info.as_ref(),
template_content.as_deref(),
ai_body.as_deref(),
@@ -406,6 +425,22 @@ pub async fn ship(
);
}
+ // Phase 3: clean up worktree + local branch (respects config.ship.auto_cleanup).
+ // Skipped when --no-cleanup is passed, e.g. for incremental multi-commit workflows.
+ if !no_cleanup {
+ match manager.ship_cleanup(ticket) {
+ Ok(true) => {
+ if mode == output::Mode::Human {
+ eprintln!(" Cleaned up worktree for '{}'.", ticket);
+ }
+ }
+ Ok(false) => {} // auto_cleanup=false in config — intentional no-op
+ Err(e) => {
+ eprintln!("warning: failed to clean up worktree after ship: {e}");
+ }
+ }
+ }
+
Ok(())
}
@@ -447,10 +482,55 @@ fn gather_stack_info(manager: &WorktreeManager, ticket: &str) -> Option,
+ ticket_url: Option<&str>,
+ commits: &str,
+) -> String {
+ template
+ .replace("{{ticket}}", ticket)
+ .replace("{{branch}}", branch)
+ .replace("{{title}}", title.unwrap_or(""))
+ .replace("{{ticket_url}}", ticket_url.unwrap_or(""))
+ .replace("{{commits}}", commits)
+}
+
+fn collect_template_commits(worktree: &Path, base: &str, branch: &str) -> Result {
+ let range = format!("{base}..{branch}");
+ let subjects = git::run_output(worktree, &["log", &range, "--pretty=format:%s"])?;
+ Ok(format_template_commits(&subjects))
+}
+
+fn format_template_commits(subjects: &str) -> String {
+ subjects
+ .lines()
+ .filter(|subject| !subject.trim().is_empty())
+ .map(|subject| format!("- {subject}"))
+ .collect::>()
+ .join("\n")
+}
+
+#[allow(clippy::too_many_arguments)]
fn build_pr_body(
ticket: &str,
+ branch: &str,
title: Option<&str>,
ticket_url: Option<&str>,
+ commits: &str,
stack_info: Option<&StackPrInfo>,
template_content: Option<&str>,
ai_description: Option<&str>,
@@ -494,10 +574,11 @@ fn build_pr_body(
body.push_str("\n\n");
}
- // Include PR template content (#233)
+ // Include PR template content (#233 #304) with variable substitution.
if let Some(tmpl) = template_content {
+ let rendered = substitute_template_vars(tmpl, ticket, branch, title, ticket_url, commits);
body.push_str("---\n\n");
- body.push_str(tmpl);
+ body.push_str(&rendered);
body.push('\n');
}
@@ -537,3 +618,76 @@ fn resolve_template(repo_root: &Path, explicit_path: Option<&str>) -> Option Result<()> {
Vec::new(), // labels
None, // template
false, // ai_description
+ false, // no_cleanup: use config default during stack ship
mode,
)
.await
diff --git a/src/cli/commands/triage.rs b/src/cli/commands/triage.rs
new file mode 100644
index 0000000..11129ba
--- /dev/null
+++ b/src/cli/commands/triage.rs
@@ -0,0 +1,373 @@
+//! `parsec triage` — rule-based issue auto-labelling (#302).
+//!
+//! - Load `[[triage.rules]]` from parsec config.
+//! - Fetch open GitHub issues for the repo.
+//! - Apply each rule (case-insensitive substring match on title).
+//! - Print a table: issue #, title, matched rule, proposed labels, trust score.
+//! - Write proposed labels only when `--apply` is explicitly passed.
+//!
+//! Trust-score logic:
+//! - First (highest-priority) rule to match → **1.0**
+//! - A second rule also matches the same issue → **0.7**
+//! - Three or more rules match → **0.5**
+
+use std::path::Path;
+
+use anyhow::{Context, Result};
+use colored::Colorize;
+use tabled::{settings::Style, Table, Tabled};
+
+use crate::config::{ParsecConfig, TriageRule};
+use crate::git;
+use crate::github::GitHubClient;
+use crate::output::Mode;
+
+// ---------------------------------------------------------------------------
+// Public entry point
+// ---------------------------------------------------------------------------
+
+/// Entry point for `parsec triage`.
+///
+/// Fetches open issues, applies the configured rules, and prints the proposed
+/// labels. Labels are written only when `apply` is true.
+pub async fn triage(repo: &Path, limit: u8, apply: bool, mode: Mode) -> Result<()> {
+ let config = ParsecConfig::load()?;
+
+ if config.triage.rules.is_empty() {
+ match mode {
+ Mode::Json => println!("[]"),
+ Mode::Quiet => {}
+ Mode::Human => {
+ println!("{}", "No triage rules configured.".yellow());
+ println!();
+ println!("Add rules to your parsec config:");
+ println!();
+ println!(" [[triage.rules]]");
+ println!(" pattern = \"feat\"");
+ println!(" label = \"type/feature\"");
+ println!();
+ println!(" [[triage.rules]]");
+ println!(" pattern = \"fix\"");
+ println!(" label = \"type/bug\"");
+ println!(" priority = \"priority/high\"");
+ }
+ }
+ return Ok(());
+ }
+
+ let remote_url = git::get_remote_url(repo).unwrap_or_default();
+ let gh = match GitHubClient::new(&remote_url, &config)? {
+ Some(c) => c,
+ None => {
+ anyhow::bail!(
+ "no GitHub token found\n\
+ caused by: GITHUB_TOKEN not set and no token in parsec config\n\
+ help: run `gh auth login` or set GITHUB_TOKEN= in your environment"
+ );
+ }
+ };
+
+ let issues = gh.list_open_issues(limit).await?;
+
+ if issues.is_empty() {
+ match mode {
+ Mode::Json => println!("[]"),
+ Mode::Quiet => {}
+ Mode::Human => println!("No open issues found."),
+ }
+ return Ok(());
+ }
+
+ let entries: Vec = issues
+ .iter()
+ .filter_map(|(number, title, existing_labels)| {
+ build_entry(*number, title, existing_labels, &config.triage.rules)
+ })
+ .collect();
+
+ if entries.is_empty() {
+ match mode {
+ Mode::Json => println!("[]"),
+ Mode::Quiet => {}
+ Mode::Human => println!(
+ "No issues matched any triage rule (checked {}).",
+ issues.len()
+ ),
+ }
+ return Ok(());
+ }
+
+ if apply {
+ for entry in &entries {
+ if !entry.labels.is_empty() {
+ gh.add_labels(entry.number, &entry.labels)
+ .await
+ .with_context(|| {
+ format!("failed to apply labels to issue #{}", entry.number)
+ })?;
+ }
+ }
+ }
+
+ match mode {
+ Mode::Json => {
+ println!("{}", serde_json::to_string_pretty(&entries)?);
+ }
+ Mode::Quiet => {
+ for e in &entries {
+ println!("#{} → {}", e.number, e.proposed_labels);
+ }
+ }
+ Mode::Human => {
+ println!();
+ println!(
+ "{}",
+ format!(
+ " parsec triage — {} ({} rule(s), {} issue(s) matched of {})",
+ if apply { "applied" } else { "dry-run" },
+ config.triage.rules.len(),
+ entries.len(),
+ issues.len()
+ )
+ .bold()
+ );
+ println!();
+
+ // Build display rows
+ let rows: Vec = entries
+ .iter()
+ .map(|e| DisplayRow {
+ number: format!("#{}", e.number),
+ title: truncate(&e.title, 42),
+ matched_rule: e.matched_rule.clone(),
+ proposed_labels: e.proposed_labels.clone(),
+ trust: format!("{:.1}", e.trust_score),
+ })
+ .collect();
+
+ let table = Table::new(rows).with(Style::rounded()).to_string();
+ println!("{table}");
+ println!();
+ if !apply {
+ println!(
+ "{}",
+ "Tip: No labels were written. Pass --apply to apply.".dimmed()
+ );
+ }
+ }
+ }
+
+ Ok(())
+}
+
+// ---------------------------------------------------------------------------
+// Internal helpers
+// ---------------------------------------------------------------------------
+
+/// Compute a triage entry for one issue.
+///
+/// Returns `None` when no rule matches.
+fn build_entry(
+ number: u64,
+ title: &str,
+ existing_labels: &[String],
+ rules: &[TriageRule],
+) -> Option {
+ let lower = title.to_lowercase();
+ let matched: Vec<&TriageRule> = rules
+ .iter()
+ .filter(|r| lower.contains(&r.pattern.to_lowercase()))
+ .collect();
+
+ if matched.is_empty() {
+ return None;
+ }
+
+ // Trust score degrades with additional matches (first rule wins).
+ let trust_score: f32 = match matched.len() {
+ 1 => 1.0,
+ 2 => 0.7,
+ _ => 0.5,
+ };
+
+ // Collect all proposed labels (first match has highest priority).
+ let first = matched[0];
+ let mut labels: Vec = vec![first.label.clone()];
+ if let Some(ref p) = first.priority {
+ labels.push(p.clone());
+ }
+ let proposed_labels = labels.join(", ");
+ let missing_labels: Vec = labels
+ .iter()
+ .filter(|proposed| {
+ !existing_labels
+ .iter()
+ .any(|existing| existing.eq_ignore_ascii_case(proposed))
+ })
+ .cloned()
+ .collect();
+
+ Some(TriageEntry {
+ number,
+ title: title.to_string(),
+ matched_rule: first.pattern.clone(),
+ proposed_labels,
+ already_labelled: missing_labels.is_empty(),
+ labels: missing_labels,
+ trust_score,
+ })
+}
+
+fn truncate(s: &str, max: usize) -> String {
+ let chars: Vec = s.chars().collect();
+ if chars.len() <= max {
+ s.to_string()
+ } else {
+ format!("{}…", chars[..max - 1].iter().collect::())
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Data types
+// ---------------------------------------------------------------------------
+
+/// One triage proposal — one issue matched one or more rules.
+#[derive(Debug, serde::Serialize)]
+pub struct TriageEntry {
+ pub number: u64,
+ pub title: String,
+ pub matched_rule: String,
+ pub proposed_labels: String,
+ #[serde(skip)]
+ labels: Vec,
+ pub trust_score: f32,
+ pub already_labelled: bool,
+}
+
+/// Table row for `tabled` human output.
+#[derive(Tabled)]
+struct DisplayRow {
+ #[tabled(rename = "#")]
+ number: String,
+ #[tabled(rename = "Title")]
+ title: String,
+ #[tabled(rename = "Matched rule")]
+ matched_rule: String,
+ #[tabled(rename = "Proposed labels")]
+ proposed_labels: String,
+ #[tabled(rename = "Trust")]
+ trust: String,
+}
+
+// ---------------------------------------------------------------------------
+// Unit tests
+// ---------------------------------------------------------------------------
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::config::TriageRule;
+
+ fn rule(pattern: &str, label: &str) -> TriageRule {
+ TriageRule {
+ pattern: pattern.to_string(),
+ label: label.to_string(),
+ priority: None,
+ }
+ }
+
+ fn rule_with_priority(pattern: &str, label: &str, priority: &str) -> TriageRule {
+ TriageRule {
+ pattern: pattern.to_string(),
+ label: label.to_string(),
+ priority: Some(priority.to_string()),
+ }
+ }
+
+ #[test]
+ fn no_match_returns_none() {
+ let rules = vec![rule("feat", "type/feature")];
+ assert!(build_entry(1, "chore: update deps", &[], &rules).is_none());
+ }
+
+ #[test]
+ fn single_match_trust_is_1() {
+ let rules = vec![rule("feat", "type/feature")];
+ let e = build_entry(42, "feat: add triage command", &[], &rules).unwrap();
+ assert_eq!(e.number, 42);
+ assert_eq!(e.matched_rule, "feat");
+ assert_eq!(e.proposed_labels, "type/feature");
+ assert!((e.trust_score - 1.0).abs() < f32::EPSILON);
+ }
+
+ #[test]
+ fn two_matches_trust_is_0_7() {
+ let rules = vec![rule("feat", "type/feature"), rule("triage", "scope/triage")];
+ let e = build_entry(1, "feat: add triage", &[], &rules).unwrap();
+ assert!((e.trust_score - 0.7).abs() < f32::EPSILON);
+ }
+
+ #[test]
+ fn three_plus_matches_trust_is_0_5() {
+ let rules = vec![
+ rule("feat", "type/feature"),
+ rule("triage", "scope/triage"),
+ rule("add", "scope/addition"),
+ ];
+ let e = build_entry(1, "feat: add triage support", &[], &rules).unwrap();
+ assert!((e.trust_score - 0.5).abs() < f32::EPSILON);
+ }
+
+ #[test]
+ fn priority_label_included() {
+ let rules = vec![rule_with_priority("fix", "type/bug", "priority/high")];
+ let e = build_entry(7, "fix: critical crash", &[], &rules).unwrap();
+ assert_eq!(e.proposed_labels, "type/bug, priority/high");
+ }
+
+ #[test]
+ fn case_insensitive_match() {
+ let rules = vec![rule("FEAT", "type/feature")];
+ let e = build_entry(3, "feat: new feature", &[], &rules).unwrap();
+ assert!(e.trust_score > 0.0);
+ }
+
+ #[test]
+ fn truncate_long_title() {
+ let long = "a".repeat(50);
+ let out = truncate(&long, 42);
+ let chars: Vec = out.chars().collect();
+ assert!(chars.len() <= 42);
+ assert!(out.ends_with('…'));
+ }
+
+ #[test]
+ fn truncate_short_title_unchanged() {
+ let short = "short title";
+ assert_eq!(truncate(short, 42), short);
+ }
+
+ #[test]
+ fn already_labelled_flag() {
+ let rules = vec![rule("feat", "type/feature")];
+ let e = build_entry(5, "feat: something", &["type/feature".to_string()], &rules).unwrap();
+ assert!(e.already_labelled);
+ assert!(e.labels.is_empty());
+ }
+
+ #[test]
+ fn unrelated_existing_label_does_not_set_already_labelled() {
+ let rules = vec![rule("feat", "type/feature")];
+ let e = build_entry(5, "feat: something", &["help wanted".to_string()], &rules).unwrap();
+ assert!(!e.already_labelled);
+ assert_eq!(e.labels, ["type/feature"]);
+ }
+
+ #[test]
+ fn only_missing_labels_are_applied_case_insensitively() {
+ let rules = vec![rule_with_priority("fix", "type/bug", "priority/high")];
+ let e = build_entry(7, "fix: crash", &["TYPE/BUG".to_string()], &rules).unwrap();
+ assert!(!e.already_labelled);
+ assert_eq!(e.labels, ["priority/high"]);
+ }
+}
diff --git a/src/cli/commands/update.rs b/src/cli/commands/update.rs
new file mode 100644
index 0000000..ab9b745
--- /dev/null
+++ b/src/cli/commands/update.rs
@@ -0,0 +1,404 @@
+//! `parsec self-update` — check for a newer release and print upgrade instructions.
+//!
+//! # Phase 1
+//! Compares the running version against the latest GitHub release and prints
+//! an upgrade command when a newer version is available. No binary download
+//! or in-place replacement is performed.
+//!
+//! # Phase 2
+//! Startup version-check throttled to once every 24 h; cached in OS cache dir.
+//!
+//! # Phase 3 (this version)
+//! - Opt-out via `[update] check_on_startup = false` in parsec config.
+//! - Install-method detection: Homebrew / Cargo / prebuilt binary, each with
+//! the appropriate upgrade command.
+
+use anyhow::Result;
+use serde::{Deserialize, Serialize};
+
+const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
+const GITHUB_REPO: &str = "erishforG/git-parsec";
+/// HTTP timeout for the GitHub releases API call (seconds).
+const CHECK_TIMEOUT_SECS: u64 = 8;
+
+#[derive(Deserialize)]
+struct GitHubRelease {
+ tag_name: String,
+ html_url: String,
+ body: Option,
+}
+
+/// Compare two semver strings of the form `"X.Y.Z"` or `"vX.Y.Z"`.
+///
+/// Each component is compared numerically so `"0.10.0"` sorts after `"0.9.0"`,
+/// unlike a plain lexicographic comparison.
+fn cmp_semver(a: &str, b: &str) -> std::cmp::Ordering {
+ let parse = |s: &str| -> (u64, u64, u64) {
+ let s = s.trim_start_matches('v');
+ let mut it = s.splitn(3, '.').map(|p| p.parse::().unwrap_or(0));
+ (
+ it.next().unwrap_or(0),
+ it.next().unwrap_or(0),
+ it.next().unwrap_or(0),
+ )
+ };
+ parse(a).cmp(&parse(b))
+}
+
+/// Fetch the latest release metadata from the GitHub releases API.
+async fn fetch_latest_release() -> anyhow::Result {
+ let client = reqwest::Client::builder()
+ .timeout(std::time::Duration::from_secs(CHECK_TIMEOUT_SECS))
+ .user_agent(format!("parsec/{CURRENT_VERSION}"))
+ .build()?;
+ let url = format!("https://api.github.com/repos/{GITHUB_REPO}/releases/latest");
+ let release = client
+ .get(&url)
+ .send()
+ .await?
+ .error_for_status()?
+ .json::()
+ .await?;
+ Ok(release)
+}
+
+/// `parsec self-update` entry point.
+///
+/// When `offline` is `true` (either `--offline` flag or config), the network
+/// call is skipped and only the current version is printed.
+pub async fn self_update(offline: bool) -> Result<()> {
+ let current = CURRENT_VERSION;
+
+ if offline {
+ println!("parsec {current}");
+ println!("note: version check skipped (--offline)");
+ return Ok(());
+ }
+
+ use std::io::Write as _;
+ print!("parsec {current} → checking for updates… ");
+ let _ = std::io::stdout().flush();
+
+ match fetch_latest_release().await {
+ Err(e) => {
+ println!("(network unavailable: {e:#})");
+ println!("Current version: parsec {current}");
+ println!("See https://github.com/{GITHUB_REPO}/releases for the latest release.");
+ }
+ Ok(release) => {
+ let latest_tag = &release.tag_name;
+ let latest = latest_tag.trim_start_matches('v');
+ match cmp_semver(latest, current) {
+ std::cmp::Ordering::Greater => {
+ println!("update available!\n");
+ println!(" {current} → {latest}");
+ println!(" {}", release.html_url);
+ // Show a brief excerpt of the release notes (up to 4 lines).
+ if let Some(notes) = &release.body {
+ let preview: String = notes.lines().take(4).collect::>().join("\n");
+ if !preview.trim().is_empty() {
+ println!("\n Release notes (preview):");
+ for line in preview.lines() {
+ println!(" {line}");
+ }
+ }
+ }
+ println!("\nTo upgrade:");
+ for line in upgrade_command().lines() {
+ println!(" {line}");
+ }
+ }
+ std::cmp::Ordering::Equal => {
+ println!("✓ already up to date ({current})");
+ }
+ std::cmp::Ordering::Less => {
+ // The user is running a dev build ahead of the published release.
+ println!(
+ "✓ {latest} is the latest published release \
+ (you are ahead — development build)"
+ );
+ }
+ }
+ }
+ }
+ Ok(())
+}
+
+// ── Install-method detection (Phase 3) ─────────────────────────────────────
+
+/// Detect how parsec was installed and return the appropriate upgrade command.
+///
+/// Detection order:
+/// 1. Homebrew — binary path contains `/homebrew/` or `/Cellar/`.
+/// 2. Cargo — binary path contains `/.cargo/bin/`.
+/// 3. Unknown / prebuilt — direct download from releases page.
+pub fn upgrade_command() -> String {
+ let exe = std::env::current_exe()
+ .map(|p| p.to_string_lossy().into_owned())
+ .unwrap_or_default();
+
+ if exe.contains("/homebrew/") || exe.contains("/Cellar/") {
+ "brew upgrade parsec".to_string()
+ } else if exe.contains("/.cargo/bin/") {
+ format!("cargo install --git https://github.com/{GITHUB_REPO} --bin parsec --force")
+ } else {
+ format!(
+ "# Download the latest prebuilt binary from:\n\
+ # https://github.com/{GITHUB_REPO}/releases/latest"
+ )
+ }
+}
+
+// ── Startup version check (Phase 2 / Phase 3) ───────────────────────────────
+
+/// Cache filename stored in the OS cache directory (e.g. `~/.cache` on Linux).
+const VERSION_CACHE_FILENAME: &str = ".parsec-version-check";
+/// Network timeout for a startup background check (2 s — must feel instant).
+const STARTUP_CHECK_TIMEOUT_SECS: u64 = 2;
+
+/// Persisted state for the startup version-check throttle.
+#[derive(Serialize, Deserialize, Default)]
+struct VersionCheckCache {
+ /// Unix epoch seconds of the last check attempt (successful or not).
+ last_checked_secs: u64,
+ /// Latest release tag returned by GitHub (e.g. `"v0.5.1"`).
+ latest_tag: Option,
+}
+
+fn version_cache_path() -> Option {
+ dirs::cache_dir().map(|d| d.join(VERSION_CACHE_FILENAME))
+}
+
+fn load_version_cache() -> VersionCheckCache {
+ version_cache_path()
+ .and_then(|p| std::fs::read_to_string(p).ok())
+ .and_then(|s| serde_json::from_str(&s).ok())
+ .unwrap_or_default()
+}
+
+fn save_version_cache(cache: &VersionCheckCache) {
+ if let Some(path) = version_cache_path() {
+ if let Ok(json) = serde_json::to_string(cache) {
+ let _ = std::fs::write(path, json);
+ }
+ }
+}
+
+fn now_secs() -> u64 {
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .unwrap_or_default()
+ .as_secs()
+}
+
+/// Returns `true` when `latest_tag` represents a version newer than the
+/// running binary. Extracted as a pure function for testability.
+fn should_show_update_hint(latest_tag: Option<&str>) -> bool {
+ latest_tag
+ .map(|tag| cmp_semver(tag.trim_start_matches('v'), CURRENT_VERSION))
+ .map(|ord| ord == std::cmp::Ordering::Greater)
+ .unwrap_or(false)
+}
+
+fn print_update_hint(latest: &str) {
+ eprintln!(
+ "\n ✦ parsec {latest} available (current: {CURRENT_VERSION})\
+ \n Run `parsec self-update` for upgrade instructions.\n"
+ );
+}
+
+/// Print a one-line update hint to **stderr** if a newer release is available.
+///
+/// Throttles the live GitHub API call using `check_interval_hours` (default 24 h)
+/// by caching the result in [`version_cache_path()`]. Always a no-op when
+/// `check_on_startup` is `false` or in offline mode; never panics.
+///
+/// Should be called after the main command has finished so it does not
+/// interleave with command output. Skipped in `--json` / `--quiet` mode
+/// and for the `parsec self-update` command itself (see `src/cli/mod.rs`).
+///
+/// # Phase 3
+/// Accepts `check_on_startup` (config opt-out) and `check_interval_hours`.
+pub async fn startup_version_hint(
+ offline: bool,
+ check_on_startup: bool,
+ check_interval_hours: u64,
+) {
+ if offline || !check_on_startup {
+ return;
+ }
+
+ let throttle_secs = check_interval_hours.saturating_mul(3_600);
+ let mut cache = load_version_cache();
+ let now = now_secs();
+ let age_secs = now.saturating_sub(cache.last_checked_secs);
+
+ if age_secs >= throttle_secs {
+ // Cache is stale — attempt a quick live refresh.
+ let client = match reqwest::Client::builder()
+ .timeout(std::time::Duration::from_secs(STARTUP_CHECK_TIMEOUT_SECS))
+ .user_agent(format!("parsec/{CURRENT_VERSION}"))
+ .build()
+ {
+ Ok(c) => c,
+ Err(_) => return,
+ };
+ let url = format!("https://api.github.com/repos/{GITHUB_REPO}/releases/latest");
+ match client.get(&url).send().await {
+ Ok(resp) => {
+ if let Ok(release) = resp.json::().await {
+ let tag = release.tag_name.clone();
+ cache = VersionCheckCache {
+ last_checked_secs: now,
+ latest_tag: Some(tag.clone()),
+ };
+ save_version_cache(&cache);
+ if should_show_update_hint(Some(&tag)) {
+ print_update_hint(tag.trim_start_matches('v'));
+ }
+ }
+ }
+ Err(_) => {
+ // Network unavailable — bump timestamp to avoid hammering
+ // the API on every run, but keep any cached latest_tag.
+ cache.last_checked_secs = now;
+ save_version_cache(&cache);
+ }
+ }
+ } else if let Some(ref tag) = cache.latest_tag.clone() {
+ // Use the cached result without a network call.
+ if should_show_update_hint(Some(tag)) {
+ print_update_hint(tag.trim_start_matches('v'));
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::{
+ cmp_semver, now_secs, should_show_update_hint, VersionCheckCache, CURRENT_VERSION,
+ };
+ use std::cmp::Ordering;
+
+ #[test]
+ fn newer_patch() {
+ assert_eq!(cmp_semver("0.5.1", "0.5.0"), Ordering::Greater);
+ }
+
+ #[test]
+ fn newer_minor_double_digit() {
+ // Numeric comparison: "0.10.0" > "0.9.0"; lexicographic would fail.
+ assert_eq!(cmp_semver("0.10.0", "0.9.0"), Ordering::Greater);
+ }
+
+ #[test]
+ fn newer_major() {
+ assert_eq!(cmp_semver("1.0.0", "0.5.0"), Ordering::Greater);
+ }
+
+ #[test]
+ fn equal_plain() {
+ assert_eq!(cmp_semver("0.5.0", "0.5.0"), Ordering::Equal);
+ }
+
+ #[test]
+ fn v_prefix_stripped() {
+ assert_eq!(cmp_semver("v1.2.3", "1.2.3"), Ordering::Equal);
+ assert_eq!(cmp_semver("v2.0.0", "v1.9.9"), Ordering::Greater);
+ }
+
+ #[test]
+ fn older() {
+ assert_eq!(cmp_semver("0.4.0", "0.5.0"), Ordering::Less);
+ }
+
+ #[test]
+ fn multi_digit_major() {
+ assert_eq!(cmp_semver("10.0.0", "9.99.99"), Ordering::Greater);
+ }
+
+ // ── Phase 2: startup version-check helpers ──────────────────────────
+
+ #[test]
+ fn version_cache_serde_round_trip() {
+ let cache = VersionCheckCache {
+ last_checked_secs: 1_700_000_000,
+ latest_tag: Some("v0.5.1".into()),
+ };
+ let json = serde_json::to_string(&cache).expect("serialize");
+ let parsed: VersionCheckCache = serde_json::from_str(&json).expect("deserialize");
+ assert_eq!(parsed.last_checked_secs, 1_700_000_000);
+ assert_eq!(parsed.latest_tag.as_deref(), Some("v0.5.1"));
+ }
+
+ #[test]
+ fn version_cache_default_is_stale() {
+ let cache = VersionCheckCache::default();
+ let age = now_secs().saturating_sub(cache.last_checked_secs);
+ assert!(
+ age >= 86_400, // DEFAULT_CHECK_INTERVAL_SECS (24 h)
+ "default cache should be stale"
+ );
+ }
+
+ #[test]
+ fn should_show_hint_newer_version() {
+ // A tag strictly newer than the running CURRENT_VERSION should trigger a hint.
+ // We use a version guaranteed to be newer than any cargo package version.
+ assert!(should_show_update_hint(Some("v999.0.0")));
+ }
+
+ #[test]
+ fn should_show_hint_older_version() {
+ assert!(!should_show_update_hint(Some("v0.0.1")));
+ }
+
+ #[test]
+ fn should_show_hint_none() {
+ assert!(!should_show_update_hint(None));
+ }
+
+ #[test]
+ fn should_show_hint_equal_version() {
+ // Equal to current — no hint.
+ assert!(!should_show_update_hint(Some(CURRENT_VERSION)));
+ }
+
+ // ── Phase 3: upgrade_command install-method detection ────────────────────────
+
+ #[test]
+ fn upgrade_command_homebrew() {
+ // Simulate a Homebrew-installed binary path.
+ // We can't actually change `current_exe`, so we test the detection
+ // logic indirectly by verifying it produces a non-empty string.
+ // A real path test would require PATH manipulation; skip for now and
+ // assert the fallback (cargo/prebuilt) is non-empty.
+ let cmd = super::upgrade_command();
+ assert!(
+ !cmd.is_empty(),
+ "upgrade_command should always return a non-empty string"
+ );
+ }
+
+ #[test]
+ fn upgrade_command_contains_repo() {
+ // Whatever the install method, the command must reference the repo or
+ // releases page so the user knows where to go.
+ let cmd = super::upgrade_command();
+ let has_ref = cmd.contains("erishforG/git-parsec")
+ || cmd.contains("brew upgrade")
+ || cmd.contains("releases");
+ assert!(
+ has_ref,
+ "upgrade_command should reference the repo/releases: {cmd}"
+ );
+ }
+
+ #[test]
+ fn throttle_secs_from_hours() {
+ // Verify the hours-to-seconds conversion arithmetic used in startup_version_hint.
+ let hours: u64 = 48;
+ let expected_secs = 48 * 3_600u64;
+ assert_eq!(hours.saturating_mul(3_600), expected_secs);
+ }
+}
diff --git a/src/cli/mod.rs b/src/cli/mod.rs
index 6f2d49a..9e46f3f 100644
--- a/src/cli/mod.rs
+++ b/src/cli/mod.rs
@@ -162,6 +162,12 @@ pub enum Command {
/// Generate PR description using AI
#[arg(long)]
ai_description: bool,
+
+ /// Skip worktree removal after PR creation.
+ /// Overrides `ship.auto_cleanup = true` in config for this invocation.
+ /// Useful when making incremental commits to the same ticket across multiple ship calls.
+ #[arg(long)]
+ no_cleanup: bool,
},
/// Remove merged or stale worktrees
@@ -570,7 +576,7 @@ pub enum Command {
/// Visualize active worktrees as a commit DAG (alias: sl)
///
/// Lists every active worktree, the commits it adds on top of its base
- /// branch, and (in later releases) PR/CI/review state. Issue #245.
+ /// branch, and its GitHub PR/CI/review state when available. Issue #245.
#[command(alias = "sl")]
Smartlog {
/// Maximum commits per worktree (default: 10)
@@ -674,6 +680,106 @@ pub enum Command {
#[command(subcommand)]
kind: CompleteKind,
},
+
+ /// Check for a newer parsec release and print upgrade instructions.
+ ///
+ /// Queries the GitHub releases API to compare the running version with
+ /// the latest published release. Prints an upgrade command when a newer
+ /// version is available.
+ ///
+ /// Phase 1 — notification only; automatic binary replacement is Phase 2.
+ /// Use the global `--offline` flag to skip the network call.
+ SelfUpdate {},
+
+ /// Manage locally-saved crash reports.
+ ///
+ /// Crash reports are opt-in JSON files written by the panic hook when
+ /// `[crash_report] enabled = true` is set in your parsec config.
+ /// See `docs/crash-report.md` for the privacy policy.
+ ///
+ /// Phase 2 — list / show / clear subcommands for cache management.
+ CrashReport {
+ #[command(subcommand)]
+ action: CrashReportAction,
+ },
+
+ /// Save and list worktree snapshots (stash-based)
+ ///
+ /// Creates a named point-in-time snapshot of the current worktree —
+ /// including staged, unstaged, and untracked files — using git stash.
+ /// Snapshots are tagged with a `parsec-checkpoint:` prefix so they can
+ /// be distinguished from ordinary stash entries.
+ ///
+ /// Phase 1: create + list. Phase 2 will add restore and drop.
+ Checkpoint {
+ #[command(subcommand)]
+ action: CheckpointAction,
+ },
+
+ /// Rule-based issue auto-labelling (dry-run by default).
+ ///
+ /// Reads `[[triage.rules]]` from your parsec config, fetches open GitHub
+ /// issues, and prints a table of proposed labels with a trust score.
+ ///
+ /// Labels are only written when `--apply` is explicitly passed.
+ ///
+ /// Example config rule:
+ /// [[triage.rules]]
+ /// pattern = "feat"
+ /// label = "type/feature"
+ Triage {
+ /// Maximum number of open issues to inspect (default: 30).
+ #[arg(long, default_value = "30")]
+ limit: u8,
+
+ /// Apply proposed labels to GitHub issues (default: dry-run).
+ #[arg(long)]
+ apply: bool,
+ },
+}
+
+/// Actions available under `parsec crash-report`.
+#[derive(Subcommand)]
+pub enum CrashReportAction {
+ /// List all crash reports with timestamp and panic preview.
+ List,
+ /// Show the full JSON of one crash report.
+ Show {
+ /// Report id (file stem, e.g. `crash-20260101T000000Z`)
+ id: String,
+ },
+ /// Delete all crash reports from the cache directory.
+ Clear,
+}
+
+/// Subcommands for `parsec checkpoint`.
+#[derive(Subcommand)]
+pub enum CheckpointAction {
+ /// Save the current worktree state as a named checkpoint
+ ///
+ /// Staged, unstaged changes, and untracked files are all captured.
+ /// If the working tree is clean, a friendly message is printed instead.
+ Create {
+ /// Optional name for the checkpoint (default: UTC timestamp)
+ name: Option,
+ },
+ /// List all parsec-managed checkpoints in this repository
+ List,
+ /// Restore a named checkpoint back into the working tree
+ ///
+ /// Pops the matching git stash entry and removes it from the stash list.
+ /// Fails if the working tree is dirty (resolve conflicts first, then retry).
+ Restore {
+ /// Name of the checkpoint to restore
+ name: String,
+ },
+ /// Permanently discard a named checkpoint
+ ///
+ /// Drops the matching git stash entry. This action is irreversible.
+ Drop {
+ /// Name of the checkpoint to drop
+ name: String,
+ },
}
/// Candidate sets the dynamic completion subcommand can emit.
@@ -809,6 +915,10 @@ pub async fn run(cli: Cli) -> Result<()> {
Command::Reviews { .. } => "reviews",
Command::Dashboard { .. } => "dashboard",
Command::Test { .. } => "test",
+ Command::SelfUpdate { .. } => "self-update",
+ Command::CrashReport { .. } => "crash-report",
+ Command::Checkpoint { .. } => "checkpoint",
+ Command::Triage { .. } => "triage",
};
let exec_id = crate::execlog::new_execution_id();
let exec_started_at = chrono::Utc::now();
@@ -868,14 +978,16 @@ pub async fn run(cli: Cli) -> Result<()> {
label,
template,
ai_description,
+ no_cleanup,
} => {
if cli.dry_run {
eprintln!(
- "[dry-run] Would ship ticket '{}' (draft: {}, no_pr: {}, base: {})",
+ "[dry-run] Would ship ticket '{}' (draft: {}, no_pr: {}, base: {}, no_cleanup: {})",
ticket,
draft,
no_pr,
- base.as_deref().unwrap_or("auto")
+ base.as_deref().unwrap_or("auto"),
+ no_cleanup
);
return Ok(());
}
@@ -891,6 +1003,7 @@ pub async fn run(cli: Cli) -> Result<()> {
label,
template,
ai_description,
+ no_cleanup,
output_mode,
)
.await
@@ -1176,8 +1289,47 @@ pub async fn run(cli: Cli) -> Result<()> {
.await
}
Command::Complete { kind } => commands::complete(&repo_path, kind).await,
+ Command::SelfUpdate {} => commands::self_update(offline).await,
+ Command::CrashReport { action } => match action {
+ CrashReportAction::List => {
+ commands::crash_report_list(output_mode == output::Mode::Json)
+ }
+ CrashReportAction::Show { id } => {
+ commands::crash_report_show(&id, output_mode == output::Mode::Json)
+ }
+ CrashReportAction::Clear => commands::crash_report_clear(cli.dry_run),
+ },
+ Command::Checkpoint { action } => match action {
+ CheckpointAction::Create { name } => {
+ commands::checkpoint_create(&repo_path, name.as_deref(), output_mode)
+ }
+ CheckpointAction::List => commands::checkpoint_list(&repo_path, output_mode),
+ CheckpointAction::Restore { name } => {
+ commands::checkpoint_restore(&repo_path, &name, output_mode)
+ }
+ CheckpointAction::Drop { name } => {
+ commands::checkpoint_drop(&repo_path, &name, output_mode)
+ }
+ },
+ Command::Triage { limit, apply } => {
+ let apply = apply && !cli.dry_run;
+ if apply && offline {
+ anyhow::bail!("cannot use --apply in offline mode");
+ }
+ commands::triage(&repo_path, limit, apply, output_mode).await
+ }
};
+ // Startup version hint — one-line stderr notice when a newer release is cached.
+ // Skipped for self-update (redundant), --json, and --quiet modes.
+ // Phase 3: respects [update] check_on_startup / check_interval_hours from config.
+ if output_mode == output::Mode::Human && cmd_name != "self-update" {
+ let (check_on_startup, check_interval_hours) = crate::config::ParsecConfig::load()
+ .map(|c| (c.update.check_on_startup, c.update.check_interval_hours))
+ .unwrap_or((true, 24));
+ commands::startup_version_hint(offline, check_on_startup, check_interval_hours).await;
+ }
+
// Record execution entry (best-effort, never fail the command)
let duration = exec_start.elapsed();
let steps = crate::execlog::take_steps();
diff --git a/src/config/mod.rs b/src/config/mod.rs
index 55e3e52..5edc2ad 100644
--- a/src/config/mod.rs
+++ b/src/config/mod.rs
@@ -4,4 +4,5 @@ pub use settings::AiProvider;
pub use settings::CacheStrategy;
pub use settings::ParsecConfig;
pub use settings::TrackerProvider;
+pub use settings::TriageRule;
pub use settings::WorktreeLayout;
diff --git a/src/config/settings.rs b/src/config/settings.rs
index a39736c..bb389bb 100644
--- a/src/config/settings.rs
+++ b/src/config/settings.rs
@@ -440,6 +440,10 @@ fn default_test_jobs() -> usize {
1
}
+fn default_update_interval_hours() -> u64 {
+ 24
+}
+
/// Settings for the `parsec test` command (issue #247).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TestConfig {
@@ -464,6 +468,96 @@ impl Default for TestConfig {
}
}
+// ---------------------------------------------------------------------------
+// UpdateConfig
+// ---------------------------------------------------------------------------
+
+/// Settings for automatic version checking (`parsec self-update` / startup hint).
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct UpdateConfig {
+ /// When `false`, suppresses the startup "update available" hint on every run.
+ /// `parsec self-update` is never suppressed.
+ #[serde(default = "default_true")]
+ pub check_on_startup: bool,
+ /// Minimum hours between live GitHub API polls for a new release (default: 24).
+ #[serde(default = "default_update_interval_hours")]
+ pub check_interval_hours: u64,
+}
+
+impl Default for UpdateConfig {
+ fn default() -> Self {
+ Self {
+ check_on_startup: true,
+ check_interval_hours: default_update_interval_hours(),
+ }
+ }
+}
+
+// ---------------------------------------------------------------------------
+// CrashReportConfig
+// ---------------------------------------------------------------------------
+
+/// Controls opt-in crash report collection (#298).
+///
+/// No data is transmitted automatically. When `enabled = true`, a JSON
+/// report is written to `/parsec/crash-.json` on panic. The
+/// user must choose to share it. See `docs/crash-report.md`.
+///
+/// # Example (`~/.config/parsec/config.toml`)
+/// ```toml
+/// [crash_report]
+/// enabled = true
+/// ```
+#[derive(Debug, Clone, Serialize, Deserialize, Default)]
+#[serde(default)]
+pub struct CrashReportConfig {
+ /// When `true`, a JSON crash report is saved locally on panic.
+ /// Default: `false` (opt-in).
+ #[serde(default)]
+ pub enabled: bool,
+}
+
+// ---------------------------------------------------------------------------
+// TriageConfig
+// ---------------------------------------------------------------------------
+
+/// One label-assignment rule: when `pattern` (case-insensitive substring)
+/// appears in an issue/PR title, `label` is proposed and optionally a
+/// `priority` label is added.
+///
+/// Example (`~/.config/parsec/config.toml`):
+/// ```toml
+/// [[triage.rules]]
+/// pattern = "feat"
+/// label = "type/feature"
+///
+/// [[triage.rules]]
+/// pattern = "fix"
+/// label = "type/bug"
+/// priority = "priority/high"
+/// ```
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct TriageRule {
+ /// Case-insensitive substring that is matched against the issue/PR title.
+ pub pattern: String,
+ /// Label to propose when `pattern` matches.
+ pub label: String,
+ /// Optional priority label to propose alongside `label`.
+ #[serde(default)]
+ pub priority: Option,
+}
+
+/// `[triage]` section of the parsec config — rule-based auto-labelling (#302).
+///
+/// Rules are evaluated in order; the **first** matching rule wins (highest
+/// confidence). Subsequent rules that also match lower the trust score.
+#[derive(Debug, Clone, Serialize, Deserialize, Default)]
+#[serde(default)]
+pub struct TriageConfig {
+ /// Ordered list of triage rules.
+ pub rules: Vec,
+}
+
// ---------------------------------------------------------------------------
// ParsecConfig
// ---------------------------------------------------------------------------
@@ -488,6 +582,14 @@ pub struct ParsecConfig {
pub ai: AiConfig,
#[serde(default)]
pub test: TestConfig,
+ #[serde(default)]
+ pub update: UpdateConfig,
+ /// Opt-in crash report collection.
+ #[serde(default)]
+ pub crash_report: CrashReportConfig,
+ /// Rule-based issue/PR auto-triage (#302).
+ #[serde(default)]
+ pub triage: TriageConfig,
/// Per-host GitHub tokens. Keys are hostnames like "github.com" or
/// "github.example.com". Serializes as `[github."hostname"]` in TOML.
#[serde(default)]
diff --git a/src/github/mod.rs b/src/github/mod.rs
index 4b6e1ea..ec812f1 100644
--- a/src/github/mod.rs
+++ b/src/github/mod.rs
@@ -948,6 +948,54 @@ impl GitHubClient {
}
/// Add labels to a PR/issue.
+ /// Fetch open issues for the current repo (excludes pull requests).
+ ///
+ /// Returns up to `limit` issues sorted by creation date (newest first).
+ /// Each item is `(number, title, existing_labels)`.
+ pub async fn list_open_issues(&self, limit: u8) -> Result)>> {
+ #[derive(serde::Deserialize)]
+ struct IssueLabel {
+ name: String,
+ }
+ #[derive(serde::Deserialize)]
+ struct IssueItem {
+ number: u64,
+ title: String,
+ labels: Vec,
+ /// GitHub includes a `pull_request` field only on PR items; use
+ /// its absence to distinguish real issues from PRs.
+ pull_request: Option,
+ }
+
+ let per_page = limit.min(100);
+ let path = format!(
+ "{}/issues?state=open&per_page={per_page}&sort=created&direction=desc",
+ self.repo_path()
+ );
+ let response = self
+ .get(&path)
+ .send()
+ .await
+ .context("Failed to list open issues")?;
+ if !response.status().is_success() {
+ let status = response.status();
+ let body = response.text().await.unwrap_or_default();
+ bail!("Failed to list open issues: {} {}", status, body);
+ }
+ let items: Vec = response
+ .json()
+ .await
+ .context("Failed to parse issue list")?;
+ Ok(items
+ .into_iter()
+ .filter(|i| i.pull_request.is_none()) // skip PRs
+ .map(|i| {
+ let labels: Vec = i.labels.into_iter().map(|l| l.name).collect();
+ (i.number, i.title, labels)
+ })
+ .collect())
+ }
+
pub async fn add_labels(&self, issue_number: u64, labels: &[String]) -> Result<()> {
if labels.is_empty() {
return Ok(());
diff --git a/src/main.rs b/src/main.rs
index bb4d4ad..e7454ac 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -14,6 +14,7 @@ mod gitlab;
mod mcp;
mod oplog;
mod output;
+mod panic_handler;
mod tracker;
mod worktree;
@@ -22,6 +23,13 @@ use cli::Cli;
#[tokio::main]
async fn main() {
+ // ── Panic hook (opt-in crash report, #298) ─────────────────────────────
+ // Load config once solely to read the crash_report setting; failures are
+ // silently ignored (hook still installs with enabled=false).
+ let crash_report_enabled = config::ParsecConfig::load()
+ .map(|c| c.crash_report.enabled)
+ .unwrap_or(false);
+ panic_handler::setup(crash_report_enabled);
let cli = Cli::parse();
let json_mode = cli.json;
diff --git a/src/output/human.rs b/src/output/human.rs
index b94e225..0b7f9b1 100644
--- a/src/output/human.rs
+++ b/src/output/human.rs
@@ -1100,6 +1100,12 @@ pub fn print_health(records: &[super::HealthRecord]) {
println!("{}", "parsec health".bold());
let mut issues = 0usize;
for r in records {
+ let missing_tag = if r.missing {
+ " ⚠ worktree missing!".red().to_string()
+ } else {
+ String::new()
+ };
+
let lock_tag = if r.has_lock {
" ⚠ lock file!".red().to_string()
} else {
@@ -1156,14 +1162,36 @@ pub fn print_health(records: &[super::HealthRecord]) {
let ci_issue = matches!(r.ci_status.as_deref(), Some("failing") | Some("failure"));
- let any_issue = r.has_lock
+ // Phase 3: in-progress git operation tags
+ let rebase_tag = if r.rebase_in_progress {
+ " ⚠ rebase in progress!".red().to_string()
+ } else {
+ String::new()
+ };
+ let merge_tag = if r.merge_in_progress {
+ " ⚠ merge in progress!".red().to_string()
+ } else {
+ String::new()
+ };
+ let cherry_tag = if r.cherry_pick_in_progress {
+ " ⚠ cherry-pick in progress!".red().to_string()
+ } else {
+ String::new()
+ };
+
+ let op_in_progress =
+ r.rebase_in_progress || r.merge_in_progress || r.cherry_pick_in_progress;
+
+ let any_issue = r.missing
+ || r.has_lock
|| r.uncommitted > 0
|| ci_issue
+ || op_in_progress
|| r.stale_days
.map(|d| d > r.stale_threshold_days)
.unwrap_or(false);
- let icon = if r.has_lock || ci_issue {
+ let icon = if r.missing || r.has_lock || ci_issue || op_in_progress {
"✗".red().to_string()
} else if any_issue {
"⚠".yellow().to_string()
@@ -1176,13 +1204,17 @@ pub fn print_health(records: &[super::HealthRecord]) {
}
println!(
- " {} {:<20}{}{}{}{}",
+ " {} {:<20}{}{}{}{}{}{}{}{}",
icon,
r.ticket.bold(),
+ missing_tag,
uncommitted_tag,
stale_tag,
lock_tag,
ci_tag,
+ rebase_tag,
+ merge_tag,
+ cherry_tag,
);
}
println!();
diff --git a/src/output/json.rs b/src/output/json.rs
index 616cf26..bcc2b54 100644
--- a/src/output/json.rs
+++ b/src/output/json.rs
@@ -373,6 +373,7 @@ pub fn print_health(records: &[super::HealthRecord]) {
let ci_failing = matches!(r.ci_status.as_deref(), Some("failing") | Some("failure"));
json!({
"ticket": r.ticket,
+ "missing": r.missing,
"has_lock": r.has_lock,
"uncommitted": r.uncommitted,
"stale_days": r.stale_days,
@@ -380,17 +381,24 @@ pub fn print_health(records: &[super::HealthRecord]) {
"ci_status": r.ci_status,
"pr_number": r.pr_number,
"ci_failing": ci_failing,
+ "rebase_in_progress": r.rebase_in_progress,
+ "merge_in_progress": r.merge_in_progress,
+ "cherry_pick_in_progress": r.cherry_pick_in_progress,
})
})
.collect();
let all_healthy = records.iter().all(|r| {
- !r.has_lock
+ !r.missing
+ && !r.has_lock
&& r.uncommitted == 0
&& !r
.stale_days
.map(|d| d > r.stale_threshold_days)
.unwrap_or(false)
&& !matches!(r.ci_status.as_deref(), Some("failing") | Some("failure"))
+ && !r.rebase_in_progress
+ && !r.merge_in_progress
+ && !r.cherry_pick_in_progress
});
println!(
"{}",
diff --git a/src/output/mod.rs b/src/output/mod.rs
index e17e3a7..155694e 100644
--- a/src/output/mod.rs
+++ b/src/output/mod.rs
@@ -47,6 +47,8 @@ pub struct DoctorCheck {
pub struct HealthRecord {
/// Ticket identifier for the worktree.
pub ticket: String,
+ /// Whether the registered worktree directory is missing from disk.
+ pub missing: bool,
/// Number of uncommitted files (staged + unstaged).
pub uncommitted: usize,
/// Days since the last commit, or `None` when the history is unreadable.
@@ -60,6 +62,15 @@ pub struct HealthRecord {
pub ci_status: Option,
/// GitHub PR number linked to this worktree's branch, if any.
pub pr_number: Option,
+ /// Phase 3: a rebase is in progress in this worktree.
+ /// Detected by the presence of `.git/rebase-merge/` or `.git/rebase-apply/`.
+ pub rebase_in_progress: bool,
+ /// Phase 3: a merge is in progress in this worktree.
+ /// Detected by the presence of `.git/MERGE_HEAD`.
+ pub merge_in_progress: bool,
+ /// Phase 3: a cherry-pick is in progress in this worktree.
+ /// Detected by the presence of `.git/CHERRY_PICK_HEAD`.
+ pub cherry_pick_in_progress: bool,
}
/// Per-worktree test outcome produced by `parsec test` (issue #247).
diff --git a/src/panic_handler.rs b/src/panic_handler.rs
new file mode 100644
index 0000000..ce78dc6
--- /dev/null
+++ b/src/panic_handler.rs
@@ -0,0 +1,274 @@
+//! `parsec` panic hook — opt-in crash report collection (#298).
+//!
+//! # Phase 1
+//! Registers a custom panic hook that:
+//! 1. Prints a user-friendly crash banner to **stderr** (always).
+//! 2. When `enabled = true` (opt-in), writes a structured JSON report to
+//! `/parsec/crash-.json` so users can share it
+//! with the maintainers.
+//!
+//! No data is **transmitted** automatically. The user must opt in via config
+//! and manually share the file. See `docs/crash-report.md` for the full
+//! privacy policy.
+//!
+//! # Report Contents
+//! - `parsec_version` — crate version from `Cargo.toml`
+//! - `timestamp` — ISO-8601 UTC timestamp of the crash
+//! - `os` — `/` (e.g. `"unix/macos"`)
+//! - `shell` — value of `$SHELL` (sanitised, may be absent)
+//! - `command_args` — `argv[1..]` (first arg only, not flags, for privacy)
+//! - `panic_message` — the panic payload as a string (may be truncated)
+//! - `panic_location` — `"file:line"` where the panic occurred
+//!
+//! The full back-trace is **not** collected by default; set the
+//! `RUST_BACKTRACE=1` environment variable for a local stack trace.
+
+use std::panic;
+use std::sync::atomic::{AtomicBool, Ordering};
+
+static REPORT_ENABLED: AtomicBool = AtomicBool::new(false);
+
+const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
+
+/// Call this once at startup, before any `tokio` threads are spawned.
+///
+/// When `enabled` is `true` the hook will also write a JSON crash report to
+/// the OS cache directory (for example, `~/.cache/parsec/` on Linux or
+/// `~/Library/Caches/parsec/` on macOS).
+pub fn setup(enabled: bool) {
+ REPORT_ENABLED.store(enabled, Ordering::SeqCst);
+
+ panic::set_hook(Box::new(|info| {
+ // ── Always: human-readable crash banner ────────────────────────────
+ let location = info
+ .location()
+ .map(|l| format!("{}:{}", l.file(), l.line()))
+ .unwrap_or_else(|| "".to_string());
+
+ let message = format_panic_message(info);
+
+ eprintln!();
+ eprintln!("╔══════════════════════════════════════════════════════╗");
+ eprintln!("║ parsec crashed — sorry about that! ║");
+ eprintln!("╚══════════════════════════════════════════════════════╝");
+ eprintln!();
+ eprintln!(" version : parsec {CURRENT_VERSION}");
+ eprintln!(" location : {location}");
+ eprintln!(" message : {message}");
+ eprintln!();
+ eprintln!(" For a stack trace, re-run with RUST_BACKTRACE=1.");
+
+ // ── Opt-in: write JSON report ───────────────────────────────────────
+ if REPORT_ENABLED.load(Ordering::SeqCst) {
+ let report = build_report(&location, &message);
+ match save_report(&report) {
+ Ok(path) => {
+ eprintln!(" Crash report saved to: {path}");
+ eprintln!(
+ " To report this bug, open:\n \
+ https://github.com/erishforG/git-parsec/issues/new"
+ );
+ }
+ Err(e) => {
+ eprintln!(" (crash report write failed: {e})");
+ }
+ }
+ } else {
+ eprintln!(
+ " Tip: enable crash reports with `[crash_report] enabled = true` in\n \
+ your parsec config to help diagnose issues. See docs/crash-report.md."
+ );
+ }
+
+ eprintln!();
+ }));
+}
+
+// ── Helpers ──────────────────────────────────────────────────────────────────
+
+/// Extract the panic payload as a trimmed, ASCII-safe string (≤ 512 chars).
+fn format_panic_message(info: &panic::PanicHookInfo<'_>) -> String {
+ let raw = if let Some(s) = info.payload().downcast_ref::<&str>() {
+ s.to_string()
+ } else if let Some(s) = info.payload().downcast_ref::() {
+ s.clone()
+ } else {
+ "".to_string()
+ };
+ // Truncate so the crash banner stays readable on narrow terminals.
+ let truncated = raw.chars().take(512).collect::();
+ if truncated.len() < raw.len() {
+ format!("{truncated}…")
+ } else {
+ truncated
+ }
+}
+
+/// Collect the crash report as a JSON string.
+fn build_report(location: &str, message: &str) -> String {
+ let timestamp = {
+ // Use SystemTime → RFC-3339-ish without chrono (already a dep but
+ // keep this helper self-contained and allocation-light).
+ use std::time::{SystemTime, UNIX_EPOCH};
+ let secs = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .map(|d| d.as_secs())
+ .unwrap_or(0);
+ // Minimal ISO-8601: "YYYY-MM-DDTHH:MM:SSZ" via integer maths.
+ let s = secs;
+ let (y, mo, d, h, mi, se) = unix_to_utc_fields(s);
+ format!("{y:04}-{mo:02}-{d:02}T{h:02}:{mi:02}:{se:02}Z")
+ };
+
+ let os_family = std::env::consts::FAMILY; // "unix" | "windows"
+ let os_name = std::env::consts::OS; // "macos" | "linux" | "windows" …
+
+ let shell = std::env::var("SHELL")
+ .ok()
+ .map(|s| {
+ // Keep only the base name for privacy (e.g. "/usr/bin/zsh" → "zsh").
+ std::path::Path::new(&s)
+ .file_name()
+ .and_then(|n| n.to_str())
+ .unwrap_or("unknown")
+ .to_string()
+ })
+ .unwrap_or_else(|| "unknown".to_string());
+
+ // Capture argv[1] only (sub-command name) — no user-supplied arguments.
+ let subcommand = std::env::args()
+ .nth(1)
+ .unwrap_or_else(|| "".to_string());
+
+ // Escape double-quotes for the naive JSON builder below.
+ let esc = |s: &str| s.replace('\\', r"\\").replace('"', r#"\""#);
+
+ format!(
+ r#"{{
+ "parsec_version": "{ver}",
+ "timestamp": "{ts}",
+ "os": "{fam}/{os}",
+ "shell": "{shell}",
+ "subcommand": "{sub}",
+ "panic_location": "{loc}",
+ "panic_message": "{msg}"
+}}"#,
+ ver = esc(CURRENT_VERSION),
+ ts = esc(×tamp),
+ fam = esc(os_family),
+ os = esc(os_name),
+ shell = esc(&shell),
+ sub = esc(&subcommand),
+ loc = esc(location),
+ msg = esc(message),
+ )
+}
+
+/// Persist the report JSON to the OS cache dir and return the file path.
+fn save_report(json: &str) -> std::io::Result {
+ use std::time::{SystemTime, UNIX_EPOCH};
+
+ let ts = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .map(|d| d.as_secs())
+ .unwrap_or(0);
+
+ let cache_dir = dirs::cache_dir()
+ .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no cache dir"))?
+ .join("parsec");
+
+ std::fs::create_dir_all(&cache_dir)?;
+
+ let file_name = format!("crash-{ts}.json");
+ let path = cache_dir.join(&file_name);
+ std::fs::write(&path, json)?;
+ Ok(path.to_string_lossy().into_owned())
+}
+
+/// Minimal UTC date/time decomposition from a Unix timestamp.
+///
+/// Intentionally avoids the `chrono` dep (even though it is available) to
+/// keep the panic hook free of code that could itself panic. The algorithm
+/// covers dates from the Unix epoch through ≥ 2100.
+fn unix_to_utc_fields(mut secs: u64) -> (u32, u32, u32, u32, u32, u32) {
+ let se = (secs % 60) as u32;
+ secs /= 60;
+ let mi = (secs % 60) as u32;
+ secs /= 60;
+ let h = (secs % 24) as u32;
+ secs /= 24;
+
+ // Days since 1970-01-01 → Gregorian calendar.
+ // Algorithm: civil_from_days from https://howardhinnant.github.io/date_algorithms.html
+ let z = secs as i64 + 719_468;
+ let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
+ let doe = (z - era * 146_097) as u64;
+ let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
+ let y = yoe as i64 + era * 400;
+ let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
+ let mp = (5 * doy + 2) / 153;
+ let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
+ let mo = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
+ let y = if mo <= 2 { y + 1 } else { y } as u32;
+
+ (y, mo, d, h, mi, se)
+}
+
+// ── Tests ─────────────────────────────────────────────────────────────────────
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn unix_to_utc_known_dates() {
+ // 2024-01-15 11:50:45 UTC = 1705319445
+ // Verified: python3 -c "import datetime; print(datetime.datetime.utcfromtimestamp(1705319445))"
+ let (y, mo, d, h, mi, se) = unix_to_utc_fields(1_705_319_445);
+ assert_eq!((y, mo, d), (2024, 1, 15));
+ assert_eq!((h, mi, se), (11, 50, 45));
+ }
+
+ #[test]
+ fn unix_to_utc_epoch() {
+ let (y, mo, d, h, mi, se) = unix_to_utc_fields(0);
+ assert_eq!((y, mo, d), (1970, 1, 1));
+ assert_eq!((h, mi, se), (0, 0, 0));
+ }
+
+ #[test]
+ fn unix_to_utc_leap_day() {
+ // 2024-02-29 00:00:00 UTC = 1709164800
+ let (y, mo, d, _h, _mi, _se) = unix_to_utc_fields(1_709_164_800);
+ assert_eq!((y, mo, d), (2024, 2, 29));
+ }
+
+ #[test]
+ fn build_report_contains_required_fields() {
+ let json = build_report("src/main.rs:42", "test panic");
+ assert!(json.contains("\"parsec_version\""), "version field");
+ assert!(json.contains("\"timestamp\""), "timestamp field");
+ assert!(json.contains("\"os\""), "os field");
+ assert!(json.contains("\"shell\""), "shell field");
+ assert!(json.contains("\"panic_location\""), "location field");
+ assert!(json.contains("\"panic_message\""), "message field");
+ assert!(json.contains("src/main.rs:42"), "location value");
+ assert!(json.contains("test panic"), "message value");
+ }
+
+ #[test]
+ fn build_report_escapes_special_chars() {
+ let json = build_report("file.rs:1", r#"something "quoted" and \slashed"#);
+ // Must be valid-ish JSON: no raw unescaped double-quotes in values.
+ assert!(json.contains(r#"\"quoted\""#), "quotes escaped: {json}");
+ assert!(json.contains(r"\\slashed"), "backslash escaped: {json}");
+ }
+
+ #[test]
+ fn setup_does_not_panic() {
+ // Calling setup a second time must not crash. (The previous hook is
+ // replaced; that's expected behaviour for our use-case.)
+ setup(false);
+ setup(false);
+ }
+}
diff --git a/tests/cli_tests.rs b/tests/cli_tests.rs
index 38db627..f827e6d 100644
--- a/tests/cli_tests.rs
+++ b/tests/cli_tests.rs
@@ -941,6 +941,117 @@ fn test_ship_dry_run() {
.stdout(predicate::str::contains("DRY-SHIP"));
}
+#[test]
+fn test_ship_no_cleanup_preserves_worktree() {
+ let (repo, _bare) = setup_repo_with_remote();
+ let repo_path = repo.path().to_str().unwrap();
+
+ parsec()
+ .args(["start", "NC-SHIP", "--repo", repo_path])
+ .assert()
+ .success();
+
+ // Read worktree path from state.
+ let state_path = repo.path().join(".parsec").join("state.json");
+ let state_contents = std::fs::read_to_string(&state_path).unwrap();
+ let state: serde_json::Value = serde_json::from_str(&state_contents).unwrap();
+ let wt_path = state["workspaces"]["NC-SHIP"]["path"]
+ .as_str()
+ .expect("state.json should contain path for NC-SHIP")
+ .to_owned();
+
+ // Commit something so git push has content to send.
+ std::fs::write(format!("{}/nc.txt", wt_path), "no-cleanup test").unwrap();
+ StdCommand::new("git")
+ .args(["add", "nc.txt"])
+ .current_dir(&wt_path)
+ .output()
+ .unwrap();
+ StdCommand::new("git")
+ .args(["commit", "-m", "nc commit"])
+ .current_dir(&wt_path)
+ .output()
+ .unwrap();
+
+ // ship --no-pr --no-cleanup: push branch, skip PR creation, skip worktree removal.
+ parsec()
+ .args([
+ "ship",
+ "NC-SHIP",
+ "--no-pr",
+ "--no-cleanup",
+ "--repo",
+ repo_path,
+ ])
+ .assert()
+ .success();
+
+ // Worktree must still be listed despite auto_cleanup=true default.
+ parsec()
+ .args(["list", "--repo", repo_path])
+ .assert()
+ .success()
+ .stdout(predicate::str::contains("NC-SHIP"));
+
+ // Worktree directory must physically exist.
+ assert!(
+ std::path::Path::new(&wt_path).exists(),
+ "worktree directory should still exist after --no-cleanup ship"
+ );
+}
+
+#[test]
+fn test_ship_auto_cleanup_removes_worktree() {
+ let (repo, _bare) = setup_repo_with_remote();
+ let repo_path = repo.path().to_str().unwrap();
+
+ parsec()
+ .args(["start", "AC-SHIP", "--repo", repo_path])
+ .assert()
+ .success();
+
+ // Read worktree path from state.
+ let state_path = repo.path().join(".parsec").join("state.json");
+ let state_contents = std::fs::read_to_string(&state_path).unwrap();
+ let state: serde_json::Value = serde_json::from_str(&state_contents).unwrap();
+ let wt_path = state["workspaces"]["AC-SHIP"]["path"]
+ .as_str()
+ .expect("state.json should contain path for AC-SHIP")
+ .to_owned();
+
+ // Commit something so git push has content to send.
+ std::fs::write(format!("{}/ac.txt", wt_path), "auto-cleanup test").unwrap();
+ StdCommand::new("git")
+ .args(["add", "ac.txt"])
+ .current_dir(&wt_path)
+ .output()
+ .unwrap();
+ StdCommand::new("git")
+ .args(["commit", "-m", "ac commit"])
+ .current_dir(&wt_path)
+ .output()
+ .unwrap();
+
+ // ship --no-pr without --no-cleanup: auto_cleanup=true (default) should remove the worktree.
+ parsec()
+ .args(["ship", "AC-SHIP", "--no-pr", "--repo", repo_path])
+ .assert()
+ .success();
+
+ // Worktree must no longer be listed.
+ parsec()
+ .args(["list", "--repo", repo_path])
+ .assert()
+ .success()
+ .stdout(predicate::str::contains("AC-SHIP").not());
+
+ // Worktree directory must be physically gone.
+ assert!(
+ !std::path::Path::new(&wt_path).exists(),
+ "worktree directory should be removed after auto_cleanup ship"
+ );
+}
+
// ---------------------------------------------------------------------------
// doctor
// ---------------------------------------------------------------------------
@@ -1509,6 +1620,16 @@ fn test_history_log_export_empty() {
// parsec smartlog / sl (issue #245, #305)
// ---------------------------------------------------------------------------
+#[test]
+fn test_smartlog_help_describes_shipped_overlay() {
+ parsec()
+ .args(["smartlog", "--help"])
+ .assert()
+ .success()
+ .stdout(predicate::str::contains("GitHub PR/CI/review state"))
+ .stdout(predicate::str::contains("in later releases").not());
+}
+
/// `parsec smartlog` in a repo with no active worktrees should exit 0 and
/// print the "No active worktrees" placeholder message.
#[test]
@@ -1799,6 +1920,38 @@ fn test_health_json_one_worktree() {
);
}
+/// A registry entry whose directory was removed externally must be reported
+/// as missing and make the aggregate health result unhealthy.
+#[test]
+fn test_health_json_flags_missing_worktree() {
+ let (repo, _bare) = setup_repo_with_remote();
+ let repo_path = repo.path().to_str().unwrap();
+
+ parsec()
+ .args(["start", "HL-MISSING", "--repo", repo_path])
+ .assert()
+ .success();
+
+ let repo_name = repo.path().file_name().unwrap().to_string_lossy();
+ let worktree_path = repo
+ .path()
+ .parent()
+ .unwrap()
+ .join(format!("{repo_name}.HL-MISSING"));
+ std::fs::remove_dir_all(&worktree_path).unwrap();
+
+ let output = parsec()
+ .args(["health", "--json", "--repo", repo_path])
+ .output()
+ .unwrap();
+ assert!(output.status.success());
+
+ let parsed: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
+ assert_eq!(parsed["all_healthy"], false);
+ assert_eq!(parsed["worktrees"][0]["ticket"], "HL-MISSING");
+ assert_eq!(parsed["worktrees"][0]["missing"], true);
+}
+
/// `parsec health` must exit 0 even when worktrees have issues — health is
/// informational and must not be used as a CI gate in Phase 1.
#[test]
@@ -2621,3 +2774,296 @@ fn test_smartlog_overlay_with_mock_github() {
"CI badge must show passed; got:\n{stdout}"
);
}
+// checkpoint — CLI integration tests (#435)
+// ---------------------------------------------------------------------------
+
+#[test]
+fn test_checkpoint_help_shows_subcommands() {
+ let repo = setup_repo();
+ parsec()
+ .args([
+ "checkpoint",
+ "--help",
+ "--repo",
+ repo.path().to_str().unwrap(),
+ ])
+ .assert()
+ .success()
+ .stdout(predicate::str::contains("create"))
+ .stdout(predicate::str::contains("list"))
+ .stdout(predicate::str::contains("restore"))
+ .stdout(predicate::str::contains("drop"));
+}
+
+#[test]
+fn test_checkpoint_list_empty() {
+ let repo = setup_repo();
+ parsec()
+ .args([
+ "checkpoint",
+ "list",
+ "--repo",
+ repo.path().to_str().unwrap(),
+ ])
+ .assert()
+ .success()
+ .stdout(predicate::str::contains("No checkpoints found"));
+}
+
+#[test]
+fn test_checkpoint_list_empty_json() {
+ let repo = setup_repo();
+ let out = parsec()
+ .args([
+ "--json",
+ "checkpoint",
+ "list",
+ "--repo",
+ repo.path().to_str().unwrap(),
+ ])
+ .output()
+ .unwrap();
+ assert!(out.status.success(), "expected success");
+ let stdout = String::from_utf8_lossy(&out.stdout);
+ assert_eq!(
+ stdout.trim(),
+ "[]",
+ "expected empty JSON array, got: {stdout}"
+ );
+}
+
+#[test]
+fn test_checkpoint_create_clean_tree() {
+ // A freshly-initialised repo with only an empty commit is clean —
+ // parsec should report nothing to checkpoint without error.
+ let repo = setup_repo();
+ parsec()
+ .args([
+ "checkpoint",
+ "create",
+ "--repo",
+ repo.path().to_str().unwrap(),
+ ])
+ .assert()
+ .success()
+ .stdout(predicate::str::contains("clean"));
+}
+
+#[test]
+fn test_checkpoint_create_with_changes() {
+ // Add an untracked file so the working tree is dirty, then create a checkpoint.
+ let repo = setup_repo();
+ let new_file = repo.path().join("work.txt");
+ std::fs::write(&new_file, "pending work").unwrap();
+
+ parsec()
+ .args([
+ "checkpoint",
+ "create",
+ "before-experiment",
+ "--repo",
+ repo.path().to_str().unwrap(),
+ ])
+ .assert()
+ .success()
+ .stdout(predicate::str::contains("before-experiment"));
+}
+
+#[test]
+fn test_checkpoint_create_then_list() {
+ // Create a checkpoint with a specific name and verify it appears in `list`.
+ let repo = setup_repo();
+ let new_file = repo.path().join("draft.rs");
+ std::fs::write(&new_file, "// wip").unwrap();
+
+ parsec()
+ .args([
+ "checkpoint",
+ "create",
+ "my-snapshot",
+ "--repo",
+ repo.path().to_str().unwrap(),
+ ])
+ .assert()
+ .success();
+
+ parsec()
+ .args([
+ "checkpoint",
+ "list",
+ "--repo",
+ repo.path().to_str().unwrap(),
+ ])
+ .assert()
+ .success()
+ .stdout(predicate::str::contains("my-snapshot"));
+}
+
+#[test]
+fn test_checkpoint_drop_removes_entry() {
+ // Create a checkpoint, then drop it; the list should return empty afterwards.
+ let repo = setup_repo();
+ let new_file = repo.path().join("temp.rs");
+ std::fs::write(&new_file, "temp").unwrap();
+
+ parsec()
+ .args([
+ "checkpoint",
+ "create",
+ "drop-me",
+ "--repo",
+ repo.path().to_str().unwrap(),
+ ])
+ .assert()
+ .success();
+
+ // Sanity: appears in list before drop.
+ parsec()
+ .args([
+ "checkpoint",
+ "list",
+ "--repo",
+ repo.path().to_str().unwrap(),
+ ])
+ .assert()
+ .success()
+ .stdout(predicate::str::contains("drop-me"));
+
+ // Drop it.
+ parsec()
+ .args([
+ "checkpoint",
+ "drop",
+ "drop-me",
+ "--repo",
+ repo.path().to_str().unwrap(),
+ ])
+ .assert()
+ .success()
+ .stdout(predicate::str::contains("drop-me"));
+
+ // Now list should be empty again.
+ parsec()
+ .args([
+ "checkpoint",
+ "list",
+ "--repo",
+ repo.path().to_str().unwrap(),
+ ])
+ .assert()
+ .success()
+ .stdout(predicate::str::contains("No checkpoints found"));
+}
+
+#[test]
+fn test_checkpoint_name_with_colon_rejected() {
+ // Names containing ':' collide with the internal prefix delimiter.
+ let repo = setup_repo();
+ let new_file = repo.path().join("dirty.txt");
+ std::fs::write(&new_file, "x").unwrap();
+
+ let out = parsec()
+ .args([
+ "checkpoint",
+ "create",
+ "bad:name",
+ "--repo",
+ repo.path().to_str().unwrap(),
+ ])
+ .output()
+ .unwrap();
+ assert!(!out.status.success(), "expected non-zero exit for bad name");
+}
+
+// ---------------------------------------------------------------------------
+// crash-report — CLI integration tests (#435)
+// ---------------------------------------------------------------------------
+
+#[test]
+fn test_crash_report_help_shows_subcommands() {
+ parsec()
+ .args(["crash-report", "--help"])
+ .assert()
+ .success()
+ .stdout(predicate::str::contains("list"))
+ .stdout(predicate::str::contains("show"))
+ .stdout(predicate::str::contains("clear"));
+}
+
+#[test]
+fn test_crash_report_list_no_reports() {
+ // Use a temp cache dir so we never see real crash reports from the host.
+ let tmp = tempfile::tempdir().unwrap();
+ parsec()
+ .env("XDG_CACHE_HOME", tmp.path().to_str().unwrap())
+ .args(["crash-report", "list"])
+ .assert()
+ .success()
+ .stdout(predicate::str::contains("No crash reports found"));
+}
+
+#[test]
+fn test_crash_report_list_json_no_reports() {
+ let tmp = tempfile::tempdir().unwrap();
+ let out = parsec()
+ .env("XDG_CACHE_HOME", tmp.path().to_str().unwrap())
+ .args(["--json", "crash-report", "list"])
+ .output()
+ .unwrap();
+ assert!(out.status.success(), "expected success");
+ let stdout = String::from_utf8_lossy(&out.stdout);
+ assert_eq!(
+ stdout.trim(),
+ "[]",
+ "expected empty JSON array, got: {stdout}"
+ );
+}
+
+// ---------------------------------------------------------------------------
+// triage — Phase 2 CLI safety tests (#302)
+// ---------------------------------------------------------------------------
+
+#[test]
+fn test_triage_help_shows_apply() {
+ parsec()
+ .args(["triage", "--help"])
+ .assert()
+ .success()
+ .stdout(predicate::str::contains("--apply"))
+ .stdout(predicate::str::contains("dry-run"));
+}
+
+#[test]
+fn test_triage_apply_rejected_offline() {
+ let repo = setup_repo();
+ parsec()
+ .args([
+ "--offline",
+ "--repo",
+ repo.path().to_str().unwrap(),
+ "triage",
+ "--apply",
+ ])
+ .assert()
+ .failure()
+ .stderr(predicate::str::contains(
+ "cannot use --apply in offline mode",
+ ));
+}
+
+#[test]
+fn test_triage_global_dry_run_overrides_apply() {
+ let repo = setup_repo();
+ parsec()
+ .args([
+ "--offline",
+ "--dry-run",
+ "--repo",
+ repo.path().to_str().unwrap(),
+ "triage",
+ "--apply",
+ ])
+ .assert()
+ .success()
+ .stdout(predicate::str::contains("No triage rules configured"));
+}