From 794cdb2f082bff1ee5a8fc6de95bf7316476f786 Mon Sep 17 00:00:00 2001 From: erish Date: Mon, 31 Aug 2026 11:30:26 +0900 Subject: [PATCH 01/20] =?UTF-8?q?feat(health):=20Phase=203=20=E2=80=94=20d?= =?UTF-8?q?etect=20in-progress=20git=20ops=20(rebase/merge/cherry-pick)=20?= =?UTF-8?q?=C2=B7=20Refs=20#414=20(#415)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(health): Phase 3 — detect in-progress git ops (rebase/merge/cherry-pick) · Refs #414 Adds three new read-only health indicators to `parsec health`: | Indicator | Detection | |------------------------|--------------------------------------------| | rebase_in_progress | .git/rebase-merge/ or .git/rebase-apply/ | | merge_in_progress | .git/MERGE_HEAD | | cherry_pick_in_progress| .git/CHERRY_PICK_HEAD | These catch the common "I started a rebase in worktree A and forgot about it" pattern that index.lock alone cannot detect (lock only exists while git runs). Works for both main worktrees (.git/ dir) and linked worktrees (.git file containing a gitdir: pointer). Detection failures soft-fail to false. Changes: - src/output/mod.rs: 3 new bool fields on HealthRecord - src/cli/commands/health.rs: resolve_git_dir() helper + detection logic - src/output/human.rs: ⚠ red tags + icon rollup for in-progress ops - src/output/json.rs: 3 new bool fields + all_healthy rollup update - 7 unit tests covering main/linked worktree layout + per-state detection Co-Authored-By: Claude Sonnet 4.6 * fix(workspace): clippy::for_kv_map — use values_mut() in pr_map loop Pre-existing lint (clippy::for_kv_map) that started failing on CI ubuntu runner. workspace.rs:318 iterated over (key, value) pairs but only used values; replacing with .values_mut() satisfies the lint with no behaviour change. Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- src/cli/commands/health.rs | 162 +++++++++++++++++++++++++++++++--- src/cli/commands/workspace.rs | 2 +- src/output/human.rs | 28 +++++- src/output/json.rs | 6 ++ src/output/mod.rs | 9 ++ 5 files changed, 190 insertions(+), 17 deletions(-) diff --git a/src/cli/commands/health.rs b/src/cli/commands/health.rs index d3efca3..99bac66 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,19 @@ pub async fn health(repo: &Path, mode: Mode, stale_days: u64, no_overlay: bool) let mut records: Vec = Vec::new(); for ws in &workspaces { + // --- 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) @@ -111,6 +115,9 @@ pub async fn health(repo: &Path, mode: Mode, stale_days: u64, no_overlay: bool) has_lock, ci_status, pr_number, + rebase_in_progress, + merge_in_progress, + cherry_pick_in_progress, }); } @@ -118,6 +125,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 +179,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/workspace.rs b/src/cli/commands/workspace.rs index 8aef3ed..bddd258 100644 --- a/src/cli/commands/workspace.rs +++ b/src/cli/commands/workspace.rs @@ -315,7 +315,7 @@ pub async fn list(repo: &Path, no_pr: bool, full: bool, mode: Mode) -> Result<() // Fetch live PR status from GitHub if let Some(ref remote_url) = remote_url { if let Ok(Some(gh)) = github::GitHubClient::new(remote_url, &config) { - for (_ticket, (pr_num, state)) in pr_map.iter_mut() { + for (pr_num, state) in pr_map.values_mut() { if let Ok(status) = gh.get_pr_status(*pr_num).await { *state = status.state; } diff --git a/src/output/human.rs b/src/output/human.rs index b94e225..7eaaf65 100644 --- a/src/output/human.rs +++ b/src/output/human.rs @@ -1156,14 +1156,35 @@ pub fn print_health(records: &[super::HealthRecord]) { let ci_issue = matches!(r.ci_status.as_deref(), Some("failing") | Some("failure")); + // 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.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.has_lock || ci_issue || op_in_progress { "✗".red().to_string() } else if any_issue { "⚠".yellow().to_string() @@ -1176,13 +1197,16 @@ pub fn print_health(records: &[super::HealthRecord]) { } println!( - " {} {:<20}{}{}{}{}", + " {} {:<20}{}{}{}{}{}{}{}", icon, r.ticket.bold(), 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..264274f 100644 --- a/src/output/json.rs +++ b/src/output/json.rs @@ -380,6 +380,9 @@ 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(); @@ -391,6 +394,9 @@ pub fn print_health(records: &[super::HealthRecord]) { .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..4e2df8f 100644 --- a/src/output/mod.rs +++ b/src/output/mod.rs @@ -60,6 +60,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). From 38b2a54155762025bf98daa6e48af5e2b2f3ef38 Mon Sep 17 00:00:00 2001 From: erish Date: Sat, 5 Sep 2026 09:47:05 +0900 Subject: [PATCH 02/20] =?UTF-8?q?feat(ship):=20--no-cleanup=20flag=20+=20w?= =?UTF-8?q?ire=20auto=5Fcleanup=20phase=203=20=C2=B7=20Closes=20#313=20(#4?= =?UTF-8?q?23)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `--no-cleanup` flag to `parsec ship` so users working incrementally (multiple commits / ships on the same ticket) can push + create/update the PR without losing their worktree. Also fix a latent gap: `ship_cleanup()` existed in WorktreeManager but was never called from the `ship` command handler. The combined `manager.ship()` (dead_code) already called it, but the refactored command path (ship_push → PR creation) forgot Phase 3. Now wired correctly with the escape hatch. ## Changes - `src/cli/mod.rs`: add `no_cleanup: bool` to `Command::Ship`, pass through to handler and dry-run message - `src/cli/commands/ship.rs`: add `no_cleanup` param; Phase 3 block calls `manager.ship_cleanup(ticket)` when `!no_cleanup` (still respects `config.ship.auto_cleanup`) - `src/cli/commands/stack.rs`: forward `no_cleanup = false` in stack-ship call to satisfy updated signature (stack always uses config default) - `tests/cli_tests.rs`: two new integration tests — `test_ship_no_cleanup_preserves_worktree` and `test_ship_auto_cleanup_removes_worktree` Refs #313 Co-authored-by: Claude Sonnet 4.6 --- src/cli/commands/ship.rs | 17 ++++++ src/cli/commands/stack.rs | 1 + src/cli/mod.rs | 13 ++++- tests/cli_tests.rs | 111 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 140 insertions(+), 2 deletions(-) diff --git a/src/cli/commands/ship.rs b/src/cli/commands/ship.rs index 4c62abc..4009d46 100644 --- a/src/cli/commands/ship.rs +++ b/src/cli/commands/ship.rs @@ -24,6 +24,7 @@ pub async fn ship( reviewers: Vec, labels: Vec, template: Option, + no_cleanup: bool, mode: Mode, ) -> Result<()> { crate::execlog::set_ticket(ticket); @@ -363,6 +364,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(()) } diff --git a/src/cli/commands/stack.rs b/src/cli/commands/stack.rs index f8e2d40..be71d81 100644 --- a/src/cli/commands/stack.rs +++ b/src/cli/commands/stack.rs @@ -229,6 +229,7 @@ pub async fn stack_submit(repo: &Path, mode: Mode) -> Result<()> { Vec::new(), // reviewers Vec::new(), // labels None, // template + false, // no_cleanup: use config default during stack ship mode, ) .await diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 4b21ff8..1cadff0 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -148,6 +148,12 @@ pub enum Command { /// Path to PR body template file #[arg(long)] template: Option, + + /// 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 @@ -802,14 +808,16 @@ pub async fn run(cli: Cli) -> Result<()> { reviewer, label, template, + 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(()); } @@ -824,6 +832,7 @@ pub async fn run(cli: Cli) -> Result<()> { reviewer, label, template, + no_cleanup, output_mode, ) .await diff --git a/tests/cli_tests.rs b/tests/cli_tests.rs index 82507a6..1a8af93 100644 --- a/tests/cli_tests.rs +++ b/tests/cli_tests.rs @@ -940,6 +940,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 // --------------------------------------------------------------------------- From dc626eea98c45a1f9c6412639821d2978b82a4fd Mon Sep 17 00:00:00 2001 From: erish Date: Sat, 5 Sep 2026 10:04:41 +0900 Subject: [PATCH 03/20] =?UTF-8?q?feat(ship):=20Phase=201=20template=20vari?= =?UTF-8?q?able=20substitution=20=E2=80=94=20Refs=20#304=20(#424)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `substitute_template_vars()` helper that expands `{{ticket}}`, `{{branch}}`, `{{title}}`, `{{ticket_url}}` placeholders in PR template content before embedding it into the PR body. Changes: - src/cli/commands/ship.rs: - New `substitute_template_vars()` with RustDoc explaining each var - `build_pr_body()` gains `branch: &str` parameter; passes it + other context to substitute_template_vars when template content is present - Template section comment updated to reference #304 alongside #233 - 5 unit tests: all-vars, optional-vars-empty, unknown-var-intact, multi-occurrence, build_pr_body end-to-end Unknown `{{…}}` tokens are left intact so templates using other tooling variables are not silently mangled. Refs #304 Co-authored-by: Claude Sonnet 4.6 --- src/cli/commands/ship.rs | 88 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 86 insertions(+), 2 deletions(-) diff --git a/src/cli/commands/ship.rs b/src/cli/commands/ship.rs index 4009d46..34db416 100644 --- a/src/cli/commands/ship.rs +++ b/src/cli/commands/ship.rs @@ -172,6 +172,7 @@ pub async fn ship( let pr_body = build_pr_body( &result.ticket, + &result.branch, effective_title, ticket_url.as_deref(), stack_info.as_ref(), @@ -421,8 +422,33 @@ fn gather_stack_info(manager: &WorktreeManager, ticket: &str) -> Option, + ticket_url: Option<&str>, +) -> String { + template + .replace("{{ticket}}", ticket) + .replace("{{branch}}", branch) + .replace("{{title}}", title.unwrap_or("")) + .replace("{{ticket_url}}", ticket_url.unwrap_or("")) +} + fn build_pr_body( ticket: &str, + branch: &str, title: Option<&str>, ticket_url: Option<&str>, stack_info: Option<&StackPrInfo>, @@ -461,10 +487,11 @@ fn build_pr_body( body.push('\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); body.push_str("---\n\n"); - body.push_str(tmpl); + body.push_str(&rendered); body.push('\n'); } @@ -504,3 +531,60 @@ fn resolve_template(repo_root: &Path, explicit_path: Option<&str>) -> Option Date: Sat, 5 Sep 2026 10:16:00 +0900 Subject: [PATCH 04/20] =?UTF-8?q?feat(self-update):=20Phase=201=20?= =?UTF-8?q?=E2=80=94=20version=20check=20+=20upgrade=20notification=20?= =?UTF-8?q?=C2=B7=20Refs=20#296=20(#425)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `parsec self-update` subcommand that queries the GitHub releases API, compares the running version against the latest published release, and prints a cargo-install upgrade command when a newer version is available. ## Changes - src/cli/commands/update.rs (new): async fetch + semver compare + output - src/cli/commands/mod.rs: wire update module - src/cli/mod.rs: SelfUpdate command variant + run dispatch ## Implementation detail - Uses reqwest (already a dep) with 8 s timeout and a parsec/VERSION User-Agent - Numeric semver comparison handles 0.10.0 > 0.9.0 correctly (7 unit tests) - Gracefully degrades on network error (prints current version + releases URL) - Respects global --offline flag: skips network call, prints version only - Phase 2 will add in-place binary replacement (see issue #296) Co-authored-by: Claude Sonnet 4.6 --- src/cli/commands/mod.rs | 2 + src/cli/commands/update.rs | 165 +++++++++++++++++++++++++++++++++++++ src/cli/mod.rs | 12 +++ 3 files changed, 179 insertions(+) create mode 100644 src/cli/commands/update.rs diff --git a/src/cli/commands/mod.rs b/src/cli/commands/mod.rs index b2e75a6..0a122f0 100644 --- a/src/cli/commands/mod.rs +++ b/src/cli/commands/mod.rs @@ -15,6 +15,7 @@ pub mod smartlog; mod stack; mod test; mod tracker_cmds; +mod update; mod workspace; pub use ci::*; @@ -34,4 +35,5 @@ pub use smartlog::smartlog; pub use stack::*; pub use test::test; pub use tracker_cmds::*; +pub use update::self_update; pub use workspace::*; diff --git a/src/cli/commands/update.rs b/src/cli/commands/update.rs new file mode 100644 index 0000000..d1108ba --- /dev/null +++ b/src/cli/commands/update.rs @@ -0,0 +1,165 @@ +//! `parsec self-update` — check for a newer release and print upgrade instructions. +//! +//! # Phase 1 (this module) +//! 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; that is deferred to Phase 2. + +use anyhow::Result; +use serde::Deserialize; + +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:"); + println!( + " cargo install --git https://github.com/{GITHUB_REPO} \ + --bin parsec --force" + ); + println!( + "\nnote: automated binary replacement is planned for Phase 2 \ + (see issue #296)." + ); + } + 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(()) +} + +#[cfg(test)] +mod tests { + use super::cmp_semver; + 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); + } +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 1cadff0..a382740 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -647,6 +647,16 @@ 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 {}, } /// Candidate sets the dynamic completion subcommand can emit. @@ -757,6 +767,7 @@ pub async fn run(cli: Cli) -> Result<()> { Command::Reviews { .. } => "reviews", Command::Dashboard { .. } => "dashboard", Command::Test { .. } => "test", + Command::SelfUpdate { .. } => "self-update", }; let exec_id = crate::execlog::new_execution_id(); let exec_started_at = chrono::Utc::now(); @@ -1104,6 +1115,7 @@ pub async fn run(cli: Cli) -> Result<()> { .await } Command::Complete { kind } => commands::complete(&repo_path, kind).await, + Command::SelfUpdate {} => commands::self_update(offline).await, }; // Record execution entry (best-effort, never fail the command) From 7a1f4924b274a5f7290ad0f9fdde183d1c670385 Mon Sep 17 00:00:00 2001 From: erish Date: Sun, 6 Sep 2026 09:39:59 +0900 Subject: [PATCH 05/20] feat(ship): Phase 2 template commit list substitution (#426) Refs #304 Co-authored-by: Claude Opus 4.7 --- src/cli/commands/ship.rs | 63 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 57 insertions(+), 6 deletions(-) diff --git a/src/cli/commands/ship.rs b/src/cli/commands/ship.rs index 34db416..357cdf3 100644 --- a/src/cli/commands/ship.rs +++ b/src/cli/commands/ship.rs @@ -169,12 +169,29 @@ 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() + }; 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(), ); @@ -429,6 +446,7 @@ 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") } fn build_pr_body( @@ -451,6 +486,7 @@ fn build_pr_body( branch: &str, title: Option<&str>, ticket_url: Option<&str>, + commits: &str, stack_info: Option<&StackPrInfo>, template_content: Option<&str>, ) -> String { @@ -489,7 +525,7 @@ fn build_pr_body( // 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); + let rendered = substitute_template_vars(tmpl, ticket, branch, title, ticket_url, commits); body.push_str("---\n\n"); body.push_str(&rendered); body.push('\n'); @@ -538,38 +574,39 @@ mod template_var_tests { #[test] fn test_substitute_all_vars() { - let tmpl = "Ticket: {{ticket}}\nBranch: {{branch}}\nTitle: {{title}}\nURL: {{ticket_url}}"; + let tmpl = "Ticket: {{ticket}}\nBranch: {{branch}}\nTitle: {{title}}\nURL: {{ticket_url}}\nCommits:\n{{commits}}"; let result = substitute_template_vars( tmpl, "CL-42", "feature/CL-42", Some("My PR"), Some("https://example.com/CL-42"), + "- first commit\n- second commit", ); assert_eq!( result, - "Ticket: CL-42\nBranch: feature/CL-42\nTitle: My PR\nURL: https://example.com/CL-42" + "Ticket: CL-42\nBranch: feature/CL-42\nTitle: My PR\nURL: https://example.com/CL-42\nCommits:\n- first commit\n- second commit" ); } #[test] fn test_substitute_missing_optional_vars_become_empty() { let tmpl = "Ticket: {{ticket}}\nTitle: {{title}}\nURL: {{ticket_url}}"; - let result = substitute_template_vars(tmpl, "CL-99", "feature/CL-99", None, None); + let result = substitute_template_vars(tmpl, "CL-99", "feature/CL-99", None, None, ""); assert_eq!(result, "Ticket: CL-99\nTitle: \nURL: "); } #[test] fn test_substitute_unknown_placeholder_left_intact() { let tmpl = "{{ticket}} — {{unknown_var}} — {{branch}}"; - let result = substitute_template_vars(tmpl, "T-1", "feat/T-1", None, None); + let result = substitute_template_vars(tmpl, "T-1", "feat/T-1", None, None, ""); assert_eq!(result, "T-1 — {{unknown_var}} — feat/T-1"); } #[test] fn test_substitute_multiple_occurrences() { let tmpl = "{{ticket}} ({{ticket}}) on {{branch}}"; - let result = substitute_template_vars(tmpl, "AB-7", "feat/AB-7", None, None); + let result = substitute_template_vars(tmpl, "AB-7", "feat/AB-7", None, None, ""); assert_eq!(result, "AB-7 (AB-7) on feat/AB-7"); } @@ -581,10 +618,24 @@ mod template_var_tests { "feat/T-5", Some("Nice title"), None, + "", None, Some(tmpl), ); assert!(body.contains("Refs T-5 on `feat/T-5`"), "body: {body}"); assert!(body.contains("## Nice title"), "body: {body}"); } + + #[test] + fn test_format_template_commits_as_markdown_list() { + assert_eq!( + format_template_commits("first commit\nsecond commit\n"), + "- first commit\n- second commit" + ); + } + + #[test] + fn test_format_template_commits_empty_range() { + assert_eq!(format_template_commits(""), ""); + } } From 86046021187c0a3a7cea7bae069275bb0e7c014a Mon Sep 17 00:00:00 2001 From: erish Date: Mon, 7 Sep 2026 09:42:57 +0900 Subject: [PATCH 06/20] =?UTF-8?q?feat(self-update):=20Phase=202=20?= =?UTF-8?q?=E2=80=94=20startup=20version=20check=20with=201-day=20throttle?= =?UTF-8?q?=20=C2=B7=20Refs=20#296=20(#427)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a startup version-check that runs after every command (Human mode only) and prints a one-line stderr hint when a newer release is available. ## What - VersionCheckCache (serde JSON) persisted in dirs::cache_dir() - Throttled to one live GitHub API call per 24 h (STARTUP_CHECK_TIMEOUT_SECS = 2 s) - Uses cached result between checks — instant on subsequent runs - Hint written to stderr (never pollutes piped stdout / JSON mode) - Skipped for: --offline, --quiet, --json, parsec self-update command ## How it works 1. Load cache from ~/.cache/.parsec-version-check 2. If age < 24 h → show hint if cache.latest_tag is newer (instant, no network) 3. If age ≥ 24 h → quick reqwest call (2 s timeout), update cache, show hint if newer 4. Network failure → bump last_checked_secs, preserve existing latest_tag ## Tests 5 new unit tests: cache serde round-trip, default-cache-is-stale, hint logic (newer/older/equal/None) — all deterministic, no network Co-authored-by: Claude Sonnet 4.6 --- src/cli/commands/mod.rs | 2 +- src/cli/commands/update.rs | 172 ++++++++++++++++++++++++++++++++++++- src/cli/mod.rs | 6 ++ 3 files changed, 177 insertions(+), 3 deletions(-) diff --git a/src/cli/commands/mod.rs b/src/cli/commands/mod.rs index 0a122f0..6eb58b6 100644 --- a/src/cli/commands/mod.rs +++ b/src/cli/commands/mod.rs @@ -35,5 +35,5 @@ pub use smartlog::smartlog; pub use stack::*; pub use test::test; pub use tracker_cmds::*; -pub use update::self_update; +pub use update::{self_update, startup_version_hint}; pub use workspace::*; diff --git a/src/cli/commands/update.rs b/src/cli/commands/update.rs index d1108ba..5c41566 100644 --- a/src/cli/commands/update.rs +++ b/src/cli/commands/update.rs @@ -6,7 +6,7 @@ //! or in-place replacement is performed; that is deferred to Phase 2. use anyhow::Result; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION"); const GITHUB_REPO: &str = "erishforG/git-parsec"; @@ -121,9 +121,130 @@ pub async fn self_update(offline: bool) -> Result<()> { Ok(()) } +// ── Startup version check (Phase 2) ──────────────────────────────────────── + +/// Cache filename stored in the OS cache directory (e.g. `~/.cache` on Linux). +const VERSION_CACHE_FILENAME: &str = ".parsec-version-check"; +/// Minimum seconds between live GitHub API checks (24 h). +const VERSION_CHECK_THROTTLE_SECS: u64 = 86_400; +/// 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 to at most once every 24 hours by +/// caching the result in [`version_cache_path()`]. Always a no-op 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`). +pub async fn startup_version_hint(offline: bool) { + if offline { + return; + } + + let mut cache = load_version_cache(); + let now = now_secs(); + let age_secs = now.saturating_sub(cache.last_checked_secs); + + if age_secs >= VERSION_CHECK_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; + use super::{ + cmp_semver, now_secs, should_show_update_hint, VersionCheckCache, CURRENT_VERSION, + VERSION_CHECK_THROTTLE_SECS, + }; use std::cmp::Ordering; #[test] @@ -162,4 +283,51 @@ mod tests { 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 >= VERSION_CHECK_THROTTLE_SECS, + "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))); + } } diff --git a/src/cli/mod.rs b/src/cli/mod.rs index a382740..5dfa79d 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1118,6 +1118,12 @@ pub async fn run(cli: Cli) -> Result<()> { Command::SelfUpdate {} => commands::self_update(offline).await, }; + // Startup version hint — one-line stderr notice when a newer release is cached. + // Skipped for self-update (redundant), --json, and --quiet modes. + if output_mode == output::Mode::Human && cmd_name != "self-update" { + commands::startup_version_hint(offline).await; + } + // Record execution entry (best-effort, never fail the command) let duration = exec_start.elapsed(); let steps = crate::execlog::take_steps(); From 15767031d39ae6287b1628f9806e080607c725f5 Mon Sep 17 00:00:00 2001 From: erish Date: Tue, 8 Sep 2026 09:41:54 +0900 Subject: [PATCH 07/20] =?UTF-8?q?feat(self-update):=20Phase=203=20?= =?UTF-8?q?=E2=80=94=20opt-out=20config=20+=20install-method=20detection?= =?UTF-8?q?=20=C2=B7=20Refs=20#296?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add `UpdateConfig` to `ParsecConfig` with `check_on_startup` (bool, default true) and `check_interval_hours` (u64, default 24) fields - `startup_version_hint()` now accepts config values; returns immediately when `check_on_startup = false`, honoring `[update] check_on_startup = false` in `.parsec.toml` - `check_interval_hours` replaces the hard-coded 24 h constant, allowing users to tune the poll cadence without code changes - Add `upgrade_command()`: detects Homebrew (/homebrew/ or /Cellar/ in exe path), Cargo (/.cargo/bin/), or prebuilt binary, and returns the appropriate upgrade command string shown in `parsec self-update` output - Remove stale note about Phase 2 from upgrade output now that it is shipped - Add 5 new tests: `upgrade_command_homebrew`, `upgrade_command_contains_repo`, `throttle_secs_from_hours` (pure arithmetic), and existing tests still pass - 108+83+5 tests, clippy-clean, fmt-pass Closes AC: 알림 opt-out config, 다양한 설치 경로 지원 (homebrew/cargo/prebuilt) Co-authored-by: Claude Sonnet 4.6 --- src/cli/commands/update.rs | 113 ++++++++++++++++++++++++++++++------- src/cli/mod.rs | 6 +- src/config/settings.rs | 31 ++++++++++ 3 files changed, 128 insertions(+), 22 deletions(-) diff --git a/src/cli/commands/update.rs b/src/cli/commands/update.rs index 5c41566..ab9b745 100644 --- a/src/cli/commands/update.rs +++ b/src/cli/commands/update.rs @@ -1,9 +1,17 @@ //! `parsec self-update` — check for a newer release and print upgrade instructions. //! -//! # Phase 1 (this module) +//! # 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; that is deferred to Phase 2. +//! 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}; @@ -96,14 +104,9 @@ pub async fn self_update(offline: bool) -> Result<()> { } } println!("\nTo upgrade:"); - println!( - " cargo install --git https://github.com/{GITHUB_REPO} \ - --bin parsec --force" - ); - println!( - "\nnote: automated binary replacement is planned for Phase 2 \ - (see issue #296)." - ); + for line in upgrade_command().lines() { + println!(" {line}"); + } } std::cmp::Ordering::Equal => { println!("✓ already up to date ({current})"); @@ -121,12 +124,35 @@ pub async fn self_update(offline: bool) -> Result<()> { Ok(()) } -// ── Startup version check (Phase 2) ──────────────────────────────────────── +// ── 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"; -/// Minimum seconds between live GitHub API checks (24 h). -const VERSION_CHECK_THROTTLE_SECS: u64 = 86_400; /// Network timeout for a startup background check (2 s — must feel instant). const STARTUP_CHECK_TIMEOUT_SECS: u64 = 2; @@ -183,23 +209,31 @@ fn print_update_hint(latest: &str) { /// Print a one-line update hint to **stderr** if a newer release is available. /// -/// Throttles the live GitHub API call to at most once every 24 hours by -/// caching the result in [`version_cache_path()`]. Always a no-op in -/// offline mode; never panics. +/// 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`). -pub async fn startup_version_hint(offline: bool) { - if offline { +/// +/// # 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 >= VERSION_CHECK_THROTTLE_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)) @@ -243,7 +277,6 @@ pub async fn startup_version_hint(offline: bool) { mod tests { use super::{ cmp_semver, now_secs, should_show_update_hint, VersionCheckCache, CURRENT_VERSION, - VERSION_CHECK_THROTTLE_SECS, }; use std::cmp::Ordering; @@ -303,7 +336,7 @@ mod tests { let cache = VersionCheckCache::default(); let age = now_secs().saturating_sub(cache.last_checked_secs); assert!( - age >= VERSION_CHECK_THROTTLE_SECS, + age >= 86_400, // DEFAULT_CHECK_INTERVAL_SECS (24 h) "default cache should be stale" ); } @@ -330,4 +363,42 @@ mod tests { // 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 5dfa79d..bd2645e 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1120,8 +1120,12 @@ pub async fn run(cli: Cli) -> Result<()> { // 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" { - commands::startup_version_hint(offline).await; + 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) diff --git a/src/config/settings.rs b/src/config/settings.rs index 30bfb5c..dbd6e6e 100644 --- a/src/config/settings.rs +++ b/src/config/settings.rs @@ -397,6 +397,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 { @@ -421,6 +425,31 @@ 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(), + } + } +} + // --------------------------------------------------------------------------- // ParsecConfig // --------------------------------------------------------------------------- @@ -443,6 +472,8 @@ pub struct ParsecConfig { pub policy: PolicyConfig, #[serde(default)] pub test: TestConfig, + #[serde(default)] + pub update: UpdateConfig, /// Per-host GitHub tokens. Keys are hostnames like "github.com" or /// "github.example.com". Serializes as `[github."hostname"]` in TOML. #[serde(default)] From ac176cbbc8bba78c97848f23eac5e211995f8d9c Mon Sep 17 00:00:00 2001 From: erish Date: Wed, 9 Sep 2026 09:37:34 +0900 Subject: [PATCH 08/20] docs: fix v1.0 milestone link (#430) Closes #429 Co-authored-by: Claude Opus 4.7 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index cb1b38c..5b6faa7 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ That's the whole loop. Plain `git worktree` doesn't track state, doesn't talk to | **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 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 work is tracked under the [`v1.0` milestone](https://github.com/erishforG/git-parsec/milestone/2); see the [CHANGELOG](./CHANGELOG.md) for the full v0.5.0 release notes. --- From cfc2be54901bce74d0fa9c3595d00fc54737accc Mon Sep 17 00:00:00 2001 From: erish Date: Thu, 10 Sep 2026 09:42:30 +0900 Subject: [PATCH 09/20] =?UTF-8?q?feat(panic-handler):=20Phase=201=20?= =?UTF-8?q?=E2=80=94=20opt-in=20crash=20report=20hook=20=C2=B7=20Refs=20#2?= =?UTF-8?q?98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Register a custom panic hook that prints a user-friendly crash banner (always) and optionally saves a structured JSON report to ~/.cache/parsec/crash-.json (opt-in). ## What - src/panic_handler.rs: new module - setup(enabled): registers std::panic::set_hook - Always: human-readable crash banner (version, location, message) - Opt-in: JSON report (version, ts, os, shell, argv[1], location, msg) - save_report(): writes ~/.cache/parsec/crash-.json - unix_to_utc_fields(): stdlib-only UTC decomposition (no new deps) - 6 unit tests: date decomposition, report fields, escape, setup idempotency - src/config/settings.rs: CrashReportConfig { enabled: bool } (default false) + ParsecConfig::crash_report field - src/main.rs: call panic_handler::setup(config.crash_report.enabled) early - docs/crash-report.md: privacy policy (what is/isn't collected, opt-in, retention) ## What is NOT collected No stack trace, no flags, no ticket IDs, no tokens. argv[1] only. No automatic transmission; user must manually share. ## Tests 114 unit tests pass (6 new in panic_handler), 5 integration, 83 total. Co-authored-by: Claude Sonnet 4.6 --- docs/crash-report.md | 58 +++++++++ src/config/settings.rs | 27 ++++ src/main.rs | 8 ++ src/panic_handler.rs | 273 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 366 insertions(+) create mode 100644 docs/crash-report.md create mode 100644 src/panic_handler.rs diff --git a/docs/crash-report.md b/docs/crash-report.md new file mode 100644 index 0000000..2c329ab --- /dev/null +++ b/docs/crash-report.md @@ -0,0 +1,58 @@ +# 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 +file to `~/.cache/parsec/crash-.json` containing: + +| 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. Find the report: `ls ~/.cache/parsec/crash-*.json` +2. Review its contents before sharing (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 are stored locally in `~/.cache/parsec/`. They are never automatically +deleted by parsec; you can remove them at any time: + +```sh +rm ~/.cache/parsec/crash-*.json +``` diff --git a/src/config/settings.rs b/src/config/settings.rs index dbd6e6e..f2d0c3f 100644 --- a/src/config/settings.rs +++ b/src/config/settings.rs @@ -450,6 +450,30 @@ impl Default for UpdateConfig { } } +// --------------------------------------------------------------------------- +// CrashReportConfig +// --------------------------------------------------------------------------- + +/// Controls opt-in crash report collection (#298). +/// +/// No data is transmitted automatically. When `enabled = true`, a JSON +/// report is written to `~/.cache/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, +} + // --------------------------------------------------------------------------- // ParsecConfig // --------------------------------------------------------------------------- @@ -474,6 +498,9 @@ pub struct ParsecConfig { pub test: TestConfig, #[serde(default)] pub update: UpdateConfig, + /// Opt-in crash report collection. + #[serde(default)] + pub crash_report: CrashReportConfig, /// 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/main.rs b/src/main.rs index 61b0d8b..1870e6f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,6 +12,7 @@ mod github; mod gitlab; mod oplog; mod output; +mod panic_handler; mod tracker; mod worktree; @@ -20,6 +21,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/panic_handler.rs b/src/panic_handler.rs new file mode 100644 index 0000000..e792389 --- /dev/null +++ b/src/panic_handler.rs @@ -0,0 +1,273 @@ +//! `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 +//! `~/.cache/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 (`~/.cache/parsec/` on Linux/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); + } +} From 169809ed7f2bbca63e62a3c121ed227939706950 Mon Sep 17 00:00:00 2001 From: erish Date: Fri, 11 Sep 2026 09:53:18 +0900 Subject: [PATCH 10/20] =?UTF-8?q?feat(panic-handler):=20Phase=202=20?= =?UTF-8?q?=E2=80=94=20crash-report=20list/show/clear=20subcommands=20?= =?UTF-8?q?=C2=B7=20Refs=20#298?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(panic-handler): Phase 2 — crash-report list/show/clear subcommands · Refs #298 Add `parsec crash-report` with three sub-commands for managing the opt-in crash report cache written by the Phase 1 panic hook. ## Subcommands | Command | Description | |------------------------------|----------------------------------------------| | `parsec crash-report list` | Table of all reports + panic preview | | `parsec crash-report show ID`| Full JSON pretty-print for one report | | `parsec crash-report clear` | Remove all reports (--dry-run safe) | ## Changes | File | Change | |-----------------------------------------|-------------------------------------| | `src/cli/commands/crash_report.rs` | New: list/show/clear + 5 unit tests | | `src/cli/commands/mod.rs` | Re-export new module | | `src/cli/mod.rs` | CrashReport + CrashReportAction enum + dispatch | ## Behaviour - `list` respects `--json` (array of {id, timestamp, panic_message, panic_location}) - `show` accepts id with or without `.json` suffix; bail if not found - `clear` respects global `--dry-run`; all operations read-only except clear - Cache dir: `/parsec/crash-*.json` (same as Phase 1 writer) Co-Authored-By: Claude Sonnet 4.6 * fix(panic-handler): use epoch-second stems in test fixtures (Windows colon-in-filename) Windows forbids in filenames, so test fixtures using ISO-8601 stems like failed on the windows-latest CI runner. Switch to Unix epoch-second stems (e.g. ) to match the actual filename format written by the Phase 1 panic hook. Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- src/cli/commands/crash_report.rs | 289 +++++++++++++++++++++++++++++++ src/cli/commands/mod.rs | 2 + src/cli/mod.rs | 36 ++++ 3 files changed, 327 insertions(+) create mode 100644 src/cli/commands/crash_report.rs diff --git a/src/cli/commands/crash_report.rs b/src/cli/commands/crash_report.rs new file mode 100644 index 0000000..f590ffc --- /dev/null +++ b/src/cli/commands/crash_report.rs @@ -0,0 +1,289 @@ +//! `parsec crash-report` — manage locally-saved crash reports (#298 Phase 2). +//! +//! Crash reports are opt-in JSON files written by the panic hook (Phase 1) to +//! `/parsec/crash-.json` +//! (e.g. `~/.cache/parsec/crash-20260101T000000Z.json` on Linux/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/mod.rs b/src/cli/commands/mod.rs index 6eb58b6..d85c10a 100644 --- a/src/cli/commands/mod.rs +++ b/src/cli/commands/mod.rs @@ -2,6 +2,7 @@ mod ci; mod complete; mod compress; mod config; +mod crash_report; mod dashboard; mod diff; mod doctor; @@ -22,6 +23,7 @@ pub use ci::*; 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::*; diff --git a/src/cli/mod.rs b/src/cli/mod.rs index bd2645e..f97297b 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -657,6 +657,32 @@ pub enum Command { /// 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, + }, +} + +/// 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, } /// Candidate sets the dynamic completion subcommand can emit. @@ -768,6 +794,7 @@ pub async fn run(cli: Cli) -> Result<()> { Command::Dashboard { .. } => "dashboard", Command::Test { .. } => "test", Command::SelfUpdate { .. } => "self-update", + Command::CrashReport { .. } => "crash-report", }; let exec_id = crate::execlog::new_execution_id(); let exec_started_at = chrono::Utc::now(); @@ -1116,6 +1143,15 @@ pub async fn run(cli: Cli) -> Result<()> { } 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), + }, }; // Startup version hint — one-line stderr notice when a newer release is cached. From 6ac7e621025982908bd5b9c654c66e381311a0a6 Mon Sep 17 00:00:00 2001 From: erish Date: Fri, 11 Sep 2026 10:05:23 +0900 Subject: [PATCH 11/20] =?UTF-8?q?feat(checkpoint):=20Phase=201=20=E2=80=94?= =?UTF-8?q?=20create=20+=20list=20subcommands=20=C2=B7=20Refs=20#300=20(#4?= =?UTF-8?q?33)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `parsec checkpoint` with two subcommands: - `parsec checkpoint create [name]` — stashes current worktree state (staged, unstaged, untracked) under a `parsec-checkpoint:` label. Defaults to a UTC timestamp when no name is given. Exits cleanly when the working tree is clean. - `parsec checkpoint list` — filters `git stash list` to show only parsec-managed checkpoints; renders a STASH REF / NAME / BRANCH table (--json supported). Implementation details: - Purely stash-based — no new files written to disk. - Prefix convention (`parsec-checkpoint:`) isolates parsec stashes from ordinary WIP stashes without data loss risk. - `--dry-run` respected via the global flag (stash not created when dry). - 8 unit tests covering happy paths, WIP-prefix variant, filtering of non-parsec stashes, and malformed input. Phase 2 will add `restore ` and `drop `. Co-authored-by: Claude Sonnet 4.6 --- src/cli/commands/checkpoint.rs | 323 +++++++++++++++++++++++++++++++++ src/cli/commands/mod.rs | 2 + src/cli/mod.rs | 35 ++++ 3 files changed, 360 insertions(+) create mode 100644 src/cli/commands/checkpoint.rs diff --git a/src/cli/commands/checkpoint.rs b/src/cli/commands/checkpoint.rs new file mode 100644 index 0000000..3f4bbe9 --- /dev/null +++ b/src/cli/commands/checkpoint.rs @@ -0,0 +1,323 @@ +//! `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 (planned) +//! - `parsec checkpoint restore ` — pop the stash with matching name. +//! - `parsec checkpoint drop ` — discard a named checkpoint. + +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. +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 +// --------------------------------------------------------------------------- + +/// 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!( + "{:, + }, + /// List all parsec-managed checkpoints in this repository + List, +} + /// Candidate sets the dynamic completion subcommand can emit. #[derive(Subcommand)] pub enum CompleteKind { @@ -795,6 +823,7 @@ pub async fn run(cli: Cli) -> Result<()> { Command::Test { .. } => "test", Command::SelfUpdate { .. } => "self-update", Command::CrashReport { .. } => "crash-report", + Command::Checkpoint { .. } => "checkpoint", }; let exec_id = crate::execlog::new_execution_id(); let exec_started_at = chrono::Utc::now(); @@ -1152,6 +1181,12 @@ pub async fn run(cli: Cli) -> Result<()> { } 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), + }, }; // Startup version hint — one-line stderr notice when a newer release is cached. From fef82f7a8293e269c23ea4864d5bb931c042bb8a Mon Sep 17 00:00:00 2001 From: erish Date: Fri, 11 Sep 2026 10:14:32 +0900 Subject: [PATCH 12/20] =?UTF-8?q?feat(checkpoint):=20Phase=202=20=E2=80=94?= =?UTF-8?q?=20restore=20+=20drop=20subcommands=20=C2=B7=20Refs=20#300=20(#?= =?UTF-8?q?434)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `parsec checkpoint restore ` and `parsec checkpoint drop ` subcommands that perform name-based lookup of parsec-managed stash entries. ## Changes - `checkpoint_restore`: finds stash by name, runs `git stash pop` - `checkpoint_drop`: finds stash by name, runs `git stash drop` - `find_checkpoint` helper: exact-match lookup with helpful error listing available checkpoint names when the given name is not found - CLI: `CheckpointAction::{Restore,Drop}` variants wired through dispatcher - Tests: 6 new unit tests covering exact-match lookup, partial-name rejection, hyphenated names, timestamp names, missing name error path Co-authored-by: Claude Sonnet 4.6 --- src/cli/commands/checkpoint.rs | 146 ++++++++++++++++++++++++++++++++- src/cli/commands/mod.rs | 2 +- src/cli/mod.rs | 23 +++++- 3 files changed, 166 insertions(+), 5 deletions(-) diff --git a/src/cli/commands/checkpoint.rs b/src/cli/commands/checkpoint.rs index 3f4bbe9..978d12c 100644 --- a/src/cli/commands/checkpoint.rs +++ b/src/cli/commands/checkpoint.rs @@ -26,9 +26,22 @@ //! ``` //! Only lines containing `parsec-checkpoint:` in the message are surfaced. //! -//! # Phase 2 (planned) -//! - `parsec checkpoint restore ` — pop the stash with matching name. -//! - `parsec checkpoint drop ` — discard a named checkpoint. +//! # 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; @@ -105,6 +118,57 @@ pub fn checkpoint_create(repo: &Path, name: Option<&str>, mode: Mode) -> Result< 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 @@ -151,6 +215,32 @@ pub fn checkpoint_list(repo: &Path, mode: Mode) -> Result<()> { // 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): @@ -320,4 +410,54 @@ stash@{1}: On main: parsec-checkpoint:beta"; let label = "bad:name"; assert!(label.contains(':')); } + + // --- parse_stash_line edge cases ---------------------------------------- + + #[test] + fn parses_checkpoint_with_hyphenated_name() { + let line = "stash@{3}: On release/1.0: parsec-checkpoint:pre-merge-2026"; + let entry = parse_stash_line(line).expect("should parse"); + assert_eq!(entry.name, "pre-merge-2026"); + assert_eq!(entry.branch, "release/1.0"); + } + + #[test] + fn parses_checkpoint_with_timestamp_name() { + let line = "stash@{0}: On main: parsec-checkpoint:20260911-103000"; + let entry = parse_stash_line(line).expect("should parse"); + assert_eq!(entry.name, "20260911-103000"); + assert_eq!(entry.stash_ref, "stash@{0}"); + } + + // --- find_checkpoint (unit-level via parse_stash_list) ------------------ + + /// Verify the lookup logic that find_checkpoint relies on. + #[test] + fn lookup_finds_exact_match() { + let raw = "\ +stash@{0}: On main: parsec-checkpoint:alpha\n\ +stash@{1}: On main: parsec-checkpoint:beta\n\ +stash@{2}: On main: WIP regular stash"; + let entries = parse_stash_list(raw); + let found = entries.into_iter().find(|e| e.name == "beta"); + assert!(found.is_some()); + assert_eq!(found.unwrap().stash_ref, "stash@{1}"); + } + + #[test] + fn lookup_returns_none_for_missing_name() { + let raw = "stash@{0}: On main: parsec-checkpoint:only-one"; + let entries = parse_stash_list(raw); + let found = entries.into_iter().find(|e| e.name == "nonexistent"); + assert!(found.is_none()); + } + + #[test] + fn lookup_does_not_match_partial_name() { + let raw = "stash@{0}: On main: parsec-checkpoint:alpha-extended"; + let entries = parse_stash_list(raw); + // Exact match only — "alpha" should not match "alpha-extended". + let found = entries.into_iter().find(|e| e.name == "alpha"); + assert!(found.is_none()); + } } diff --git a/src/cli/commands/mod.rs b/src/cli/commands/mod.rs index 965a750..88c88f6 100644 --- a/src/cli/commands/mod.rs +++ b/src/cli/commands/mod.rs @@ -20,7 +20,7 @@ mod tracker_cmds; mod update; mod workspace; -pub use checkpoint::{checkpoint_create, checkpoint_list}; +pub use checkpoint::{checkpoint_create, checkpoint_drop, checkpoint_list, checkpoint_restore}; pub use ci::*; pub use complete::complete; pub use compress::*; diff --git a/src/cli/mod.rs b/src/cli/mod.rs index b5aabf8..625a161 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -703,7 +703,7 @@ pub enum CrashReportAction { pub enum CheckpointAction { /// Save the current worktree state as a named checkpoint /// - /// Stages, unstaged changes, and untracked files are all captured. + /// 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) @@ -711,6 +711,21 @@ pub enum CheckpointAction { }, /// 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. @@ -1186,6 +1201,12 @@ pub async fn run(cli: Cli) -> Result<()> { 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) + } }, }; From 1c7ac3cdc619835d8cdf0aaabe65a7f38e9ebd54 Mon Sep 17 00:00:00 2001 From: erish Date: Sat, 12 Sep 2026 09:41:28 +0900 Subject: [PATCH 13/20] =?UTF-8?q?test:=20CLI=20integration=20tests=20for?= =?UTF-8?q?=20checkpoint=20+=20crash-report=20commands=20=C2=B7=20Refs=20#?= =?UTF-8?q?435?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add 11 integration tests to tests/cli_tests.rs covering the checkpoint and crash-report commands (both shipped in Phase 1+2 with zero prior CLI-level test coverage). ## checkpoint (8 tests) - help shows all four subcommands - list empty repo → human 'No checkpoints found' - list empty repo --json → [] - create on clean tree → exit 0 + 'clean' message - create with uncommitted changes → checkpoint created, name in stdout - create then list → name appears in list - drop removes checkpoint → list returns empty again - name with ':' is rejected (colon collides with prefix delimiter) ## crash-report (3 tests) - help shows list/show/clear - list with no reports → human 'No crash reports found' - list --json with no reports → [] (uses XDG_CACHE_HOME isolation to avoid host crash reports) All tests: cargo test --quiet passes (94/94 cli_tests, 132/132 unit). cargo fmt + clippy -D warnings clean. Co-authored-by: Claude Sonnet 4.6 --- tests/cli_tests.rs | 246 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 246 insertions(+) diff --git a/tests/cli_tests.rs b/tests/cli_tests.rs index 1a8af93..406ba83 100644 --- a/tests/cli_tests.rs +++ b/tests/cli_tests.rs @@ -2548,3 +2548,249 @@ fn test_dashboard_quiet_rejected() { combined ); } + +// --------------------------------------------------------------------------- +// 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}" + ); +} From c78d39ccbab6c108767a6889f9ca3e497d15d9e1 Mon Sep 17 00:00:00 2001 From: erish Date: Sun, 13 Sep 2026 09:43:27 +0900 Subject: [PATCH 14/20] =?UTF-8?q?feat(triage):=20Phase=201=20=E2=80=94=20r?= =?UTF-8?q?ule-based=20issue=20auto-labelling=20(dry-run)=20=C2=B7=20Refs?= =?UTF-8?q?=20#302=20(#437)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auto-merged by parsec-daily-improvement cron. All CI checks passed. Gates: CI CLEAN · diff matches #302 · no breaking changes · 10 unit tests. --- src/cli/commands/mod.rs | 2 + src/cli/commands/triage.rs | 334 +++++++++++++++++++++++++++++++++++++ src/cli/mod.rs | 20 +++ src/config/mod.rs | 1 + src/config/settings.rs | 44 +++++ src/github/mod.rs | 48 ++++++ 6 files changed, 449 insertions(+) create mode 100644 src/cli/commands/triage.rs diff --git a/src/cli/commands/mod.rs b/src/cli/commands/mod.rs index 88c88f6..4ff5d1f 100644 --- a/src/cli/commands/mod.rs +++ b/src/cli/commands/mod.rs @@ -17,6 +17,7 @@ pub mod smartlog; mod stack; mod test; mod tracker_cmds; +mod triage; mod update; mod workspace; @@ -39,5 +40,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/triage.rs b/src/cli/commands/triage.rs new file mode 100644 index 0000000..0ed9f66 --- /dev/null +++ b/src/cli/commands/triage.rs @@ -0,0 +1,334 @@ +//! `parsec triage` — rule-based issue/PR auto-labelling (#302). +//! +//! # Phase 1 +//! - 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. +//! - Dry-run only: no labels are written to GitHub. +//! +//! # Phase 2 (planned) +//! - `--apply` flag: write proposed labels via `gh` API (`add_labels`). +//! - AI-assisted labelling from issue body (requires LLM integration). +//! +//! 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::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 a dry-run +/// table of proposed labels. No changes are written to GitHub in Phase 1. +pub async fn triage(repo: &Path, limit: u8, 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(()); + } + + 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 — dry-run ({} rule(s), {} issue(s) matched of {})", + 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!(); + println!( + "{}", + "Tip: No labels were written (dry-run). Phase 2 will add --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(", "); + + Some(TriageEntry { + number, + title: title.to_string(), + matched_rule: first.pattern.clone(), + proposed_labels, + trust_score, + already_labelled: !existing_labels.is_empty(), + }) +} + +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, + 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); + } +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 625a161..2b383dd 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -682,6 +682,24 @@ pub enum Command { #[command(subcommand)] action: CheckpointAction, }, + + /// Rule-based issue/PR auto-labelling (dry-run in Phase 1). + /// + /// Reads `[[triage.rules]]` from your parsec config, fetches open GitHub + /// issues, and prints a table of proposed labels with a trust score. + /// + /// Phase 1: dry-run only — no labels are written to GitHub. + /// Phase 2 will add `--apply` to write proposed labels. + /// + /// 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, + }, } /// Actions available under `parsec crash-report`. @@ -839,6 +857,7 @@ pub async fn run(cli: Cli) -> Result<()> { 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(); @@ -1208,6 +1227,7 @@ pub async fn run(cli: Cli) -> Result<()> { commands::checkpoint_drop(&repo_path, &name, output_mode) } }, + Command::Triage { limit } => commands::triage(&repo_path, limit, output_mode).await, }; // Startup version hint — one-line stderr notice when a newer release is cached. diff --git a/src/config/mod.rs b/src/config/mod.rs index 9e4a140..e651678 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -3,4 +3,5 @@ mod settings; 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 f2d0c3f..1f2d677 100644 --- a/src/config/settings.rs +++ b/src/config/settings.rs @@ -474,6 +474,47 @@ pub struct CrashReportConfig { 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 // --------------------------------------------------------------------------- @@ -501,6 +542,9 @@ pub struct ParsecConfig { /// 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 d153471..15f99f8 100644 --- a/src/github/mod.rs +++ b/src/github/mod.rs @@ -897,6 +897,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(()); From ff4dedcd6fa0258ad28ba9f3011013b5eefebcaf Mon Sep 17 00:00:00 2001 From: erish Date: Mon, 14 Sep 2026 09:42:24 +0900 Subject: [PATCH 15/20] feat(triage): add explicit label apply mode (#438) Co-authored-by: Claude Opus 4.7 --- src/cli/commands/triage.rs | 41 ++++++++++++++++++------------- src/cli/mod.rs | 17 +++++++++---- tests/cli_tests.rs | 49 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 87 insertions(+), 20 deletions(-) diff --git a/src/cli/commands/triage.rs b/src/cli/commands/triage.rs index 0ed9f66..dc46d3a 100644 --- a/src/cli/commands/triage.rs +++ b/src/cli/commands/triage.rs @@ -1,15 +1,10 @@ -//! `parsec triage` — rule-based issue/PR auto-labelling (#302). +//! `parsec triage` — rule-based issue auto-labelling (#302). //! -//! # Phase 1 //! - 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. -//! - Dry-run only: no labels are written to GitHub. -//! -//! # Phase 2 (planned) -//! - `--apply` flag: write proposed labels via `gh` API (`add_labels`). -//! - AI-assisted labelling from issue body (requires LLM integration). +//! - Write proposed labels only when `--apply` is explicitly passed. //! //! Trust-score logic: //! - First (highest-priority) rule to match → **1.0** @@ -18,7 +13,7 @@ use std::path::Path; -use anyhow::Result; +use anyhow::{Context, Result}; use colored::Colorize; use tabled::{settings::Style, Table, Tabled}; @@ -33,9 +28,9 @@ use crate::output::Mode; /// Entry point for `parsec triage`. /// -/// Fetches open issues, applies the configured rules, and prints a dry-run -/// table of proposed labels. No changes are written to GitHub in Phase 1. -pub async fn triage(repo: &Path, limit: u8, mode: Mode) -> Result<()> { +/// 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() { @@ -102,6 +97,14 @@ pub async fn triage(repo: &Path, limit: u8, mode: Mode) -> Result<()> { return Ok(()); } + if apply { + for entry in &entries { + 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)?); @@ -116,7 +119,8 @@ pub async fn triage(repo: &Path, limit: u8, mode: Mode) -> Result<()> { println!( "{}", format!( - " parsec triage — dry-run ({} rule(s), {} issue(s) matched of {})", + " parsec triage — {} ({} rule(s), {} issue(s) matched of {})", + if apply { "applied" } else { "dry-run" }, config.triage.rules.len(), entries.len(), issues.len() @@ -140,10 +144,12 @@ pub async fn triage(repo: &Path, limit: u8, mode: Mode) -> Result<()> { let table = Table::new(rows).with(Style::rounded()).to_string(); println!("{table}"); println!(); - println!( - "{}", - "Tip: No labels were written (dry-run). Phase 2 will add --apply.".dimmed() - ); + if !apply { + println!( + "{}", + "Tip: No labels were written. Pass --apply to apply.".dimmed() + ); + } } } @@ -193,6 +199,7 @@ fn build_entry( title: title.to_string(), matched_rule: first.pattern.clone(), proposed_labels, + labels, trust_score, already_labelled: !existing_labels.is_empty(), }) @@ -218,6 +225,8 @@ pub struct TriageEntry { pub title: String, pub matched_rule: String, pub proposed_labels: String, + #[serde(skip)] + labels: Vec, pub trust_score: f32, pub already_labelled: bool, } diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 2b383dd..30f8fc5 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -683,13 +683,12 @@ pub enum Command { action: CheckpointAction, }, - /// Rule-based issue/PR auto-labelling (dry-run in Phase 1). + /// 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. /// - /// Phase 1: dry-run only — no labels are written to GitHub. - /// Phase 2 will add `--apply` to write proposed labels. + /// Labels are only written when `--apply` is explicitly passed. /// /// Example config rule: /// [[triage.rules]] @@ -699,6 +698,10 @@ pub enum Command { /// 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, }, } @@ -1227,7 +1230,13 @@ pub async fn run(cli: Cli) -> Result<()> { commands::checkpoint_drop(&repo_path, &name, output_mode) } }, - Command::Triage { limit } => commands::triage(&repo_path, limit, output_mode).await, + 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. diff --git a/tests/cli_tests.rs b/tests/cli_tests.rs index 406ba83..4fafbb7 100644 --- a/tests/cli_tests.rs +++ b/tests/cli_tests.rs @@ -2794,3 +2794,52 @@ fn test_crash_report_list_json_no_reports() { "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")); +} From 424a196c61ae78eb133f7004b9629fe7eb51d496 Mon Sep 17 00:00:00 2001 From: erish Date: Tue, 15 Sep 2026 09:40:52 +0900 Subject: [PATCH 16/20] fix(triage): skip labels already present (#440) Closes #439 Co-authored-by: OpenAI Codex --- src/cli/commands/triage.rs | 40 +++++++++++++++++++++++++++++++++----- 1 file changed, 35 insertions(+), 5 deletions(-) diff --git a/src/cli/commands/triage.rs b/src/cli/commands/triage.rs index dc46d3a..11129ba 100644 --- a/src/cli/commands/triage.rs +++ b/src/cli/commands/triage.rs @@ -99,9 +99,13 @@ pub async fn triage(repo: &Path, limit: u8, apply: bool, mode: Mode) -> Result<( if apply { for entry in &entries { - gh.add_labels(entry.number, &entry.labels) - .await - .with_context(|| format!("failed to apply labels to issue #{}", entry.number))?; + if !entry.labels.is_empty() { + gh.add_labels(entry.number, &entry.labels) + .await + .with_context(|| { + format!("failed to apply labels to issue #{}", entry.number) + })?; + } } } @@ -193,15 +197,24 @@ fn build_entry( 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, - labels, + already_labelled: missing_labels.is_empty(), + labels: missing_labels, trust_score, - already_labelled: !existing_labels.is_empty(), }) } @@ -339,5 +352,22 @@ mod tests { 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"]); } } From cfb06a2980295bed4f6f504002ee7e3d98f09b04 Mon Sep 17 00:00:00 2001 From: erish Date: Wed, 16 Sep 2026 09:42:50 +0900 Subject: [PATCH 17/20] docs: correct crash report cache paths (#442) Closes #441 Co-authored-by: OpenAI Codex --- docs/crash-report.md | 22 ++++++++++++++++------ src/cli/commands/crash_report.rs | 3 ++- src/config/settings.rs | 2 +- src/panic_handler.rs | 5 +++-- 4 files changed, 22 insertions(+), 10 deletions(-) diff --git a/docs/crash-report.md b/docs/crash-report.md index 2c329ab..720d4ea 100644 --- a/docs/crash-report.md +++ b/docs/crash-report.md @@ -6,7 +6,16 @@ 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 -file to `~/.cache/parsec/crash-.json` containing: +`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 | |---|---|---| @@ -41,8 +50,8 @@ The default is `enabled = false` — **nothing is saved unless you opt in**. If you experience a crash and want to help: -1. Find the report: `ls ~/.cache/parsec/crash-*.json` -2. Review its contents before sharing (it is plain JSON) +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 @@ -50,9 +59,10 @@ You are never required to share a crash report. ## Retention -Reports are stored locally in `~/.cache/parsec/`. They are never automatically -deleted by parsec; you can remove them at any time: +Reports stay in the platform cache directory until you remove them. Preview a +cleanup first, then delete all saved reports with: ```sh -rm ~/.cache/parsec/crash-*.json +parsec --dry-run crash-report clear +parsec crash-report clear ``` diff --git a/src/cli/commands/crash_report.rs b/src/cli/commands/crash_report.rs index f590ffc..dc58e54 100644 --- a/src/cli/commands/crash_report.rs +++ b/src/cli/commands/crash_report.rs @@ -2,7 +2,8 @@ //! //! Crash reports are opt-in JSON files written by the panic hook (Phase 1) to //! `/parsec/crash-.json` -//! (e.g. `~/.cache/parsec/crash-20260101T000000Z.json` on Linux/macOS). +//! (e.g. `~/.cache/parsec/crash-1704067200.json` on Linux or +//! `~/Library/Caches/parsec/crash-1704067200.json` on macOS). //! //! ## Subcommands //! diff --git a/src/config/settings.rs b/src/config/settings.rs index 1f2d677..cd60afa 100644 --- a/src/config/settings.rs +++ b/src/config/settings.rs @@ -457,7 +457,7 @@ impl Default for UpdateConfig { /// Controls opt-in crash report collection (#298). /// /// No data is transmitted automatically. When `enabled = true`, a JSON -/// report is written to `~/.cache/parsec/crash-.json` on panic. The +/// 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`) diff --git a/src/panic_handler.rs b/src/panic_handler.rs index e792389..ce78dc6 100644 --- a/src/panic_handler.rs +++ b/src/panic_handler.rs @@ -4,7 +4,7 @@ //! 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 -//! `~/.cache/parsec/crash-.json` so users can share it +//! `/parsec/crash-.json` so users can share it //! with the maintainers. //! //! No data is **transmitted** automatically. The user must opt in via config @@ -33,7 +33,8 @@ 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 (`~/.cache/parsec/` on Linux/macOS). +/// 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); From 658567916d05424ee3c00d6a0c7d18fc785fad01 Mon Sep 17 00:00:00 2001 From: erish Date: Thu, 17 Sep 2026 09:40:51 +0900 Subject: [PATCH 18/20] docs: align v0.5 command references with CLI (#444) Closes #443 Co-authored-by: OpenAI Codex --- CHANGELOG.md | 3 --- README.md | 1 - docs/llms.txt | 1 - 3 files changed, 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bd4a555..e926ec6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,9 +34,6 @@ v0.5 마일스톤 **16/16 완료**. Polish & Power-User UX: 워크트리/PR/CI line-level 충돌 시뮬레이션. `git merge-tree --write-tree`로 워크트리 vs base + 워크트리 페어 cross-simulate 두 패스. 머지 전 실제 충돌 파일을 read-only로 노출. (`#246`, `#335`) -- **`parsec commit`** — AI 커밋 메시지 생성 (OpenAI / Anthropic). staged diff 분석 - 후 자동 prefix + Conventional Commits 포맷(`--conventional`). 수동 메시지 - override(`--message`). (`#274`) - **`parsec sync`** — auto-sync `main`/`develop` into stale worktrees (rebase 또는 merge 전략, `--all` 일괄, `--dry-run` behind 카운트, conflict hint). (`#290`) - **AI-generated PR descriptions** — `parsec ship`이 OpenAI / Anthropic / Ollama diff --git a/README.md b/README.md index 5b6faa7..8a6339d 100644 --- a/README.md +++ b/README.md @@ -147,7 +147,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/llms.txt b/docs/llms.txt index a990a11..e888659 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -20,7 +20,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 From 375d2e5b9155e07237cdb1803328cc7b907ce0bd Mon Sep 17 00:00:00 2001 From: erish Date: Fri, 18 Sep 2026 09:42:25 +0900 Subject: [PATCH 19/20] fix(health): flag missing registered worktrees (#446) Co-authored-by: OpenAI Codex --- src/cli/commands/health.rs | 3 +++ src/output/human.rs | 14 +++++++++++--- src/output/json.rs | 4 +++- src/output/mod.rs | 2 ++ tests/cli_tests.rs | 32 ++++++++++++++++++++++++++++++++ 5 files changed, 51 insertions(+), 4 deletions(-) diff --git a/src/cli/commands/health.rs b/src/cli/commands/health.rs index 99bac66..c3f1f27 100644 --- a/src/cli/commands/health.rs +++ b/src/cli/commands/health.rs @@ -76,6 +76,8 @@ 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. @@ -109,6 +111,7 @@ 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, diff --git a/src/output/human.rs b/src/output/human.rs index 7eaaf65..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 { @@ -1176,7 +1182,8 @@ pub fn print_health(records: &[super::HealthRecord]) { let op_in_progress = r.rebase_in_progress || r.merge_in_progress || r.cherry_pick_in_progress; - let any_issue = r.has_lock + let any_issue = r.missing + || r.has_lock || r.uncommitted > 0 || ci_issue || op_in_progress @@ -1184,7 +1191,7 @@ pub fn print_health(records: &[super::HealthRecord]) { .map(|d| d > r.stale_threshold_days) .unwrap_or(false); - let icon = if r.has_lock || ci_issue || op_in_progress { + let icon = if r.missing || r.has_lock || ci_issue || op_in_progress { "✗".red().to_string() } else if any_issue { "⚠".yellow().to_string() @@ -1197,9 +1204,10 @@ pub fn print_health(records: &[super::HealthRecord]) { } println!( - " {} {:<20}{}{}{}{}{}{}{}", + " {} {:<20}{}{}{}{}{}{}{}{}", icon, r.ticket.bold(), + missing_tag, uncommitted_tag, stale_tag, lock_tag, diff --git a/src/output/json.rs b/src/output/json.rs index 264274f..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, @@ -387,7 +388,8 @@ pub fn print_health(records: &[super::HealthRecord]) { }) .collect(); let all_healthy = records.iter().all(|r| { - !r.has_lock + !r.missing + && !r.has_lock && r.uncommitted == 0 && !r .stale_days diff --git a/src/output/mod.rs b/src/output/mod.rs index 4e2df8f..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. diff --git a/tests/cli_tests.rs b/tests/cli_tests.rs index 4fafbb7..400d843 100644 --- a/tests/cli_tests.rs +++ b/tests/cli_tests.rs @@ -1909,6 +1909,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] From 3c801568b61291602f0cc18279c249ff6b0a236f Mon Sep 17 00:00:00 2001 From: erish Date: Sat, 19 Sep 2026 09:39:18 +0900 Subject: [PATCH 20/20] docs(cli): align smartlog help with shipped overlays (#448) Closes #447 Co-authored-by: OpenAI Codex --- src/cli/mod.rs | 2 +- tests/cli_tests.rs | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 30f8fc5..c87b69e 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -543,7 +543,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) diff --git a/tests/cli_tests.rs b/tests/cli_tests.rs index 400d843..1d18922 100644 --- a/tests/cli_tests.rs +++ b/tests/cli_tests.rs @@ -1619,6 +1619,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]