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")); +}