From 3847e57ae40183335b407c8abe4390e084c6b3bd Mon Sep 17 00:00:00 2001 From: erish Date: Wed, 6 May 2026 21:59:25 +0900 Subject: [PATCH 01/26] chore(v0.5): roadmap section + ship gh auth token fallback (#281) (#289) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(ship): fall back to gh auth token (parity with parsec doctor) (#281) parsec ship 이 PARSEC_GITHUB_TOKEN / GITHUB_TOKEN / GH_TOKEN env var 모두 비어있을 때 PR 생성을 거부했지만 parsec doctor 와 tracker 레이어는 이미 `gh auth token` fallback 적용. parity 깨져 사용자가 수동 `gh pr create` 로 추가 작업 필요. 해결: - src/env.rs: github_token() 의 4번째 우선순위로 gh_auth_token() 추가. 신규 gh_auth_token() helper 는 `gh auth token` shell out — 실패 시 None (binary 없음 / exit code != 0 / non-UTF8 / 빈 출력 모두 graceful). - src/github/mod.rs: resolve_github_token 의 env-var/gh fallback 을 GitHub host (github.com / *.ghe.com / *.github.* GHE) 에만 적용. 신규 is_github_host() helper. Bitbucket / GitLab remote 가 `gh auth login` 한 환경에서 GitHub 토큰을 잘못 픽업하지 않도록 가드. - src/cli/commands/doctor.rs: 중복 `gh auth token` shell-out 코드 제거 → env::gh_auth_token() 공통 helper 호출. parity at the helper level. 신규 테스트 (src/env.rs): github_token_priority_order — PARSEC > GITHUB > GH 우선순위 + 빈값 fallback 4 시나리오 sequential 검사. EnvGuard 로 process-wide env 보존+복원. gh_auth_token_returns_option_string_or_none — gh binary 가용성에 무관하게 trim 보장. github_token_returns_none_when_all_missing_and_gh_fails — env 모두 미설정 시 None 또는 valid Some 모두 허용 (CI 와 dev 환경 양립). 검증: - cargo test: 79 tests PASS (env tests 6 + integration 73) - cargo clippy --all-targets -- -D warnings: clean - cargo fmt --check: clean - bitbucket integration tests: 5/5 PASS (이전 발견된 host-gated 이슈 해결) 회귀 위험: 매우 낮음 - 환경에 gh CLI 미로그인 / 미설치 → 기존과 동일 (None 반환) - 환경에 gh 로그인됨 + GitHub remote → 신규 fallback 활용 (issue #281 의도) - 환경에 gh 로그인됨 + Bitbucket/GitLab remote → is_github_host 로 차단, 기존과 동일 Closes #281 * docs(v0.5): open roadmap milestone — visualization release vision v0.5 마일스톤 공식 출발 마커. README 와 CHANGELOG 양쪽에 향후 비전 명시. README.md (## Roadmap 섹션 신설, ## Why use it 과 ## Install 사이): - vision tagline: "parsec = AI agents + human devs both — worktree-native git CLI" - 4단계 milestone: · v0.4.0 ✅ Released (2026-05-04): Multi-forge + multi-tracker foundation · v0.5 🚧 Next — _The visualization release_: smartlog · TUI dashboard · speculative merge · parsec test · AI PR descriptions · v1.0 🔜 — _AI-Native Standard_: MCP server signature, Claude/Cursor/Copilot 가 parsec 을 first-class tool 로 invoke · v2.0+ 🔮 — _Ecosystem Hub_: plugins · VS Code extension · Linear tracker - v0.5 milestone link (github.com/erishforG/git-parsec/milestone/3) CHANGELOG.md (## [Unreleased] 확장): - ### Added: v0.5 milestone opened, README Roadmap 참조 - ### Fixed: #281 ship gh auth token fallback 노트 (별 commit ae1a2d3 와 동일 entry) 회귀 위험: 0 (문서 변경만) * fix(env): serialize env-touching tests via process-wide mutex (Windows CI) PR #289 Windows CI 1건 fail. macOS / Ubuntu Test 통과, Windows Test 만 실패. 원인: - env::tests 의 github_token_priority_order 와 github_token_returns_none_when_all_ missing_and_gh_fails 가 cargo test 병렬 실행 시 process-wide env vars 를 race. - priority_order 가 PARSEC=p / GITHUB=g / GH=h 셋업 후 assert 사이에 sibling 테스트의 EnvGuard::new() 가 모든 env 를 clear → assert 가 PARSEC 못 보고 GH=h 반환. - macOS/Ubuntu 는 timing 우연히 안전, Windows 는 다른 thread scheduling 으로 race 발현 (Some("h") vs Some("p") at src/env.rs:205). 수정: - std::sync::OnceLock> 의 env_lock() 신규 — env 만지는 테스트들 직렬화. std 만 사용 (외부 deps 추가 X 제약 준수). - github_token_priority_order 와 github_token_returns_none_when_all_missing_and_gh_fails 를 단일 함수 github_token_priority_order_and_fallback 로 통합 + env_lock() 의 Mutex guard 획득. 5 시나리오 (PARSEC 우선 / GITHUB / GH / 빈값 / 모두 미설정) 직렬 실행. - gh_auth_token_returns_option_string_or_none 은 env 미터치라 lock 불필요 — 그대로 유지. - production 로직 (env::github_token / env::gh_auth_token) 변경 0. 검증: - 로컬 cargo test 78 PASS (env tests 5 + integration 73), clippy clean, fmt clean. - Windows CI 검증은 force-push 후 PR #289 워크플로우에서 확인. --- CHANGELOG.md | 10 +++ README.md | 15 ++++ src/cli/commands/doctor.rs | 17 ++-- src/env.rs | 158 ++++++++++++++++++++++++++++++++++++- src/github/mod.rs | 28 ++++--- 5 files changed, 205 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e99b10b..ea5bc79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **v0.5 milestone opened** — see Roadmap in README. Themes: smartlog · TUI + dashboard · speculative merge · `parsec test` · AI PR descriptions. + +### Fixed +- `parsec ship` falls back to `gh auth token` when `PARSEC_GITHUB_TOKEN` / + `GITHUB_TOKEN` / `GH_TOKEN` env vars are absent — parity with `parsec + doctor` and the tracker layer (#281). The fallback is restricted to + GitHub hosts so Bitbucket / GitLab remotes are unaffected. + ## [0.4.0] - 2026-05-04 ### Added diff --git a/README.md b/README.md index 6685b72..c48e4f8 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,21 @@ That's the whole loop. Plain `git worktree` doesn't track state, doesn't talk to --- +## Roadmap + +> **Vision**: parsec = AI agents + human devs both — worktree-native git CLI. + +| Milestone | Status | Theme | +|---|---|---| +| **v0.4.0** | ✅ Released (2026-05-04) | Multi-forge + multi-tracker foundation (GitHub / GitLab / Bitbucket; Jira / Linear) | +| **v0.5** — _The visualization release_ | 🚧 Next | smartlog · TUI dashboard · speculative merge · `parsec test` · AI PR descriptions | +| **v1.0** — _AI-Native Standard_ | 🔜 | 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 | + +Open issues for v0.5 are tracked under the [`v0.5` milestone](https://github.com/erishforG/git-parsec/milestone/3). + +--- + ## Install ```bash diff --git a/src/cli/commands/doctor.rs b/src/cli/commands/doctor.rs index a2082cd..7109e9f 100644 --- a/src/cli/commands/doctor.rs +++ b/src/cli/commands/doctor.rs @@ -86,15 +86,14 @@ pub async fn doctor(repo: &Path, mode: Mode) -> Result<()> { // ------------------------------------------------------------------ { let config_result = crate::config::ParsecConfig::load(); + // issue #281: gh auth token fallback 은 lib (`crate::env::gh_auth_token`) 에서 + // 단일 정의 — `ship` / tracker 와 parity. doctor 는 SOURCE 를 사람이 읽기 위한 + // 진단 메시지로 분기하므로 별도 매핑 유지. + let from_gh = crate::env::gh_auth_token().is_some(); + let from_env = std::env::var("GITHUB_TOKEN").is_ok(); let github_token_found = match &config_result { Ok(cfg) => { let from_config = cfg.github.values().any(|h| h.token.is_some()); - let from_env = std::env::var("GITHUB_TOKEN").is_ok(); - let from_gh = StdCommand::new("gh") - .args(["auth", "token"]) - .output() - .map(|o| o.status.success()) - .unwrap_or(false); if from_config { Some("config file") } else if from_env { @@ -106,12 +105,6 @@ pub async fn doctor(repo: &Path, mode: Mode) -> Result<()> { } } Err(_) => { - let from_env = std::env::var("GITHUB_TOKEN").is_ok(); - let from_gh = StdCommand::new("gh") - .args(["auth", "token"]) - .output() - .map(|o| o.status.success()) - .unwrap_or(false); if from_env { Some("GITHUB_TOKEN env var") } else if from_gh { diff --git a/src/env.rs b/src/env.rs index de824fa..73df3bf 100644 --- a/src/env.rs +++ b/src/env.rs @@ -47,7 +47,12 @@ pub fn jira_token(config_token: Option<&str>) -> Option { .map(|t| t.to_string()) } -/// Resolve GitHub token. Priority: PARSEC_GITHUB_TOKEN > GITHUB_TOKEN > GH_TOKEN +/// Resolve GitHub token. Priority: +/// 1. `PARSEC_GITHUB_TOKEN` +/// 2. `GITHUB_TOKEN` +/// 3. `GH_TOKEN` +/// 4. `gh auth token` shell fallback (issue #281 — parity with `parsec doctor` / +/// tracker layer; `parsec ship` previously rejected this path) pub fn github_token() -> Option { for var in [PARSEC_GITHUB_TOKEN, GITHUB_TOKEN, GH_TOKEN] { if let Ok(token) = std::env::var(var) { @@ -56,7 +61,30 @@ pub fn github_token() -> Option { } } } - None + gh_auth_token() +} + +/// Shell out to `gh auth token` and capture stdout. Returns `None` on failure: +/// binary not found, exit code != 0, non-UTF8 stdout, or empty token. +/// +/// Used as the final fallback in [`github_token`] (issue #281 — parity with +/// `parsec doctor` and the tracker layer). Cross-platform: relies on `gh` +/// being on PATH; failures are silent so callers present a unified "no token +/// found" message. +pub fn gh_auth_token() -> Option { + let out = std::process::Command::new("gh") + .args(["auth", "token"]) + .output() + .ok()?; + if !out.status.success() { + return None; + } + let token = String::from_utf8(out.stdout).ok()?.trim().to_string(); + if token.is_empty() { + None + } else { + Some(token) + } } /// Resolve GitLab token. Priority: PARSEC_GITLAB_TOKEN > GITLAB_TOKEN @@ -113,3 +141,129 @@ pub fn is_offline() -> bool { .map(|v| v == "1" || v == "true") .unwrap_or(false) } + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- +#[cfg(test)] +mod tests { + use super::*; + use std::sync::{Mutex, OnceLock}; + + /// Process-wide mutex to serialize env-touching tests. cargo test runs + /// tests in parallel by default, so any test that mutates env vars must + /// hold this lock — otherwise sibling tests racing through `set_var` / + /// `remove_var` clobber each other (Windows CI hit this with priority_order + /// reading PARSEC=p but seeing GH=h because another test cleared PARSEC + /// mid-assertion). + fn env_lock() -> &'static Mutex<()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + } + + /// Helper: snapshot/clear env vars affecting github_token, then restore. + /// std::env::set_var/remove_var is unsafe in Rust 2024. Tests holding + /// `env_lock()` only run serially, so the snapshot+restore is sufficient. + struct EnvGuard { + orig: Vec<(&'static str, Option)>, + } + impl EnvGuard { + fn new(vars: &[&'static str]) -> Self { + let orig = vars.iter().map(|v| (*v, std::env::var(v).ok())).collect(); + for v in vars { + // SAFETY: tests run serially within a module by default in Rust 2024. + #[allow(unused_unsafe)] + unsafe { + std::env::remove_var(v) + }; + } + Self { orig } + } + fn set(&self, key: &str, val: &str) { + #[allow(unused_unsafe)] + unsafe { + std::env::set_var(key, val) + }; + } + } + impl Drop for EnvGuard { + fn drop(&mut self) { + for (k, v) in &self.orig { + #[allow(unused_unsafe)] + unsafe { + if let Some(val) = v { + std::env::set_var(k, val); + } else { + std::env::remove_var(k); + } + } + } + } + } + + /// 우선순위 + 빈값 fallback + 모두 미설정 시나리오를 한 함수에서 sequential 검사. + /// `env_lock()` 으로 process-wide 직렬화 (cargo test 병렬 실행 환경에서 sibling + /// 테스트가 env 를 클로버하지 않도록). Windows CI 에서 race 발견 (#289). + #[test] + fn github_token_priority_order_and_fallback() { + let _guard = env_lock().lock().unwrap_or_else(|p| p.into_inner()); + // 1. PARSEC_GITHUB_TOKEN 우선 + { + let g = EnvGuard::new(&[PARSEC_GITHUB_TOKEN, GITHUB_TOKEN, GH_TOKEN]); + g.set(PARSEC_GITHUB_TOKEN, "p"); + g.set(GITHUB_TOKEN, "g"); + g.set(GH_TOKEN, "h"); + assert_eq!(github_token().as_deref(), Some("p")); + drop(g); + } + // 2. PARSEC_GITHUB_TOKEN 미설정 → GITHUB_TOKEN + { + let g = EnvGuard::new(&[PARSEC_GITHUB_TOKEN, GITHUB_TOKEN, GH_TOKEN]); + g.set(GITHUB_TOKEN, "g"); + g.set(GH_TOKEN, "h"); + assert_eq!(github_token().as_deref(), Some("g")); + drop(g); + } + // 3. PARSEC_GITHUB_TOKEN / GITHUB_TOKEN 미설정 → GH_TOKEN + { + let g = EnvGuard::new(&[PARSEC_GITHUB_TOKEN, GITHUB_TOKEN, GH_TOKEN]); + g.set(GH_TOKEN, "h"); + assert_eq!(github_token().as_deref(), Some("h")); + drop(g); + } + // 4. 빈 PARSEC_GITHUB_TOKEN 은 무시 → GITHUB_TOKEN + { + let g = EnvGuard::new(&[PARSEC_GITHUB_TOKEN, GITHUB_TOKEN, GH_TOKEN]); + g.set(PARSEC_GITHUB_TOKEN, ""); + g.set(GITHUB_TOKEN, "g"); + assert_eq!(github_token().as_deref(), Some("g")); + drop(g); + } + // 5. 모두 미설정 + gh 실패 → None. CI 환경 (gh 로그인 X) 이 일반. + // local dev 에서 gh auth login 돼있으면 Some(token) 도 허용 (smoke). + { + let g = EnvGuard::new(&[PARSEC_GITHUB_TOKEN, GITHUB_TOKEN, GH_TOKEN]); + match github_token() { + None => {} + Some(t) => assert!( + !t.is_empty(), + "if gh auth token is available, it must not be empty" + ), + } + drop(g); + } + } + + #[test] + fn gh_auth_token_returns_option_string_or_none() { + // 외부 gh binary 에 의존 — CI 환경 (로그인 X) 에서는 None 기대. + // local dev 에서 gh auth login 돼있으면 Some(token). 둘 다 허용 (smoke check only). + match gh_auth_token() { + None => {} + Some(t) => { + assert!(!t.is_empty()); + assert!(!t.contains('\n'), "trimmed"); + } + } + } +} diff --git a/src/github/mod.rs b/src/github/mod.rs index 45e953e..f85a74f 100644 --- a/src/github/mod.rs +++ b/src/github/mod.rs @@ -190,14 +190,25 @@ pub fn parse_github_remote(url: &str) -> Option { }) } +/// Returns true when `host` looks like a GitHub host. github.com and any host +/// with `.github.` (GHE) substring qualifies. Used to gate env-var and +/// `gh auth token` fallbacks so they don't leak into other forges. +pub fn is_github_host(host: &str) -> bool { + let h = host.trim().to_ascii_lowercase(); + h == "github.com" || h.contains(".github.") || h.ends_with(".ghe.com") +} + /// Resolve a GitHub token for the given host. /// /// Resolution priority: -/// 1. `config.github..token` — host-specific config -/// 2. `PARSEC_GITHUB_TOKEN` env var — explicit override -/// 3. `GITHUB_TOKEN` / `GH_TOKEN` — generic fallback +/// 1. `config.github..token` — host-specific config (any host) +/// 2. `PARSEC_GITHUB_TOKEN` / `GITHUB_TOKEN` / `GH_TOKEN` env vars (GitHub host only) +/// 3. `gh auth token` shell fallback (GitHub host only) — issue #281 parity +/// +/// 2 & 3 are gated on host being a GitHub host so that bitbucket / gitlab remotes +/// don't accidentally pick up a GitHub token via `gh auth login`. pub fn resolve_github_token(host: &str, config: &ParsecConfig) -> Option { - // 1. Host-specific config token + // 1. Host-specific config token (any host — opt-in via config) if let Some(host_cfg) = config.github.get(host) { if let Some(ref token) = host_cfg.token { if !token.is_empty() { @@ -206,12 +217,11 @@ pub fn resolve_github_token(host: &str, config: &ParsecConfig) -> Option } } - // 2 & 3. Environment variables (PARSEC_GITHUB_TOKEN > GITHUB_TOKEN > GH_TOKEN) - if let Some(token) = crate::env::github_token() { - return Some(token); + // 2 & 3: env / gh CLI fallback — only for actual GitHub hosts. + if !is_github_host(host) { + return None; } - - None + crate::env::github_token() } // --------------------------------------------------------------------------- From 02cb26da4a67b07e2a341298fe8e1714291c3c90 Mon Sep 17 00:00:00 2001 From: erish Date: Wed, 13 May 2026 09:12:54 +0900 Subject: [PATCH 02/26] =?UTF-8?q?[245]=20feat(smartlog):=20skeleton=20?= =?UTF-8?q?=E2=80=94=20DAG=20data=20model=20+=20ASCII=20tree=20+=20JSON=20?= =?UTF-8?q?output=20(#305)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 of #245. Establishes the command surface and rendering path without any forge-side enrichment. Adds `parsec smartlog` (alias `sl`): - `SmartlogNode` per active worktree: ticket, branch, base, commits, plus `pr` / `ci` placeholder fields gated on `skip_serializing_if` so the JSON output stays clean until follow-up PRs populate them. - `collect_commits` shells out to `git log ..` (no `git2` dependency — matches the rest of `git/`). Soft-fails per worktree so a corrupt worktree can't take the whole command down. - `render_text` groups nodes by base branch and draws ASCII tree glyphs (○ │ ├─● └─). Returns the rendered string for testability. - `--json` emits the same structure for tooling. - `--depth N` caps commits per worktree (default 10). Out of scope for this PR (follow-ups on the same epic): - PR overlay (open/draft/merged) - CI overlay (passed/failed/running) - Review state (approved / changes requested) - Stack relationship visualization - Color / smarter wrapping Tests (9 new in `cli::commands::smartlog::tests`): - `parse_commit_line` happy path + tab-in-subject + garbage + empty SHA - `render_text` empty / single / no-commits / multi-base grouping - JSON serialization omits placeholder overlay fields Smoke run on parsec's own worktrees produces: ○ develop (base) ├─● 207 #207 [feature/207] │ └─ (no commits since develop) └─● 279 #279 [feature/279] └─ (no commits since develop) --- src/cli/commands/mod.rs | 2 + src/cli/commands/smartlog.rs | 315 +++++++++++++++++++++++++++++++++++ src/cli/mod.rs | 13 ++ 3 files changed, 330 insertions(+) create mode 100644 src/cli/commands/smartlog.rs diff --git a/src/cli/commands/mod.rs b/src/cli/commands/mod.rs index 5f7a5b0..ca06e4e 100644 --- a/src/cli/commands/mod.rs +++ b/src/cli/commands/mod.rs @@ -7,6 +7,7 @@ mod history; mod pr; mod release; mod ship; +pub mod smartlog; mod stack; mod tracker_cmds; mod workspace; @@ -20,6 +21,7 @@ pub use history::*; pub use pr::*; pub use release::*; pub use ship::*; +pub use smartlog::smartlog; pub use stack::*; pub use tracker_cmds::*; pub use workspace::*; diff --git a/src/cli/commands/smartlog.rs b/src/cli/commands/smartlog.rs new file mode 100644 index 0000000..50f987a --- /dev/null +++ b/src/cli/commands/smartlog.rs @@ -0,0 +1,315 @@ +//! `parsec smartlog` (alias `sl`) — visualize active worktrees as a commit DAG. +//! +//! Issue #245 — Phase 1 (skeleton): +//! - Collect every active worktree via [`WorktreeManager`] +//! - Read each worktree's commits since its base branch (`base..branch`) +//! - Render as ASCII tree, or emit JSON +//! +//! PR/CI/review overlay is intentionally **out of scope** for this PR; +//! [`SmartlogNode::pr`] / [`SmartlogNode::ci`] fields are placeholders that +//! later PRs will populate (e.g., GitHub PR state, CI run state, review state). + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use anyhow::Result; +use chrono::{DateTime, Utc}; +use serde::Serialize; + +use crate::config::ParsecConfig; +use crate::git; +use crate::output::Mode; +use crate::worktree::WorktreeManager; + +/// Default number of commits per worktree shown in the DAG. +const DEFAULT_DEPTH: usize = 10; + +/// One worktree's row in the smartlog output. +#[derive(Debug, Clone, Serialize)] +pub struct SmartlogNode { + pub ticket: String, + pub ticket_title: Option, + pub branch: String, + pub base_branch: String, + pub worktree_path: PathBuf, + pub commits: Vec, + /// PR overlay — populated by a later PR (see #245 follow-up). + #[serde(skip_serializing_if = "Option::is_none")] + pub pr: Option, + /// CI overlay — populated by a later PR (see #245 follow-up). + #[serde(skip_serializing_if = "Option::is_none")] + pub ci: Option, +} + +/// Single commit in a worktree's diff against its base. +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct CommitSummary { + pub sha_short: String, + pub subject: String, + pub author: String, + pub timestamp: DateTime, +} + +/// Entry point for the `smartlog` subcommand. +pub async fn smartlog(repo: &Path, depth: Option, mode: Mode) -> Result<()> { + let depth = depth.unwrap_or(DEFAULT_DEPTH); + let config = ParsecConfig::load()?; + let manager = WorktreeManager::new(repo, &config)?; + let workspaces = manager.list()?; + + let mut nodes = Vec::with_capacity(workspaces.len()); + for ws in workspaces { + let commits = collect_commits(&ws.path, &ws.base_branch, &ws.branch, depth) + // Soft-fail per worktree: a corrupt worktree shouldn't take the whole + // command down. Empty list is rendered as "(no commits)" instead. + .unwrap_or_default(); + nodes.push(SmartlogNode { + ticket: ws.ticket, + ticket_title: ws.ticket_title, + branch: ws.branch, + base_branch: ws.base_branch, + worktree_path: ws.path, + commits, + pr: None, + ci: None, + }); + } + + match mode { + Mode::Json => { + println!("{}", serde_json::to_string_pretty(&nodes)?); + } + _ => { + print!("{}", render_text(&nodes)); + } + } + Ok(()) +} + +/// Read commits in `base..branch` from a worktree, capped at `depth`. +/// +/// Pure shell-out to `git log` — no `git2` dependency, matches the rest of the +/// `git/` module's style. Returns empty `Vec` (not error) when range is empty +/// or git refuses to walk (e.g., orphan branch). +fn collect_commits( + worktree: &Path, + base: &str, + branch: &str, + depth: usize, +) -> Result> { + let range = format!("{}..{}", base, branch); + let limit = format!("-n{}", depth); + let raw = git::run_output( + worktree, + &["log", &range, "--pretty=format:%h\t%s\t%an\t%aI", &limit], + )?; + Ok(raw.lines().filter_map(parse_commit_line).collect()) +} + +/// Parse a single tab-separated line emitted by our `git log --pretty` format. +/// +/// Format: `\t\t\t`. +/// Any line that doesn't conform is silently dropped; this keeps the parser +/// resilient to commit messages containing tabs (we splitn by 4 so the first +/// three tabs are guaranteed to be the field separators). +fn parse_commit_line(line: &str) -> Option { + let mut parts = line.splitn(4, '\t'); + let sha_short = parts.next()?.trim().to_string(); + let subject = parts.next()?.to_string(); + let author = parts.next()?.to_string(); + let ts_raw = parts.next()?.trim(); + let timestamp = DateTime::parse_from_rfc3339(ts_raw) + .ok()? + .with_timezone(&Utc); + if sha_short.is_empty() { + return None; + } + Some(CommitSummary { + sha_short, + subject, + author, + timestamp, + }) +} + +/// Render an ASCII commit DAG, grouped by base branch. +/// +/// Returns the rendered string (instead of printing) so it's testable. Empty +/// node list returns a single explanatory line. +pub fn render_text(nodes: &[SmartlogNode]) -> String { + if nodes.is_empty() { + return "No active worktrees. Run `parsec start ` to create one.\n".to_string(); + } + + let mut by_base: BTreeMap> = BTreeMap::new(); + for n in nodes { + by_base.entry(n.base_branch.clone()).or_default().push(n); + } + + let mut out = String::new(); + let base_count = by_base.len(); + for (base_idx, (base, group)) in by_base.iter().enumerate() { + out.push_str(&format!("○ {} (base)\n", base)); + let last_idx = group.len().saturating_sub(1); + for (i, node) in group.iter().enumerate() { + let is_last = i == last_idx; + let branch_glyph = if is_last { "└" } else { "├" }; + let title = node.ticket_title.as_deref().unwrap_or("(no title)"); + out.push_str("│\n"); + out.push_str(&format!( + "{}─● {} {} [{}]\n", + branch_glyph, node.ticket, title, node.branch + )); + + let prefix = if is_last { " " } else { "│ " }; + if node.commits.is_empty() { + out.push_str(&format!("{}└─ (no commits since {})\n", prefix, base)); + } else { + let last_c = node.commits.len() - 1; + for (ci, c) in node.commits.iter().enumerate() { + let glyph = if ci == last_c { "└" } else { "├" }; + out.push_str(&format!( + "{}{}─ {} {}\n", + prefix, glyph, c.sha_short, c.subject + )); + } + } + } + if base_idx + 1 < base_count { + out.push('\n'); + } + } + out +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use chrono::TimeZone; + + fn mk_commit(sha: &str, subject: &str) -> CommitSummary { + CommitSummary { + sha_short: sha.to_string(), + subject: subject.to_string(), + author: "Eric".to_string(), + timestamp: Utc.with_ymd_and_hms(2026, 5, 13, 0, 0, 0).unwrap(), + } + } + + fn mk_node( + ticket: &str, + title: Option<&str>, + branch: &str, + commits: Vec, + ) -> SmartlogNode { + SmartlogNode { + ticket: ticket.to_string(), + ticket_title: title.map(|t| t.to_string()), + branch: branch.to_string(), + base_branch: "main".to_string(), + worktree_path: PathBuf::from(format!("/tmp/{}", ticket)), + commits, + pr: None, + ci: None, + } + } + + #[test] + fn parse_commit_line_basic() { + let c = + parse_commit_line("a1b2c3d\tFix auth bug\tEric\t2026-05-13T09:30:00+09:00").unwrap(); + assert_eq!(c.sha_short, "a1b2c3d"); + assert_eq!(c.subject, "Fix auth bug"); + assert_eq!(c.author, "Eric"); + } + + #[test] + fn parse_commit_line_subject_with_tabs() { + // splitn(4) means tabs in the subject are preserved (first 3 tabs are separators) + let c = parse_commit_line("aa\tsub\twith\ttab\tEric\t2026-05-13T09:30:00+09:00"); + // splitn(4, '\t') → ["aa", "sub", "with", "tab\tEric\t2026-05-13T09:30:00+09:00"] + // Last segment isn't a valid timestamp → None. + assert!(c.is_none(), "ambiguous line should be rejected"); + } + + #[test] + fn parse_commit_line_rejects_garbage() { + assert!(parse_commit_line("").is_none()); + assert!(parse_commit_line("only_one_field").is_none()); + assert!(parse_commit_line("a\tb\tc\tnot-a-date").is_none()); + } + + #[test] + fn parse_commit_line_rejects_empty_sha() { + assert!(parse_commit_line("\tsubject\tEric\t2026-05-13T09:30:00+09:00").is_none()); + } + + #[test] + fn render_text_empty() { + let s = render_text(&[]); + assert!(s.contains("No active worktrees")); + } + + #[test] + fn render_text_single_node() { + let nodes = vec![mk_node( + "CL-2283", + Some("Add rate limiting"), + "feature/CL-2283", + vec![mk_commit("a1b2c3d", "Implement rate limiter")], + )]; + let s = render_text(&nodes); + assert!(s.contains("○ main (base)")); + assert!(s.contains("CL-2283")); + assert!(s.contains("Add rate limiting")); + assert!(s.contains("[feature/CL-2283]")); + assert!(s.contains("a1b2c3d Implement rate limiter")); + } + + #[test] + fn render_text_no_commits_shows_placeholder() { + let nodes = vec![mk_node("CL-2291", None, "scratch/CL-2291", vec![])]; + let s = render_text(&nodes); + assert!(s.contains("(no commits since main)")); + assert!(s.contains("(no title)")); + } + + #[test] + fn render_text_multiple_nodes_groups_by_base() { + let mut a = mk_node( + "CL-1", + Some("A"), + "f/CL-1", + vec![mk_commit("aaaaaaa", "first commit")], + ); + a.base_branch = "main".to_string(); + let mut b = mk_node( + "CL-2", + Some("B"), + "f/CL-2", + vec![mk_commit("bbbbbbb", "second commit")], + ); + b.base_branch = "develop".to_string(); + let s = render_text(&[a, b]); + assert!(s.contains("○ main (base)")); + assert!(s.contains("○ develop (base)")); + // Both nodes should render their commits. + assert!(s.contains("first commit")); + assert!(s.contains("second commit")); + } + + #[test] + fn smartlog_node_serializes_without_overlay_fields() { + let node = mk_node("CL-1", Some("A"), "f/CL-1", vec![]); + let json = serde_json::to_string(&node).unwrap(); + // PR/CI placeholder fields use skip_serializing_if so they don't pollute + // the JSON output until a follow-up PR populates them. + assert!(!json.contains("\"pr\"")); + assert!(!json.contains("\"ci\"")); + assert!(json.contains("\"ticket\":\"CL-1\"")); + } +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 94f71f8..318faa8 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -501,6 +501,17 @@ pub enum Command { /// New ticket identifier new_ticket: String, }, + + /// 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. + #[command(alias = "sl")] + Smartlog { + /// Maximum commits per worktree (default: 10) + #[arg(long, short)] + depth: Option, + }, } #[derive(Subcommand)] @@ -596,6 +607,7 @@ pub async fn run(cli: Cli) -> Result<()> { Command::Create { .. } => "create", Command::Rename { .. } => "rename", Command::Compress { .. } => "compress", + Command::Smartlog { .. } => "smartlog", }; let exec_id = crate::execlog::new_execution_id(); let exec_started_at = chrono::Utc::now(); @@ -882,6 +894,7 @@ pub async fn run(cli: Cli) -> Result<()> { Command::Compress { ticket, message } => { commands::compress(&repo_path, ticket.as_deref(), message, output_mode).await } + Command::Smartlog { depth } => commands::smartlog(&repo_path, depth, output_mode).await, }; // Record execution entry (best-effort, never fail the command) From 66d560b1c45099ed81694a462863d390d052328a Mon Sep 17 00:00:00 2001 From: erish Date: Wed, 13 May 2026 09:42:50 +0900 Subject: [PATCH 03/26] [303] refactor(errors): adopt 3-line standard (error / caused by / help) (#306) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [303] refactor(errors): adopt 3-line standard (error / caused by / help) Issue #303. Add the infrastructure for the standard error format and wire main.rs to render it. Existing call sites keep rendering as a single line — migration is gradual (see docs/error-format.md). Changes: - `ParsecError` gains `caused_by: Option` and `help: Option` fields plus builder-style setters (`with_caused_by`, `with_help`). `Display` now writes one to three lines depending on which fields are populated. - `JsonError` gains the same two fields with `skip_serializing_if = "Option::is_none"` so existing `--json` consumers see no schema change. - `extract_full(&anyhow::Error) -> Option<&ParsecError>` helper for new callers; the legacy `extract_code` is kept untouched. - `main.rs` prefers the typed error: when the error is a `ParsecError`, print it directly (its `Display` already includes the `error:` prefix + code); otherwise fall back to the legacy single-line `error: {err:#}`. - `bail_code!` macro unchanged (for the "summary only" case). - `docs/error-format.md` documents the format, builder API, JSON shape, recipes, and what NOT to do. Tests: +9 in `errors::tests` covering 1/2/3-line display, help-only, extract_full happy/None, backward-compat extract_code, JSON skip-if-none, JSON with-fields, bail_code macro round-trip. Full suite 53 + 5 + 40 = 98 pass, fmt clean. Migration: any existing `ParsecError::new(...)` site can opt in by chaining `.with_caused_by(...)` and/or `.with_help(...)`. Prioritize cli/commands/ and worktree/ first (highest user contact). * [303] chore(errors): allow dead_code on Phase 1 builder + legacy extract_code Clippy `-D warnings` flagged `with_caused_by`, `with_help`, and `extract_code` as never-used. Phase 1 of #303 is the infra-only PR — call sites land in follow-up PRs. Annotation matches the existing `#[allow(dead_code)]` on `ErrorCode` for the same forward-looking reason. --- docs/error-format.md | 111 ++++++++++++++++++++++ src/errors.rs | 213 ++++++++++++++++++++++++++++++++++++++++++- src/main.rs | 39 +++++--- 3 files changed, 346 insertions(+), 17 deletions(-) create mode 100644 docs/error-format.md diff --git a/docs/error-format.md b/docs/error-format.md new file mode 100644 index 0000000..fc24360 --- /dev/null +++ b/docs/error-format.md @@ -0,0 +1,111 @@ +# Error message format (3-line standard) + +Issue [#303](https://github.com/erishforG/git-parsec/issues/303). All +user-facing errors should follow this format so users can quickly +distinguish *what* failed, *why*, and *what to do next*: + +``` +error: [] +caused by: +help: +``` + +Lines 2 and 3 are optional. If you only have the summary, that's a single +line — the format is additive. + +## Why this matters + +The first user contact with a failure is almost always a CLI line. If the +line answers all three of *what / why / now what?* the user does not need +to leave the terminal. If only *what* is shown, the user has to grep code +or open docs to figure out the next step. + +## How to write one + +Build a `ParsecError` with the builder methods: + +```rust +use crate::errors::{ErrorCode, ParsecError}; + +return Err(ParsecError::new( + ErrorCode::E005, + format!("workspace '{}' not found", ticket), +) +.with_caused_by(format!( + "directory missing or .git/parsec/state.json out of sync ({})", + path.display() +)) +.with_help("run `parsec doctor` to diagnose, or `parsec clean --orphans` to drop stale state") +.into()); +``` + +Renders as: + +``` +error: workspace 'CL-2283' not found [E005] +caused by: directory missing or .git/parsec/state.json out of sync (/Users/.../parsec/state.json) +help: run `parsec doctor` to diagnose, or `parsec clean --orphans` to drop stale state +``` + +JSON mode (`--json`) renders the same fields: + +```json +{ + "error": true, + "code": "E005", + "message": "workspace 'CL-2283' not found", + "caused_by": "directory missing or .git/parsec/state.json out of sync (/Users/.../parsec/state.json)", + "help": "run `parsec doctor` to diagnose, or `parsec clean --orphans` to drop stale state" +} +``` + +`caused_by` and `help` use `skip_serializing_if = "Option::is_none"`, so +existing JSON consumers see no schema change for errors that don't yet +adopt the format. + +## When to fill each line + +| Line | Fill when… | Skip when… | +|---|---|---| +| `error:` (always) | Always required — short, no period at the end. Mention the user-facing identifier (ticket / branch / file). | — | +| `caused by:` | The actual upstream reason is non-obvious or contains a path / numeric / external code. | The summary already names the cause unambiguously. | +| `help:` | There is a concrete next command, config key, or doc link. | Truly unrecoverable — but those are rare; prefer at least naming the docs. | + +## Quick recipes + +- **Missing config / token** → `caused by` names the env var / config key + searched, `help` lists the resolution order (e.g., `PARSEC_GITHUB_TOKEN`, + `gh auth login`). +- **State drift** (`.git/parsec/state.json` out of sync with disk) → + `caused by` mentions the path, `help` recommends `doctor` or + `clean --orphans`. +- **Network / forge error** → `caused by` includes the HTTP status and + the URL path (no secrets), `help` suggests `--offline` or retry. +- **Hook failure** → `caused by` includes the hook command and exit + code, `help` links to the hook config doc. + +## What not to do + +- ❌ Don't put the full anyhow chain into `caused by` — that's what the + underlying error chain is for. `caused by` should be one line a human + reads first. +- ❌ Don't include secrets (tokens, passwords, paths under `~/.config/` + that include credentials) — assume the line shows up in CI logs. +- ❌ Don't end any line with a period — match `git`'s house style. +- ❌ Don't write `help` as a question ("did you forget to set X?"). Make + it imperative ("set X" or "run `parsec ...`"). + +## Migration + +This PR adds the format; the existing `ParsecError::new(...)` call sites +keep rendering as a single line. Migrate them gradually: + +1. Whenever you touch an error site for any reason, add `with_caused_by` + and / or `with_help`. +2. Prioritize sites in `cli/commands/` and `worktree/` (highest user + contact). +3. Untyped `anyhow::anyhow!(...)` errors in user-facing paths should be + converted to `ParsecError::new(ErrorCode::E???, ...)` over time. + +The `bail_code!` macro stays as a quick path for the common "summary +only" case. For richer errors, build the `ParsecError` directly. diff --git a/src/errors.rs b/src/errors.rs index 958e337..89503b3 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -69,16 +69,41 @@ impl fmt::Display for ErrorCode { } } -/// A structured parsec error carrying an error code. -#[derive(Debug)] +/// A structured parsec error carrying an error code, plus optional cause and +/// help text. +/// +/// Issue #303 — error messages follow a 3-line standard so users can +/// distinguish *what failed*, *why*, and *what to do next*: +/// +/// ```text +/// error: workspace 'CL-2283' not found [E005] +/// caused by: directory missing or .git/parsec/state.json out of sync +/// help: run `parsec doctor`, or `parsec clean --orphans` to drop stale state +/// ``` +/// +/// `caused_by` and `help` are optional. Existing call sites that only set +/// `message` keep rendering as a single line — the format is additive. +#[derive(Debug, Clone)] pub struct ParsecError { pub code: ErrorCode, pub message: String, + pub caused_by: Option, + pub help: Option, } impl fmt::Display for ParsecError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "[{}] {}", self.code, self.message) + // Line 1: error summary + code (always present) + write!(f, "error: {} [{}]", self.message, self.code)?; + // Line 2: optional `caused by` + if let Some(ref cb) = self.caused_by { + write!(f, "\ncaused by: {}", cb)?; + } + // Line 3: optional `help` + if let Some(ref h) = self.help { + write!(f, "\nhelp: {}", h)?; + } + Ok(()) } } @@ -89,20 +114,56 @@ impl ParsecError { Self { code, message: message.into(), + caused_by: None, + help: None, } } + + /// Attach a `caused by` line — the upstream cause in plain language. + /// + /// Phase 1 of #303 ships the builder; call sites are migrated in + /// follow-up PRs (cli/commands/, worktree/). dead_code allow matches + /// the pattern used elsewhere in this module (e.g., `ErrorCode`). + #[allow(dead_code)] + pub fn with_caused_by(mut self, cause: impl Into) -> Self { + self.caused_by = Some(cause.into()); + self + } + + /// Attach a `help` line — the next action the user should take. + /// + /// See [`Self::with_caused_by`] for the dead_code rationale. + #[allow(dead_code)] + pub fn with_help(mut self, help: impl Into) -> Self { + self.help = Some(help.into()); + self + } } /// Structured JSON error envelope for `--json` mode. +/// +/// `caused_by` / `help` use `skip_serializing_if` so unset values don't appear +/// in the JSON output (older consumers continue to see the same shape they +/// always did — the additions are strictly opt-in per call site). #[derive(Serialize)] pub struct JsonError { pub error: bool, pub code: ErrorCode, pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub caused_by: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub help: Option, } -/// Try to extract a [`ParsecError`] (and its code) from an `anyhow::Error` chain. -/// Falls back to `E999` for untyped errors. +/// Try to extract a [`ParsecError`] (and its code) from an `anyhow::Error` +/// chain. Falls back to `E999` for untyped errors. +/// +/// Kept for backward compat with existing callers (returns just the code + +/// message). New code should prefer [`extract_full`]. dead_code allow +/// because main.rs migrated to extract_full as part of #303 and no other +/// caller exists yet. +#[allow(dead_code)] pub fn extract_code(err: &anyhow::Error) -> (ErrorCode, &str) { if let Some(pe) = err.downcast_ref::() { (pe.code, &pe.message) @@ -111,10 +172,152 @@ pub fn extract_code(err: &anyhow::Error) -> (ErrorCode, &str) { } } +/// Like [`extract_code`] but also returns optional `caused_by` / `help`. +/// +/// Returns `None` when the error is not a [`ParsecError`] — callers can fall +/// back to the raw `anyhow` chain in that case (typically `format!("{err:#}")`). +pub fn extract_full(err: &anyhow::Error) -> Option<&ParsecError> { + err.downcast_ref::() +} + /// Convenience macro: `bail_code!(ErrorCode::E005, "workspace '{}' not found", ticket)` +/// +/// For `caused_by` / `help`, build the `ParsecError` directly: +/// +/// ```ignore +/// return Err(ParsecError::new(ErrorCode::E005, format!("workspace '{}' not found", ticket)) +/// .with_caused_by("directory missing") +/// .with_help("run `parsec doctor`") +/// .into()); +/// ``` #[macro_export] macro_rules! bail_code { ($code:expr, $($arg:tt)*) => { return Err($crate::errors::ParsecError::new($code, format!($($arg)*)).into()) }; } + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn display_single_line_when_no_cause_or_help() { + let e = ParsecError::new(ErrorCode::E005, "workspace 'X' not found"); + assert_eq!(e.to_string(), "error: workspace 'X' not found [E005]"); + } + + #[test] + fn display_two_lines_with_caused_by() { + let e = ParsecError::new(ErrorCode::E005, "workspace 'X' not found") + .with_caused_by("directory missing"); + assert_eq!( + e.to_string(), + "error: workspace 'X' not found [E005]\ncaused by: directory missing" + ); + } + + #[test] + fn display_three_lines_with_caused_by_and_help() { + let e = ParsecError::new(ErrorCode::E005, "workspace 'X' not found") + .with_caused_by("directory missing or state.json out of sync") + .with_help("run `parsec doctor`, or `parsec clean --orphans`"); + let expected = "error: workspace 'X' not found [E005]\n\ + caused by: directory missing or state.json out of sync\n\ + help: run `parsec doctor`, or `parsec clean --orphans`"; + assert_eq!(e.to_string(), expected); + } + + #[test] + fn display_skips_caused_by_when_only_help_is_set() { + // help-only is allowed too — useful for "you need to do X" hints + // without a clear underlying cause. + let e = ParsecError::new(ErrorCode::E001, "no token configured") + .with_help("set PARSEC_GITHUB_TOKEN or run `gh auth login`"); + assert_eq!( + e.to_string(), + "error: no token configured [E001]\nhelp: set PARSEC_GITHUB_TOKEN or run `gh auth login`" + ); + } + + #[test] + fn extract_full_returns_typed_error() { + let pe = ParsecError::new(ErrorCode::E005, "msg") + .with_caused_by("cb") + .with_help("h"); + let err: anyhow::Error = pe.into(); + let extracted = extract_full(&err).expect("typed error"); + assert_eq!(extracted.code, ErrorCode::E005); + assert_eq!(extracted.caused_by.as_deref(), Some("cb")); + assert_eq!(extracted.help.as_deref(), Some("h")); + } + + #[test] + fn extract_full_returns_none_for_untyped_error() { + let err = anyhow::anyhow!("plain error"); + assert!(extract_full(&err).is_none()); + } + + #[test] + fn extract_code_backward_compat_for_typed() { + let pe = ParsecError::new(ErrorCode::E007, "no active workspaces"); + let err: anyhow::Error = pe.into(); + let (code, msg) = extract_code(&err); + assert_eq!(code, ErrorCode::E007); + assert_eq!(msg, "no active workspaces"); + } + + #[test] + fn extract_code_backward_compat_for_untyped() { + let err = anyhow::anyhow!("plain"); + let (code, msg) = extract_code(&err); + assert_eq!(code, ErrorCode::E999); + assert_eq!(msg, ""); + } + + #[test] + fn json_error_omits_unset_fields() { + let je = JsonError { + error: true, + code: ErrorCode::E005, + message: "msg".to_string(), + caused_by: None, + help: None, + }; + let s = serde_json::to_string(&je).unwrap(); + // Backward-compat: existing JSON consumers see the same 3 keys. + assert!(!s.contains("caused_by")); + assert!(!s.contains("\"help\"")); + assert!(s.contains("\"code\":\"E005\"")); + assert!(s.contains("\"message\":\"msg\"")); + } + + #[test] + fn json_error_includes_set_fields() { + let je = JsonError { + error: true, + code: ErrorCode::E005, + message: "msg".to_string(), + caused_by: Some("cb".to_string()), + help: Some("h".to_string()), + }; + let s = serde_json::to_string(&je).unwrap(); + assert!(s.contains("\"caused_by\":\"cb\"")); + assert!(s.contains("\"help\":\"h\"")); + } + + #[test] + fn bail_code_macro_still_works() { + fn doit() -> anyhow::Result<()> { + bail_code!(ErrorCode::E005, "ticket {} missing", "X"); + } + let err = doit().unwrap_err(); + let (code, msg) = extract_code(&err); + assert_eq!(code, ErrorCode::E005); + assert_eq!(msg, "ticket X missing"); + } +} diff --git a/src/main.rs b/src/main.rs index 654d49f..61b0d8b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -26,24 +26,39 @@ async fn main() { match cli::run(cli).await { Ok(()) => {} Err(err) => { - let (code, typed_msg) = errors::extract_code(&err); + // issue #303: prefer the typed ParsecError (which renders as the + // standard `error: / caused by: / help:` 3-line format via its + // Display impl). Fall back to the anyhow chain for untyped errors. + let typed = errors::extract_full(&err); + let code = typed.map(|pe| pe.code).unwrap_or(errors::ErrorCode::E999); if json_mode { - let msg = if typed_msg.is_empty() { - format!("{err:#}") - } else { - typed_msg.to_string() - }; - let je = errors::JsonError { - error: true, - code, - message: msg, + let je = match typed { + Some(pe) => errors::JsonError { + error: true, + code: pe.code, + message: pe.message.clone(), + caused_by: pe.caused_by.clone(), + help: pe.help.clone(), + }, + None => errors::JsonError { + error: true, + code: errors::ErrorCode::E999, + message: format!("{err:#}"), + caused_by: None, + help: None, + }, }; let _ = serde_json::to_writer(std::io::stdout(), &je); println!(); } else { - // Display the error with full context chain - eprintln!("error: {err:#}"); + match typed { + // Typed error already includes the `error:` prefix in its + // Display, so print it directly (3-line format, #303). + Some(pe) => eprintln!("{pe}"), + // Untyped: keep the legacy single-line behavior. + None => eprintln!("error: {err:#}"), + } } std::process::exit(code.exit_code()); From fb68e033c2a7428b8b55b319182b1117b350a370 Mon Sep 17 00:00:00 2001 From: erish Date: Mon, 18 May 2026 22:10:41 +0900 Subject: [PATCH 04/26] ci(windows): add VS2026 pre-validation job (#307) (#311) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an informational windows-2025-vs2026 job that runs cargo build/test on the new Visual Studio 2026 default image ahead of the GitHub Actions runner migration window (2026-06-08 ~ 06-15). continue-on-error: true keeps it advisory only — main matrix stays gated on existing windows-latest. After the migration window closes we either delete this job (if main matrix passes) or pin windows-2022 here (if it fails). Refs: #307 --- .github/workflows/ci.yml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6ba2807..45c69fa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -96,3 +96,18 @@ jobs: - uses: dtolnay/rust-toolchain@stable - run: cargo install cargo-audit --locked - run: cargo audit + + # Pre-validation for GitHub Actions Windows runner migration to VS 2026 + # (windows-latest / windows-2025 labels move to VS2026 default between + # 2026-06-08 and 2026-06-15). Informational until migration completes. + # Tracked in issue #307 — remove after migration window closes. + windows-vs2026-prevalidation: + name: Windows VS2026 Pre-Validation + runs-on: windows-2025-vs2026 + continue-on-error: true + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + - run: cargo build --release + - run: cargo test From 0aa2c0acd40b840df53a90b4c5a5da50a73d5009 Mon Sep 17 00:00:00 2001 From: erish Date: Sun, 24 May 2026 12:33:15 +0900 Subject: [PATCH 05/26] feat(completion): hidden __complete subcommand foundation (#291) (#312) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `parsec __complete ` — a hidden subcommand that shell completion scripts can call to enumerate candidates dynamically. Failure paths (no repo / no config) silently emit nothing so completion never errors at the prompt. Also adds `git::list_local_branches`, a helper for enumerating local branch names via `git for-each-ref refs/heads/`. This is the **foundation only**. The follow-up work — post-processing clap-generated completion scripts (zsh/bash/fish) so that ticket-shaped arguments call `parsec __complete worktrees` and branch-shaped arguments call `parsec __complete branches` — lands in a separate PR. Refs: #291 --- src/cli/commands/complete.rs | 55 ++++++++++++++++++++++++ src/cli/commands/mod.rs | 2 + src/cli/mod.rs | 21 ++++++++++ src/git/mod.rs | 14 +++++++ tests/cli_tests.rs | 81 ++++++++++++++++++++++++++++++++++++ 5 files changed, 173 insertions(+) create mode 100644 src/cli/commands/complete.rs diff --git a/src/cli/commands/complete.rs b/src/cli/commands/complete.rs new file mode 100644 index 0000000..c560f5f --- /dev/null +++ b/src/cli/commands/complete.rs @@ -0,0 +1,55 @@ +//! `parsec __complete ` — hidden helper for dynamic shell completion (#291). +//! +//! Emits newline-separated candidates to stdout. Shell completion scripts +//! (zsh/bash/fish) call this from inside the generated completion function +//! whenever the cursor is on a worktree- or branch-shaped argument. +//! +//! Failures are silent (empty output, exit 0) so that completion never +//! interrupts the user when, e.g., the cwd is not a git repo. + +use std::path::Path; + +use anyhow::Result; + +use crate::cli::CompleteKind; +use crate::config::ParsecConfig; +use crate::git; +use crate::worktree::WorktreeManager; + +pub async fn complete(repo_path: &Path, kind: CompleteKind) -> Result<()> { + match kind { + CompleteKind::Worktrees => emit_worktrees(repo_path), + CompleteKind::Branches => emit_branches(repo_path), + } + Ok(()) +} + +fn emit_worktrees(repo_path: &Path) { + let Ok(repo_root) = git::get_main_repo_root(repo_path) else { + return; + }; + let Ok(config) = ParsecConfig::load() else { + return; + }; + let Ok(manager) = WorktreeManager::new(&repo_root, &config) else { + return; + }; + let Ok(workspaces) = manager.list() else { + return; + }; + for ws in workspaces { + println!("{}", ws.ticket); + } +} + +fn emit_branches(repo_path: &Path) { + let Ok(repo_root) = git::get_main_repo_root(repo_path) else { + return; + }; + let Ok(branches) = git::list_local_branches(&repo_root) else { + return; + }; + for b in branches { + println!("{}", b); + } +} diff --git a/src/cli/commands/mod.rs b/src/cli/commands/mod.rs index ca06e4e..7a3b9b5 100644 --- a/src/cli/commands/mod.rs +++ b/src/cli/commands/mod.rs @@ -1,4 +1,5 @@ mod ci; +mod complete; mod compress; mod config; mod diff; @@ -13,6 +14,7 @@ mod tracker_cmds; mod workspace; pub use ci::*; +pub use complete::complete; pub use compress::*; pub use config::*; pub use diff::*; diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 318faa8..44e4b88 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -512,6 +512,25 @@ pub enum Command { #[arg(long, short)] depth: Option, }, + + /// Internal: emit dynamic completion candidates (issue #291). + /// + /// Used by shell completion scripts to enumerate worktrees / branches / + /// tickets at completion time. Not intended for direct user invocation. + #[command(name = "__complete", hide = true)] + Complete { + #[command(subcommand)] + kind: CompleteKind, + }, +} + +/// Candidate sets the dynamic completion subcommand can emit. +#[derive(Subcommand)] +pub enum CompleteKind { + /// Print active worktree ticket identifiers, one per line. + Worktrees, + /// Print local branch names, one per line. + Branches, } #[derive(Subcommand)] @@ -608,6 +627,7 @@ pub async fn run(cli: Cli) -> Result<()> { Command::Rename { .. } => "rename", Command::Compress { .. } => "compress", Command::Smartlog { .. } => "smartlog", + Command::Complete { .. } => "__complete", }; let exec_id = crate::execlog::new_execution_id(); let exec_started_at = chrono::Utc::now(); @@ -895,6 +915,7 @@ pub async fn run(cli: Cli) -> Result<()> { commands::compress(&repo_path, ticket.as_deref(), message, output_mode).await } Command::Smartlog { depth } => commands::smartlog(&repo_path, depth, output_mode).await, + Command::Complete { kind } => commands::complete(&repo_path, kind).await, }; // Record execution entry (best-effort, never fail the command) diff --git a/src/git/mod.rs b/src/git/mod.rs index a2d9467..6f7ee4d 100644 --- a/src/git/mod.rs +++ b/src/git/mod.rs @@ -165,6 +165,20 @@ pub fn get_current_branch(repo: &Path) -> Result { run_output(repo, &["rev-parse", "--abbrev-ref", "HEAD"]) } +/// Return all local branch names (no `refs/heads/` prefix, no leading `*`). +pub fn list_local_branches(repo: &Path) -> Result> { + let out = run_output( + repo, + &["for-each-ref", "--format=%(refname:short)", "refs/heads/"], + )?; + Ok(out + .lines() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) + .collect()) +} + /// Create a new worktree at `path` on a new branch `branch` based on `base`. pub fn worktree_add(repo: &Path, path: &Path, branch: &str, base: &str) -> Result<()> { let path_str = path diff --git a/tests/cli_tests.rs b/tests/cli_tests.rs index 3bc79ee..687f1b3 100644 --- a/tests/cli_tests.rs +++ b/tests/cli_tests.rs @@ -1166,6 +1166,87 @@ cache_strategy = "symlink" ); } +// --------------------------------------------------------------------------- +// __complete (hidden dynamic-completion helper, #291) +// --------------------------------------------------------------------------- + +#[test] +fn test_complete_is_hidden_from_help() { + let assertion = parsec().arg("--help").assert().success(); + let stdout = String::from_utf8(assertion.get_output().stdout.clone()).unwrap(); + assert!( + !stdout.contains("__complete"), + "__complete should be hidden from --help, got:\n{stdout}" + ); +} + +#[test] +fn test_complete_branches_lists_local_branches() { + let repo = setup_repo(); + let repo_path = repo.path().to_str().unwrap(); + + // Add a second local branch on top of the default main. + StdCommand::new("git") + .args(["branch", "feature/x"]) + .current_dir(repo.path()) + .output() + .unwrap(); + + let assertion = parsec() + .args(["__complete", "branches", "--repo", repo_path]) + .assert() + .success(); + let stdout = String::from_utf8(assertion.get_output().stdout.clone()).unwrap(); + + assert!(stdout.contains("main"), "expected 'main', got:\n{stdout}"); + assert!( + stdout.contains("feature/x"), + "expected 'feature/x', got:\n{stdout}" + ); +} + +#[test] +fn test_complete_worktrees_lists_tickets() { + let (repo, _bare) = setup_repo_with_remote(); + let repo_path = repo.path().to_str().unwrap(); + + parsec() + .args(["start", "COMP-001", "--repo", repo_path]) + .assert() + .success(); + + let assertion = parsec() + .args(["__complete", "worktrees", "--repo", repo_path]) + .assert() + .success(); + let stdout = String::from_utf8(assertion.get_output().stdout.clone()).unwrap(); + + assert!( + stdout.contains("COMP-001"), + "expected 'COMP-001', got:\n{stdout}" + ); +} + +#[test] +fn test_complete_outside_git_repo_is_silent_success() { + // Empty temp dir, no git repo — completion must not error or print noise. + let dir = TempDir::new().unwrap(); + let assertion = parsec() + .args([ + "__complete", + "branches", + "--repo", + dir.path().to_str().unwrap(), + ]) + .assert() + .success(); + let stdout = String::from_utf8(assertion.get_output().stdout.clone()).unwrap(); + assert!( + stdout.trim().is_empty(), + "expected empty stdout outside repo, got:\n{stdout}" + ); +} + // --------------------------------------------------------------------------- // JSON error format // --------------------------------------------------------------------------- From 8fb0e07b1adf1a53820cb0d2d0d8c5c44aebcd79 Mon Sep 17 00:00:00 2001 From: erish Date: Sun, 24 May 2026 12:33:57 +0900 Subject: [PATCH 06/26] =?UTF-8?q?test(cli):=20compress=20/=20config=20sche?= =?UTF-8?q?ma=20/=20log=20--export=20=ED=86=B5=ED=95=A9=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80=20(#315)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(cli): add integration tests for compress, config schema, log --export Cover three commands added in v0.4.0 that had zero test coverage: - test_compress_nothing_to_do: single-commit worktree exits cleanly - test_compress_squashes_commits: 2-commit branch is reduced to 1 - test_config_schema_outputs_json: output is valid JSON Schema - test_history_log_export_empty: empty log exits 0 with no stdout Closes #314 Co-Authored-By: Claude Opus 4.7 * ci: re-trigger Branch Policy (base now develop) --------- Co-authored-by: Claude Opus 4.7 --- tests/cli_tests.rs | 166 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 166 insertions(+) diff --git a/tests/cli_tests.rs b/tests/cli_tests.rs index 687f1b3..50f0947 100644 --- a/tests/cli_tests.rs +++ b/tests/cli_tests.rs @@ -1274,3 +1274,169 @@ fn test_json_error_format() { assert!(parsed.get("code").is_some()); assert!(parsed.get("message").is_some()); } + +// --------------------------------------------------------------------------- +// compress command (issue #314) +// --------------------------------------------------------------------------- + +/// When the worktree has only the initial "start" commit (0 commits above +/// merge-base), compress must report "Nothing to compress" and exit 0. +#[test] +fn test_compress_nothing_to_do() { + let (repo, _bare) = setup_repo_with_remote(); + let repo_path = repo.path().to_str().unwrap(); + + // Create a workspace + parsec() + .args(["start", "COMP-1", "--repo", repo_path]) + .assert() + .success(); + + // compress: single commit (merge-base == HEAD), nothing to squash + parsec() + .args(["compress", "COMP-1", "--repo", repo_path]) + .assert() + .success() + .stdout(predicate::str::contains("Nothing to compress")); +} + +/// When the worktree has 2+ commits above merge-base, compress squashes them +/// into one and reports the count. +#[test] +fn test_compress_squashes_commits() { + let (repo, _bare) = setup_repo_with_remote(); + let repo_path = repo.path(); + + // Start a workspace + parsec() + .args(["start", "COMP-2", "--repo", repo_path.to_str().unwrap()]) + .assert() + .success(); + + // Locate the sibling worktree directory + let repo_name = repo_path.file_name().unwrap().to_string_lossy().to_string(); + let wt_path = repo_path + .parent() + .unwrap() + .join(format!("{}.COMP-2", repo_name)); + + // Make two distinct commits in the worktree + std::fs::write(wt_path.join("a.txt"), "alpha").unwrap(); + StdCommand::new("git") + .args(["add", "a.txt"]) + .current_dir(&wt_path) + .output() + .unwrap(); + StdCommand::new("git") + .args(["commit", "-m", "first change"]) + .current_dir(&wt_path) + .output() + .unwrap(); + + std::fs::write(wt_path.join("b.txt"), "beta").unwrap(); + StdCommand::new("git") + .args(["add", "b.txt"]) + .current_dir(&wt_path) + .output() + .unwrap(); + StdCommand::new("git") + .args(["commit", "-m", "second change"]) + .current_dir(&wt_path) + .output() + .unwrap(); + + // compress should squash both commits and report "Compressed 2 commits" + parsec() + .args(["compress", "COMP-2", "--repo", repo_path.to_str().unwrap()]) + .assert() + .success() + .stdout(predicate::str::contains("Compressed 2 commits")); + + // Verify only 1 commit now sits above merge-base + let merge_base = StdCommand::new("git") + .args(["merge-base", "HEAD", "main"]) + .current_dir(&wt_path) + .output() + .unwrap(); + let merge_base_sha = String::from_utf8(merge_base.stdout) + .unwrap() + .trim() + .to_string(); + + let count_out = StdCommand::new("git") + .args(["rev-list", "--count", &format!("{}..HEAD", merge_base_sha)]) + .current_dir(&wt_path) + .output() + .unwrap(); + let count: u64 = String::from_utf8(count_out.stdout) + .unwrap() + .trim() + .parse() + .unwrap(); + assert_eq!( + count, 1, + "compress should leave exactly 1 commit above merge-base" + ); +} + +// --------------------------------------------------------------------------- +// config schema command (issue #314) +// --------------------------------------------------------------------------- + +/// `parsec config schema` must exit 0 and emit well-formed JSON. +#[test] +fn test_config_schema_outputs_json() { + let repo = setup_repo(); + + let output = parsec() + .args(["config", "schema", "--repo", repo.path().to_str().unwrap()]) + .output() + .unwrap(); + + assert!(output.status.success(), "config schema should exit 0"); + + let stdout = String::from_utf8(output.stdout).unwrap(); + let parsed: serde_json::Value = + serde_json::from_str(&stdout).expect("config schema output must be valid JSON"); + + // JSON Schema documents must have a $schema or type/properties field + assert!( + parsed.get("$schema").is_some() + || parsed.get("type").is_some() + || parsed.get("properties").is_some(), + "output does not look like a JSON Schema document" + ); +} + +// --------------------------------------------------------------------------- +// history log --export command (issue #314) +// --------------------------------------------------------------------------- + +/// `parsec log --export` in a repo with no prior parsec operations should exit +/// 0. When the execlog is empty it writes a message to stderr and nothing to +/// stdout (or exits successfully with empty stdout). +#[test] +fn test_history_log_export_empty() { + let repo = setup_repo(); + + let output = parsec() + .args(["log", "--export", "--repo", repo.path().to_str().unwrap()]) + .output() + .unwrap(); + + // Should not fail + assert!( + output.status.success(), + "log --export should succeed even when log is empty" + ); + + // Either stdout is empty OR stderr mentions the empty state + let stdout = String::from_utf8(output.stdout).unwrap(); + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!( + stdout.is_empty() || stderr.contains("No execution log"), + "expected empty stdout or informational stderr, got stdout={:?} stderr={:?}", + stdout, + stderr + ); +} From 757e927e3f868902db5df8e8cd1dd3aba35e4e29 Mon Sep 17 00:00:00 2001 From: erish Date: Mon, 25 May 2026 09:57:06 +0900 Subject: [PATCH 07/26] =?UTF-8?q?docs(changelog):=20[Unreleased]=EC=97=90?= =?UTF-8?q?=20smartlog=C2=B7complete=C2=B7errors=C2=B7win-ci=20=ED=95=AD?= =?UTF-8?q?=EB=AA=A9=20=EC=B6=94=EA=B0=80=20(#317)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit develop에 머지됐지만 CHANGELOG.md [Unreleased]에 누락된 항목 4개를 추가한다: - parsec smartlog (alias sl): commit DAG 시각화 (#245, #305) - parsec __complete: 동적 shell completion 헬퍼 (#291, #312) - 에러 메시지 3줄 표준화 (error/caused by/help) (#303, #306) - Windows VS2026 pre-validation CI job (#307, #311) Closes #316 Co-authored-by: Claude Sonnet 4.6 --- CHANGELOG.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ea5bc79..ee85916 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - **v0.5 milestone opened** — see Roadmap in README. Themes: smartlog · TUI dashboard · speculative merge · `parsec test` · AI PR descriptions. +- **`parsec smartlog` (alias `sl`)** — visualize active worktrees as a commit + DAG. ASCII tree groups worktrees by base branch and shows commits since + merge-base; `--json` output for tooling. PR/CI overlay reserved for a + follow-up (#245, #305). +- **`parsec __complete` shell-completion helper** — hidden `__complete + ` subcommand emits newline-separated completion + candidates. Enables dynamic worktree/branch tab-completion in zsh, bash, + and fish without bundling shell-specific generators (#291, #312). ### Fixed - `parsec ship` falls back to `gh auth token` when `PARSEC_GITHUB_TOKEN` / @@ -17,6 +25,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 doctor` and the tracker layer (#281). The fallback is restricted to GitHub hosts so Bitbucket / GitLab remotes are unaffected. +### Changed +- **Error messages standardized to 3-line format** — every user-facing error + now follows `error: / caused by: / help: ` + so error output is consistent and actionable (#303, #306). + +### CI +- Windows VS2026 (Visual Studio 2026 runner) pre-validation job added to the + test matrix, catching MSVC toolchain regressions before release (#307, #311). + ## [0.4.0] - 2026-05-04 ### Added From 5bd889a4b4d5ce14ad4089f8ad88e53c15377203 Mon Sep 17 00:00:00 2001 From: erish Date: Tue, 26 May 2026 10:07:25 +0900 Subject: [PATCH 08/26] =?UTF-8?q?test(cli):=20parsec=20smartlog=20/=20sl?= =?UTF-8?q?=20=ED=86=B5=ED=95=A9=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(#318)=20(#319)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit parsec smartlog (#305)과 sl 별칭이 develop에 머지됐지만 CLI 통합 테스트가 전혀 없었음. 5개 시나리오 추가: - test_smartlog_empty_repo: 빈 repo → 'No active worktrees' 확인 - test_sl_alias_works_like_smartlog: sl == smartlog 동일 출력 확인 - test_smartlog_json_empty_is_array: --json 빈 repo → [] JSON 배열 - test_smartlog_shows_worktree: worktree 1개 → 티켓·(base) 마커 표시 - test_smartlog_json_one_worktree: --json 1 worktree → 필드 검증 (ticket, branch, base_branch, commits, pr/ci 미노출) Closes #318 Co-authored-by: Claude Sonnet 4.6 --- tests/cli_tests.rs | 154 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 154 insertions(+) diff --git a/tests/cli_tests.rs b/tests/cli_tests.rs index 50f0947..e350cff 100644 --- a/tests/cli_tests.rs +++ b/tests/cli_tests.rs @@ -1440,3 +1440,157 @@ fn test_history_log_export_empty() { stderr ); } + +// --------------------------------------------------------------------------- +// parsec smartlog / sl (issue #245, #305) +// --------------------------------------------------------------------------- + +/// `parsec smartlog` in a repo with no active worktrees should exit 0 and +/// print the "No active worktrees" placeholder message. +#[test] +fn test_smartlog_empty_repo() { + let repo = setup_repo(); + + parsec() + .args(["smartlog", "--repo", repo.path().to_str().unwrap()]) + .assert() + .success() + .stdout(predicate::str::contains("No active worktrees")); +} + +/// `parsec sl` (alias) must behave identically to `parsec smartlog`. +#[test] +fn test_sl_alias_works_like_smartlog() { + let repo = setup_repo(); + + let smartlog_out = parsec() + .args(["smartlog", "--repo", repo.path().to_str().unwrap()]) + .output() + .unwrap(); + let sl_out = parsec() + .args(["sl", "--repo", repo.path().to_str().unwrap()]) + .output() + .unwrap(); + + assert!(smartlog_out.status.success(), "smartlog should succeed"); + assert!(sl_out.status.success(), "sl alias should succeed"); + assert_eq!( + smartlog_out.stdout, sl_out.stdout, + "`sl` and `smartlog` must produce identical output" + ); +} + +/// `parsec smartlog --json` in an empty repo must return a valid, empty JSON +/// array and exit 0. +#[test] +fn test_smartlog_json_empty_is_array() { + let repo = setup_repo(); + + let output = parsec() + .args([ + "smartlog", + "--json", + "--repo", + repo.path().to_str().unwrap(), + ]) + .output() + .unwrap(); + + assert!(output.status.success(), "smartlog --json should exit 0"); + + let stdout = String::from_utf8(output.stdout).unwrap(); + let parsed: serde_json::Value = + serde_json::from_str(&stdout).expect("smartlog --json must emit valid JSON"); + assert!( + parsed.is_array(), + "smartlog --json should emit a JSON array, got: {parsed}" + ); + assert_eq!( + parsed.as_array().unwrap().len(), + 0, + "empty repo → empty array" + ); +} + +/// After creating a workspace, `parsec smartlog` should display the ticket +/// name, branch, and base branch in the ASCII tree. +#[test] +fn test_smartlog_shows_worktree() { + let (repo, _bare) = setup_repo_with_remote(); + let repo_path = repo.path().to_str().unwrap(); + + parsec() + .args(["start", "SL-1", "--repo", repo_path]) + .assert() + .success(); + + let output = parsec() + .args(["smartlog", "--repo", repo_path]) + .assert() + .success(); + + let stdout = String::from_utf8(output.get_output().stdout.clone()).unwrap(); + assert!( + stdout.contains("SL-1"), + "smartlog should show ticket 'SL-1', got:\n{stdout}" + ); + // The ASCII tree marks base branch with "○ (base)" + assert!( + stdout.contains("(base)"), + "smartlog should show a base-branch marker, got:\n{stdout}" + ); +} + +/// `parsec smartlog --json` with one active worktree must return a JSON array +/// containing exactly one object with expected fields. +#[test] +fn test_smartlog_json_one_worktree() { + let (repo, _bare) = setup_repo_with_remote(); + let repo_path = repo.path().to_str().unwrap(); + + parsec() + .args(["start", "SL-2", "--repo", repo_path]) + .assert() + .success(); + + let output = parsec() + .args(["smartlog", "--json", "--repo", repo_path]) + .output() + .unwrap(); + + assert!(output.status.success(), "smartlog --json should exit 0"); + + let stdout = String::from_utf8(output.stdout).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&stdout).expect("must be valid JSON"); + + let arr = parsed.as_array().expect("must be a JSON array"); + assert_eq!(arr.len(), 1, "expected exactly 1 worktree entry"); + + let entry = &arr[0]; + assert_eq!( + entry["ticket"].as_str().unwrap(), + "SL-2", + "ticket field mismatch" + ); + assert!( + entry.get("branch").is_some(), + "entry must have a 'branch' field" + ); + assert!( + entry.get("base_branch").is_some(), + "entry must have a 'base_branch' field" + ); + assert!( + entry.get("commits").is_some(), + "entry must have a 'commits' field" + ); + // PR / CI overlay fields must NOT appear when unset (skip_serializing_if) + assert!( + entry.get("pr").is_none(), + "unset 'pr' field must be omitted from JSON" + ); + assert!( + entry.get("ci").is_none(), + "unset 'ci' field must be omitted from JSON" + ); +} From ff122c08f69fc49b8b85db80b2f6aae107ab7e10 Mon Sep 17 00:00:00 2001 From: erish Date: Wed, 27 May 2026 10:32:17 +0900 Subject: [PATCH 09/26] =?UTF-8?q?docs(commands):=20diff=C2=B7history=20?= =?UTF-8?q?=EB=AA=A8=EB=93=88=20RustDoc=20=EC=B6=94=EA=B0=80=20(#321)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit diff.rs, history.rs에 module-level(//!) 및 function-level(///) RustDoc을 추가. 인접한 smartlog.rs·complete.rs와 일관성 맞춤. - diff.rs: //! 모듈 헤더(diff/conflicts/sync 역할·관련 이슈), pub async fn diff/conflicts/sync에 파라미터·출력모드·동작 설명 추가 - history.rs: //! 모듈 헤더(OpLog·ExecLog 구조 설명), pub async fn log/log_export/undo에 동작·edge-case 설명 추가 프로덕션 코드 변경 없음 (주석 전용). Closes #320 Co-authored-by: Claude Sonnet 4.6 --- src/cli/commands/diff.rs | 41 +++++++++++++++++++++++++++++++++++++ src/cli/commands/history.rs | 30 +++++++++++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/src/cli/commands/diff.rs b/src/cli/commands/diff.rs index 80dcdd6..e661873 100644 --- a/src/cli/commands/diff.rs +++ b/src/cli/commands/diff.rs @@ -1,3 +1,14 @@ +//! `parsec diff` / `parsec conflicts` / `parsec sync` — worktree-aware diff and sync. +//! +//! ## Commands +//! - **`parsec diff [ticket]`** — show changes in a worktree against its merge-base. +//! Supports `--stat`, `--name-only`, and `--json` output modes. +//! - **`parsec conflicts`** — pre-flight check that scans all active worktrees for +//! files that diverge from a common ancestor (speculative conflict detection). +//! - **`parsec sync [ticket]`** — fast-forward an active worktree against the latest +//! upstream base branch via rebase (default) or merge. See issue #290 for the +//! full roadmap. + use std::path::Path; use anyhow::Result; @@ -8,6 +19,18 @@ use crate::git; use crate::output::{self, Mode}; use crate::worktree::WorktreeManager; +/// Show the diff between a worktree's current state and its merge-base with the +/// upstream base branch (`origin/`). +/// +/// If `ticket` is `None`, the function auto-detects the worktree by comparing +/// `cwd` against known worktree paths; returns an error if the cwd is outside +/// any parsec-managed worktree. +/// +/// Output modes: +/// - `--name-only` → list of changed file paths (human or JSON) +/// - `--stat` → diffstat summary (human or JSON) +/// - default → full unified diff piped to the terminal (human) or +/// name-status pairs (JSON) pub async fn diff( repo: &Path, ticket: Option<&str>, @@ -69,6 +92,12 @@ pub async fn diff( Ok(()) } +/// Detect files that are modified in multiple active worktrees simultaneously. +/// +/// Scans every workspace returned by [`WorktreeManager::list`] and compares +/// the set of changed files. Pairs of worktrees that touch the same path are +/// reported as potential conflicts so the developer can resolve them before +/// merging. Does **not** modify any worktree state. pub async fn conflicts(repo: &Path, mode: Mode) -> Result<()> { let config = ParsecConfig::load()?; let manager = WorktreeManager::new(repo, &config)?; @@ -80,6 +109,18 @@ pub async fn conflicts(repo: &Path, mode: Mode) -> Result<()> { Ok(()) } +/// Sync one or more worktrees with the latest state of their upstream base branch. +/// +/// Fetches `origin/` and applies either a **rebase** (default, +/// `strategy = "rebase"`) or a **merge** (`strategy = "merge"`). A failed +/// rebase/merge is automatically aborted so the worktree is left clean. +/// +/// Selection logic (in order): +/// 1. `--all` → all active worktrees +/// 2. `ticket` → the named worktree only +/// 3. auto-detect → the worktree whose path contains `cwd` +/// +/// Returns a summary of synced tickets and any failures via [`output::print_sync`]. pub async fn sync( repo: &Path, ticket: Option<&str>, diff --git a/src/cli/commands/history.rs b/src/cli/commands/history.rs index abf8b29..1ce95d7 100644 --- a/src/cli/commands/history.rs +++ b/src/cli/commands/history.rs @@ -1,3 +1,12 @@ +//! `parsec log` / `parsec log --export` / `parsec undo` — operation history and undo. +//! +//! Parsec maintains two complementary audit trails: +//! - **OpLog** (`~/.parsec/oplog.json`) — structured log of high-level parsec +//! operations (start, ship, clean, adopt, undo). Displayed by `parsec log` and +//! used by `parsec undo` to reconstruct the previous state. +//! - **ExecLog** (`.parsec/execlog.ndjson` in the repo) — low-level shell command +//! trace for debugging. Exported verbatim by `parsec log --export`. + use std::path::Path; use anyhow::{Context, Result}; @@ -7,6 +16,11 @@ use crate::errors::ErrorCode; use crate::git; use crate::output::{self, Mode}; +/// Display the parsec operation log, optionally filtered to a single ticket. +/// +/// `last` controls how many entries to show (counted from the end of the log). +/// When `ticket` is `Some`, only entries matching that ticket are shown. +/// Uses [`output::print_log`] for human/JSON rendering. pub async fn log(repo: &Path, ticket: Option<&str>, last: usize, mode: Mode) -> Result<()> { let repo_root = git::get_main_repo_root(repo).or_else(|_| git::get_repo_root(repo))?; let oplog = crate::oplog::OpLog::load(&repo_root)?; @@ -18,6 +32,9 @@ pub async fn log(repo: &Path, ticket: Option<&str>, last: usize, mode: Mode) -> Ok(()) } +/// Dump the raw execution log (ndjson) to stdout for debugging or external tooling. +/// +/// Prints a warning to stderr when the log is empty; exits cleanly in either case. pub async fn log_export(repo: &Path) -> Result<()> { let repo_root = git::get_main_repo_root(repo).or_else(|_| git::get_repo_root(repo))?; let raw = crate::execlog::read_raw(&repo_root)?; @@ -29,6 +46,19 @@ pub async fn log_export(repo: &Path) -> Result<()> { Ok(()) } +/// Undo the most recent reversible parsec operation. +/// +/// Reads the last [`OpLog`] entry and reverses it: +/// - `start` / `adopt` — removes the worktree, deletes the local branch, and +/// drops the workspace from state. +/// - `ship` / `clean` — re-creates the worktree from the local branch (or +/// restores it from `origin/` if the local ref is gone). +/// +/// `undo` itself is **not** re-undoable; attempting `parsec undo` after `parsec +/// undo` returns [`ErrorCode::E013`]. +/// +/// When `dry_run = true` the intended action is printed but no mutations are +/// performed. pub async fn undo(repo: &Path, dry_run: bool, mode: Mode) -> Result<()> { let config = ParsecConfig::load()?; let repo_root = git::get_main_repo_root(repo).or_else(|_| git::get_repo_root(repo))?; From 9366df594522da9c7e7208808051aa9982de2e50 Mon Sep 17 00:00:00 2001 From: erish Date: Thu, 28 May 2026 11:12:14 +0900 Subject: [PATCH 10/26] =?UTF-8?q?docs(commands):=20stack=C2=B7ci=20?= =?UTF-8?q?=EB=AA=A8=EB=93=88=20RustDoc=20=EC=B6=94=EA=B0=80=20(#323)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit stack.rs: //! 모듈 헤더 + stack/stack_sync/stack_submit 함수 doc ci.rs: //! 모듈 헤더 + ci/fetch_bitbucket_ci 함수 doc 프로덕션 코드 변경 없음 — 주석 73줄 추가만. Closes #322 Co-authored-by: Claude Sonnet 4.6 --- src/cli/commands/ci.rs | 32 ++++++++++++++++++++++++++++++ src/cli/commands/stack.rs | 41 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+) diff --git a/src/cli/commands/ci.rs b/src/cli/commands/ci.rs index f20cb3c..23d88a8 100644 --- a/src/cli/commands/ci.rs +++ b/src/cli/commands/ci.rs @@ -1,3 +1,17 @@ +//! `parsec ci` — forge-agnostic CI status for shipped PRs. +//! +//! Queries GitHub Actions check runs or Bitbucket Pipelines for PRs that were +//! created by `parsec ship`. The forge backend is selected automatically from +//! the `origin` remote URL; GitHub takes priority when both tokens are set. +//! +//! ## Commands +//! - **`parsec ci [ticket…]`** — print the current CI status for one or more +//! tickets. +//! - **`parsec ci --all`** — check every ticket that has a shipped PR in the +//! oplog. +//! - **`parsec ci --watch`** — poll every 5 s and redraw the terminal until all +//! checks reach a terminal state (human mode only). + use std::path::Path; use anyhow::{Context, Result}; @@ -16,6 +30,20 @@ enum Forge { Bitbucket(bitbucket::BitbucketClient), } +/// Show CI check status for one or more worktrees' shipped PRs. +/// +/// # Resolution order +/// 1. `--all` → every `Ship` oplog entry. +/// 2. `tickets` (non-empty) → look up the most recent `Ship` entry per ticket, +/// falling back to a live PR search by branch name. +/// 3. Neither → auto-detect from `cwd`. +/// +/// # Watch mode +/// When `watch = true` (and `mode == Human`) the terminal is cleared every 5 s +/// and CI statuses are redrawn until all checks reach a terminal state. +/// Watch mode is silently disabled for JSON output to allow piping. +/// +/// Exits with [`ErrorCode::E002`] when any check is in a failing state. pub async fn ci(repo: &Path, tickets: &[&str], watch: bool, all: bool, mode: Mode) -> Result<()> { let config = ParsecConfig::load()?; let repo_root = git::get_main_repo_root(repo).or_else(|_| git::get_repo_root(repo))?; @@ -193,6 +221,10 @@ pub async fn ci(repo: &Path, tickets: &[&str], watch: bool, all: bool, mode: Mod /// Fetch the latest pipeline for the PR's source branch and shape it into the /// same `CiStatus` struct GitHub emits, so the renderer stays forge-agnostic. +/// +/// Returns an empty [`CiStatus`] (overall = `"no checks"`) when the PR's +/// source branch cannot be resolved — matching the behaviour of GitHub's +/// "no checks" path rather than propagating an error. async fn fetch_bitbucket_ci( bb: &bitbucket::BitbucketClient, pr_id: u64, diff --git a/src/cli/commands/stack.rs b/src/cli/commands/stack.rs index 4004f3d..55ee261 100644 --- a/src/cli/commands/stack.rs +++ b/src/cli/commands/stack.rs @@ -1,3 +1,18 @@ +//! `parsec stack` / `parsec stack sync` / `parsec stack submit` — stacked worktree management. +//! +//! A *stack* is a chain of worktrees where each child is based on its parent's +//! branch instead of the shared base branch. Stacks are created via +//! `parsec start --on ` and allow dependent changes to +//! be developed in parallel without waiting for parent PRs to land. +//! +//! ## Commands +//! - **`parsec stack`** — list all worktrees that participate in at least one +//! stack (have a `parent_ticket` or are themselves a parent). +//! - **`parsec stack sync`** — rebase every stack from root to leaves so that +//! each child always sits on top of its parent's latest commit. +//! - **`parsec stack submit`** — ship the entire stack in topological order +//! (root first, leaves last) by calling [`super::ship`] for each member. + use std::path::Path; use anyhow::Result; @@ -7,6 +22,14 @@ use crate::git; use crate::output::{self, Mode}; use crate::worktree::WorktreeManager; +/// List all worktrees that are part of a stack. +/// +/// A worktree participates in a stack when it either: +/// - has a `parent_ticket` (it is a child), **or** +/// - is referenced as the parent of another worktree. +/// +/// Outputs nothing (human) or an empty JSON array when no stacks exist, +/// with a hint message in human mode. pub async fn stack(repo: &Path, mode: Mode) -> Result<()> { let config = ParsecConfig::load()?; let manager = WorktreeManager::new(repo, &config)?; @@ -38,6 +61,17 @@ pub async fn stack(repo: &Path, mode: Mode) -> Result<()> { Ok(()) } +/// Synchronise a stack by rebasing every worktree onto its dependency. +/// +/// Traversal order (BFS from each root): +/// 1. Root worktrees (no parent) are rebased onto `origin/`. +/// 2. Each child is then rebased onto its parent's local branch tip. +/// +/// A failed rebase is automatically aborted (`git rebase --abort`) so the +/// worktree is left in a clean state. The failed ticket is recorded in +/// `failed` and processing continues with the next root. +/// +/// Returns a summary of synced and failed tickets via [`output::print_sync`]. pub async fn stack_sync(repo: &Path, mode: Mode) -> Result<()> { let config = ParsecConfig::load()?; let manager = WorktreeManager::new(repo, &config)?; @@ -118,6 +152,13 @@ pub async fn stack_sync(repo: &Path, mode: Mode) -> Result<()> { } /// Ship the entire stack in topological order (#235). +/// +/// Determines the topological order (BFS from roots) and calls +/// [`super::ship`] for each worktree. Processing stops at the first failure +/// to avoid creating PRs with a broken dependency chain. +/// +/// `mode` is forwarded to `ship` for consistent output formatting. +/// Returns an error when one or more tickets could not be shipped. pub async fn stack_submit(repo: &Path, mode: Mode) -> Result<()> { let config = ParsecConfig::load()?; let manager = WorktreeManager::new(repo, &config)?; From 65b4fd142d5bd85f554d44b449723c3d1e3b867d Mon Sep 17 00:00:00 2001 From: erish Date: Fri, 29 May 2026 10:12:22 +0900 Subject: [PATCH 11/26] =?UTF-8?q?feat(health):=20parsec=20health=20Phase?= =?UTF-8?q?=201=20=E2=80=94=20lock/uncommitted/stale=20check=20skeleton=20?= =?UTF-8?q?(#324)=20(#325)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `parsec health` command that scans all active worktrees for three lightweight health indicators: - has_lock — .git/index.lock exists (interrupted git process) - uncommitted — count of staged + unstaged files - stale — last commit older than 7 days Output (human mode): colored table with ✓/⚠/✗ per worktree and a summary line. JSON mode emits a structured array with `all_healthy` flag. Files changed: - src/cli/commands/health.rs (new, ~99 lines) - src/cli/commands/mod.rs (+2 lines, re-export) - src/cli/mod.rs (+11 lines, Health variant + dispatch) - src/output/mod.rs (+15 lines, HealthRecord + dispatch_output!) - src/output/human.rs (+66 lines, colored table renderer) - src/output/json.rs (+34 lines, JSON renderer) CI-status overlay deferred to Phase 2 (depends on #309/#310). All checks are read-only; no worktree state is modified. Closes #324 Refs #299 Co-authored-by: Claude Opus 4.7 --- src/cli/commands/health.rs | 99 ++++++++++++++++++++++++++++++++++++++ src/cli/commands/mod.rs | 2 + src/cli/mod.rs | 11 +++++ src/output/human.rs | 66 +++++++++++++++++++++++++ src/output/json.rs | 34 +++++++++++++ src/output/mod.rs | 15 ++++++ 6 files changed, 227 insertions(+) create mode 100644 src/cli/commands/health.rs diff --git a/src/cli/commands/health.rs b/src/cli/commands/health.rs new file mode 100644 index 0000000..2b0efd9 --- /dev/null +++ b/src/cli/commands/health.rs @@ -0,0 +1,99 @@ +//! `parsec health` — quick sanity-check for all active worktrees (#299, Phase 1). +//! +//! Iterates every active worktree and reports three lightweight indicators: +//! +//! | Indicator | Signal | +//! |---------------|-----------------------------------------------------| +//! | **lock** | `.git/index.lock` exists → hung git process | +//! | **uncommitted** | unstaged or staged files not yet committed | +//! | **stale** | last commit older than [`STALE_THRESHOLD_DAYS`] days | +//! +//! CI-status overlay is deferred to Phase 2 (depends on #309 / #310). +//! All checks are read-only; no worktree state is modified. + +use std::path::Path; + +use anyhow::Result; + +use crate::config::ParsecConfig; +use crate::git; +use crate::output::{self, HealthRecord, Mode}; +use crate::worktree::WorktreeManager; + +/// Worktrees with no commit activity within this many days are flagged stale. +const STALE_THRESHOLD_DAYS: i64 = 7; + +/// Run health checks for all active worktrees and print a summary. +/// +/// Checks performed per worktree: +/// - `index.lock` presence (indicates an interrupted git operation). +/// - Uncommitted file count via `git diff --name-only` (staged + unstaged). +/// - Days since last commit via `git log -1 --format=%ct`. +/// +/// Failures are non-fatal: a worktree whose git commands error out is still +/// included in the output with `None` for the affected field. +/// +/// Returns `Ok(())` regardless of how many worktrees have issues so that the +/// exit code stays `0` (health is informational, not a CI gate in Phase 1). +pub async fn health(repo: &Path, mode: Mode) -> Result<()> { + let config = ParsecConfig::load()?; + let manager = WorktreeManager::new(repo, &config)?; + let workspaces = manager.list()?; + + if workspaces.is_empty() { + if mode == Mode::Human { + println!("No active worktrees."); + } else if mode == Mode::Json { + println!("[]"); + } + return Ok(()); + } + + let mut records: Vec = Vec::new(); + + for ws in &workspaces { + // --- lock file ------------------------------------------------- + // Bare worktrees expose the git dir as a file that contains + // `gitdir: `. Resolve the real git dir before checking. + let git_dir = ws.path.join(".git"); + let lock_path = if git_dir.is_file() { + // Linked worktree: read the `gitdir:` pointer + 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(); + + // --- uncommitted ----------------------------------------------- + let uncommitted = git::get_uncommitted_files(&ws.path) + .unwrap_or_default() + .len(); + + // --- stale (days since last commit) ---------------------------- + let stale_days = git::run_output(&ws.path, &["log", "-1", "--format=%ct"]) + .ok() + .and_then(|s| s.trim().parse::().ok()) + .map(|ts| { + let now = chrono::Utc::now().timestamp(); + (now - ts) / 86_400 + }); + + records.push(HealthRecord { + ticket: ws.ticket.clone(), + uncommitted, + stale_days, + stale_threshold_days: STALE_THRESHOLD_DAYS, + has_lock, + }); + } + + output::print_health(&records, mode); + Ok(()) +} diff --git a/src/cli/commands/mod.rs b/src/cli/commands/mod.rs index 7a3b9b5..b99baea 100644 --- a/src/cli/commands/mod.rs +++ b/src/cli/commands/mod.rs @@ -4,6 +4,7 @@ mod compress; mod config; mod diff; mod doctor; +mod health; mod history; mod pr; mod release; @@ -19,6 +20,7 @@ pub use compress::*; pub use config::*; pub use diff::*; pub use doctor::*; +pub use health::*; pub use history::*; pub use pr::*; pub use release::*; diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 44e4b88..6137bf5 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -428,6 +428,15 @@ pub enum Command { ai: bool, }, + /// Check all active worktrees for common issues + /// + /// Scans every parsec-managed worktree for three lightweight health + /// indicators: lingering index.lock files (hung git process), uncommitted + /// changes, and stale branches (no commits in 7+ days). + /// + /// Phase 1 — CI-status overlay is reserved for a follow-up (#299). + Health, + /// Create a release: merge to release branch, tag, and create GitHub Release /// /// Merges the current develop branch into the release branch (default: main), @@ -622,6 +631,7 @@ pub async fn run(cli: Cli) -> Result<()> { Command::Init { .. } => "init", Command::Config { .. } => "config", Command::Doctor { .. } => "doctor", + Command::Health => "health", Command::Release { .. } => "release", Command::Create { .. } => "create", Command::Rename { .. } => "rename", @@ -862,6 +872,7 @@ pub async fn run(cli: Cli) -> Result<()> { commands::doctor(&repo_path, output_mode).await } } + Command::Health => commands::health(&repo_path, output_mode).await, Command::Release { version, from, diff --git a/src/output/human.rs b/src/output/human.rs index d8e6ce3..f4a3fca 100644 --- a/src/output/human.rs +++ b/src/output/human.rs @@ -1001,3 +1001,69 @@ pub fn print_rename(old_ticket: &str, new_ticket: &str, workspace: &Workspace) { println!(" {} {}", "Branch:".bold(), workspace.branch); println!(" {} {}", "Path:".bold(), workspace.path.display()); } + +pub fn print_health(records: &[super::HealthRecord]) { + println!("{}", "parsec health".bold()); + let mut issues = 0usize; + for r in records { + let lock_tag = if r.has_lock { + " ⚠ lock file!".red().to_string() + } else { + String::new() + }; + + let uncommitted_tag = if r.uncommitted > 0 { + format!(" {} uncommitted", r.uncommitted) + .yellow() + .to_string() + } else { + " 0 uncommitted".dimmed().to_string() + }; + + let stale_tag = match r.stale_days { + None => " last commit unknown".dimmed().to_string(), + Some(d) if d > r.stale_threshold_days => format!(" last commit {}d ago (stale)", d) + .yellow() + .to_string(), + Some(d) => format!(" last commit {}d ago", d).dimmed().to_string(), + }; + + let any_issue = r.has_lock + || r.uncommitted > 0 + || r.stale_days + .map(|d| d > r.stale_threshold_days) + .unwrap_or(false); + + let icon = if r.has_lock { + "✗".red().to_string() + } else if any_issue { + "⚠".yellow().to_string() + } else { + "✓".green().to_string() + }; + + if any_issue { + issues += 1; + } + + println!( + " {} {:<20}{}{}{}", + icon, + r.ticket.bold(), + uncommitted_tag, + stale_tag, + lock_tag, + ); + } + println!(); + if issues == 0 { + println!("{}", "All worktrees healthy.".green().bold()); + } else { + println!( + "{}", + format!("{}/{} worktrees need attention.", issues, records.len()) + .yellow() + .bold() + ); + } +} diff --git a/src/output/json.rs b/src/output/json.rs index fee760d..bf66228 100644 --- a/src/output/json.rs +++ b/src/output/json.rs @@ -351,3 +351,37 @@ pub fn print_rename(old_ticket: &str, new_ticket: &str, workspace: &crate::workt }); println!("{}", value); } + +pub fn print_health(records: &[super::HealthRecord]) { + let items: Vec = records + .iter() + .map(|r| { + let stale = r + .stale_days + .map(|d| d > r.stale_threshold_days) + .unwrap_or(false); + json!({ + "ticket": r.ticket, + "has_lock": r.has_lock, + "uncommitted": r.uncommitted, + "stale_days": r.stale_days, + "stale": stale, + }) + }) + .collect(); + let all_healthy = records.iter().all(|r| { + !r.has_lock + && r.uncommitted == 0 + && !r + .stale_days + .map(|d| d > r.stale_threshold_days) + .unwrap_or(false) + }); + println!( + "{}", + json!({ + "worktrees": items, + "all_healthy": all_healthy, + }) + ); +} diff --git a/src/output/mod.rs b/src/output/mod.rs index 2dcbc26..762eff3 100644 --- a/src/output/mod.rs +++ b/src/output/mod.rs @@ -43,6 +43,20 @@ pub struct DoctorCheck { pub fix: Option, } +/// One health record per worktree, produced by `parsec health`. +pub struct HealthRecord { + /// Ticket identifier for the worktree. + pub ticket: String, + /// Number of uncommitted files (staged + unstaged). + pub uncommitted: usize, + /// Days since the last commit, or `None` when the history is unreadable. + pub stale_days: Option, + /// Threshold above which the worktree is considered stale. + pub stale_threshold_days: i64, + /// Whether a `.git/index.lock` file exists (hung git process indicator). + pub has_lock: bool, +} + /// Generate a dispatch function that routes to json:: and human:: based on Mode. /// /// Standard form (both Json and Human): @@ -117,6 +131,7 @@ dispatch_output!(print_ticket, ticket: &TrackerTicket); dispatch_output!(print_comment, ticket_id: &str); dispatch_output!(print_inbox, tickets: &[InboxTicket]); dispatch_output!(print_doctor, checks: &[DoctorCheck]); +dispatch_output!(print_health, records: &[HealthRecord]); dispatch_output!( print_list_full, infos: &[WorkspaceFullInfo], From a991520d19d00b2e8541c0f44e978a73d509c576 Mon Sep 17 00:00:00 2001 From: erish Date: Sat, 30 May 2026 09:40:10 +0900 Subject: [PATCH 12/26] =?UTF-8?q?test(health):=20parsec=20health=20CLI=20?= =?UTF-8?q?=ED=86=B5=ED=95=A9=20=ED=85=8C=EC=8A=A4=ED=8A=B8=205=EA=B0=9C?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80=20(#324)=20(#326)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 구현(#325)을 커버하는 통합 테스트: - test_health_empty_repo: 빈 repo → 'No active worktrees.' 확인 - test_health_empty_repo_json: 빈 repo → --json 시 '[]' 출력 확인 - test_health_shows_worktree: 워크트리 1개 → 티켓명 포함 출력 확인 - test_health_json_one_worktree: --json 구조 검증 (worktrees 배열, all_healthy 불리언, ticket/has_lock/uncommitted/stale_days/stale 필드) - test_health_exit_zero_with_issues: lock 파일 존재해도 exit 0 (정보성 only) Co-authored-by: Claude Sonnet 4.6 --- tests/cli_tests.rs | 186 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 186 insertions(+) diff --git a/tests/cli_tests.rs b/tests/cli_tests.rs index e350cff..c5d1b1e 100644 --- a/tests/cli_tests.rs +++ b/tests/cli_tests.rs @@ -1594,3 +1594,189 @@ fn test_smartlog_json_one_worktree() { "unset 'ci' field must be omitted from JSON" ); } + +// --------------------------------------------------------------------------- +// parsec health (#324, Phase 1) +// --------------------------------------------------------------------------- + +/// `parsec health` on a repo with no active worktrees must exit 0 and print +/// "No active worktrees." +#[test] +fn test_health_empty_repo() { + let repo = setup_repo(); + let repo_path = repo.path().to_str().unwrap(); + + parsec() + .args(["health", "--repo", repo_path]) + .assert() + .success() + .stdout(predicate::str::contains("No active worktrees.")); +} + +/// `parsec health --json` on a repo with no active worktrees must exit 0 and +/// emit exactly the JSON array `[]` (Health.rs emits `[]` for empty set). +#[test] +fn test_health_empty_repo_json() { + let repo = setup_repo(); + let repo_path = repo.path().to_str().unwrap(); + + let output = parsec() + .args(["health", "--json", "--repo", repo_path]) + .output() + .unwrap(); + + assert!( + output.status.success(), + "health --json should exit 0 on empty repo" + ); + + let stdout = String::from_utf8(output.stdout).unwrap(); + let trimmed = stdout.trim(); + assert_eq!(trimmed, "[]", "empty repo → health --json must emit `[]`"); +} + +/// After creating a workspace, `parsec health` must exit 0 and display the +/// ticket name in the output. +#[test] +fn test_health_shows_worktree() { + let (repo, _bare) = setup_repo_with_remote(); + let repo_path = repo.path().to_str().unwrap(); + + parsec() + .args(["start", "HL-1", "--repo", repo_path]) + .assert() + .success(); + + parsec() + .args(["health", "--repo", repo_path]) + .assert() + .success() + .stdout(predicate::str::contains("HL-1")); +} + +/// `parsec health --json` with one active worktree must return a JSON object +/// with `worktrees` array and `all_healthy` boolean. The single entry must +/// contain the mandatory fields: `ticket`, `has_lock`, `uncommitted`, +/// `stale_days`, `stale`. +#[test] +fn test_health_json_one_worktree() { + let (repo, _bare) = setup_repo_with_remote(); + let repo_path = repo.path().to_str().unwrap(); + + parsec() + .args(["start", "HL-2", "--repo", repo_path]) + .assert() + .success(); + + let output = parsec() + .args(["health", "--json", "--repo", repo_path]) + .output() + .unwrap(); + + assert!(output.status.success(), "health --json should exit 0"); + + let stdout = String::from_utf8(output.stdout).unwrap(); + let parsed: serde_json::Value = + serde_json::from_str(&stdout).expect("health --json must emit valid JSON"); + + // Top-level shape + assert!( + parsed.get("worktrees").is_some(), + "top-level must have 'worktrees' key" + ); + assert!( + parsed.get("all_healthy").is_some(), + "top-level must have 'all_healthy' key" + ); + assert!( + parsed["all_healthy"].is_boolean(), + "'all_healthy' must be a boolean" + ); + + let worktrees = parsed["worktrees"] + .as_array() + .expect("'worktrees' must be an array"); + assert_eq!(worktrees.len(), 1, "expected exactly 1 worktree entry"); + + let entry = &worktrees[0]; + assert_eq!( + entry["ticket"].as_str().unwrap(), + "HL-2", + "ticket field mismatch" + ); + assert!( + entry.get("has_lock").is_some(), + "entry must have 'has_lock' field" + ); + assert!( + entry.get("uncommitted").is_some(), + "entry must have 'uncommitted' field" + ); + assert!( + entry.get("stale_days").is_some(), + "entry must have 'stale_days' field" + ); + assert!( + entry.get("stale").is_some(), + "entry must have 'stale' field" + ); + + // A fresh worktree must NOT have a lock file + assert_eq!( + entry["has_lock"].as_bool().unwrap(), + false, + "fresh worktree must not have index.lock" + ); + + // A fresh worktree with no pending changes has 0 uncommitted files + assert_eq!( + entry["uncommitted"].as_u64().unwrap(), + 0, + "fresh worktree must have 0 uncommitted files" + ); +} + +/// `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] +fn test_health_exit_zero_with_issues() { + let (repo, _bare) = setup_repo_with_remote(); + let repo_path = repo.path().to_str().unwrap(); + + parsec() + .args(["start", "HL-3", "--repo", repo_path]) + .assert() + .success(); + + // Simulate a stale lock file inside the worktree's git dir. + // The worktree is a linked worktree, so its .git is a file pointing to the + // real git dir. Locate the real git dir and write a lock file there. + let worktree_path = repo.path().parent().unwrap().join("HL-3"); + let git_file = worktree_path.join(".git"); + let lock_path = if git_file.is_file() { + let contents = std::fs::read_to_string(&git_file).unwrap(); + let real_git = contents + .strip_prefix("gitdir: ") + .unwrap_or("") + .trim() + .to_string(); + std::path::PathBuf::from(&real_git).join("index.lock") + } else { + git_file.join("index.lock") + }; + + // Write a dummy lock file + if let Some(parent) = lock_path.parent() { + std::fs::create_dir_all(parent).ok(); + } + std::fs::write(&lock_path, b"dummy lock").unwrap(); + + // Health must still exit 0 — it is purely informational in Phase 1 + parsec() + .args(["health", "--repo", repo_path]) + .assert() + .success(); + + // Clean up + std::fs::remove_file(&lock_path).ok(); +} From 67014478b45bf82a7bcaf1f78d81a24a54a0d101 Mon Sep 17 00:00:00 2001 From: erish Date: Sat, 30 May 2026 10:42:57 +0900 Subject: [PATCH 13/26] =?UTF-8?q?feat(smartlog):=20Phase=202=20=E2=80=94?= =?UTF-8?q?=20PR/CI=20status=20overlay=20(#327)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(health): parsec health CLI 통합 테스트 5개 추가 (#324) Phase 1 구현(#325)을 커버하는 통합 테스트: - test_health_empty_repo: 빈 repo → 'No active worktrees.' 확인 - test_health_empty_repo_json: 빈 repo → --json 시 '[]' 출력 확인 - test_health_shows_worktree: 워크트리 1개 → 티켓명 포함 출력 확인 - test_health_json_one_worktree: --json 구조 검증 (worktrees 배열, all_healthy 불리언, ticket/has_lock/uncommitted/stale_days/stale 필드) - test_health_exit_zero_with_issues: lock 파일 존재해도 exit 0 (정보성 only) Co-Authored-By: Claude Sonnet 4.6 * feat(smartlog): Phase 2 — PR/CI status overlay (Refs #245) Phase 1 (#305) skeleton에서 `SmartlogNode.pr` 가 placeholder로 남아있던 걸 실제 GitHub PR/CI 데이터로 채움. Issue #245 의 예시 (`[PR #42 ✓ CI passed, ✓ approved]`) 가 이제 ASCII 트리에 그대로 렌더링됨. ## 변경 - `SmartlogPrOverlay` struct 신설 (number/state/ci_status/review_status/url) - `smartlog()` 가 GitHubClient 통해 brach → PR 매칭 + PrStatus 조회 후 node.pr 채움. 토큰 없거나 비-GitHub remote거나 HTTP 실패 = 그냥 overlay 생략 (best-effort, 명령 실패 안 함) - `--no-overlay` 플래그 추가 (강제 offline 모드) - ASCII 렌더러: ticket 라인 아래 `├─ [PR #N ● open ✓ CI ✓ approved]` 한 줄 - 글리프 규칙: 기존 `parsec pr status` / `parsec ci` 와 동일 (✓/✗/●/○) ## CI 필드는? `SmartlogNode.ci` 는 일단 None 유지. Phase 2 는 CI 요약을 overlay 안에 포함시켜 한 줄로 표시 (스마트로그 본질은 한눈 — per-check 디테일은 `parsec ci` 가 이미 담당). ## 드라이브-바이 tests/cli_tests.rs:1725 에 `assert_eq!(bool, false)` clippy 경고 (#326 회귀) → `assert!(!...)` 로 수정. develop 의 clippy strict 빌드 회복. ## 테스트 - 신규 unit 5개 (format_pr_badge × 3 + render_text overlay + JSON 직렬화) - 기존 9개 smartlog unit + 5개 통합 테스트 모두 통과 - cargo build / clippy -D warnings / fmt --check / test 전체 clean ## 다음 Phase 힌트 - Phase 3: per-worktree filtering (`--ticket CL-2283` 등) - Phase 4: stack 관계 시각화 (PR base = 다른 PR head) - Phase 5: review state 색상 강조 (terminal color) Refs #245 Co-Authored-By: Claude Opus 4.7 --------- Co-authored-by: Claude Sonnet 4.6 --- src/cli/commands/smartlog.rs | 223 +++++++++++++++++++++++++++++++++-- src/cli/mod.rs | 9 +- tests/cli_tests.rs | 5 +- 3 files changed, 224 insertions(+), 13 deletions(-) diff --git a/src/cli/commands/smartlog.rs b/src/cli/commands/smartlog.rs index 50f987a..f190711 100644 --- a/src/cli/commands/smartlog.rs +++ b/src/cli/commands/smartlog.rs @@ -1,23 +1,30 @@ //! `parsec smartlog` (alias `sl`) — visualize active worktrees as a commit DAG. //! -//! Issue #245 — Phase 1 (skeleton): +//! Issue #245 +//! +//! Phase 1 (PR #305 skeleton): //! - Collect every active worktree via [`WorktreeManager`] //! - Read each worktree's commits since its base branch (`base..branch`) //! - Render as ASCII tree, or emit JSON //! -//! PR/CI/review overlay is intentionally **out of scope** for this PR; -//! [`SmartlogNode::pr`] / [`SmartlogNode::ci`] fields are placeholders that -//! later PRs will populate (e.g., GitHub PR state, CI run state, review state). +//! Phase 2 (this PR — PR/CI overlay): +//! - For each worktree, look up the GitHub PR by branch name and attach a +//! compact overlay ([`SmartlogPrOverlay`]) describing the PR number, state, +//! CI status and review status. +//! - Overlay is best-effort: missing token / no PR / network errors all +//! degrade gracefully to "no overlay" without failing the command. +//! - Users can opt out with `--no-overlay` for a fully offline run. use std::collections::BTreeMap; use std::path::{Path, PathBuf}; use anyhow::Result; use chrono::{DateTime, Utc}; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use crate::config::ParsecConfig; use crate::git; +use crate::github::GitHubClient; use crate::output::Mode; use crate::worktree::WorktreeManager; @@ -33,14 +40,34 @@ pub struct SmartlogNode { pub base_branch: String, pub worktree_path: PathBuf, pub commits: Vec, - /// PR overlay — populated by a later PR (see #245 follow-up). + /// PR overlay — populated by Phase 2 when a matching GitHub PR is found. + /// Omitted from JSON entirely when no PR was attached (skip_serializing_if). #[serde(skip_serializing_if = "Option::is_none")] - pub pr: Option, - /// CI overlay — populated by a later PR (see #245 follow-up). + pub pr: Option, + /// CI overlay — reserved for a follow-up that emits per-check detail + /// (Phase 2 folds the CI summary into [`SmartlogPrOverlay::ci_status`]). #[serde(skip_serializing_if = "Option::is_none")] pub ci: Option, } +/// Compact PR/CI summary attached to a smartlog row. +/// +/// Subset of [`crate::github::PrStatus`] kept intentionally small: only the +/// fields that fit on the one-line ticket row in the ASCII renderer, plus the +/// browse URL so JSON consumers can click through. CI detail (per-check) is +/// out of scope here — `parsec ci` already prints that view. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct SmartlogPrOverlay { + pub number: u64, + /// `open` / `closed` / `merged` / `draft` / `unknown`. + pub state: String, + /// `success` / `failure` / `pending` / `unknown`. + pub ci_status: String, + /// `approved` / `changes_requested` / `pending` / `no reviews`. + pub review_status: String, + pub url: String, +} + /// Single commit in a worktree's diff against its base. #[derive(Debug, Clone, Serialize, PartialEq, Eq)] pub struct CommitSummary { @@ -51,7 +78,12 @@ pub struct CommitSummary { } /// Entry point for the `smartlog` subcommand. -pub async fn smartlog(repo: &Path, depth: Option, mode: Mode) -> Result<()> { +pub async fn smartlog( + repo: &Path, + depth: Option, + no_overlay: bool, + mode: Mode, +) -> Result<()> { let depth = depth.unwrap_or(DEFAULT_DEPTH); let config = ParsecConfig::load()?; let manager = WorktreeManager::new(repo, &config)?; @@ -75,6 +107,10 @@ pub async fn smartlog(repo: &Path, depth: Option, mode: Mode) -> Result<( }); } + if !no_overlay { + attach_pr_overlay(repo, &config, &mut nodes).await; + } + match mode { Mode::Json => { println!("{}", serde_json::to_string_pretty(&nodes)?); @@ -86,6 +122,58 @@ pub async fn smartlog(repo: &Path, depth: Option, mode: Mode) -> Result<( Ok(()) } +/// Look up each node's PR via GitHub and populate `node.pr`. +/// +/// Best-effort: no token, unknown remote host, or any HTTP failure all +/// degrade to "no overlay" silently (the operator can re-run with +/// `parsec pr status` for a full error report). Network errors are logged to +/// stderr at info-level via `eprintln!` so a flaky run doesn't look like a +/// silent bug, but they never fail the whole command. +async fn attach_pr_overlay(repo: &Path, config: &ParsecConfig, nodes: &mut [SmartlogNode]) { + if nodes.is_empty() { + return; + } + let remote_url = match git::run_output(repo, &["remote", "get-url", "origin"]) { + Ok(url) => url.trim().to_string(), + Err(_) => return, // no origin → nothing to overlay + }; + let client = match GitHubClient::new(&remote_url, config) { + Ok(Some(c)) => c, + // Either non-GitHub remote, no token, or a parse error — all of which + // mean "skip overlay" rather than fail. + _ => return, + }; + + for node in nodes.iter_mut() { + match fetch_overlay(&client, &node.branch).await { + Ok(Some(overlay)) => node.pr = Some(overlay), + Ok(None) => {} // no open PR for this branch + Err(e) => { + eprintln!( + "smartlog: GitHub overlay failed for {} ({}): {}", + node.ticket, node.branch, e + ); + } + } + } +} + +/// Resolve a single branch to a [`SmartlogPrOverlay`], or `None` if no open PR. +async fn fetch_overlay(client: &GitHubClient, branch: &str) -> Result> { + let pr_num = match client.find_pr_by_branch(branch).await? { + Some(n) => n, + None => return Ok(None), + }; + let status = client.get_pr_status(pr_num).await?; + Ok(Some(SmartlogPrOverlay { + number: status.number, + state: status.state, + ci_status: status.ci_status, + review_status: status.review_status, + url: status.url, + })) +} + /// Read commits in `base..branch` from a worktree, capped at `depth`. /// /// Pure shell-out to `git log` — no `git2` dependency, matches the rest of the @@ -106,6 +194,42 @@ fn collect_commits( Ok(raw.lines().filter_map(parse_commit_line).collect()) } +/// Format a one-line PR/CI summary for the ASCII tree. +/// +/// Glyphs match `parsec pr status` / `parsec ci` conventions so users see the +/// same vocabulary across commands: +/// - state: `open` → `●`, `merged`/`closed` → `✓`, `draft` → `○`, other → `?` +/// - CI: `success` → `✓ CI`, `failure` → `✗ CI`, `pending` → `● CI`, else `? CI` +/// - review: `approved` → `✓ approved`, `changes_requested` → `✗ changes`, +/// `pending` → `● review`, `no reviews` → omitted +fn format_pr_badge(pr: &SmartlogPrOverlay) -> String { + let state_glyph = match pr.state.as_str() { + "open" => "●", + "merged" | "closed" => "✓", + "draft" => "○", + _ => "?", + }; + let ci = match pr.ci_status.as_str() { + "success" => "✓ CI", + "failure" => "✗ CI", + "pending" => "● CI", + _ => "? CI", + }; + let mut out = format!("[PR #{} {} {} {}]", pr.number, state_glyph, pr.state, ci); + let review = match pr.review_status.as_str() { + "approved" => Some("✓ approved"), + "changes_requested" => Some("✗ changes"), + "pending" => Some("● review"), + _ => None, // "no reviews" or unknown → omit + }; + if let Some(r) = review { + // Strip the closing bracket, append review, re-close. + out.pop(); + out.push_str(&format!(" {}]", r)); + } + out +} + /// Parse a single tab-separated line emitted by our `git log --pretty` format. /// /// Format: `\t\t\t`. @@ -162,6 +286,12 @@ pub fn render_text(nodes: &[SmartlogNode]) -> String { )); let prefix = if is_last { " " } else { "│ " }; + // PR overlay (Phase 2): one line above commits when overlay set. + // Always uses `├─` so it visually attaches to the ticket above and + // the commits below; the actual tree closes on the last commit. + if let Some(pr) = &node.pr { + out.push_str(&format!("{}├─ {}\n", prefix, format_pr_badge(pr))); + } if node.commits.is_empty() { out.push_str(&format!("{}└─ (no commits since {})\n", prefix, base)); } else { @@ -312,4 +442,79 @@ mod tests { assert!(!json.contains("\"ci\"")); assert!(json.contains("\"ticket\":\"CL-1\"")); } + + fn mk_overlay(state: &str, ci: &str, review: &str) -> SmartlogPrOverlay { + SmartlogPrOverlay { + number: 42, + state: state.to_string(), + ci_status: ci.to_string(), + review_status: review.to_string(), + url: "https://github.com/erishforG/git-parsec/pull/42".to_string(), + } + } + + #[test] + fn format_pr_badge_open_passing_approved() { + let badge = format_pr_badge(&mk_overlay("open", "success", "approved")); + assert!(badge.starts_with("[PR #42 ● open ✓ CI")); + assert!(badge.ends_with("✓ approved]")); + } + + #[test] + fn format_pr_badge_no_reviews_drops_review_segment() { + let badge = format_pr_badge(&mk_overlay("open", "pending", "no reviews")); + assert_eq!(badge, "[PR #42 ● open ● CI]"); + } + + #[test] + fn format_pr_badge_merged_pr() { + let badge = format_pr_badge(&mk_overlay("merged", "success", "approved")); + // `merged` carries no special CI semantics — render the API-reported CI as-is. + assert!(badge.contains("✓ merged")); + assert!(badge.contains("✓ CI")); + assert!(badge.contains("✓ approved")); + } + + #[test] + fn render_text_with_pr_overlay_attaches_badge_above_commits() { + let mut node = mk_node( + "CL-2283", + Some("Add rate limiting"), + "feature/CL-2283", + vec![mk_commit("a1b2c3d", "Implement rate limiter")], + ); + node.pr = Some(mk_overlay("open", "success", "approved")); + let s = render_text(&[node]); + assert!(s.contains("CL-2283"), "ticket line still present"); + assert!(s.contains("[PR #42"), "PR badge rendered"); + assert!(s.contains("✓ approved"), "review badge rendered"); + // Badge must appear above the commit line (above as in earlier in the string). + let badge_pos = s.find("[PR #42").unwrap(); + let commit_pos = s.find("a1b2c3d").unwrap(); + assert!( + badge_pos < commit_pos, + "PR badge should render above commits, got:\n{}", + s + ); + } + + #[test] + fn smartlog_node_serializes_pr_overlay_when_set() { + let mut node = mk_node("CL-1", Some("A"), "f/CL-1", vec![]); + node.pr = Some(mk_overlay("open", "success", "approved")); + let v: serde_json::Value = serde_json::to_value(&node).unwrap(); + let pr = v.get("pr").expect("pr field should serialize when set"); + assert_eq!(pr.get("number").and_then(|n| n.as_u64()), Some(42)); + assert_eq!(pr.get("state").and_then(|s| s.as_str()), Some("open")); + assert_eq!( + pr.get("ci_status").and_then(|s| s.as_str()), + Some("success") + ); + assert_eq!( + pr.get("review_status").and_then(|s| s.as_str()), + Some("approved") + ); + // ci field still omitted — Phase 2 folds CI into the overlay. + assert!(v.get("ci").is_none(), "ci field stays omitted in Phase 2"); + } } diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 6137bf5..4b47beb 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -520,6 +520,11 @@ pub enum Command { /// Maximum commits per worktree (default: 10) #[arg(long, short)] depth: Option, + + /// Skip GitHub PR/CI overlay (faster, no network calls). + /// Overlay is also auto-skipped when no GitHub token is configured. + #[arg(long)] + no_overlay: bool, }, /// Internal: emit dynamic completion candidates (issue #291). @@ -925,7 +930,9 @@ pub async fn run(cli: Cli) -> Result<()> { Command::Compress { ticket, message } => { commands::compress(&repo_path, ticket.as_deref(), message, output_mode).await } - Command::Smartlog { depth } => commands::smartlog(&repo_path, depth, output_mode).await, + Command::Smartlog { depth, no_overlay } => { + commands::smartlog(&repo_path, depth, no_overlay, output_mode).await + } Command::Complete { kind } => commands::complete(&repo_path, kind).await, }; diff --git a/tests/cli_tests.rs b/tests/cli_tests.rs index c5d1b1e..4a3f55e 100644 --- a/tests/cli_tests.rs +++ b/tests/cli_tests.rs @@ -1722,9 +1722,8 @@ fn test_health_json_one_worktree() { ); // A fresh worktree must NOT have a lock file - assert_eq!( - entry["has_lock"].as_bool().unwrap(), - false, + assert!( + !entry["has_lock"].as_bool().unwrap(), "fresh worktree must not have index.lock" ); From 714c34d8eea3a4b296b46275f8c908ae711c1d26 Mon Sep 17 00:00:00 2001 From: erish Date: Sat, 30 May 2026 10:43:15 +0900 Subject: [PATCH 14/26] =?UTF-8?q?feat(completion):=20Phase=202=20=E2=80=94?= =?UTF-8?q?=20dynamic=20zsh/bash/fish=20shell=20scripts=20(Refs=20#291)=20?= =?UTF-8?q?(#328)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 (#312) 가 `parsec __complete ` 내부 명령을 만들어 shell completion 스크립트가 동적으로 worktree/branch 후보를 가져올 수 있게 했음. 이번에는 그 위에 실제 zsh/bash/fish 스크립트 3개를 추가해서 사용자가 sourcing 만 하면 `parsec switch ` 처럼 live ticket 자동완성을 받을 수 있게 함. ## 변경 - `completions/_parsec` (zsh, #compdef) — `_parsec_worktrees` / `_parsec_branches` helper + 모든 주요 subcommand 의 positional/option 자동완성 - `completions/parsec.bash` — `complete -F _parsec parsec`. bash-completion 의존 (`_init_completion`). prev word 기반 dispatch + `compgen -W` - `completions/parsec.fish` — `__fish_seen_subcommand_from` 기반. per-subcommand 옵션 (`--base`, `--on`, `--branch`) 도 동적 branch/worktree 후보 연결 - README "Install > Shell completion" 섹션 추가 (3개 shell install 명령) ## 커버 범위 (Tier 1 + 2) - ticket 받는 subcommand: start, switch, ship, open, clean, status, ticket, pr-status, ci, merge, diff, sync, log, compress, adopt, rename - branch 받는 옵션: `start --base|--on|--branch`, `ship --base`, `adopt --branch` - smartlog: `--depth`, `--no-overlay` (PR #327 와 호환) ## __complete kind 추가 없음. Phase 1 의 `worktrees` / `branches` 두 개로 충분 — future Phase 3 에서 필요하면 추가 (예: `tickets` 트래커, `reviewers` GitHub 사용자). ## 드라이브-바이 PR #327 에서 같이 잡았던 `tests/cli_tests.rs:1725` clippy 회귀 (#326) develop 머지 전이라 또 한 번 적용. PR #327 머지되면 충돌 안 남. ## 테스트 - 신규 4개 통합 테스트: - `completion_zsh_present_and_dynamic` — `#compdef parsec` + `__complete` 호출 + 핵심 sub 7개 - `completion_bash_present_and_dynamic` — `complete -F _parsec parsec` + 동일 - `completion_fish_present_and_dynamic` — `__parsec_worktrees` 함수 + 동일 - `completion_scripts_reference_phase1_subcommand_signature` — 스크립트가 Phase 1 가 지원하지 않는 kind 부르지 않는지 (silent fail 방지) - 전체 62 통합 테스트 + smartlog 14 단위 + health 5 통과 - cargo build / clippy -D warnings / fmt --check 전부 clean ## 다음 Phase 힌트 - Phase 3: `__complete tickets` (트래커 미해결 티켓), `__complete reviewers` (GitHub mention) - Phase 4: shell auto-install hook (parsec init 에서 자동 설치 옵션) Refs #291 Co-authored-by: Claude Opus 4.7 --- README.md | 17 ++++++ completions/_parsec | 111 ++++++++++++++++++++++++++++++++++++++++ completions/parsec.bash | 98 +++++++++++++++++++++++++++++++++++ completions/parsec.fish | 84 ++++++++++++++++++++++++++++++ tests/cli_tests.rs | 84 ++++++++++++++++++++++++++++++ 5 files changed, 394 insertions(+) create mode 100644 completions/_parsec create mode 100644 completions/parsec.bash create mode 100644 completions/parsec.fish diff --git a/README.md b/README.md index c48e4f8..77f4c27 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,23 @@ cargo install git-parsec Other targets (macOS arm64/x86_64, Windows x86_64) ship on every release — see [Releases](https://github.com/erishforG/git-parsec/releases). After install, run `parsec config init` for the interactive first-time setup, then `parsec doctor` to validate. +### Shell completion + +`parsec` ships dynamic completion scripts that suggest **live worktrees and branches** as you type (e.g. `parsec switch ` lists your active tickets). + +```bash +# zsh — copy to a site fpath dir, or add the repo path to fpath: +cp completions/_parsec ~/.zsh/completions/ # then: fpath=(~/.zsh/completions $fpath) + +# bash: +source completions/parsec.bash # or symlink into /etc/bash_completion.d/ + +# fish: +cp completions/parsec.fish ~/.config/fish/completions/ +``` + +A purely static fallback (no live candidates) is also available via `parsec config completions ` if you'd rather not source the dynamic scripts. + --- ## 60-second tour diff --git a/completions/_parsec b/completions/_parsec new file mode 100644 index 0000000..5b38002 --- /dev/null +++ b/completions/_parsec @@ -0,0 +1,111 @@ +#compdef parsec +# zsh completion for git-parsec — dynamic worktree/branch candidates. +# +# Issue #291 Phase 2. Source this file from ~/.zshrc: +# +# fpath=(/path/to/git-parsec/completions $fpath) +# autoload -U compinit && compinit +# +# Or run once to install into a site fpath dir: +# +# cp completions/_parsec /usr/local/share/zsh/site-functions/ +# +# Companion to the static structure emitted by `parsec config completions zsh`; +# this file replaces it with dynamic completion that calls +# `parsec __complete worktrees|branches` for ticket and branch arguments. + +# -- Dynamic candidate fetchers ------------------------------------------------ +_parsec_worktrees() { + local -a tickets + tickets=("${(@f)$(parsec __complete worktrees 2>/dev/null)}") + _describe -t tickets 'parsec worktree' tickets +} + +_parsec_branches() { + local -a branches + branches=("${(@f)$(parsec __complete branches 2>/dev/null)}") + _describe -t branches 'git branch' branches +} + +# -- Top-level subcommand list ------------------------------------------------- +_parsec_subcommands() { + local -a subs + subs=( + 'start:Create new worktree for ticket' + 'switch:Print path to a worktree' + 'ship:Open or update PR for current worktree' + 'open:Open ticket/PR in browser' + 'list:List all active worktrees' + 'status:Show ticket status' + 'ticket:Show current ticket' + 'clean:Remove merged worktree' + 'pr-status:Show PR status' + 'ci:Show CI status' + 'merge:Merge one or more PRs' + 'diff:Diff against base' + 'sync:Rebase/merge from base' + 'log:Audit log of recent ops' + 'compress:Squash worktree commits' + 'rename:Rename a ticket' + 'adopt:Adopt existing branch as ticket' + 'smartlog:Visualize worktrees as DAG' + 'sl:Alias of smartlog' + 'config:Configuration commands' + 'doctor:Diagnose environment' + 'health:Check worktree health' + 'conflicts:Detect file overlap' + 'history:Show command history' + 'stack:Stack-aware operations' + 'release:Cut a release' + ) + _describe -t commands 'parsec subcommand' subs +} + +# -- Main dispatcher ----------------------------------------------------------- +_parsec() { + local context state state_descr line + typeset -A opt_args + + _arguments -C \ + '1: :_parsec_subcommands' \ + '*::arg:->args' \ + '--json[Emit JSON output]' \ + '--quiet[Suppress output]' + + case $state in + (args) + case $line[1] in + start) + _arguments \ + '1:ticket:_parsec_worktrees' \ + '--base[Base branch]:branch:_parsec_branches' \ + '--on[Stack on ticket]:ticket:_parsec_worktrees' \ + '--branch[Use existing branch]:branch:_parsec_branches' \ + '--title[Title for PR]:title:' + ;; + switch|ship|open|clean|status|ticket|pr-status|diff|sync|compress|log|adopt) + _arguments '1:ticket:_parsec_worktrees' + ;; + merge|ci) + _arguments '*:ticket:_parsec_worktrees' + ;; + rename) + _arguments \ + '1:old-ticket:_parsec_worktrees' \ + '2:new-ticket:' + ;; + smartlog|sl) + _arguments \ + '--depth[Max commits per worktree]:n:' \ + '--no-overlay[Skip GitHub PR/CI lookup]' + ;; + *) + # Defer to default file completion for unknown commands. + _default + ;; + esac + ;; + esac +} + +_parsec "$@" diff --git a/completions/parsec.bash b/completions/parsec.bash new file mode 100644 index 0000000..dffa477 --- /dev/null +++ b/completions/parsec.bash @@ -0,0 +1,98 @@ +# bash completion for git-parsec — dynamic worktree/branch candidates. +# +# Issue #291 Phase 2. Source this file from ~/.bashrc: +# +# source /path/to/git-parsec/completions/parsec.bash +# +# Or install to the bash-completion directory: +# +# cp completions/parsec.bash /etc/bash_completion.d/parsec +# +# Companion to the static structure emitted by `parsec config completions bash`; +# this file replaces it with dynamic completion calling +# `parsec __complete worktrees|branches` for ticket and branch arguments. + +_parsec_subcommands="start switch ship open list status ticket clean pr-status \ +ci merge diff sync log compress rename adopt smartlog sl config doctor health \ +conflicts history stack release" + +_parsec_worktrees() { + parsec __complete worktrees 2>/dev/null +} + +_parsec_branches() { + parsec __complete branches 2>/dev/null +} + +_parsec() { + local cur prev words cword + _init_completion || return + + # Find the subcommand (first non-flag word after `parsec`). + local sub="" + local i=1 + while [ $i -lt $cword ]; do + case "${words[i]}" in + --json|--quiet) ;; + -*) ;; + *) sub="${words[i]}"; break ;; + esac + ((i++)) + done + + # Completing the subcommand itself. + if [ -z "$sub" ]; then + COMPREPLY=( $(compgen -W "$_parsec_subcommands" -- "$cur") ) + return + fi + + # Option arguments first (only --base / --on / --branch take dynamic values). + case "$prev" in + --base|--branch) + COMPREPLY=( $(compgen -W "$(_parsec_branches)" -- "$cur") ) + return + ;; + --on) + COMPREPLY=( $(compgen -W "$(_parsec_worktrees)" -- "$cur") ) + return + ;; + --depth|--title) + return # free text + ;; + esac + + # Don't complete option flag values, defer to default. + if [[ "$cur" == -* ]]; then + case "$sub" in + start) + COMPREPLY=( $(compgen -W "--base --on --branch --title" -- "$cur") ) + ;; + ship) + COMPREPLY=( $(compgen -W "--base --reviewer" -- "$cur") ) + ;; + smartlog|sl) + COMPREPLY=( $(compgen -W "--depth --no-overlay --json" -- "$cur") ) + ;; + *) + COMPREPLY=( $(compgen -W "--json --quiet" -- "$cur") ) + ;; + esac + return + fi + + # Positional argument by subcommand. + case "$sub" in + start|switch|ship|open|clean|status|ticket|pr-status|diff|sync|compress|log|adopt|rename) + COMPREPLY=( $(compgen -W "$(_parsec_worktrees)" -- "$cur") ) + ;; + merge|ci) + COMPREPLY=( $(compgen -W "$(_parsec_worktrees)" -- "$cur") ) + ;; + *) + # Fall back to filename completion for unknown subcommands. + _filedir + ;; + esac +} + +complete -F _parsec parsec diff --git a/completions/parsec.fish b/completions/parsec.fish new file mode 100644 index 0000000..327749e --- /dev/null +++ b/completions/parsec.fish @@ -0,0 +1,84 @@ +# fish completion for git-parsec — dynamic worktree/branch candidates. +# +# Issue #291 Phase 2. Install: +# +# cp completions/parsec.fish ~/.config/fish/completions/ +# +# Companion to the static structure emitted by `parsec config completions fish`; +# this file replaces it with dynamic completion calling +# `parsec __complete worktrees|branches` for ticket and branch arguments. + +# -- Dynamic candidate providers ---------------------------------------------- +function __parsec_worktrees + parsec __complete worktrees 2>/dev/null +end + +function __parsec_branches + parsec __complete branches 2>/dev/null +end + +# -- Top-level subcommand list ------------------------------------------------ +complete -c parsec -f -n __fish_use_subcommand -a start -d 'Create new worktree' +complete -c parsec -f -n __fish_use_subcommand -a switch -d 'Print worktree path' +complete -c parsec -f -n __fish_use_subcommand -a ship -d 'Open or update PR' +complete -c parsec -f -n __fish_use_subcommand -a open -d 'Open ticket/PR in browser' +complete -c parsec -f -n __fish_use_subcommand -a list -d 'List all worktrees' +complete -c parsec -f -n __fish_use_subcommand -a status -d 'Show ticket status' +complete -c parsec -f -n __fish_use_subcommand -a ticket -d 'Show current ticket' +complete -c parsec -f -n __fish_use_subcommand -a clean -d 'Remove merged worktree' +complete -c parsec -f -n __fish_use_subcommand -a pr-status -d 'Show PR status' +complete -c parsec -f -n __fish_use_subcommand -a ci -d 'Show CI status' +complete -c parsec -f -n __fish_use_subcommand -a merge -d 'Merge PRs' +complete -c parsec -f -n __fish_use_subcommand -a diff -d 'Diff against base' +complete -c parsec -f -n __fish_use_subcommand -a sync -d 'Rebase/merge from base' +complete -c parsec -f -n __fish_use_subcommand -a log -d 'Audit log' +complete -c parsec -f -n __fish_use_subcommand -a compress -d 'Squash worktree commits' +complete -c parsec -f -n __fish_use_subcommand -a rename -d 'Rename a ticket' +complete -c parsec -f -n __fish_use_subcommand -a adopt -d 'Adopt existing branch' +complete -c parsec -f -n __fish_use_subcommand -a smartlog -d 'Visualize worktrees as DAG' +complete -c parsec -f -n __fish_use_subcommand -a sl -d 'Alias of smartlog' +complete -c parsec -f -n __fish_use_subcommand -a config -d 'Configuration' +complete -c parsec -f -n __fish_use_subcommand -a doctor -d 'Diagnose environment' +complete -c parsec -f -n __fish_use_subcommand -a health -d 'Check worktree health' +complete -c parsec -f -n __fish_use_subcommand -a conflicts -d 'Detect file overlap' +complete -c parsec -f -n __fish_use_subcommand -a history -d 'Command history' +complete -c parsec -f -n __fish_use_subcommand -a stack -d 'Stack-aware operations' +complete -c parsec -f -n __fish_use_subcommand -a release -d 'Cut a release' + +# -- Per-subcommand positional: worktree ticket ------------------------------- +set -l ticket_cmds start switch ship open clean status ticket pr-status \ + ci merge diff sync log compress adopt rename + +for cmd in $ticket_cmds + complete -c parsec -f -n "__fish_seen_subcommand_from $cmd" \ + -a '(__parsec_worktrees)' +end + +# -- Per-subcommand option flags ---------------------------------------------- +# start: --base / --on / --branch / --title +complete -c parsec -f -n '__fish_seen_subcommand_from start' \ + -l base -d 'Base branch' -a '(__parsec_branches)' +complete -c parsec -f -n '__fish_seen_subcommand_from start' \ + -l on -d 'Stack on ticket' -a '(__parsec_worktrees)' +complete -c parsec -f -n '__fish_seen_subcommand_from start' \ + -l branch -d 'Use existing branch' -a '(__parsec_branches)' +complete -c parsec -n '__fish_seen_subcommand_from start' \ + -l title -d 'Title for PR' + +# ship: --base +complete -c parsec -f -n '__fish_seen_subcommand_from ship' \ + -l base -d 'Base branch for PR' -a '(__parsec_branches)' + +# smartlog: --depth / --no-overlay +complete -c parsec -n '__fish_seen_subcommand_from smartlog sl' \ + -l depth -d 'Max commits per worktree' +complete -c parsec -f -n '__fish_seen_subcommand_from smartlog sl' \ + -l no-overlay -d 'Skip GitHub PR/CI overlay' + +# adopt: --branch +complete -c parsec -f -n '__fish_seen_subcommand_from adopt' \ + -l branch -d 'Branch to adopt' -a '(__parsec_branches)' + +# -- Global flags -------------------------------------------------------------- +complete -c parsec -f -l json -d 'Emit JSON output' +complete -c parsec -f -l quiet -d 'Suppress output' diff --git a/tests/cli_tests.rs b/tests/cli_tests.rs index 4a3f55e..a30a41f 100644 --- a/tests/cli_tests.rs +++ b/tests/cli_tests.rs @@ -1779,3 +1779,87 @@ fn test_health_exit_zero_with_issues() { // Clean up std::fs::remove_file(&lock_path).ok(); } + +// --------------------------------------------------------------------------- +// Shell completion scripts (issue #291 Phase 2) +// +// Sanity tests only — verify the scripts ship in the repo and reference the +// __complete subcommand from PR #312. We cannot exercise real shell behavior +// (zsh/bash/fish parsers in the test sandbox is too fragile / heavy), so the +// scripts themselves stand in for the "would this complete?" question. +// --------------------------------------------------------------------------- + +fn read_completion(name: &str) -> String { + let path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("completions") + .join(name); + std::fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("completion script {} should exist: {}", path.display(), e)) +} + +#[test] +fn completion_zsh_present_and_dynamic() { + let s = read_completion("_parsec"); + assert!(s.contains("#compdef parsec"), "must start with #compdef"); + assert!( + s.contains("parsec __complete worktrees"), + "zsh script must call __complete worktrees" + ); + assert!( + s.contains("parsec __complete branches"), + "zsh script must call __complete branches" + ); + // Confirm we wire ticket-shaped subcommands. + for sub in ["start", "switch", "ship", "open", "clean", "merge", "ci"] { + assert!(s.contains(sub), "zsh script must mention {}", sub); + } +} + +#[test] +fn completion_bash_present_and_dynamic() { + let s = read_completion("parsec.bash"); + assert!(s.contains("complete -F _parsec parsec")); + assert!(s.contains("parsec __complete worktrees")); + assert!(s.contains("parsec __complete branches")); + for sub in ["start", "switch", "ship", "open", "clean", "merge", "ci"] { + assert!(s.contains(sub), "bash script must mention {}", sub); + } +} + +#[test] +fn completion_fish_present_and_dynamic() { + let s = read_completion("parsec.fish"); + assert!(s.contains("__parsec_worktrees")); + assert!(s.contains("__parsec_branches")); + assert!(s.contains("parsec __complete worktrees")); + assert!(s.contains("parsec __complete branches")); + for sub in ["start", "switch", "ship", "open", "clean", "merge", "ci"] { + assert!(s.contains(sub), "fish script must mention {}", sub); + } +} + +#[test] +fn completion_scripts_reference_phase1_subcommand_signature() { + // The __complete subcommand only accepts `worktrees` and `branches` kinds + // today (PR #312). Phase 2 scripts must not use any other kind name or + // they'll silently emit nothing. + for name in ["_parsec", "parsec.bash", "parsec.fish"] { + let s = read_completion(name); + let valid_kinds = ["worktrees", "branches"]; + for line in s.lines() { + if let Some(rest) = line.find("parsec __complete ").map(|i| &line[i + 18..]) { + let kind: String = rest + .chars() + .take_while(|c| c.is_ascii_alphanumeric()) + .collect(); + assert!( + valid_kinds.contains(&kind.as_str()), + "{}: unknown __complete kind '{}' (allowed: {:?})", + name, + kind, + valid_kinds + ); + } + } + } +} From 4fb915f8f9dc3bc4e0d5856ca6a46147debe243d Mon Sep 17 00:00:00 2001 From: erishforG Date: Sun, 31 May 2026 09:35:11 +0900 Subject: [PATCH 15/26] =?UTF-8?q?feat(health):=20Phase=202=20=E2=80=94=20C?= =?UTF-8?q?I=20status=20overlay=20+=20configurable=20stale=20threshold=20(?= =?UTF-8?q?Refs=20#299)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 무엇 parsec health Phase 2: GitHub CI 상태 오버레이 + 설정 가능한 stale 임계값. ## 변경 - health.rs: --stale-days 플래그로 stale 임계값 설정 (기본 7일) - health.rs: --no-overlay 플래그로 오프라인 모드 지원 - health.rs: fetch_ci_overlay() — 브랜치 → PR 번호 → CI 상태 (best-effort) - output/mod.rs: HealthRecord에 ci_status: Option, pr_number: Option 추가 - output/human.rs: CI 상태를 색상 아이콘으로 표시 (✓/✗/● CI) - output/json.rs: ci_status, pr_number, ci_failing 필드를 JSON 출력에 포함 - cli/mod.rs: Health 명령을 struct variant로 변경 (--stale-days, --no-overlay) - tests/cli_tests.rs: Phase 2 플래그 검증 테스트 3개 추가 (8/8 통과) ## 다음 Phase 힌트 Phase 3: threshold config (parsec.toml [health] stale_days), 경고 exit code 옵션 Co-Authored-By: Claude Sonnet 4.6 --- src/cli/commands/health.rs | 105 ++++++++++++++++++++++++++++--------- src/cli/mod.rs | 25 ++++++--- src/output/human.rs | 40 +++++++++++++- src/output/json.rs | 5 ++ src/output/mod.rs | 5 ++ tests/cli_tests.rs | 81 ++++++++++++++++++++++++++++ 6 files changed, 227 insertions(+), 34 deletions(-) diff --git a/src/cli/commands/health.rs b/src/cli/commands/health.rs index 2b0efd9..d3efca3 100644 --- a/src/cli/commands/health.rs +++ b/src/cli/commands/health.rs @@ -1,14 +1,19 @@ -//! `parsec health` — quick sanity-check for all active worktrees (#299, Phase 1). +//! `parsec health` — quick sanity-check for all active worktrees (#299). //! -//! Iterates every active worktree and reports three lightweight indicators: +//! Iterates every active worktree and reports health indicators: //! -//! | Indicator | Signal | -//! |---------------|-----------------------------------------------------| -//! | **lock** | `.git/index.lock` exists → hung git process | -//! | **uncommitted** | unstaged or staged files not yet committed | -//! | **stale** | last commit older than [`STALE_THRESHOLD_DAYS`] days | +//! | Indicator | Signal | +//! |-----------------|-----------------------------------------------------| +//! | **lock** | `.git/index.lock` exists → hung git process | +//! | **uncommitted** | unstaged or staged files not yet committed | +//! | **stale** | last commit older than `--stale-days` (default: 7) | +//! | **ci_status** | Phase 2: CI overall status for open PR (best-effort)| +//! +//! Phase 2 additions: +//! - CI-status overlay via GitHub PR lookup (per-worktree branch). +//! - Configurable stale-threshold via `--stale-days` CLI flag. +//! - Opt-out via `--no-overlay` for fully offline mode. //! -//! CI-status overlay is deferred to Phase 2 (depends on #309 / #310). //! All checks are read-only; no worktree state is modified. use std::path::Path; @@ -17,25 +22,27 @@ use anyhow::Result; use crate::config::ParsecConfig; use crate::git; +use crate::github::GitHubClient; use crate::output::{self, HealthRecord, Mode}; use crate::worktree::WorktreeManager; -/// Worktrees with no commit activity within this many days are flagged stale. -const STALE_THRESHOLD_DAYS: i64 = 7; - /// Run health checks for all active worktrees and print a summary. /// -/// Checks performed per worktree: -/// - `index.lock` presence (indicates an interrupted git operation). -/// - Uncommitted file count via `git diff --name-only` (staged + unstaged). -/// - Days since last commit via `git log -1 --format=%ct`. +/// # Arguments +/// * `repo` — path to the repository root +/// * `mode` — output mode (human / json / quiet) +/// * `stale_days` — number of days before a branch is flagged stale +/// * `no_overlay` — when `true`, skip GitHub CI-status lookup entirely /// -/// Failures are non-fatal: a worktree whose git commands error out is still -/// included in the output with `None` for the affected field. +/// Phase 2 extends Phase 1 with: +/// - Per-worktree GitHub PR lookup via branch name. +/// - CI overall status fetched from the check-runs endpoint. +/// - Graceful degradation: missing token / no PR / network errors leave +/// `ci_status` as `None` without failing the command. /// /// Returns `Ok(())` regardless of how many worktrees have issues so that the -/// exit code stays `0` (health is informational, not a CI gate in Phase 1). -pub async fn health(repo: &Path, mode: Mode) -> Result<()> { +/// exit code stays `0` (health is informational, not a CI gate). +pub async fn health(repo: &Path, mode: Mode, stale_days: u64, no_overlay: bool) -> Result<()> { let config = ParsecConfig::load()?; let manager = WorktreeManager::new(repo, &config)?; let workspaces = manager.list()?; @@ -49,15 +56,23 @@ pub async fn health(repo: &Path, mode: Mode) -> Result<()> { return Ok(()); } + let stale_threshold = stale_days as i64; + + // Resolve GitHub client once for all worktrees (best-effort). + // Failure to build a client is non-fatal; overlay is simply skipped. + let gh_client: Option = if no_overlay { + None + } else { + let remote_url = git::get_remote_url(repo).unwrap_or_default(); + GitHubClient::new(&remote_url, &config).unwrap_or(None) + }; + let mut records: Vec = Vec::new(); for ws in &workspaces { // --- lock file ------------------------------------------------- - // Bare worktrees expose the git dir as a file that contains - // `gitdir: `. Resolve the real git dir before checking. let git_dir = ws.path.join(".git"); let lock_path = if git_dir.is_file() { - // Linked worktree: read the `gitdir:` pointer std::fs::read_to_string(&git_dir) .ok() .and_then(|s| { @@ -77,7 +92,7 @@ pub async fn health(repo: &Path, mode: Mode) -> Result<()> { .len(); // --- stale (days since last commit) ---------------------------- - let stale_days = git::run_output(&ws.path, &["log", "-1", "--format=%ct"]) + let stale_days_val = git::run_output(&ws.path, &["log", "-1", "--format=%ct"]) .ok() .and_then(|s| s.trim().parse::().ok()) .map(|ts| { @@ -85,15 +100,55 @@ pub async fn health(repo: &Path, mode: Mode) -> Result<()> { (now - ts) / 86_400 }); + // --- CI status overlay (Phase 2) -------------------------------- + let (ci_status, pr_number) = fetch_ci_overlay(&gh_client, &ws.branch).await; + records.push(HealthRecord { ticket: ws.ticket.clone(), uncommitted, - stale_days, - stale_threshold_days: STALE_THRESHOLD_DAYS, + stale_days: stale_days_val, + stale_threshold_days: stale_threshold, has_lock, + ci_status, + pr_number, }); } output::print_health(&records, mode); Ok(()) } + +/// Resolve CI status for a worktree branch via the GitHub client. +/// +/// Returns `(ci_status, pr_number)`. Both are `None` when: +/// - `client` is `None` (no token or `--no-overlay`), +/// - no open PR exists for `branch`, or +/// - any network / API error occurs. +/// +/// Errors are swallowed so the overall health command stays non-fatal. +async fn fetch_ci_overlay( + client: &Option, + branch: &str, +) -> (Option, Option) { + let client = match client { + Some(c) => c, + None => return (None, None), + }; + + let pr_num = match client.find_pr_by_branch(branch).await { + Ok(Some(n)) => n, + Ok(None) => return (None, None), + Err(e) => { + eprintln!("health: PR lookup failed for {}: {}", branch, e); + return (None, None); + } + }; + + match client.get_pr_status(pr_num).await { + Ok(status) => (Some(status.ci_status), Some(pr_num)), + Err(e) => { + eprintln!("health: CI status fetch failed for PR #{}: {}", pr_num, e); + (None, Some(pr_num)) + } + } +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 4b47beb..822140d 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -430,12 +430,20 @@ pub enum Command { /// Check all active worktrees for common issues /// - /// Scans every parsec-managed worktree for three lightweight health - /// indicators: lingering index.lock files (hung git process), uncommitted - /// changes, and stale branches (no commits in 7+ days). + /// Scans every parsec-managed worktree for health indicators: + /// lingering index.lock files (hung git process), uncommitted changes, + /// stale branches, and (when a GitHub token is available) live CI status. /// - /// Phase 1 — CI-status overlay is reserved for a follow-up (#299). - Health, + /// Phase 2 — CI-status overlay via GitHub PR lookup (opt-out: --no-overlay). + Health { + /// Override the stale-branch threshold (default: 7 days). + #[arg(long, default_value = "7")] + stale_days: u64, + + /// Skip GitHub CI-status overlay (fully offline mode). + #[arg(long)] + no_overlay: bool, + }, /// Create a release: merge to release branch, tag, and create GitHub Release /// @@ -636,7 +644,7 @@ pub async fn run(cli: Cli) -> Result<()> { Command::Init { .. } => "init", Command::Config { .. } => "config", Command::Doctor { .. } => "doctor", - Command::Health => "health", + Command::Health { .. } => "health", Command::Release { .. } => "release", Command::Create { .. } => "create", Command::Rename { .. } => "rename", @@ -877,7 +885,10 @@ pub async fn run(cli: Cli) -> Result<()> { commands::doctor(&repo_path, output_mode).await } } - Command::Health => commands::health(&repo_path, output_mode).await, + Command::Health { + stale_days, + no_overlay, + } => commands::health(&repo_path, output_mode, stale_days, no_overlay).await, Command::Release { version, from, diff --git a/src/output/human.rs b/src/output/human.rs index f4a3fca..a278707 100644 --- a/src/output/human.rs +++ b/src/output/human.rs @@ -1028,13 +1028,48 @@ pub fn print_health(records: &[super::HealthRecord]) { Some(d) => format!(" last commit {}d ago", d).dimmed().to_string(), }; + // Phase 2: CI status overlay tag + let ci_tag = match &r.ci_status { + None => String::new(), + Some(s) => match s.as_str() { + "passing" | "success" => format!( + " PR#{} ✓ CI", + r.pr_number.map(|n| n.to_string()).unwrap_or_default() + ) + .green() + .to_string(), + "failing" | "failure" => format!( + " PR#{} ✗ CI", + r.pr_number.map(|n| n.to_string()).unwrap_or_default() + ) + .red() + .to_string(), + "pending" => format!( + " PR#{} ● CI", + r.pr_number.map(|n| n.to_string()).unwrap_or_default() + ) + .yellow() + .to_string(), + other => format!( + " PR#{} {} CI", + r.pr_number.map(|n| n.to_string()).unwrap_or_default(), + other + ) + .dimmed() + .to_string(), + }, + }; + + let ci_issue = matches!(r.ci_status.as_deref(), Some("failing") | Some("failure")); + let any_issue = r.has_lock || r.uncommitted > 0 + || ci_issue || r.stale_days .map(|d| d > r.stale_threshold_days) .unwrap_or(false); - let icon = if r.has_lock { + let icon = if r.has_lock || ci_issue { "✗".red().to_string() } else if any_issue { "⚠".yellow().to_string() @@ -1047,12 +1082,13 @@ pub fn print_health(records: &[super::HealthRecord]) { } println!( - " {} {:<20}{}{}{}", + " {} {:<20}{}{}{}{}", icon, r.ticket.bold(), uncommitted_tag, stale_tag, lock_tag, + ci_tag, ); } println!(); diff --git a/src/output/json.rs b/src/output/json.rs index bf66228..cc1f4a3 100644 --- a/src/output/json.rs +++ b/src/output/json.rs @@ -360,12 +360,16 @@ pub fn print_health(records: &[super::HealthRecord]) { .stale_days .map(|d| d > r.stale_threshold_days) .unwrap_or(false); + let ci_failing = matches!(r.ci_status.as_deref(), Some("failing") | Some("failure")); json!({ "ticket": r.ticket, "has_lock": r.has_lock, "uncommitted": r.uncommitted, "stale_days": r.stale_days, "stale": stale, + "ci_status": r.ci_status, + "pr_number": r.pr_number, + "ci_failing": ci_failing, }) }) .collect(); @@ -376,6 +380,7 @@ pub fn print_health(records: &[super::HealthRecord]) { .stale_days .map(|d| d > r.stale_threshold_days) .unwrap_or(false) + && !matches!(r.ci_status.as_deref(), Some("failing") | Some("failure")) }); println!( "{}", diff --git a/src/output/mod.rs b/src/output/mod.rs index 762eff3..f54979c 100644 --- a/src/output/mod.rs +++ b/src/output/mod.rs @@ -55,6 +55,11 @@ pub struct HealthRecord { pub stale_threshold_days: i64, /// Whether a `.git/index.lock` file exists (hung git process indicator). pub has_lock: bool, + /// GitHub Actions / CI overall status for the worktree's open PR, if any. + /// Populated by Phase 2 CI overlay; `None` when no PR or no token. + pub ci_status: Option, + /// GitHub PR number linked to this worktree's branch, if any. + pub pr_number: Option, } /// Generate a dispatch function that routes to json:: and human:: based on Mode. diff --git a/tests/cli_tests.rs b/tests/cli_tests.rs index a30a41f..07747fa 100644 --- a/tests/cli_tests.rs +++ b/tests/cli_tests.rs @@ -1863,3 +1863,84 @@ fn completion_scripts_reference_phase1_subcommand_signature() { } } } + +// --------------------------------------------------------------------------- +// parsec health Phase 2 (#299) — --stale-days and --no-overlay flags +// --------------------------------------------------------------------------- + +/// `parsec health --stale-days 99` exits 0 — the flag is accepted and a +/// generous threshold means the fresh worktree is never flagged stale. +#[test] +fn test_health_stale_days_flag_accepted() { + let (repo, _bare) = setup_repo_with_remote(); + let repo_path = repo.path().to_str().unwrap(); + + parsec() + .args(["start", "PH2-1", "--repo", repo_path]) + .assert() + .success(); + + parsec() + .args(["health", "--stale-days", "99", "--repo", repo_path]) + .assert() + .success(); +} + +/// `parsec health --no-overlay` must exit 0 on an empty repo — the flag is +/// accepted and the command degrades gracefully to "No active worktrees." +#[test] +fn test_health_no_overlay_flag_accepted() { + let repo = setup_repo(); + let repo_path = repo.path().to_str().unwrap(); + + parsec() + .args(["health", "--no-overlay", "--repo", repo_path]) + .assert() + .success() + .stdout(predicate::str::contains("No active worktrees.")); +} + +/// `parsec health --no-overlay --json` with one worktree must include the +/// `ci_status` key (value `null`) in the JSON output — schema is stable +/// regardless of whether the CI overlay was attempted. +#[test] +fn test_health_no_overlay_json_has_ci_status_key() { + let (repo, _bare) = setup_repo_with_remote(); + let repo_path = repo.path().to_str().unwrap(); + + parsec() + .args(["start", "PH2-2", "--repo", repo_path]) + .assert() + .success(); + + let output = parsec() + .args(["health", "--no-overlay", "--json", "--repo", repo_path]) + .output() + .unwrap(); + assert!( + output.status.success(), + "health --no-overlay --json must exit 0; stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + let parsed: serde_json::Value = + serde_json::from_str(&stdout).expect("health --no-overlay --json must emit valid JSON"); + + let worktrees = parsed["worktrees"] + .as_array() + .expect("'worktrees' must be an array"); + assert!( + !worktrees.is_empty(), + "expected at least one worktree entry" + ); + + let entry = &worktrees[0]; + assert!( + entry.get("ci_status").is_some(), + "JSON entry must have 'ci_status' key (null when no-overlay)" + ); + assert!( + entry.get("pr_number").is_some(), + "JSON entry must have 'pr_number' key" + ); +} From e293878f0211cf643f1369df0492f92b2263d6a6 Mon Sep 17 00:00:00 2001 From: erish Date: Mon, 1 Jun 2026 15:39:59 +0900 Subject: [PATCH 16/26] =?UTF-8?q?feat(reviews):=20Phase=201=20=E2=80=94=20?= =?UTF-8?q?unified=20PR=20review=20table=20across=20worktrees=20(Refs=20#3?= =?UTF-8?q?01)=20(#331)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `parsec reviews` (alias `rv`) — a new command that scans every active worktree, resolves its open GitHub PR by branch name, and prints a unified review table with per-PR review decisions and CI status. ## What - New `src/cli/commands/reviews.rs` with `reviews()` async function - `ReviewEntry` struct added to `src/output/mod.rs` - Human table renderer in `src/output/human.rs` (color-coded review/CI badges) - JSON array renderer in `src/output/json.rs` - `Reviews` CLI variant wired in `src/cli/mod.rs` (alias `rv`) - 3 unit tests for ReviewEntry construction ## Phase 2 hint Add `--requested` flag: use GitHub Search API (`/search/issues?q=review-requested:{login}`) to show PRs from *others* where the current user is a requested reviewer. Co-authored-by: Claude Sonnet 4.6 --- src/cli/commands/mod.rs | 2 + src/cli/commands/reviews.rs | 143 ++++++++++++++++++++++++++++++++++++ src/cli/mod.rs | 12 +++ src/output/human.rs | 95 ++++++++++++++++++++++++ src/output/json.rs | 22 ++++++ src/output/mod.rs | 19 +++++ 6 files changed, 293 insertions(+) create mode 100644 src/cli/commands/reviews.rs diff --git a/src/cli/commands/mod.rs b/src/cli/commands/mod.rs index b99baea..cd386b7 100644 --- a/src/cli/commands/mod.rs +++ b/src/cli/commands/mod.rs @@ -8,6 +8,7 @@ mod health; mod history; mod pr; mod release; +mod reviews; mod ship; pub mod smartlog; mod stack; @@ -24,6 +25,7 @@ pub use health::*; pub use history::*; pub use pr::*; pub use release::*; +pub use reviews::reviews; pub use ship::*; pub use smartlog::smartlog; pub use stack::*; diff --git a/src/cli/commands/reviews.rs b/src/cli/commands/reviews.rs new file mode 100644 index 0000000..3023bfc --- /dev/null +++ b/src/cli/commands/reviews.rs @@ -0,0 +1,143 @@ +//! `parsec reviews` — unified PR review status across all active worktrees (#301). +//! +//! Phase 1 (this PR): +//! - Scan every active worktree via [`WorktreeManager`]. +//! - For each worktree, resolve its open GitHub PR by branch name. +//! - Fetch the PR's review + CI status and collect into a [`ReviewEntry`]. +//! - Render a table (human) or JSON array. +//! +//! Scope is intentionally the "author" view: PRs that the current user opened +//! and that have a pending review request from others. Both pending and +//! approved/changes-requested states are shown so that nothing falls through. +//! +//! Phase 2 hint: +//! - Add `--requested` flag: use GitHub Search API +//! (`/search/issues?q=review-requested:{login}`) to show PRs *from others* +//! where the current user is a requested reviewer. +//! - Add `--all` to include closed/merged PRs. + +use std::path::Path; + +use anyhow::Result; + +use crate::config::ParsecConfig; +use crate::git; +use crate::github::GitHubClient; +use crate::output::{self, Mode, ReviewEntry}; +use crate::worktree::WorktreeManager; + +/// Entry point for the `parsec reviews` subcommand. +/// +/// Iterates all active worktrees, resolves their associated open GitHub PRs, +/// and prints the aggregated review table. +/// +/// # Errors +/// Returns an error if GitHub credentials are missing. Individual per-worktree +/// failures (e.g. no PR for the branch) are silently skipped so that the rest +/// of the table still renders. +pub async fn reviews(repo: &Path, mode: Mode) -> Result<()> { + let config = ParsecConfig::load()?; + let manager = WorktreeManager::new(repo, &config)?; + let workspaces = manager.list()?; + + if workspaces.is_empty() { + match mode { + Mode::Human => println!("No active worktrees — nothing to review."), + Mode::Json => println!("[]"), + Mode::Quiet => {} + } + 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 mut entries: Vec = Vec::new(); + + for ws in &workspaces { + // Resolve PR number from branch name — skip worktrees without an open PR. + let pr_number = match gh.find_pr_by_branch(&ws.branch).await { + Ok(Some(n)) => n, + Ok(None) => continue, + Err(_) => continue, + }; + + // Fetch PR status (title, state, ci_status, review_status, url). + let status = match gh.get_pr_status(pr_number).await { + Ok(s) => s, + Err(_) => continue, + }; + + // Phase 1 shows open + draft PRs only; closed/merged are filtered out. + if status.state == "closed" { + continue; + } + + entries.push(ReviewEntry { + ticket: ws.ticket.clone(), + pr_number: status.number, + title: status.title.clone(), + state: status.state.clone(), + review_status: status.review_status.clone(), + ci_status: status.ci_status.clone(), + url: status.url.clone(), + }); + } + + output::print_reviews(&entries, mode); + Ok(()) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + + use crate::output::ReviewEntry; + + fn mk_entry(ticket: &str, pr: u64, review: &str, ci: &str) -> ReviewEntry { + ReviewEntry { + ticket: ticket.to_string(), + pr_number: pr, + title: format!("feat: {ticket} title"), + state: "open".to_string(), + review_status: review.to_string(), + ci_status: ci.to_string(), + url: format!("https://github.com/owner/repo/pull/{pr}"), + } + } + + #[test] + fn review_entry_fields() { + let e = mk_entry("CL-100", 42, "approved", "success"); + assert_eq!(e.ticket, "CL-100"); + assert_eq!(e.pr_number, 42); + assert_eq!(e.review_status, "approved"); + assert_eq!(e.ci_status, "success"); + } + + #[test] + fn review_entry_pending_state() { + let e = mk_entry("CL-200", 99, "pending", "pending"); + assert_eq!(e.state, "open"); + assert_eq!(e.review_status, "pending"); + } + + #[test] + fn review_entry_changes_requested() { + let e = mk_entry("CL-300", 55, "changes_requested", "failure"); + assert_eq!(e.review_status, "changes_requested"); + assert_eq!(e.ci_status, "failure"); + } +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 822140d..b0cffbf 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -535,6 +535,16 @@ pub enum Command { no_overlay: bool, }, + /// Show PR review status across all active worktrees (issue #301). + /// + /// Scans each active worktree, finds its associated open GitHub PR, and + /// prints a unified review table showing review decisions and CI status. + /// + /// Phase 2 will add `--requested` to show PRs from *others* where you are + /// a requested reviewer (uses GitHub Search API). + #[command(name = "reviews", alias = "rv")] + Reviews, + /// Internal: emit dynamic completion candidates (issue #291). /// /// Used by shell completion scripts to enumerate worktrees / branches / @@ -651,6 +661,7 @@ pub async fn run(cli: Cli) -> Result<()> { Command::Compress { .. } => "compress", Command::Smartlog { .. } => "smartlog", Command::Complete { .. } => "__complete", + Command::Reviews => "reviews", }; let exec_id = crate::execlog::new_execution_id(); let exec_started_at = chrono::Utc::now(); @@ -944,6 +955,7 @@ pub async fn run(cli: Cli) -> Result<()> { Command::Smartlog { depth, no_overlay } => { commands::smartlog(&repo_path, depth, no_overlay, output_mode).await } + Command::Reviews => commands::reviews(&repo_path, output_mode).await, Command::Complete { kind } => commands::complete(&repo_path, kind).await, }; diff --git a/src/output/human.rs b/src/output/human.rs index a278707..bff7233 100644 --- a/src/output/human.rs +++ b/src/output/human.rs @@ -1103,3 +1103,98 @@ pub fn print_health(records: &[super::HealthRecord]) { ); } } + +/// Render the `parsec reviews` table — one row per open PR found across worktrees. +/// +/// Review status is color-coded: +/// - ✓ approved → green +/// - ✗ changes requested → red +/// - ● pending → yellow +/// - no reviews → dimmed +pub fn print_reviews(entries: &[super::ReviewEntry]) { + if entries.is_empty() { + println!("No open PRs found in active worktrees."); + return; + } + + #[derive(Tabled)] + struct Row { + #[tabled(rename = "Ticket")] + ticket: String, + #[tabled(rename = "PR")] + pr: String, + #[tabled(rename = "Title")] + title: String, + #[tabled(rename = "State")] + state: String, + #[tabled(rename = "Review")] + review: String, + #[tabled(rename = "CI")] + ci: String, + } + + let rows: Vec = entries + .iter() + .map(|e| { + let state = match e.state.as_str() { + "open" => "open".green().to_string(), + "draft" => "draft".dimmed().to_string(), + "merged" => "merged".cyan().to_string(), + _ => e.state.clone(), + }; + let review = match e.review_status.as_str() { + "approved" => "✓ approved".green().to_string(), + "changes_requested" => "✗ changes requested".red().to_string(), + "pending" => "● pending".yellow().to_string(), + "no reviews" => "– no reviews".dimmed().to_string(), + _ => e.review_status.clone(), + }; + let ci = match e.ci_status.as_str() { + "success" => "✓ CI".green().to_string(), + "failure" | "error" => "✗ CI".red().to_string(), + "pending" => "● CI".yellow().to_string(), + _ => e.ci_status.clone(), + }; + // Truncate long titles for readability. + let title = if e.title.len() > 48 { + format!("{}…", &e.title[..47]) + } else { + e.title.clone() + }; + Row { + ticket: e.ticket.clone(), + pr: format!("#{}", e.pr_number), + title, + state, + review, + ci, + } + }) + .collect(); + + let table = Table::new(rows) + .with(tabled::settings::Style::modern()) + .to_string(); + println!("{}", "parsec reviews".bold()); + println!("{table}"); + println!(); + let pending = entries + .iter() + .filter(|e| e.review_status == "pending" || e.review_status == "no reviews") + .count(); + if pending > 0 { + println!( + "{}", + format!("{}/{} PRs awaiting review.", pending, entries.len()) + .yellow() + .bold() + ); + } else { + println!( + "{}", + format!("All {} PRs reviewed.", entries.len()) + .green() + .bold() + ); + } +} diff --git a/src/output/json.rs b/src/output/json.rs index cc1f4a3..9d8e815 100644 --- a/src/output/json.rs +++ b/src/output/json.rs @@ -390,3 +390,25 @@ pub fn print_health(records: &[super::HealthRecord]) { }) ); } + +/// Emit `parsec reviews` output as a JSON array. +pub fn print_reviews(entries: &[super::ReviewEntry]) { + let items: Vec = entries + .iter() + .map(|e| { + json!({ + "ticket": e.ticket, + "pr_number": e.pr_number, + "title": e.title, + "state": e.state, + "review_status": e.review_status, + "ci_status": e.ci_status, + "url": e.url, + }) + }) + .collect(); + println!( + "{}", + serde_json::to_string_pretty(&items).unwrap_or_else(|_| "[]".to_string()) + ); +} diff --git a/src/output/mod.rs b/src/output/mod.rs index f54979c..c73c6e3 100644 --- a/src/output/mod.rs +++ b/src/output/mod.rs @@ -62,6 +62,24 @@ pub struct HealthRecord { pub pr_number: Option, } +/// One entry in the `parsec reviews` output — one open PR per worktree. +pub struct ReviewEntry { + /// Ticket identifier for the worktree. + pub ticket: String, + /// GitHub PR number. + pub pr_number: u64, + /// PR title. + pub title: String, + /// PR state: `open`, `draft`, `merged`, `closed`. + pub state: String, + /// Review decision: `approved`, `changes_requested`, `pending`, `no reviews`. + pub review_status: String, + /// CI overall: `success`, `failure`, `pending`, `unknown`. + pub ci_status: String, + /// HTML URL to the pull request. + pub url: String, +} + /// Generate a dispatch function that routes to json:: and human:: based on Mode. /// /// Standard form (both Json and Human): @@ -142,6 +160,7 @@ dispatch_output!( infos: &[WorkspaceFullInfo], pr_map: &std::collections::HashMap ); +dispatch_output!(print_reviews, entries: &[ReviewEntry]); dispatch_output!(print_create, ticket_id: &str, title: &str, url: &str); pub fn print_diff_full_json(files: &[(String, String)], ticket: &str) { From 8f408a7b0728c6e1f53620d065c635904b8a9fa5 Mon Sep 17 00:00:00 2001 From: erish Date: Mon, 1 Jun 2026 15:54:54 +0900 Subject: [PATCH 17/26] feat(sync): stale-threshold filter, dry-run behind-count, conflict hint (#290) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(sync): stale-threshold filter, dry-run behind-count, conflict hint (#290) - Add --min-behind N flag (default: 1) to skip worktrees fewer than N commits behind origin/; skipped entries shown in output - dry-run now fetches behind-count and reports per-worktree before exit - Conflict detection: append "(conflict detected — resolve manually)" hint to failed sync reason when git output contains "conflict" - Extend print_sync signature with skipped: &[(String, u32)] across human/json/mod output layers; stack_sync passes &[] (no change) - Add 3 CLI integration tests: up-to-date skip, dry-run output, rebase Co-Authored-By: Claude Sonnet 4.6 (1M context) * style: cargo fmt Co-Authored-By: Claude Sonnet 4.6 (1M context) --------- Co-authored-by: Claude Sonnet 4.6 (1M context) --- src/cli/commands/diff.rs | 62 +++++++++++++++++++++++++++++++++----- src/cli/commands/stack.rs | 2 +- src/cli/mod.rs | 31 +++++++++++-------- src/output/human.rs | 19 ++++++++++-- src/output/json.rs | 8 ++++- src/output/mod.rs | 1 + tests/cli_tests.rs | 63 +++++++++++++++++++++++++++++++++++++++ 7 files changed, 161 insertions(+), 25 deletions(-) diff --git a/src/cli/commands/diff.rs b/src/cli/commands/diff.rs index e661873..e14233d 100644 --- a/src/cli/commands/diff.rs +++ b/src/cli/commands/diff.rs @@ -115,17 +115,25 @@ pub async fn conflicts(repo: &Path, mode: Mode) -> Result<()> { /// `strategy = "rebase"`) or a **merge** (`strategy = "merge"`). A failed /// rebase/merge is automatically aborted so the worktree is left clean. /// +/// `min_behind`: skip worktrees with fewer than this many commits behind +/// `origin/` (default 1 — skip worktrees already up-to-date). +/// +/// With `dry_run = true`, the function prints what would be synced and the +/// behind-count for each worktree, then returns without modifying anything. +/// /// Selection logic (in order): /// 1. `--all` → all active worktrees /// 2. `ticket` → the named worktree only /// 3. auto-detect → the worktree whose path contains `cwd` /// -/// Returns a summary of synced tickets and any failures via [`output::print_sync`]. +/// Returns a summary of synced/skipped/failed tickets via [`output::print_sync`]. pub async fn sync( repo: &Path, ticket: Option<&str>, all: bool, strategy: &str, + min_behind: u32, + dry_run: bool, mode: Mode, ) -> Result<()> { let config = ParsecConfig::load()?; @@ -140,7 +148,6 @@ pub async fn sync( } else if let Some(t) = ticket { vec![manager.get(t)?] } else { - // Try to detect which worktree we're in let cwd = std::env::current_dir()?; let all_ws = manager.list()?; let found = all_ws @@ -153,20 +160,50 @@ pub async fn sync( }; let mut synced = Vec::new(); + let mut skipped = Vec::new(); let mut failed = Vec::new(); for ws in &workspaces { let ws_path = std::path::Path::new(&ws.path); - // Fetch the base branch from remote - if let Err(e) = git::run(ws_path, &["fetch", "origin", &ws.base_branch]) { - failed.push((ws.ticket.clone(), format!("fetch failed: {e}"))); - continue; + + // Fetch the base branch from remote (skip in dry-run to stay offline) + if !dry_run { + if let Err(e) = git::run(ws_path, &["fetch", "origin", &ws.base_branch]) { + failed.push((ws.ticket.clone(), format!("fetch failed: {e}"))); + continue; + } } + let remote_base = format!("origin/{}", ws.base_branch); + + // Count commits behind remote base + let behind: u32 = git::run_output( + ws_path, + &["rev-list", "--count", &format!("HEAD..{remote_base}")], + ) + .ok() + .and_then(|s| s.trim().parse().ok()) + .unwrap_or(0); + + if behind < min_behind { + skipped.push((ws.ticket.clone(), behind)); + continue; + } + + if dry_run { + eprintln!( + "[dry-run] Would {} '{}' ({} commit(s) behind {})", + strategy, ws.ticket, behind, remote_base + ); + synced.push(ws.ticket.clone()); + continue; + } + let result = match strategy { "merge" => git::run(ws_path, &["merge", &remote_base]), _ => git::run(ws_path, &["rebase", &remote_base]), }; + match result { Ok(()) => synced.push(ws.ticket.clone()), Err(e) => { @@ -176,11 +213,20 @@ pub async fn sync( } else { let _ = git::run(ws_path, &["merge", "--abort"]); } - failed.push((ws.ticket.clone(), format!("{strategy} failed: {e}"))); + let conflict_hint = + if e.to_string().contains("CONFLICT") || e.to_string().contains("conflict") { + " (conflict detected — resolve manually)" + } else { + "" + }; + failed.push(( + ws.ticket.clone(), + format!("{strategy} failed: {e}{conflict_hint}"), + )); } } } - output::print_sync(&synced, &failed, strategy, mode); + output::print_sync(&synced, &skipped, &failed, strategy, mode); Ok(()) } diff --git a/src/cli/commands/stack.rs b/src/cli/commands/stack.rs index 55ee261..f8e2d40 100644 --- a/src/cli/commands/stack.rs +++ b/src/cli/commands/stack.rs @@ -147,7 +147,7 @@ pub async fn stack_sync(repo: &Path, mode: Mode) -> Result<()> { } } - output::print_sync(&synced, &failed, "rebase (stack)", mode); + output::print_sync(&synced, &[], &failed, "rebase (stack)", mode); Ok(()) } diff --git a/src/cli/mod.rs b/src/cli/mod.rs index b0cffbf..1f0225a 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -255,6 +255,9 @@ pub enum Command { /// Fetches the latest changes from the remote base branch and rebases /// (or merges) the worktree branch on top. Use --all to sync every /// active worktree at once. Strategy is configurable in config.toml. + /// + /// Use --min-behind to skip worktrees that are already nearly up-to-date + /// (e.g. --min-behind 3 skips any worktree fewer than 3 commits behind). Sync { /// Ticket identifier (syncs current worktree if omitted) ticket: Option, @@ -266,6 +269,10 @@ pub enum Command { /// Sync strategy: rebase or merge (default: rebase) #[arg(long, default_value = "rebase")] strategy: String, + + /// Only sync worktrees at least N commits behind their base (default: 1) + #[arg(long, default_value = "1")] + min_behind: u32, }, /// Open PR/MR or ticket page in browser @@ -759,20 +766,18 @@ pub async fn run(cli: Cli) -> Result<()> { ticket, all, strategy, + min_behind, } => { - if cli.dry_run { - eprintln!( - "[dry-run] Would sync {} (strategy: {})", - if all { - "all worktrees".to_string() - } else { - format!("ticket '{}'", ticket.as_deref().unwrap_or("auto")) - }, - strategy - ); - return Ok(()); - } - commands::sync(&repo_path, ticket.as_deref(), all, &strategy, output_mode).await + commands::sync( + &repo_path, + ticket.as_deref(), + all, + &strategy, + min_behind, + cli.dry_run, + output_mode, + ) + .await } Command::Adopt { ticket, diff --git a/src/output/human.rs b/src/output/human.rs index bff7233..3542298 100644 --- a/src/output/human.rs +++ b/src/output/human.rs @@ -446,7 +446,12 @@ pub fn print_undo_preview(entry: &OpEntry) { } } -pub fn print_sync(synced: &[String], failed: &[(String, String)], strategy: &str) { +pub fn print_sync( + synced: &[String], + skipped: &[(String, u32)], + failed: &[(String, String)], + strategy: &str, +) { if !synced.is_empty() { println!( "{} {} {} worktree(s):", @@ -458,6 +463,16 @@ pub fn print_sync(synced: &[String], failed: &[(String, String)], strategy: &str println!(" - {}", ticket); } } + if !skipped.is_empty() { + println!( + "{} Skipped {} worktree(s) (already up-to-date):", + "–".dimmed(), + skipped.len() + ); + for (ticket, behind) in skipped { + println!(" - {} ({} commit(s) behind)", ticket, behind); + } + } if !failed.is_empty() { println!( "{} Failed to {} {} worktree(s):", @@ -469,7 +484,7 @@ pub fn print_sync(synced: &[String], failed: &[(String, String)], strategy: &str println!(" - {}: {}", ticket, reason.red()); } } - if synced.is_empty() && failed.is_empty() { + if synced.is_empty() && skipped.is_empty() && failed.is_empty() { println!("Nothing to sync."); } } diff --git a/src/output/json.rs b/src/output/json.rs index 9d8e815..84af3d2 100644 --- a/src/output/json.rs +++ b/src/output/json.rs @@ -125,11 +125,17 @@ pub fn print_config_show(config: &ParsecConfig) { emit(config); } -pub fn print_sync(synced: &[String], failed: &[(String, String)], strategy: &str) { +pub fn print_sync( + synced: &[String], + skipped: &[(String, u32)], + failed: &[(String, String)], + strategy: &str, +) { let value = json!({ "action": "sync", "strategy": strategy, "synced": synced, + "skipped": skipped.iter().map(|(t, b)| json!({"ticket": t, "behind": b})).collect::>(), "failed": failed.iter().map(|(t, r)| json!({"ticket": t, "reason": r})).collect::>(), }); println!("{}", value); diff --git a/src/output/mod.rs b/src/output/mod.rs index c73c6e3..7226356 100644 --- a/src/output/mod.rs +++ b/src/output/mod.rs @@ -129,6 +129,7 @@ dispatch_output!(print_undo_preview, entry: &OpEntry); dispatch_output!( print_sync, synced: &[String], + skipped: &[(String, u32)], failed: &[(String, String)], strategy: &str ); diff --git a/tests/cli_tests.rs b/tests/cli_tests.rs index 07747fa..bd43f41 100644 --- a/tests/cli_tests.rs +++ b/tests/cli_tests.rs @@ -428,6 +428,69 @@ fn test_sync_rebases_worktree() { .success(); } +#[test] +fn test_sync_skips_when_already_up_to_date() { + let (repo, _bare) = setup_repo_with_remote(); + let repo_path = repo.path().to_str().unwrap(); + + // Create a worktree — it starts up-to-date. + parsec() + .args(["start", "SYNC-002", "--repo", repo_path]) + .assert() + .success(); + + // With default --min-behind 1, a worktree that is 0 commits behind should + // be skipped (no error, no sync output line). + let out = parsec() + .args(["sync", "SYNC-002", "--repo", repo_path]) + .output() + .unwrap(); + assert!(out.status.success()); + let stdout = String::from_utf8_lossy(&out.stdout); + // Should not report a successful rebase of SYNC-002. + assert!(!stdout.contains("rebase") || stdout.contains("Skipped") || stdout.contains("Nothing")); +} + +#[test] +fn test_sync_dry_run_shows_behind_count() { + let (repo, _bare) = setup_repo_with_remote(); + let repo_path = repo.path().to_str().unwrap(); + + parsec() + .args(["start", "SYNC-003", "--repo", repo_path]) + .assert() + .success(); + + // Advance main by one commit so SYNC-003 is 1 behind. + StdCommand::new("git") + .args([ + "commit", + "--allow-empty", + "-m", + "advance main for dry-run test", + ]) + .current_dir(repo.path()) + .output() + .unwrap(); + StdCommand::new("git") + .args(["push", "origin", "main"]) + .current_dir(repo.path()) + .output() + .unwrap(); + + // --dry-run should report the action without modifying the worktree. + let out = parsec() + .args(["sync", "SYNC-003", "--dry-run", "--repo", repo_path]) + .output() + .unwrap(); + assert!(out.status.success()); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("dry-run"), + "expected dry-run output, got: {stderr}" + ); +} + // --------------------------------------------------------------------------- // adopt // --------------------------------------------------------------------------- From 6878de954b76edec35be4ffe09f754999aecc8f4 Mon Sep 17 00:00:00 2001 From: erish Date: Tue, 2 Jun 2026 09:50:10 +0900 Subject: [PATCH 18/26] =?UTF-8?q?feat(smartlog):=20Phase=203=20=E2=80=94?= =?UTF-8?q?=20worktree=20filter,=20ANSI=20color,=20stack=20indicator=20(#3?= =?UTF-8?q?33)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs #245 ## What - Add `--worktree ` flag (-w) to `parsec smartlog` / `sl`: case- insensitive substring match on ticket or branch name so users can focus on a subset of worktrees without noise from unrelated ones. - ANSI color in the PR/CI badge: green for success/approved/merged, red for failure/changes-requested, yellow for pending, blue for open PR, dim for draft/closed. Automatically disabled when `NO_COLOR` is set or stdout is not a TTY; force-enable via `PARSEC_COLOR=always`. - Stack indicator: when a worktree's base branch matches another active worktree's branch name, the base-group header now reads `○ (stacked on )` instead of `(base)`, making stacked-PR flows immediately visible without extra commands. ## Tests added (5 new) - `worktree_filter_matches_ticket_substring` - `worktree_filter_branch_fallback` - `stack_indicator_appears_when_base_is_sibling_branch` - `color_badge_contains_ansi_codes_when_enabled` - `color_badge_failure_ci_is_red` All 138 tests pass (cargo build / clippy / fmt / test ✓). Co-authored-by: Claude Sonnet 4.6 --- src/cli/commands/smartlog.rs | 239 ++++++++++++++++++++++++++++++----- src/cli/mod.rs | 21 ++- 2 files changed, 224 insertions(+), 36 deletions(-) diff --git a/src/cli/commands/smartlog.rs b/src/cli/commands/smartlog.rs index f190711..463bcc1 100644 --- a/src/cli/commands/smartlog.rs +++ b/src/cli/commands/smartlog.rs @@ -7,15 +7,26 @@ //! - Read each worktree's commits since its base branch (`base..branch`) //! - Render as ASCII tree, or emit JSON //! -//! Phase 2 (this PR — PR/CI overlay): +//! Phase 2 (PR #327 — PR/CI overlay): //! - For each worktree, look up the GitHub PR by branch name and attach a //! compact overlay ([`SmartlogPrOverlay`]) describing the PR number, state, //! CI status and review status. //! - Overlay is best-effort: missing token / no PR / network errors all //! degrade gracefully to "no overlay" without failing the command. //! - Users can opt out with `--no-overlay` for a fully offline run. - -use std::collections::BTreeMap; +//! +//! Phase 3 (this PR — filter · color · stack indicators): +//! - `--worktree `: show only worktrees whose ticket or branch contains +//! the pattern (case-insensitive substring match). +//! - ANSI color in the PR/CI badge: green=success, red=failure, yellow=pending, +//! blue=open PR, dim=draft. Automatically disabled when `NO_COLOR` is set or +//! stdout is not a TTY. +//! - Stack indicator: when a worktree's base branch is itself another active +//! worktree's branch, annotate it with `⤷ stacked on ` so stacked-PR +//! flows are immediately visible. + +use std::collections::{BTreeMap, HashMap}; +use std::io::IsTerminal as _; use std::path::{Path, PathBuf}; use anyhow::Result; @@ -82,6 +93,7 @@ pub async fn smartlog( repo: &Path, depth: Option, no_overlay: bool, + worktree_filter: Option<&str>, mode: Mode, ) -> Result<()> { let depth = depth.unwrap_or(DEFAULT_DEPTH); @@ -107,16 +119,27 @@ pub async fn smartlog( }); } + // Phase 3: apply --worktree filter (case-insensitive substring match on + // ticket or branch name). + if let Some(pat) = worktree_filter { + let pat_lower = pat.to_lowercase(); + nodes.retain(|n| { + n.ticket.to_lowercase().contains(&pat_lower) + || n.branch.to_lowercase().contains(&pat_lower) + }); + } + if !no_overlay { attach_pr_overlay(repo, &config, &mut nodes).await; } + let color = color_enabled(); match mode { Mode::Json => { println!("{}", serde_json::to_string_pretty(&nodes)?); } _ => { - print!("{}", render_text(&nodes)); + print!("{}", render_text(&nodes, color)); } } Ok(()) @@ -199,27 +222,44 @@ fn collect_commits( /// Glyphs match `parsec pr status` / `parsec ci` conventions so users see the /// same vocabulary across commands: /// - state: `open` → `●`, `merged`/`closed` → `✓`, `draft` → `○`, other → `?` -/// - CI: `success` → `✓ CI`, `failure` → `✗ CI`, `pending` → `● CI`, else `? CI` -/// - review: `approved` → `✓ approved`, `changes_requested` → `✗ changes`, -/// `pending` → `● review`, `no reviews` → omitted -fn format_pr_badge(pr: &SmartlogPrOverlay) -> String { - let state_glyph = match pr.state.as_str() { - "open" => "●", - "merged" | "closed" => "✓", - "draft" => "○", - _ => "?", +/// - CI: `success` → `✓ CI` (green), `failure` → `✗ CI` (red), +/// `pending` → `● CI` (yellow), else `? CI` +/// - review: `approved` → `✓ approved` (green), `changes_requested` → `✗ changes` (red), +/// `pending` → `● review` (yellow), `no reviews` → omitted +/// +/// `color` enables ANSI SGR codes. Pass `false` in tests / piped output. +fn format_pr_badge(pr: &SmartlogPrOverlay, color: bool) -> String { + // ANSI SGR codes used: 32=green 31=red 33=yellow 34=blue 2=dim + let (state_glyph, state_str) = match pr.state.as_str() { + "open" => ( + ansi_wrap(34, "●", color), // blue + ansi_wrap(34, &pr.state, color), + ), + "merged" => ( + ansi_wrap(32, "✓", color), // green + ansi_wrap(32, &pr.state, color), + ), + "closed" => ( + ansi_wrap(2, "✓", color), // dim + ansi_wrap(2, &pr.state, color), + ), + "draft" => ( + ansi_wrap(2, "○", color), // dim + ansi_wrap(2, &pr.state, color), + ), + _ => ("?".to_string(), pr.state.clone()), }; let ci = match pr.ci_status.as_str() { - "success" => "✓ CI", - "failure" => "✗ CI", - "pending" => "● CI", - _ => "? CI", + "success" => ansi_wrap(32, "✓ CI", color), // green + "failure" => ansi_wrap(31, "✗ CI", color), // red + "pending" => ansi_wrap(33, "● CI", color), // yellow + _ => "? CI".to_string(), }; - let mut out = format!("[PR #{} {} {} {}]", pr.number, state_glyph, pr.state, ci); + let mut out = format!("[PR #{} {} {} {}]", pr.number, state_glyph, state_str, ci); let review = match pr.review_status.as_str() { - "approved" => Some("✓ approved"), - "changes_requested" => Some("✗ changes"), - "pending" => Some("● review"), + "approved" => Some(ansi_wrap(32, "✓ approved", color)), + "changes_requested" => Some(ansi_wrap(31, "✗ changes", color)), + "pending" => Some(ansi_wrap(33, "● review", color)), _ => None, // "no reviews" or unknown → omit }; if let Some(r) = review { @@ -260,11 +300,20 @@ fn parse_commit_line(line: &str) -> Option { /// /// Returns the rendered string (instead of printing) so it's testable. Empty /// node list returns a single explanatory line. -pub fn render_text(nodes: &[SmartlogNode]) -> String { +/// +/// `color` enables ANSI escape codes in the PR/CI badge. Pass `false` in tests +/// or when `NO_COLOR` is set to keep output predictable. +pub fn render_text(nodes: &[SmartlogNode], color: bool) -> String { if nodes.is_empty() { return "No active worktrees. Run `parsec start ` to create one.\n".to_string(); } + // Phase 3: build branch → ticket lookup for stack indicator. + let branch_to_ticket: HashMap<&str, &str> = nodes + .iter() + .map(|n| (n.branch.as_str(), n.ticket.as_str())) + .collect(); + let mut by_base: BTreeMap> = BTreeMap::new(); for n in nodes { by_base.entry(n.base_branch.clone()).or_default().push(n); @@ -273,7 +322,13 @@ pub fn render_text(nodes: &[SmartlogNode]) -> String { let mut out = String::new(); let base_count = by_base.len(); for (base_idx, (base, group)) in by_base.iter().enumerate() { - out.push_str(&format!("○ {} (base)\n", base)); + // Phase 3: if the base branch is itself a worktree branch, mark it as + // a stacked group rather than a plain base label. + if let Some(parent_ticket) = branch_to_ticket.get(base.as_str()) { + out.push_str(&format!("○ {} (stacked on {})\n", base, parent_ticket)); + } else { + out.push_str(&format!("○ {} (base)\n", base)); + } let last_idx = group.len().saturating_sub(1); for (i, node) in group.iter().enumerate() { let is_last = i == last_idx; @@ -287,10 +342,9 @@ pub fn render_text(nodes: &[SmartlogNode]) -> String { let prefix = if is_last { " " } else { "│ " }; // PR overlay (Phase 2): one line above commits when overlay set. - // Always uses `├─` so it visually attaches to the ticket above and - // the commits below; the actual tree closes on the last commit. + // Phase 3: badge is now optionally colorized. if let Some(pr) = &node.pr { - out.push_str(&format!("{}├─ {}\n", prefix, format_pr_badge(pr))); + out.push_str(&format!("{}├─ {}\n", prefix, format_pr_badge(pr, color))); } if node.commits.is_empty() { out.push_str(&format!("{}└─ (no commits since {})\n", prefix, base)); @@ -312,6 +366,38 @@ pub fn render_text(nodes: &[SmartlogNode]) -> String { out } +// --------------------------------------------------------------------------- +// Phase 3 helpers: color support +// --------------------------------------------------------------------------- + +/// Returns `true` when ANSI color output is appropriate. +/// +/// Rules (first-match): +/// 1. `NO_COLOR` env var set (any value) → false (XDG spec). +/// 2. `PARSEC_COLOR=always` → true (force-on override). +/// 3. Stdout is not a TTY → false (piped / redirected output). +/// 4. Otherwise → true. +fn color_enabled() -> bool { + if std::env::var_os("NO_COLOR").is_some() { + return false; + } + if std::env::var("PARSEC_COLOR").as_deref() == Ok("always") { + return true; + } + std::io::stdout().is_terminal() +} + +/// Wrap `text` in the given ANSI SGR code when `color` is true. +/// +/// Always appends SGR reset (0) after the text so colors don't bleed. +fn ansi_wrap(code: u8, text: &str, color: bool) -> String { + if color { + format!("\x1b[{}m{}\x1b[0m", code, text) + } else { + text.to_string() + } +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -380,7 +466,7 @@ mod tests { #[test] fn render_text_empty() { - let s = render_text(&[]); + let s = render_text(&[], false); assert!(s.contains("No active worktrees")); } @@ -392,7 +478,7 @@ mod tests { "feature/CL-2283", vec![mk_commit("a1b2c3d", "Implement rate limiter")], )]; - let s = render_text(&nodes); + let s = render_text(&nodes, false); assert!(s.contains("○ main (base)")); assert!(s.contains("CL-2283")); assert!(s.contains("Add rate limiting")); @@ -403,7 +489,7 @@ mod tests { #[test] fn render_text_no_commits_shows_placeholder() { let nodes = vec![mk_node("CL-2291", None, "scratch/CL-2291", vec![])]; - let s = render_text(&nodes); + let s = render_text(&nodes, false); assert!(s.contains("(no commits since main)")); assert!(s.contains("(no title)")); } @@ -424,7 +510,7 @@ mod tests { vec![mk_commit("bbbbbbb", "second commit")], ); b.base_branch = "develop".to_string(); - let s = render_text(&[a, b]); + let s = render_text(&[a, b], false); assert!(s.contains("○ main (base)")); assert!(s.contains("○ develop (base)")); // Both nodes should render their commits. @@ -455,20 +541,20 @@ mod tests { #[test] fn format_pr_badge_open_passing_approved() { - let badge = format_pr_badge(&mk_overlay("open", "success", "approved")); + let badge = format_pr_badge(&mk_overlay("open", "success", "approved"), false); assert!(badge.starts_with("[PR #42 ● open ✓ CI")); assert!(badge.ends_with("✓ approved]")); } #[test] fn format_pr_badge_no_reviews_drops_review_segment() { - let badge = format_pr_badge(&mk_overlay("open", "pending", "no reviews")); + let badge = format_pr_badge(&mk_overlay("open", "pending", "no reviews"), false); assert_eq!(badge, "[PR #42 ● open ● CI]"); } #[test] fn format_pr_badge_merged_pr() { - let badge = format_pr_badge(&mk_overlay("merged", "success", "approved")); + let badge = format_pr_badge(&mk_overlay("merged", "success", "approved"), false); // `merged` carries no special CI semantics — render the API-reported CI as-is. assert!(badge.contains("✓ merged")); assert!(badge.contains("✓ CI")); @@ -484,7 +570,7 @@ mod tests { vec![mk_commit("a1b2c3d", "Implement rate limiter")], ); node.pr = Some(mk_overlay("open", "success", "approved")); - let s = render_text(&[node]); + let s = render_text(&[node], false); assert!(s.contains("CL-2283"), "ticket line still present"); assert!(s.contains("[PR #42"), "PR badge rendered"); assert!(s.contains("✓ approved"), "review badge rendered"); @@ -517,4 +603,89 @@ mod tests { // ci field still omitted — Phase 2 folds CI into the overlay. assert!(v.get("ci").is_none(), "ci field stays omitted in Phase 2"); } + + // ----------------------------------------------------------------------- + // Phase 3 tests: filter, color, stack indicator + // ----------------------------------------------------------------------- + + #[test] + fn worktree_filter_matches_ticket_substring() { + // Only PROJ-1 should survive a "PROJ-1" filter applied before render. + let nodes = vec![ + mk_node("PROJ-10", Some("Ten"), "feat/PROJ-10", vec![]), + mk_node("PROJ-20", Some("Twenty"), "feat/PROJ-20", vec![]), + ]; + let pat = "proj-1"; + let pat_lower = pat.to_lowercase(); + let filtered: Vec<_> = nodes + .into_iter() + .filter(|n| { + n.ticket.to_lowercase().contains(&pat_lower) + || n.branch.to_lowercase().contains(&pat_lower) + }) + .collect(); + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].ticket, "PROJ-10"); + } + + #[test] + fn worktree_filter_branch_fallback() { + // Pattern matches branch name even when ticket differs. + let mut node = mk_node("CL-99", Some("T"), "feat/special-ui", vec![]); + node.base_branch = "main".to_string(); + let nodes = vec![node]; + let pat_lower = "special".to_lowercase(); + let filtered: Vec<_> = nodes + .into_iter() + .filter(|n| { + n.ticket.to_lowercase().contains(&pat_lower) + || n.branch.to_lowercase().contains(&pat_lower) + }) + .collect(); + assert_eq!(filtered.len(), 1); + } + + #[test] + fn stack_indicator_appears_when_base_is_sibling_branch() { + // PROJ-2 stacks on top of PROJ-1's branch. + let parent = mk_node("PROJ-1", Some("Parent"), "feat/PROJ-1", vec![]); + let mut child = mk_node("PROJ-2", Some("Child"), "feat/PROJ-2", vec![]); + child.base_branch = "feat/PROJ-1".to_string(); // base = parent's branch + + let nodes = vec![parent, child]; + let s = render_text(&nodes, false); + assert!( + s.contains("stacked on PROJ-1"), + "stack indicator missing in:\n{}", + s + ); + } + + #[test] + fn color_badge_contains_ansi_codes_when_enabled() { + let overlay = mk_overlay("open", "success", "approved"); + let badge_color = format_pr_badge(&overlay, true); + let badge_plain = format_pr_badge(&overlay, false); + // Color badge should contain ESC character; plain should not. + assert!( + badge_color.contains('\x1b'), + "colored badge should contain ANSI escape" + ); + assert!( + !badge_plain.contains('\x1b'), + "plain badge should not contain ANSI escape" + ); + } + + #[test] + fn color_badge_failure_ci_is_red() { + let overlay = mk_overlay("open", "failure", "pending"); + let badge = format_pr_badge(&overlay, true); + // Red = ESC[31m before "✗ CI" + assert!( + badge.contains("\x1b[31m"), + "failure CI should use red (31) ANSI code, got: {:?}", + badge + ); + } } diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 1f0225a..d964b8c 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -540,6 +540,12 @@ pub enum Command { /// Overlay is also auto-skipped when no GitHub token is configured. #[arg(long)] no_overlay: bool, + + /// Show only worktrees whose ticket or branch name contains this + /// pattern (case-insensitive substring match). + /// Example: `parsec sl --worktree PROJ-4` shows all PROJ-4* tickets. + #[arg(long, short = 'w', value_name = "PATTERN")] + worktree: Option, }, /// Show PR review status across all active worktrees (issue #301). @@ -957,8 +963,19 @@ pub async fn run(cli: Cli) -> Result<()> { Command::Compress { ticket, message } => { commands::compress(&repo_path, ticket.as_deref(), message, output_mode).await } - Command::Smartlog { depth, no_overlay } => { - commands::smartlog(&repo_path, depth, no_overlay, output_mode).await + Command::Smartlog { + depth, + no_overlay, + worktree, + } => { + commands::smartlog( + &repo_path, + depth, + no_overlay, + worktree.as_deref(), + output_mode, + ) + .await } Command::Reviews => commands::reviews(&repo_path, output_mode).await, Command::Complete { kind } => commands::complete(&repo_path, kind).await, From 2d66eb68a01aae8e1a18fd629de0358893694acf Mon Sep 17 00:00:00 2001 From: erish Date: Wed, 3 Jun 2026 10:11:44 +0900 Subject: [PATCH 19/26] =?UTF-8?q?feat(reviews):=20Phase=202=20=E2=80=94=20?= =?UTF-8?q?--requested=20flag=20via=20GitHub=20Search=20API=20(Refs=20#301?= =?UTF-8?q?)=20(#334)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `--requested` flag to `parsec reviews` (alias `rv -r`) that shows open PRs *from others* in this repo where the authenticated user is a requested reviewer. ## What changed - `src/github/mod.rs`: add `get_authenticated_user()` (GET /user) and `search_review_requested_prs(login)` (GET /search/issues with review-requested query). Reqwest .query() handles URL encoding — no new dependencies. - `src/cli/commands/reviews.rs`: split into `collect_authored_reviews` (Phase 1 logic) and `collect_requested_reviews` (Phase 2). Each returns Vec; the caller merges and dispatches. - `src/cli/mod.rs`: add `requested: bool` field to `Reviews` variant, pass through to command handler. - 4 unit tests: existing 3 preserved + 1 new for reviewer-mode ticket placeholder. ## Phase 3 hint - Add `--all` flag to include closed/merged PRs. - Add author filter (`--author`) to narrow results. Co-authored-by: Claude Sonnet 4.6 --- src/cli/commands/reviews.rs | 123 ++++++++++++++++++++++++++---------- src/cli/mod.rs | 19 ++++-- src/github/mod.rs | 63 ++++++++++++++++++ 3 files changed, 165 insertions(+), 40 deletions(-) diff --git a/src/cli/commands/reviews.rs b/src/cli/commands/reviews.rs index 3023bfc..5610e88 100644 --- a/src/cli/commands/reviews.rs +++ b/src/cli/commands/reviews.rs @@ -1,20 +1,16 @@ //! `parsec reviews` — unified PR review status across all active worktrees (#301). //! -//! Phase 1 (this PR): +//! Phase 1 (PR #331): //! - Scan every active worktree via [`WorktreeManager`]. //! - For each worktree, resolve its open GitHub PR by branch name. //! - Fetch the PR's review + CI status and collect into a [`ReviewEntry`]. //! - Render a table (human) or JSON array. //! -//! Scope is intentionally the "author" view: PRs that the current user opened -//! and that have a pending review request from others. Both pending and -//! approved/changes-requested states are shown so that nothing falls through. -//! -//! Phase 2 hint: -//! - Add `--requested` flag: use GitHub Search API +//! Phase 2 (this PR): +//! - Add `--requested` flag: uses GitHub Search API //! (`/search/issues?q=review-requested:{login}`) to show PRs *from others* //! where the current user is a requested reviewer. -//! - Add `--all` to include closed/merged PRs. +//! - Both views (author + requested) share the same `ReviewEntry` table output. use std::path::Path; @@ -28,26 +24,18 @@ use crate::worktree::WorktreeManager; /// Entry point for the `parsec reviews` subcommand. /// -/// Iterates all active worktrees, resolves their associated open GitHub PRs, -/// and prints the aggregated review table. +/// When `requested` is `false` (default): iterates all active worktrees, +/// resolves their associated open GitHub PRs, and prints the aggregated +/// review table (author view). +/// +/// When `requested` is `true`: uses the GitHub Search API to find open PRs +/// *in this repo* where the authenticated user is a requested reviewer. /// /// # Errors /// Returns an error if GitHub credentials are missing. Individual per-worktree -/// failures (e.g. no PR for the branch) are silently skipped so that the rest -/// of the table still renders. -pub async fn reviews(repo: &Path, mode: Mode) -> Result<()> { +/// failures (e.g. no PR for the branch) are silently skipped. +pub async fn reviews(repo: &Path, requested: bool, mode: Mode) -> Result<()> { let config = ParsecConfig::load()?; - let manager = WorktreeManager::new(repo, &config)?; - let workspaces = manager.list()?; - - if workspaces.is_empty() { - match mode { - Mode::Human => println!("No active worktrees — nothing to review."), - Mode::Json => println!("[]"), - Mode::Quiet => {} - } - return Ok(()); - } let remote_url = git::get_remote_url(repo).unwrap_or_default(); let gh = match GitHubClient::new(&remote_url, &config)? { @@ -61,27 +49,57 @@ pub async fn reviews(repo: &Path, mode: Mode) -> Result<()> { } }; - let mut entries: Vec = Vec::new(); + let entries = if requested { + collect_requested_reviews(&gh).await? + } else { + collect_authored_reviews(repo, &config, &gh).await? + }; + + if entries.is_empty() { + match mode { + Mode::Human => { + if requested { + println!("No open PRs where you are a requested reviewer."); + } else { + println!("No open PRs found in active worktrees."); + } + } + Mode::Json => println!("[]"), + Mode::Quiet => {} + } + return Ok(()); + } + + output::print_reviews(&entries, mode); + Ok(()) +} + +// --------------------------------------------------------------------------- +// Private helpers +// --------------------------------------------------------------------------- + +/// Collect PRs authored by the user — one per active worktree (Phase 1 logic). +async fn collect_authored_reviews( + repo: &Path, + config: &ParsecConfig, + gh: &GitHubClient, +) -> Result> { + let manager = WorktreeManager::new(repo, config)?; + let workspaces = manager.list()?; + let mut entries = Vec::new(); for ws in &workspaces { - // Resolve PR number from branch name — skip worktrees without an open PR. let pr_number = match gh.find_pr_by_branch(&ws.branch).await { Ok(Some(n)) => n, - Ok(None) => continue, - Err(_) => continue, + Ok(None) | Err(_) => continue, }; - - // Fetch PR status (title, state, ci_status, review_status, url). let status = match gh.get_pr_status(pr_number).await { Ok(s) => s, Err(_) => continue, }; - - // Phase 1 shows open + draft PRs only; closed/merged are filtered out. if status.state == "closed" { continue; } - entries.push(ReviewEntry { ticket: ws.ticket.clone(), pr_number: status.number, @@ -92,9 +110,36 @@ pub async fn reviews(repo: &Path, mode: Mode) -> Result<()> { url: status.url.clone(), }); } + Ok(entries) +} - output::print_reviews(&entries, mode); - Ok(()) +/// Collect PRs from *others* where the current user is a requested reviewer +/// (Phase 2 — uses GitHub Search API). +async fn collect_requested_reviews(gh: &GitHubClient) -> Result> { + let login = gh.get_authenticated_user().await?; + let found = gh.search_review_requested_prs(&login).await?; + + let mut entries = Vec::new(); + for (pr_number, title, url, state) in found { + // Fetch full PR status to get CI + review data. + // Fall back to "–" on individual fetch failure rather than aborting. + let (review_status, ci_status) = match gh.get_pr_status(pr_number).await { + Ok(s) => (s.review_status, s.ci_status), + Err(_) => ("–".to_string(), "–".to_string()), + }; + + // No worktree is associated with reviewer-mode PRs. + entries.push(ReviewEntry { + ticket: "–".to_string(), + pr_number, + title, + state, + review_status, + ci_status, + url, + }); + } + Ok(entries) } // --------------------------------------------------------------------------- @@ -140,4 +185,12 @@ mod tests { assert_eq!(e.review_status, "changes_requested"); assert_eq!(e.ci_status, "failure"); } + + #[test] + fn review_entry_requested_mode_ticket_placeholder() { + // In --requested mode, ticket is set to "–" because no worktree is associated. + let e = mk_entry("–", 77, "pending", "success"); + assert_eq!(e.ticket, "–"); + assert_eq!(e.pr_number, 77); + } } diff --git a/src/cli/mod.rs b/src/cli/mod.rs index d964b8c..7563490 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -553,10 +553,17 @@ pub enum Command { /// Scans each active worktree, finds its associated open GitHub PR, and /// prints a unified review table showing review decisions and CI status. /// - /// Phase 2 will add `--requested` to show PRs from *others* where you are - /// a requested reviewer (uses GitHub Search API). + /// Use `--requested` to show PRs from *others* where you are a requested + /// reviewer (Phase 2 — uses GitHub Search API). #[command(name = "reviews", alias = "rv")] - Reviews, + Reviews { + /// Show PRs from others where you are a requested reviewer. + /// + /// Uses the GitHub Search API to find open PRs in this repo where + /// the authenticated user (`gh auth status`) is listed as a reviewer. + #[arg(long, short = 'r')] + requested: bool, + }, /// Internal: emit dynamic completion candidates (issue #291). /// @@ -674,7 +681,7 @@ pub async fn run(cli: Cli) -> Result<()> { Command::Compress { .. } => "compress", Command::Smartlog { .. } => "smartlog", Command::Complete { .. } => "__complete", - Command::Reviews => "reviews", + Command::Reviews { .. } => "reviews", }; let exec_id = crate::execlog::new_execution_id(); let exec_started_at = chrono::Utc::now(); @@ -977,7 +984,9 @@ pub async fn run(cli: Cli) -> Result<()> { ) .await } - Command::Reviews => commands::reviews(&repo_path, output_mode).await, + Command::Reviews { requested } => { + commands::reviews(&repo_path, requested, output_mode).await + } Command::Complete { kind } => commands::complete(&repo_path, kind).await, }; diff --git a/src/github/mod.rs b/src/github/mod.rs index f85a74f..d153471 100644 --- a/src/github/mod.rs +++ b/src/github/mod.rs @@ -306,6 +306,30 @@ struct ApiPrListItem { number: Option, } +#[derive(Deserialize)] +struct ApiUserResponse { + #[serde(default)] + login: String, +} + +#[derive(Deserialize)] +struct ApiSearchItem { + #[serde(default)] + number: u64, + #[serde(default)] + title: String, + #[serde(default)] + html_url: String, + #[serde(default)] + state: String, +} + +#[derive(Deserialize)] +struct ApiSearchResponse { + #[serde(default)] + items: Vec, +} + // --------------------------------------------------------------------------- // GitHubClient // --------------------------------------------------------------------------- @@ -537,6 +561,45 @@ impl GitHubClient { Ok(resp.first().and_then(|pr| pr.number)) } + /// Return the login of the authenticated GitHub user. + /// + /// Used by `parsec reviews --requested` to build the search query. + pub async fn get_authenticated_user(&self) -> Result { + let resp: ApiUserResponse = send_with_retry(self.get("/user")).await?.json().await?; + if resp.login.is_empty() { + anyhow::bail!("GitHub API returned empty login; is the token valid?"); + } + Ok(resp.login) + } + + /// Search for open PRs in *this repo* where `login` is a requested reviewer. + /// + /// Uses the GitHub Search Issues API: + /// `GET /search/issues?q=repo:{owner}/{repo}+type:pr+state:open+review-requested:{login}` + /// + /// Returns a list of `(pr_number, title, html_url, state)` tuples. + /// Up to 30 results (GitHub Search default page size). + pub async fn search_review_requested_prs( + &self, + login: &str, + ) -> Result> { + let q = format!( + "repo:{}/{} type:pr state:open review-requested:{}", + self.remote.owner, self.remote.repo, login + ); + // Use reqwest's .query() so the value is properly percent-encoded. + let resp: ApiSearchResponse = + send_with_retry(self.get("/search/issues").query(&[("q", &q)])) + .await? + .json() + .await?; + Ok(resp + .items + .into_iter() + .map(|item| (item.number, item.title, item.html_url, item.state)) + .collect()) + } + /// Merge a GitHub PR. /// `method` should be "squash", "rebase", or "merge". pub async fn merge_pr( From e18f90f73117e6c8c366274ce30328a150dfeba0 Mon Sep 17 00:00:00 2001 From: erish Date: Wed, 3 Jun 2026 21:00:20 +0900 Subject: [PATCH 20/26] feat(conflicts): --simulate flag for line-level speculative merge (Closes #246) (#335) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `parsec conflicts --simulate`로 실제 in-memory three-way 머지를 수행해 라인-레벨 충돌을 사전 탐지. 기존 filename overlap 휴리스틱 (`parsec conflicts`)을 보완. ## 추가 모듈 - `src/conflict/simulator.rs` (240줄) - `MergeSimulation { vs_base, cross, skipped }` 결과 구조 - `simulate(repo, &workspaces)` 진입점 - Pass 1: 각 워크트리 HEAD vs origin/ (`merge-base` → fallback to local base) - Pass 2: 워크트리 페어 cross-simulate - 도구: `git merge-tree --write-tree --name-only --merge-base=` (git 2.38+) - read-only — 워킹디렉터리/index 무수정, merge tree 객체는 DB에 남되 참조 없음 ## CLI - `Command::Conflicts` variant에 `--simulate` bool 플래그 추가 - 디스패치 시 simulate true이면 `conflict::simulate(...)`, false면 기존 `conflict::detect(...)` ## Output - Human: 'Worktree → base conflicts' + 'Cross-worktree conflicts' 섹션, 파일 목록 ●로 표시 - JSON: `{ vs_base: [...], cross: [...], skipped: [...] }` 머신 readable ## 가드레일 - WorkspaceStatus::Active 만 처리 - merge-base 계산 실패시 skipped로 다운그레이드 (전체 리포트 중단 방지) - `merge-tree` 비-0 exit + 빈 출력은 transient로 간주 ## 검증 - cargo build clean - cargo clippy -D warnings clean - cargo fmt clean - cargo test: 66 lib + 5 simulator + 71 cli = 142 통과 (이전 138 + 신규 4 simulate) ## 신규 테스트 (tests/cli_tests.rs) - test_conflicts_simulate_empty_repo - test_conflicts_simulate_json_empty_is_object - test_conflicts_simulate_single_clean_worktree - test_conflicts_simulate_detects_cross_worktree_line_conflict (실제 충돌 시드) v0.5 마일스톤. Co-authored-by: Pochacco --- src/cli/commands/diff.rs | 24 ++-- src/cli/mod.rs | 20 ++- src/conflict/mod.rs | 2 + src/conflict/simulator.rs | 268 ++++++++++++++++++++++++++++++++++++++ src/output/human.rs | 81 +++++++++++- src/output/json.rs | 4 + src/output/mod.rs | 3 +- tests/cli_tests.rs | 159 ++++++++++++++++++++++ 8 files changed, 546 insertions(+), 15 deletions(-) create mode 100644 src/conflict/simulator.rs diff --git a/src/cli/commands/diff.rs b/src/cli/commands/diff.rs index e14233d..9eb73a3 100644 --- a/src/cli/commands/diff.rs +++ b/src/cli/commands/diff.rs @@ -94,18 +94,26 @@ pub async fn diff( /// Detect files that are modified in multiple active worktrees simultaneously. /// -/// Scans every workspace returned by [`WorktreeManager::list`] and compares -/// the set of changed files. Pairs of worktrees that touch the same path are -/// reported as potential conflicts so the developer can resolve them before -/// merging. Does **not** modify any worktree state. -pub async fn conflicts(repo: &Path, mode: Mode) -> Result<()> { +/// Default mode is a fast filename-overlap heuristic — every workspace returned +/// by [`WorktreeManager::list`] has its changed files compared; pairs of +/// worktrees that touch the same path are reported. +/// +/// With `simulate = true` (issue #246), runs an in-memory three-way merge +/// using `git merge-tree` for each worktree vs. its base branch AND each pair +/// of worktrees, surfacing real line-level conflicts. Read-only — no worktree +/// or index changes. +pub async fn conflicts(repo: &Path, simulate: bool, mode: Mode) -> Result<()> { let config = ParsecConfig::load()?; let manager = WorktreeManager::new(repo, &config)?; - let workspaces = manager.list()?; - let conflicts = conflict::detect(&workspaces)?; - output::print_conflicts(&conflicts, mode); + if simulate { + let result = conflict::simulate(repo, &workspaces)?; + output::print_conflict_simulation(&result, mode); + } else { + let conflicts = conflict::detect(&workspaces)?; + output::print_conflicts(&conflicts, mode); + } Ok(()) } diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 7563490..0a0852b 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -175,9 +175,17 @@ pub enum Command { /// Detect file conflicts across active worktrees /// - /// Compares modified files across all active worktrees and reports - /// any files that are being edited in more than one workspace. - Conflicts, + /// Default: filename-overlap heuristic — fast, reports files touched in + /// 2+ worktrees. + /// + /// `--simulate`: run an in-memory three-way merge (git merge-tree) for + /// each worktree vs. its base branch AND each worktree pair. Catches + /// real line-level conflicts before they bite at merge time. Read-only. + Conflicts { + /// Run speculative merges to detect line-level conflicts (issue #246) + #[arg(long)] + simulate: bool, + }, /// Check PR/MR CI and review status /// @@ -656,7 +664,7 @@ pub async fn run(cli: Cli) -> Result<()> { Command::Ticket { .. } => "ticket", Command::Ship { .. } => "ship", Command::Clean { .. } => "clean", - Command::Conflicts => "conflicts", + Command::Conflicts { .. } => "conflicts", Command::PrStatus { .. } => "pr-status", Command::Merge { .. } => "merge", Command::Ci { .. } => "ci", @@ -855,7 +863,9 @@ pub async fn run(cli: Cli) -> Result<()> { stat, name_only, } => commands::diff(&repo_path, ticket.as_deref(), stat, name_only, output_mode).await, - Command::Conflicts => commands::conflicts(&repo_path, output_mode).await, + Command::Conflicts { simulate } => { + commands::conflicts(&repo_path, simulate, output_mode).await + } Command::Switch { ticket } => { commands::switch(&repo_path, ticket.as_deref(), output_mode).await } diff --git a/src/conflict/mod.rs b/src/conflict/mod.rs index ce8322c..98d2be9 100644 --- a/src/conflict/mod.rs +++ b/src/conflict/mod.rs @@ -1,3 +1,5 @@ mod detector; +mod simulator; pub use detector::{detect, FileConflict}; +pub use simulator::{simulate, MergeSimulation}; diff --git a/src/conflict/simulator.rs b/src/conflict/simulator.rs new file mode 100644 index 0000000..03a2004 --- /dev/null +++ b/src/conflict/simulator.rs @@ -0,0 +1,268 @@ +//! Line-level merge conflict simulator using `git merge-tree --write-tree`. +//! +//! Issue #246: speculative merge. Performs in-memory three-way merges to detect +//! actual content-level conflicts, going beyond the filename-overlap heuristic +//! in [`detect`](super::detect). +//! +//! Two simulation passes: +//! 1. **vs base** — merge each worktree HEAD against its base branch tip. +//! 2. **cross** — merge each pair of worktree HEADs against their common +//! merge-base. Reveals ordering hazards before they bite at merge time. +//! +//! All operations are read-only (no working-directory or index writes); the +//! merge tree object created by `git merge-tree --write-tree` is left in the +//! repo's object database but never referenced. +//! +//! Requires git 2.38+ for the `--write-tree` flag (released 2022-10). + +use std::path::Path; +use std::process::Command; + +use anyhow::Result; +use serde::{Deserialize, Serialize}; + +use crate::worktree::Workspace; + +/// Outcome of a single simulated three-way merge. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BaseConflict { + /// Ticket whose worktree HEAD was merged. + pub ticket: String, + /// Base branch the worktree targets (e.g. `main`, `develop`). + pub base_branch: String, + /// Files reported as conflicting by `git merge-tree`. + pub files: Vec, +} + +/// Outcome of a simulated merge between two worktrees. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CrossConflict { + pub ticket_a: String, + pub ticket_b: String, + pub files: Vec, +} + +/// Full simulation result for [`simulate`]. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MergeSimulation { + pub vs_base: Vec, + pub cross: Vec, + /// Worktrees skipped (e.g. status != Active, missing HEAD, etc.). + pub skipped: Vec, +} + +/// Run speculative merge simulation across all active worktrees. +/// +/// For each active worktree: +/// 1. Compute merge-base with `origin/` (fall back to local base). +/// 2. `git merge-tree --write-tree --name-only --merge-base= origin/ HEAD` +/// — if exit != 0, capture reported conflict paths. +/// +/// For each pair of active worktrees, run the same simulation with their two +/// HEADs and their common merge-base. +/// +/// Returns a [`MergeSimulation`] with both result sets. Failures of individual +/// git commands are downgraded to "skipped" so one broken worktree doesn't kill +/// the whole report. +pub fn simulate(repo: &Path, workspaces: &[Workspace]) -> Result { + let active: Vec<&Workspace> = workspaces + .iter() + .filter(|w| w.status == crate::worktree::WorkspaceStatus::Active) + .collect(); + + let mut vs_base = Vec::new(); + let mut cross = Vec::new(); + let mut skipped = Vec::new(); + + // Pass 1: each worktree vs. its base branch + for ws in &active { + let origin_base = format!("origin/{}", ws.base_branch); + let head = match worktree_head(&ws.path) { + Some(h) => h, + None => { + skipped.push(ws.ticket.clone()); + continue; + } + }; + + let mb = match merge_base(repo, &origin_base, &head) { + Some(mb) => mb, + None => match merge_base(repo, &ws.base_branch, &head) { + Some(mb) => mb, + None => { + skipped.push(ws.ticket.clone()); + continue; + } + }, + }; + + match merge_tree_conflicts(repo, &mb, &origin_base, &head) { + Ok(Some(files)) => vs_base.push(BaseConflict { + ticket: ws.ticket.clone(), + base_branch: ws.base_branch.clone(), + files, + }), + Ok(None) => {} + Err(_) => skipped.push(ws.ticket.clone()), + } + } + + // Pass 2: pairwise simulation + for i in 0..active.len() { + for j in (i + 1)..active.len() { + let a = active[i]; + let b = active[j]; + let head_a = match worktree_head(&a.path) { + Some(h) => h, + None => continue, + }; + let head_b = match worktree_head(&b.path) { + Some(h) => h, + None => continue, + }; + let mb = match merge_base(repo, &head_a, &head_b) { + Some(mb) => mb, + None => continue, + }; + if let Ok(Some(files)) = merge_tree_conflicts(repo, &mb, &head_a, &head_b) { + cross.push(CrossConflict { + ticket_a: a.ticket.clone(), + ticket_b: b.ticket.clone(), + files, + }); + } + } + } + + cross.sort_by(|x, y| { + x.ticket_a + .cmp(&y.ticket_a) + .then_with(|| x.ticket_b.cmp(&y.ticket_b)) + }); + vs_base.sort_by(|x, y| x.ticket.cmp(&y.ticket)); + skipped.sort(); + skipped.dedup(); + + Ok(MergeSimulation { + vs_base, + cross, + skipped, + }) +} + +/// Returns the current HEAD SHA of a worktree (or None on error). +fn worktree_head(path: &Path) -> Option { + let out = Command::new("git") + .args(["rev-parse", "HEAD"]) + .current_dir(path) + .output() + .ok()?; + if !out.status.success() { + return None; + } + let s = String::from_utf8(out.stdout).ok()?; + Some(s.trim().to_string()) +} + +fn merge_base(repo: &Path, a: &str, b: &str) -> Option { + let out = Command::new("git") + .args(["merge-base", a, b]) + .current_dir(repo) + .output() + .ok()?; + if !out.status.success() { + return None; + } + let s = String::from_utf8(out.stdout).ok()?; + let trimmed = s.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } +} + +/// Runs `git merge-tree --write-tree --name-only --merge-base= ` +/// and returns `Ok(Some(files))` if conflicts are reported, `Ok(None)` if the +/// merge is clean. +/// +/// Output format (modern git 2.38+): +/// - exit 0 → tree-oid only, no conflicts +/// - exit 1 → tree-oid first line, then conflicting paths (one per line) +fn merge_tree_conflicts(repo: &Path, mb: &str, a: &str, b: &str) -> Result>> { + let out = Command::new("git") + .args([ + "merge-tree", + "--write-tree", + "--name-only", + &format!("--merge-base={mb}"), + a, + b, + ]) + .current_dir(repo) + .output()?; + + // exit 0 = clean merge; >0 = conflict reported. + if out.status.success() { + return Ok(None); + } + + let stdout = String::from_utf8_lossy(&out.stdout); + let files: Vec = stdout + .lines() + .skip(1) // first line is the merge tree OID + .filter(|l| !l.trim().is_empty()) + .map(|l| l.trim().to_string()) + .collect(); + + if files.is_empty() { + // Non-zero exit but no parsed files — treat as transient/unknown error. + return Ok(None); + } + Ok(Some(files)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn merge_tree_conflicts_returns_none_for_clean_merge() { + // We can't easily build a real git repo in unit tests without fixtures, + // but at least exercise the parsing logic on a synthetic exit=0 case + // indirectly through the public API. Integration tests cover the + // git invocation path. + let sim = MergeSimulation { + vs_base: vec![], + cross: vec![], + skipped: vec![], + }; + assert!(sim.vs_base.is_empty()); + assert!(sim.cross.is_empty()); + } + + #[test] + fn base_conflict_serializes_with_expected_fields() { + let bc = BaseConflict { + ticket: "CL-1".into(), + base_branch: "main".into(), + files: vec!["src/lib.rs".into()], + }; + let s = serde_json::to_string(&bc).unwrap(); + assert!(s.contains("\"ticket\":\"CL-1\"")); + assert!(s.contains("\"base_branch\":\"main\"")); + assert!(s.contains("\"src/lib.rs\"")); + } + + #[test] + fn cross_conflict_serializes_with_expected_fields() { + let cc = CrossConflict { + ticket_a: "CL-1".into(), + ticket_b: "CL-2".into(), + files: vec!["src/main.rs".into()], + }; + let s = serde_json::to_string(&cc).unwrap(); + assert!(s.contains("\"ticket_a\":\"CL-1\"")); + assert!(s.contains("\"ticket_b\":\"CL-2\"")); + } +} diff --git a/src/output/human.rs b/src/output/human.rs index 3542298..69ba47c 100644 --- a/src/output/human.rs +++ b/src/output/human.rs @@ -4,7 +4,7 @@ use tabled::{Table, Tabled}; use super::{BoardTicketDisplay, WorkspaceFullInfo}; use crate::config::ParsecConfig; -use crate::conflict::FileConflict; +use crate::conflict::{FileConflict, MergeSimulation}; use crate::oplog::OpEntry; use crate::tracker::jira::{InboxTicket, SprintInfo}; use crate::tracker::Ticket as TrackerTicket; @@ -308,6 +308,85 @@ pub fn print_clean(removed: &[Workspace], dry_run: bool) { } } +pub fn print_conflict_simulation(sim: &MergeSimulation) { + if sim.vs_base.is_empty() && sim.cross.is_empty() { + println!( + "{}", + "Speculative merge: clean — no line-level conflicts.".green() + ); + if !sim.skipped.is_empty() { + println!( + "{}", + format!( + "note: {} worktree(s) skipped: {}", + sim.skipped.len(), + sim.skipped.join(", ") + ) + .dimmed() + ); + } + return; + } + + if !sim.vs_base.is_empty() { + println!( + "{}", + format!( + "Worktree → base conflicts ({} worktree(s)):", + sim.vs_base.len() + ) + .yellow() + .bold() + ); + for bc in &sim.vs_base { + println!( + " {} → {} ({} file(s))", + bc.ticket.cyan(), + bc.base_branch.dimmed(), + bc.files.len() + ); + for f in &bc.files { + println!(" {} {}", "●".red(), f); + } + } + } + + if !sim.cross.is_empty() { + if !sim.vs_base.is_empty() { + println!(); + } + println!( + "{}", + format!("Cross-worktree conflicts ({} pair(s)):", sim.cross.len()) + .yellow() + .bold() + ); + for cc in &sim.cross { + println!( + " {} ↔ {} ({} file(s))", + cc.ticket_a.cyan(), + cc.ticket_b.cyan(), + cc.files.len() + ); + for f in &cc.files { + println!(" {} {}", "●".red(), f); + } + } + } + + if !sim.skipped.is_empty() { + println!( + "{}", + format!( + "note: {} worktree(s) skipped: {}", + sim.skipped.len(), + sim.skipped.join(", ") + ) + .dimmed() + ); + } +} + pub fn print_conflicts(conflicts: &[FileConflict]) { if conflicts.is_empty() { println!("{}", "No conflicts detected.".green()); diff --git a/src/output/json.rs b/src/output/json.rs index 84af3d2..a897fe2 100644 --- a/src/output/json.rs +++ b/src/output/json.rs @@ -107,6 +107,10 @@ pub fn print_conflicts(conflicts: &[FileConflict]) { emit(&conflicts); } +pub fn print_conflict_simulation(sim: &crate::conflict::MergeSimulation) { + emit(sim); +} + pub fn print_switch(workspace: &Workspace) { let value = json!({ "path": workspace.path }); println!("{}", value); diff --git a/src/output/mod.rs b/src/output/mod.rs index 7226356..3cdcb46 100644 --- a/src/output/mod.rs +++ b/src/output/mod.rs @@ -2,7 +2,7 @@ mod human; mod json; use crate::config::ParsecConfig; -use crate::conflict::FileConflict; +use crate::conflict::{FileConflict, MergeSimulation}; use crate::oplog::OpEntry; use crate::tracker::jira::{InboxTicket, SprintInfo}; use crate::tracker::Ticket as TrackerTicket; @@ -121,6 +121,7 @@ dispatch_output!(print_status, workspaces: &[Workspace], ticket_infos: &[Option< dispatch_output!(print_ship, result: &ShipResult); dispatch_output!(print_clean, removed: &[Workspace], dry_run: bool); dispatch_output!(print_conflicts, conflicts: &[FileConflict]); +dispatch_output!(print_conflict_simulation, sim: &MergeSimulation); dispatch_output!(print_switch, workspace: &Workspace); dispatch_output!(print_config_init); dispatch_output!(print_log, entries: &[&OpEntry]); diff --git a/tests/cli_tests.rs b/tests/cli_tests.rs index bd43f41..3976b52 100644 --- a/tests/cli_tests.rs +++ b/tests/cli_tests.rs @@ -2007,3 +2007,162 @@ fn test_health_no_overlay_json_has_ci_status_key() { "JSON entry must have 'pr_number' key" ); } + +// --------------------------------------------------------------------------- +// conflicts --simulate (issue #246: speculative merge) +// --------------------------------------------------------------------------- + +#[test] +fn test_conflicts_simulate_empty_repo() { + let repo = setup_repo(); + parsec() + .args([ + "conflicts", + "--simulate", + "--repo", + repo.path().to_str().unwrap(), + ]) + .assert() + .success() + .stdout(predicate::str::contains("clean").or(predicate::str::contains("No"))); +} + +#[test] +fn test_conflicts_simulate_json_empty_is_object() { + let repo = setup_repo(); + let out = parsec() + .args([ + "conflicts", + "--simulate", + "--json", + "--repo", + repo.path().to_str().unwrap(), + ]) + .output() + .unwrap(); + assert!(out.status.success(), "exit must be 0 for empty simulate"); + let stdout = String::from_utf8(out.stdout).unwrap(); + let v: serde_json::Value = + serde_json::from_str(stdout.trim()).expect("simulate --json must emit valid JSON"); + assert!(v.get("vs_base").is_some(), "JSON must contain vs_base"); + assert!(v.get("cross").is_some(), "JSON must contain cross"); + assert!(v.get("skipped").is_some(), "JSON must contain skipped"); + assert_eq!(v["vs_base"].as_array().unwrap().len(), 0); + assert_eq!(v["cross"].as_array().unwrap().len(), 0); +} + +#[test] +fn test_conflicts_simulate_single_clean_worktree() { + let (repo, _bare) = setup_repo_with_remote(); + let repo_path = repo.path(); + + parsec() + .args(["start", "SIM-1", "--repo", repo_path.to_str().unwrap()]) + .assert() + .success(); + + // Worktree exists but no changes → simulate should report clean. + parsec() + .args([ + "conflicts", + "--simulate", + "--repo", + repo_path.to_str().unwrap(), + ]) + .assert() + .success() + .stdout(predicate::str::contains("clean").or(predicate::str::contains("No"))); +} + +#[test] +fn test_conflicts_simulate_detects_cross_worktree_line_conflict() { + let (repo, _bare) = setup_repo_with_remote(); + let repo_path = repo.path(); + let repo_name = repo_path.file_name().unwrap().to_string_lossy().to_string(); + + // Seed a shared file on main so both worktrees fork from the same base. + std::fs::write(repo_path.join("shared.txt"), "hello\nworld\n").unwrap(); + StdCommand::new("git") + .args(["add", "shared.txt"]) + .current_dir(repo_path) + .output() + .unwrap(); + StdCommand::new("git") + .args(["commit", "-m", "seed shared file"]) + .current_dir(repo_path) + .output() + .unwrap(); + StdCommand::new("git") + .args(["push", "origin", "main"]) + .current_dir(repo_path) + .output() + .unwrap(); + + // Two worktrees, each modifying the SAME line of shared.txt → real line-level conflict. + parsec() + .args(["start", "SIM-A", "--repo", repo_path.to_str().unwrap()]) + .assert() + .success(); + parsec() + .args(["start", "SIM-B", "--repo", repo_path.to_str().unwrap()]) + .assert() + .success(); + + let wt_a = repo_path + .parent() + .unwrap() + .join(format!("{}.SIM-A", repo_name)); + let wt_b = repo_path + .parent() + .unwrap() + .join(format!("{}.SIM-B", repo_name)); + + std::fs::write(wt_a.join("shared.txt"), "hello\nALPHA\n").unwrap(); + StdCommand::new("git") + .args(["commit", "-am", "alpha change"]) + .current_dir(&wt_a) + .output() + .unwrap(); + + std::fs::write(wt_b.join("shared.txt"), "hello\nBETA\n").unwrap(); + StdCommand::new("git") + .args(["commit", "-am", "beta change"]) + .current_dir(&wt_b) + .output() + .unwrap(); + + // JSON output to introspect the cross-pair section reliably. + let out = parsec() + .args([ + "conflicts", + "--simulate", + "--json", + "--repo", + repo_path.to_str().unwrap(), + ]) + .output() + .unwrap(); + assert!(out.status.success()); + let stdout = String::from_utf8(out.stdout).unwrap(); + let v: serde_json::Value = serde_json::from_str(stdout.trim()) + .expect("simulate --json must emit valid JSON even with conflicts"); + + let cross = v["cross"].as_array().expect("cross array expected"); + assert!( + !cross.is_empty(), + "expected at least one cross-worktree conflict, got: {}", + stdout + ); + // The conflict must mention shared.txt as a conflicting file. + let mentions_shared = cross.iter().any(|c| { + c["files"] + .as_array() + .map(|f| f.iter().any(|x| x.as_str() == Some("shared.txt"))) + .unwrap_or(false) + }); + assert!( + mentions_shared, + "expected shared.txt in a cross conflict, got: {}", + stdout + ); +} From 0cfc88865ca172a9fd8a4f20e9176be8ad37c8d9 Mon Sep 17 00:00:00 2001 From: erish Date: Wed, 3 Jun 2026 21:27:33 +0900 Subject: [PATCH 21/26] =?UTF-8?q?feat(test):=20parsec=20test=20=E2=80=94?= =?UTF-8?q?=20parallel=20test=20runner=20with=20tree-hash=20caching=20(Clo?= =?UTF-8?q?ses=20#247)=20(#336)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(test): parsec test — parallel test runner with tree-hash caching Implements `parsec test` (issue #247): runs a configurable shell command inside one or every parsec-managed worktree, with optional parallelism (`--jobs N`) and tree-hash result caching (`--cache`). - New `[test]` section in ParsecConfig (command / jobs / cache defaults). - New `Command::Test` variant with --all / --jobs / --cache / --command flags plus auto-detect of the current worktree from CWD. - Cache files live under `/.parsec/test-cache/.json`; only successful runs are persisted. - Output dispatched through `print_test_results` (human table + JSON array with from_cache / exit_code / duration_ms / stdout_tail). - Tail of stdout/stderr (40 lines) surfaced on failure for fast triage. - Six new CLI integration tests covering help, single-worktree, --all, cache hit replay, non-zero propagation, and parallel completion. Closes #247 * fix(test): cross-platform shell + Windows test gating Windows CI fail 원인: bash가 WSL로 해석되는데 distro 미설치 → exec 실패. - 러너: `bash -c` → 플랫폼별로 `sh -c` (Unix) / `cmd /C` (Windows) - 테스트 명령: `true` → `exit 0` (cmd.exe + sh 양쪽 동작) - `test_test_jobs_parallel_completes`는 sleep 의존 → `#[cfg(unix)]` 게이트 cargo test 73 cli + 5 bitbucket + 69 unit = 147 통과. Co-Authored-By: Pochacco --------- Co-authored-by: Pochacco --- src/cli/commands/mod.rs | 2 + src/cli/commands/test.rs | 287 +++++++++++++++++++++++++++++++++++++++ src/cli/mod.rs | 61 +++++++++ src/config/settings.rs | 38 ++++++ src/output/human.rs | 84 ++++++++++++ src/output/json.rs | 20 +++ src/output/mod.rs | 15 ++ tests/cli_tests.rs | 190 ++++++++++++++++++++++++++ 8 files changed, 697 insertions(+) create mode 100644 src/cli/commands/test.rs diff --git a/src/cli/commands/mod.rs b/src/cli/commands/mod.rs index cd386b7..6ed2434 100644 --- a/src/cli/commands/mod.rs +++ b/src/cli/commands/mod.rs @@ -12,6 +12,7 @@ mod reviews; mod ship; pub mod smartlog; mod stack; +mod test; mod tracker_cmds; mod workspace; @@ -29,5 +30,6 @@ pub use reviews::reviews; pub use ship::*; pub use smartlog::smartlog; pub use stack::*; +pub use test::test; pub use tracker_cmds::*; pub use workspace::*; diff --git a/src/cli/commands/test.rs b/src/cli/commands/test.rs new file mode 100644 index 0000000..c0c1564 --- /dev/null +++ b/src/cli/commands/test.rs @@ -0,0 +1,287 @@ +//! `parsec test` — run tests inside parsec-managed worktrees (issue #247). +//! +//! Runs a configurable shell command (default: `cargo test`) inside a single +//! worktree or across every active worktree, with optional parallelism and +//! tree-hash result caching. +//! +//! Selection logic (in order): +//! 1. `--all` → all active worktrees +//! 2. `ticket` → the named worktree only +//! 3. auto-detect → the worktree whose path contains `cwd` +//! +//! Caching: when `--cache` is set, the test result for each worktree is keyed +//! by the worktree's `git rev-parse HEAD^{tree}` output and stored under +//! `/.parsec/test-cache/.json`. Only successful (`exit 0`) +//! runs are cached. + +use std::path::{Path, PathBuf}; +use std::time::Instant; + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use tokio::sync::Semaphore; +use tokio::task::JoinSet; + +use crate::config::ParsecConfig; +use crate::git; +use crate::output::{self, Mode, TestResult}; +use crate::worktree::{Workspace, WorktreeManager}; + +/// On-disk cache entry for a single worktree+tree-hash combination. +#[derive(Debug, Clone, Serialize, Deserialize)] +struct CacheEntry { + tree_hash: String, + exit_code: i32, + duration_ms: u64, + stdout_tail: String, +} + +/// Run `parsec test` against one or more worktrees. +pub async fn test( + repo: &Path, + ticket: Option<&str>, + all: bool, + jobs: usize, + cache: bool, + command_override: Option<&str>, + mode: Mode, +) -> Result<()> { + let config = ParsecConfig::load()?; + let manager = WorktreeManager::new(repo, &config)?; + + // Effective settings: CLI flags override config values. + let command = command_override + .map(|s| s.to_string()) + .unwrap_or_else(|| config.test.command.clone()); + let jobs = if jobs == 0 { config.test.jobs } else { jobs }; + let jobs = jobs.max(1); + let cache_enabled = cache || config.test.cache; + + // Resolve target workspaces. + let workspaces = if all { + let ws = manager.list()?; + if ws.is_empty() { + anyhow::bail!("no active workspaces to test"); + } + ws + } else if let Some(t) = ticket { + vec![manager.get(t)?] + } else { + let cwd = std::env::current_dir()?; + let all_ws = manager.list()?; + let found = all_ws + .into_iter() + .find(|w| cwd.starts_with(&w.path)) + .ok_or_else(|| { + anyhow::anyhow!("not inside a parsec worktree. Specify a ticket or use --all.") + })?; + vec![found] + }; + + let cache_dir = manager.repo_root().join(".parsec").join("test-cache"); + if cache_enabled { + std::fs::create_dir_all(&cache_dir).with_context(|| { + format!( + "failed to create test cache directory: {}", + cache_dir.display() + ) + })?; + } + + let results = if jobs > 1 && workspaces.len() > 1 { + run_parallel(workspaces, command, cache_enabled, &cache_dir, jobs).await + } else { + run_sequential(workspaces, command, cache_enabled, &cache_dir).await + }; + + let any_failed = results.iter().any(|r| r.exit_code != 0); + output::print_test_results(&results, mode); + + if any_failed { + // Propagate non-zero exit via ParsecError to keep the existing + // error pipeline consistent. The first failing exit code is used. + let first_fail = results + .iter() + .find(|r| r.exit_code != 0) + .map(|r| r.exit_code) + .unwrap_or(1); + anyhow::bail!("one or more worktree tests failed (exit_code={first_fail})"); + } + + Ok(()) +} + +/// Run each workspace's command sequentially. +async fn run_sequential( + workspaces: Vec, + command: String, + cache: bool, + cache_dir: &Path, +) -> Vec { + let mut out = Vec::with_capacity(workspaces.len()); + for ws in workspaces { + out.push(run_one(ws, command.clone(), cache, cache_dir.to_path_buf()).await); + } + out +} + +/// Run workspaces in parallel using a tokio `JoinSet` with a semaphore-bounded +/// concurrency limit equal to `jobs`. +async fn run_parallel( + workspaces: Vec, + command: String, + cache: bool, + cache_dir: &Path, + jobs: usize, +) -> Vec { + let semaphore = std::sync::Arc::new(Semaphore::new(jobs)); + let mut set: JoinSet = JoinSet::new(); + let cache_dir = cache_dir.to_path_buf(); + for ws in workspaces { + let sem = semaphore.clone(); + let cmd = command.clone(); + let cdir = cache_dir.clone(); + set.spawn(async move { + let _permit = sem.acquire().await.expect("semaphore closed"); + run_one(ws, cmd, cache, cdir).await + }); + } + let mut out = Vec::new(); + while let Some(joined) = set.join_next().await { + match joined { + Ok(r) => out.push(r), + Err(e) => out.push(TestResult { + ticket: "".to_string(), + exit_code: 1, + duration_ms: 0, + from_cache: false, + stdout_tail: format!("task join error: {e}"), + }), + } + } + // Stable order by ticket for deterministic output. + out.sort_by(|a, b| a.ticket.cmp(&b.ticket)); + out +} + +/// Run the test command for a single workspace, consulting / updating the +/// cache when enabled. Always returns a [`TestResult`] (never panics). +async fn run_one(ws: Workspace, command: String, cache: bool, cache_dir: PathBuf) -> TestResult { + let tree_hash = git::run_output(&ws.path, &["rev-parse", "HEAD^{tree}"]) + .map(|s| s.trim().to_string()) + .unwrap_or_default(); + + if cache && !tree_hash.is_empty() { + if let Some(entry) = load_cache(&cache_dir, &tree_hash) { + return TestResult { + ticket: ws.ticket, + exit_code: entry.exit_code, + duration_ms: entry.duration_ms, + from_cache: true, + stdout_tail: entry.stdout_tail, + }; + } + } + + let started = Instant::now(); + let ws_path = ws.path.clone(); + let cmd_str = command.clone(); + let exec = tokio::task::spawn_blocking(move || { + // Cross-platform shell: cmd.exe on Windows (bash on Windows resolves + // to WSL which may not be installed), sh -c elsewhere. + let mut c = if cfg!(windows) { + let mut cmd = std::process::Command::new("cmd"); + cmd.arg("/C"); + cmd + } else { + let mut cmd = std::process::Command::new("sh"); + cmd.arg("-c"); + cmd + }; + c.arg(&cmd_str).current_dir(&ws_path).output() + }) + .await; + let duration_ms = started.elapsed().as_millis() as u64; + + let (exit_code, stdout_tail) = match exec { + Ok(Ok(out)) => { + let code = out.status.code().unwrap_or(-1); + let stdout = String::from_utf8_lossy(&out.stdout).to_string(); + let stderr = String::from_utf8_lossy(&out.stderr).to_string(); + let combined = if stderr.is_empty() { + stdout + } else if stdout.is_empty() { + stderr + } else { + format!("{stdout}\n{stderr}") + }; + (code, tail_lines(&combined, 40)) + } + Ok(Err(e)) => (-1, format!("failed to spawn command: {e}")), + Err(e) => (-1, format!("join error: {e}")), + }; + + if cache && exit_code == 0 && !tree_hash.is_empty() { + let entry = CacheEntry { + tree_hash: tree_hash.clone(), + exit_code, + duration_ms, + stdout_tail: stdout_tail.clone(), + }; + let _ = save_cache(&cache_dir, &entry); + } + + TestResult { + ticket: ws.ticket, + exit_code, + duration_ms, + from_cache: false, + stdout_tail, + } +} + +/// Return the last `n` lines of `text`, joined by `\n`. +fn tail_lines(text: &str, n: usize) -> String { + let lines: Vec<&str> = text.lines().collect(); + if lines.len() <= n { + return lines.join("\n"); + } + lines[lines.len() - n..].join("\n") +} + +/// Try to read a cached test result for `tree_hash`. Returns `None` on +/// any I/O or parse error. +fn load_cache(cache_dir: &Path, tree_hash: &str) -> Option { + let path = cache_dir.join(format!("{tree_hash}.json")); + let bytes = std::fs::read(&path).ok()?; + serde_json::from_slice::(&bytes).ok() +} + +/// Persist `entry` to `/.json` (best-effort). +fn save_cache(cache_dir: &Path, entry: &CacheEntry) -> Result<()> { + let path = cache_dir.join(format!("{}.json", entry.tree_hash)); + let bytes = serde_json::to_vec_pretty(entry)?; + std::fs::write(&path, bytes) + .with_context(|| format!("failed to write cache file: {}", path.display()))?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tail_lines_returns_all_when_short() { + assert_eq!(tail_lines("a\nb\nc", 5), "a\nb\nc"); + } + + #[test] + fn tail_lines_truncates_when_long() { + let text = (1..=10) + .map(|i| i.to_string()) + .collect::>() + .join("\n"); + let tail = tail_lines(&text, 3); + assert_eq!(tail, "8\n9\n10"); + } +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 0a0852b..9e87842 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -556,6 +556,48 @@ pub enum Command { worktree: Option, }, + /// Run tests inside parsec-managed worktrees (issue #247). + /// + /// Executes a shell command (default: `cargo test`, configurable via + /// `[test].command` in `~/.config/parsec/config.toml`) inside one or + /// every active worktree. Supports parallel execution via `--jobs N` + /// and tree-hash result caching via `--cache`. + /// + /// Selection logic (in order): + /// 1. `--all` → all active worktrees + /// 2. `[TICKET]` → the named worktree only + /// 3. auto-detect → the worktree whose path contains `cwd` + Test { + /// Ticket identifier (auto-detects current worktree if omitted) + ticket: Option, + + /// Run tests in every active worktree + #[arg(long)] + all: bool, + + /// Number of worktrees to test in parallel (default: 1). + /// + /// Only takes effect together with `--all`. Falls back to the + /// configured `[test].jobs` value when omitted. + #[arg(long, short = 'j', default_value = "0")] + jobs: usize, + + /// Cache test results by worktree tree-hash. + /// + /// Successful runs are persisted under `.parsec/test-cache/.json` + /// and replayed instantly on subsequent runs while the tree-hash + /// is unchanged. + #[arg(long)] + cache: bool, + + /// Override the configured `[test].command`. + /// + /// Useful for ad-hoc invocations (e.g. `--command 'pytest -x'`) + /// and for the integration tests of this command. + #[arg(long)] + command: Option, + }, + /// Show PR review status across all active worktrees (issue #301). /// /// Scans each active worktree, finds its associated open GitHub PR, and @@ -690,6 +732,7 @@ pub async fn run(cli: Cli) -> Result<()> { Command::Smartlog { .. } => "smartlog", Command::Complete { .. } => "__complete", Command::Reviews { .. } => "reviews", + Command::Test { .. } => "test", }; let exec_id = crate::execlog::new_execution_id(); let exec_started_at = chrono::Utc::now(); @@ -997,6 +1040,24 @@ pub async fn run(cli: Cli) -> Result<()> { Command::Reviews { requested } => { commands::reviews(&repo_path, requested, output_mode).await } + Command::Test { + ticket, + all, + jobs, + cache, + command, + } => { + commands::test( + &repo_path, + ticket.as_deref(), + all, + jobs, + cache, + command.as_deref(), + output_mode, + ) + .await + } Command::Complete { kind } => commands::complete(&repo_path, kind).await, }; diff --git a/src/config/settings.rs b/src/config/settings.rs index 14dda07..30bfb5c 100644 --- a/src/config/settings.rs +++ b/src/config/settings.rs @@ -385,6 +385,42 @@ impl PolicyConfig { } } +// --------------------------------------------------------------------------- +// TestConfig +// --------------------------------------------------------------------------- + +fn default_test_command() -> String { + "cargo test".to_string() +} + +fn default_test_jobs() -> usize { + 1 +} + +/// Settings for the `parsec test` command (issue #247). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TestConfig { + /// Shell command to run inside each worktree (default: `cargo test`). + #[serde(default = "default_test_command")] + pub command: String, + /// Number of worktrees to test in parallel (default: 1, sequential). + #[serde(default = "default_test_jobs")] + pub jobs: usize, + /// When `true`, cache test results by worktree tree-hash. + #[serde(default)] + pub cache: bool, +} + +impl Default for TestConfig { + fn default() -> Self { + Self { + command: default_test_command(), + jobs: default_test_jobs(), + cache: false, + } + } +} + // --------------------------------------------------------------------------- // ParsecConfig // --------------------------------------------------------------------------- @@ -405,6 +441,8 @@ pub struct ParsecConfig { pub release: ReleaseConfig, #[serde(default)] pub policy: PolicyConfig, + #[serde(default)] + pub test: TestConfig, /// 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/output/human.rs b/src/output/human.rs index 69ba47c..b94e225 100644 --- a/src/output/human.rs +++ b/src/output/human.rs @@ -1292,3 +1292,87 @@ pub fn print_reviews(entries: &[super::ReviewEntry]) { ); } } + +/// Render the `parsec test` results table — one row per worktree, with +/// status (✓/✗), duration, and cache indicator. +pub fn print_test_results(results: &[super::TestResult]) { + if results.is_empty() { + println!("{}", "No worktrees to test.".dimmed()); + return; + } + + #[derive(Tabled)] + struct Row { + #[tabled(rename = "Ticket")] + ticket: String, + #[tabled(rename = "Status")] + status: String, + #[tabled(rename = "Duration")] + duration: String, + #[tabled(rename = "Cache")] + cache: String, + } + + let rows: Vec = results + .iter() + .map(|r| { + let status = if r.exit_code == 0 { + "✓ pass".green().to_string() + } else { + format!("✗ exit {}", r.exit_code).red().to_string() + }; + let duration = format!("{}ms", r.duration_ms); + let cache = if r.from_cache { + "cached".cyan().to_string() + } else { + "fresh".dimmed().to_string() + }; + Row { + ticket: r.ticket.clone(), + status, + duration, + cache, + } + }) + .collect(); + + let table = Table::new(rows).with(Style::modern()).to_string(); + println!("{}", "parsec test".bold()); + println!("{table}"); + + let failed = results.iter().filter(|r| r.exit_code != 0).count(); + let cached = results.iter().filter(|r| r.from_cache).count(); + println!(); + if failed == 0 { + println!( + "{}", + format!( + "All {} worktree(s) passed ({} cached).", + results.len(), + cached + ) + .green() + .bold() + ); + } else { + println!( + "{}", + format!("{}/{} worktree(s) failed.", failed, results.len()) + .red() + .bold() + ); + // Surface stdout tails for failing entries so users can debug. + for r in results.iter().filter(|r| r.exit_code != 0) { + println!(); + println!( + "{}", + format!("--- {} (exit {}) ---", r.ticket, r.exit_code) + .red() + .bold() + ); + if !r.stdout_tail.is_empty() { + println!("{}", r.stdout_tail.dimmed()); + } + } + } +} diff --git a/src/output/json.rs b/src/output/json.rs index a897fe2..616cf26 100644 --- a/src/output/json.rs +++ b/src/output/json.rs @@ -422,3 +422,23 @@ pub fn print_reviews(entries: &[super::ReviewEntry]) { serde_json::to_string_pretty(&items).unwrap_or_else(|_| "[]".to_string()) ); } + +/// Emit `parsec test` results as a JSON array. +pub fn print_test_results(results: &[super::TestResult]) { + let items: Vec = results + .iter() + .map(|r| { + json!({ + "ticket": r.ticket, + "exit_code": r.exit_code, + "duration_ms": r.duration_ms, + "from_cache": r.from_cache, + "stdout_tail": r.stdout_tail, + }) + }) + .collect(); + println!( + "{}", + serde_json::to_string_pretty(&items).unwrap_or_else(|_| "[]".to_string()) + ); +} diff --git a/src/output/mod.rs b/src/output/mod.rs index 3cdcb46..e17e3a7 100644 --- a/src/output/mod.rs +++ b/src/output/mod.rs @@ -62,6 +62,20 @@ pub struct HealthRecord { pub pr_number: Option, } +/// Per-worktree test outcome produced by `parsec test` (issue #247). +pub struct TestResult { + /// Ticket identifier of the worktree the command ran in. + pub ticket: String, + /// Process exit code (`0` = success). `-1` indicates a spawn / join failure. + pub exit_code: i32, + /// Wall-clock duration of the command, in milliseconds. + pub duration_ms: u64, + /// `true` when the result was served from the tree-hash cache (no command run). + pub from_cache: bool, + /// Tail of the captured stdout/stderr (last 40 lines). + pub stdout_tail: String, +} + /// One entry in the `parsec reviews` output — one open PR per worktree. pub struct ReviewEntry { /// Ticket identifier for the worktree. @@ -164,6 +178,7 @@ dispatch_output!( ); dispatch_output!(print_reviews, entries: &[ReviewEntry]); dispatch_output!(print_create, ticket_id: &str, title: &str, url: &str); +dispatch_output!(print_test_results, results: &[TestResult]); pub fn print_diff_full_json(files: &[(String, String)], ticket: &str) { json::print_diff_full(files, ticket); diff --git a/tests/cli_tests.rs b/tests/cli_tests.rs index 3976b52..5554caf 100644 --- a/tests/cli_tests.rs +++ b/tests/cli_tests.rs @@ -2008,6 +2008,196 @@ fn test_health_no_overlay_json_has_ci_status_key() { ); } +// --------------------------------------------------------------------------- +// test (parsec test — issue #247) +// --------------------------------------------------------------------------- + +#[test] +fn test_test_help_shows_command() { + parsec() + .args(["test", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("worktree")); +} + +#[test] +fn test_test_runs_in_single_worktree() { + let (repo, _bare) = setup_repo_with_remote(); + let repo_path = repo.path().to_str().unwrap(); + + parsec() + .args(["start", "TEST-T01", "--repo", repo_path]) + .assert() + .success(); + + parsec() + .args([ + "test", + "TEST-T01", + "--command", + "exit 0", + "--repo", + repo_path, + ]) + .assert() + .success() + .stdout(predicate::str::contains("TEST-T01")); +} + +#[test] +fn test_test_all_runs_each_worktree() { + let (repo, _bare) = setup_repo_with_remote(); + let repo_path = repo.path().to_str().unwrap(); + + parsec() + .args(["start", "TEST-T02", "--repo", repo_path]) + .assert() + .success(); + parsec() + .args(["start", "TEST-T03", "--repo", repo_path]) + .assert() + .success(); + + parsec() + .args(["test", "--all", "--command", "exit 0", "--repo", repo_path]) + .assert() + .success() + .stdout(predicate::str::contains("TEST-T02")) + .stdout(predicate::str::contains("TEST-T03")); +} + +#[test] +fn test_test_cache_skips_second_run() { + let (repo, _bare) = setup_repo_with_remote(); + let repo_path = repo.path().to_str().unwrap(); + + parsec() + .args(["start", "TEST-T04", "--repo", repo_path]) + .assert() + .success(); + + // First run: populates the cache. + parsec() + .args([ + "test", + "TEST-T04", + "--cache", + "--command", + "exit 0", + "--repo", + repo_path, + ]) + .assert() + .success(); + + // Second run: must serve from cache (from_cache = true in JSON). + let output = parsec() + .args([ + "--json", + "test", + "TEST-T04", + "--cache", + "--command", + "exit 0", + "--repo", + repo_path, + ]) + .output() + .unwrap(); + assert!( + output.status.success(), + "second cached run must exit 0; stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + let parsed: serde_json::Value = + serde_json::from_str(&stdout).expect("test --json must emit valid JSON"); + let arr = parsed.as_array().expect("test --json must be an array"); + assert_eq!(arr.len(), 1); + let entry = &arr[0]; + assert_eq!( + entry["from_cache"].as_bool(), + Some(true), + "second invocation must hit the tree-hash cache" + ); + assert_eq!(entry["exit_code"].as_i64(), Some(0)); +} + +#[test] +fn test_test_failure_propagates_nonzero() { + let (repo, _bare) = setup_repo_with_remote(); + let repo_path = repo.path().to_str().unwrap(); + + parsec() + .args(["start", "TEST-T05", "--repo", repo_path]) + .assert() + .success(); + + let output = parsec() + .args([ + "--json", + "test", + "TEST-T05", + "--command", + "exit 7", + "--repo", + repo_path, + ]) + .output() + .unwrap(); + + assert!( + !output.status.success(), + "test with failing command must exit non-zero" + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("\"exit_code\": 7") || stdout.contains("\"exit_code\":7"), + "JSON must surface the underlying exit code; got: {stdout}" + ); +} + +#[cfg(unix)] +#[test] +fn test_test_jobs_parallel_completes() { + let (repo, _bare) = setup_repo_with_remote(); + let repo_path = repo.path().to_str().unwrap(); + + parsec() + .args(["start", "TEST-T06", "--repo", repo_path]) + .assert() + .success(); + parsec() + .args(["start", "TEST-T07", "--repo", repo_path]) + .assert() + .success(); + + let started = std::time::Instant::now(); + parsec() + .args([ + "test", + "--all", + "--jobs", + "4", + "--command", + "sleep 0.2", + "--repo", + repo_path, + ]) + .assert() + .success(); + let elapsed = started.elapsed(); + + // Two sequential sleeps would take >= 0.4s. Parallel must beat that + // comfortably even with process spawn overhead. + assert!( + elapsed < std::time::Duration::from_millis(2_000), + "parallel --jobs run should finish well under 2s, took {:?}", + elapsed + ); +} + // --------------------------------------------------------------------------- // conflicts --simulate (issue #246: speculative merge) // --------------------------------------------------------------------------- From ba181c210685ec4d764bba278bee1aeca69904b0 Mon Sep 17 00:00:00 2001 From: erish Date: Wed, 3 Jun 2026 21:45:44 +0900 Subject: [PATCH 22/26] feat(dashboard): interactive TUI dashboard for worktrees, CI, and PRs (#337) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce `parsec dashboard` (alias `dash`), a `ratatui` + `crossterm` based terminal UI that aggregates worktree, CI, and GitHub PR status into a single live view. Layout (three panes + status bar): - Worktrees pane (top-left): list of every active worktree with a color-coded CI dot, ticket ID, title/branch, and status. - CI pane (top-right): per-worktree `PR #N · ✓/✗/●` summary. - PRs pane (bottom): table — PR · title · state · review · CI. - Status bar: key hints + last-update timestamp + last-error badge. Concurrency: a single background `tokio::task` refreshes the shared `Arc>` every `--refresh` seconds (default 10). The UI loop never blocks on network — it redraws the latest snapshot on each tick or key event via `tokio::select!`. Keys: `q` / Esc quit · `r` force refresh · `?` / F1 help overlay · `↑/↓` (or `j/k`) move selection · Ctrl-C quit. Robustness: - Terminal state (alternate screen + raw mode) is restored by a `Drop` guard that runs on every exit path, including panic. - `--no-overlay` (or missing GitHub token) renders `–` placeholders instead of erroring. - `--json` / `--quiet` are rejected early with an actionable message pointing to `parsec list --json` / `parsec reviews --json`. Tests: four new integration tests for help text (both `dashboard` and `dash`), `--json` rejection, and `--quiet` rejection, plus five new unit tests for the rendering helpers. Deps: ratatui 0.28, crossterm 0.28. Closes #248 Co-authored-by: Claude Opus 4.7 --- Cargo.toml | 2 + src/cli/commands/dashboard.rs | 712 ++++++++++++++++++++++++++++++++++ src/cli/commands/mod.rs | 2 + src/cli/mod.rs | 36 ++ tests/cli_tests.rs | 81 ++++ 5 files changed, 833 insertions(+) create mode 100644 src/cli/commands/dashboard.rs diff --git a/Cargo.toml b/Cargo.toml index 933c91f..480747d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,6 +35,8 @@ clap_mangen = "0.3" clap_complete = "4" dunce = "1" uuid = { version = "1", features = ["v4"] } +ratatui = "0.28" +crossterm = "0.28" [dev-dependencies] assert_cmd = "2" diff --git a/src/cli/commands/dashboard.rs b/src/cli/commands/dashboard.rs new file mode 100644 index 0000000..0aeb1fe --- /dev/null +++ b/src/cli/commands/dashboard.rs @@ -0,0 +1,712 @@ +//! `parsec dashboard` (alias `dash`) — interactive TUI dashboard (#248). +//! +//! Built on `ratatui` + `crossterm`, the dashboard renders three panes in a +//! single terminal screen: +//! +//! | Pane | Contents | +//! |---------------|---------------------------------------------------------| +//! | Worktrees | List of every active worktree (ticket · branch · status)| +//! | CI Status | Per-worktree CI summary (`PR #N · ✓ / ✗ / ●`) | +//! | PRs | Table view: PR · title · state · review · CI | +//! +//! Keys: `q` / `Esc` to quit, `r` to force-refresh, `?` to toggle help, and +//! `↑/↓` to move the selection in the worktrees pane. +//! +//! Data is loaded once on entry and refreshed in the background every +//! `refresh_secs` seconds via a `tokio::task`. The draw loop never blocks on +//! network I/O — when a refresh is in flight the previous snapshot stays on +//! screen. When `--no-overlay` is passed, no GitHub calls are made and PR/CI +//! columns show `–` as a placeholder. +//! +//! Terminal state (alternate screen + raw mode) is restored by an RAII guard +//! that runs even on panic, so an unexpected error never leaves the user with +//! a corrupted terminal. + +use std::io::{self, Stdout}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; + +use anyhow::{Context, Result}; +use chrono::{DateTime, Local, Utc}; +use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers}; +use crossterm::execute; +use crossterm::terminal::{ + disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, +}; +use ratatui::backend::CrosstermBackend; +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{ + Block, Borders, Cell, Clear, List, ListItem, ListState, Paragraph, Row, Table, +}; +use ratatui::Terminal; +use tokio::sync::{mpsc, Mutex}; + +use crate::config::ParsecConfig; +use crate::git; +use crate::github::GitHubClient; +use crate::worktree::{Workspace, WorktreeManager}; + +/// Compact PR + CI overlay attached to a worktree row. +#[derive(Debug, Clone)] +struct PrOverlay { + number: u64, + title: String, + state: String, + ci_status: String, + review_status: String, +} + +/// One row in the worktrees pane (and the index into pr-map for cross-references). +#[derive(Debug, Clone)] +struct DashboardRow { + ticket: String, + ticket_title: Option, + branch: String, + status: String, + pr: Option, +} + +/// Snapshot of all dashboard state — atomically swapped on each refresh. +#[derive(Debug, Clone, Default)] +struct DashboardSnapshot { + rows: Vec, + last_update: Option>, + last_error: Option, + /// Whether overlay fetching is active. `false` when `--no-overlay` was set + /// or no GitHub token was available. + overlay_enabled: bool, +} + +/// Messages from background refresh task to the UI loop. +/// +/// The payload is intentionally a unit — the UI re-reads the shared snapshot +/// from the `Arc>` after each tick, so we only need a wake-up +/// signal rather than the snapshot itself. +enum RefreshMessage { + /// A new snapshot has been written; wake the UI loop so it redraws. + Tick, +} + +/// Entry point for the `parsec dashboard` subcommand. +/// +/// Opens an interactive terminal UI showing worktrees, CI, and PR status. +/// Runs until the user quits (`q` / `Esc`). Terminal state is always +/// restored on exit, even on panic. +pub async fn dashboard(repo: &Path, refresh_secs: u64, no_overlay: bool) -> Result<()> { + // Build initial snapshot synchronously so the first frame has data. + let initial = collect_snapshot(repo, no_overlay).await; + + // Shared snapshot — UI reads it, background task writes it. + let snapshot = Arc::new(Mutex::new(initial)); + + // Channel to nudge the UI loop on each refresh + on manual `r` press. + let (tx, mut rx) = mpsc::channel::(8); + + // Background refresh task. + let bg_repo = repo.to_path_buf(); + let bg_snapshot = Arc::clone(&snapshot); + let bg_tx = tx.clone(); + let refresh_secs = refresh_secs.max(1); + let bg_handle = tokio::spawn(async move { + let mut interval = tokio::time::interval(Duration::from_secs(refresh_secs)); + // Skip the immediate tick — we already loaded data synchronously. + interval.tick().await; + loop { + interval.tick().await; + let snap = collect_snapshot(&bg_repo, no_overlay).await; + { + let mut guard = bg_snapshot.lock().await; + *guard = snap; + } + if bg_tx.send(RefreshMessage::Tick).await.is_err() { + break; // UI exited. + } + } + }); + + // ----- Set up terminal (with RAII restore on Drop) ----- + let mut guard = TerminalGuard::new()?; + let res = run_ui( + &mut guard.terminal, + snapshot, + &mut rx, + repo.to_path_buf(), + no_overlay, + tx, + ) + .await; + drop(guard); + + bg_handle.abort(); + res +} + +/// Drive the UI event loop. Returns when the user quits or an unrecoverable +/// error occurs (the `TerminalGuard` restores state on drop either way). +async fn run_ui( + terminal: &mut Terminal>, + snapshot: Arc>, + rx: &mut mpsc::Receiver, + repo: PathBuf, + no_overlay: bool, + tx: mpsc::Sender, +) -> Result<()> { + let mut list_state = ListState::default(); + list_state.select(Some(0)); + let mut show_help = false; + + loop { + // Draw current frame from the latest snapshot. + { + let snap = snapshot.lock().await.clone(); + terminal + .draw(|f| render(f, &snap, &mut list_state, show_help)) + .context("failed to draw frame")?; + } + + // Wait for either a key event or a refresh message — whichever fires first. + tokio::select! { + biased; + msg = rx.recv() => { + if msg.is_none() { + break; // Channel closed — bail. + } + // Snapshot already updated by background task; just redraw. + } + ev = tokio::task::spawn_blocking(|| -> io::Result> { + if event::poll(Duration::from_millis(250))? { + Ok(Some(event::read()?)) + } else { + Ok(None) + } + }) => { + match ev { + Ok(Ok(Some(Event::Key(key)))) if key.kind == KeyEventKind::Press => { + // Ctrl-C — quit. + if key.modifiers.contains(KeyModifiers::CONTROL) + && matches!(key.code, KeyCode::Char('c')) + { + break; + } + match key.code { + KeyCode::Char('q') | KeyCode::Esc => break, + KeyCode::Char('?') | KeyCode::F(1) => show_help = !show_help, + KeyCode::Char('r') => { + // Force a fresh snapshot in the background. + let bg_repo = repo.clone(); + let bg_snapshot = Arc::clone(&snapshot); + let bg_tx = tx.clone(); + tokio::spawn(async move { + let snap = collect_snapshot(&bg_repo, no_overlay).await; + { + let mut g = bg_snapshot.lock().await; + *g = snap; + } + let _ = bg_tx.send(RefreshMessage::Tick).await; + }); + } + KeyCode::Down | KeyCode::Char('j') => { + let snap = snapshot.lock().await; + let len = snap.rows.len(); + if len > 0 { + let i = list_state.selected().unwrap_or(0); + let next = (i + 1).min(len.saturating_sub(1)); + list_state.select(Some(next)); + } + } + KeyCode::Up | KeyCode::Char('k') => { + let i = list_state.selected().unwrap_or(0); + list_state.select(Some(i.saturating_sub(1))); + } + _ => {} + } + } + Ok(Ok(_)) => {} // Non-key event or no event — ignore. + Ok(Err(e)) => return Err(anyhow::anyhow!("terminal poll error: {}", e)), + Err(e) => return Err(anyhow::anyhow!("event task join error: {}", e)), + } + } + } + } + + Ok(()) +} + +// --------------------------------------------------------------------------- +// Snapshot collection +// --------------------------------------------------------------------------- + +/// Build a complete dashboard snapshot: list worktrees, then (optionally) +/// overlay PR/CI status from GitHub. Errors during overlay are logged into +/// `last_error` but do not fail the function. +async fn collect_snapshot(repo: &Path, no_overlay: bool) -> DashboardSnapshot { + let mut snap = DashboardSnapshot { + rows: Vec::new(), + last_update: Some(Utc::now()), + last_error: None, + overlay_enabled: false, + }; + + let config = match ParsecConfig::load() { + Ok(c) => c, + Err(e) => { + snap.last_error = Some(format!("config load failed: {e}")); + return snap; + } + }; + + let manager = match WorktreeManager::new(repo, &config) { + Ok(m) => m, + Err(e) => { + snap.last_error = Some(format!("worktree manager init failed: {e}")); + return snap; + } + }; + + let workspaces = match manager.list() { + Ok(w) => w, + Err(e) => { + snap.last_error = Some(format!("worktree list failed: {e}")); + return snap; + } + }; + + snap.rows = workspaces.iter().map(workspace_to_row).collect(); + + if no_overlay { + return snap; + } + + let remote_url = git::run_output(repo, &["remote", "get-url", "origin"]) + .map(|s| s.trim().to_string()) + .unwrap_or_default(); + let client = match GitHubClient::new(&remote_url, &config) { + Ok(Some(c)) => c, + Ok(None) => return snap, // no token — placeholders stay + Err(e) => { + snap.last_error = Some(format!("github client init failed: {e}")); + return snap; + } + }; + snap.overlay_enabled = true; + + // Best-effort per-worktree overlay. A single failure logs into `last_error` + // but never aborts the whole refresh. + for row in &mut snap.rows { + match fetch_overlay_for_branch(&client, &row.branch).await { + Ok(Some(o)) => row.pr = Some(o), + Ok(None) => {} + Err(e) => { + snap.last_error = Some(format!("{}: {e}", row.ticket)); + } + } + } + + snap +} + +/// Map a [`Workspace`] to a UI-ready row. +fn workspace_to_row(ws: &Workspace) -> DashboardRow { + DashboardRow { + ticket: ws.ticket.clone(), + ticket_title: ws.ticket_title.clone(), + branch: ws.branch.clone(), + status: format!("{:?}", ws.status).to_lowercase(), + pr: None, + } +} + +/// Look up a single PR + CI snapshot for `branch`, or `None` if no open PR. +async fn fetch_overlay_for_branch( + client: &GitHubClient, + branch: &str, +) -> Result> { + let pr_num = match client.find_pr_by_branch(branch).await? { + Some(n) => n, + None => return Ok(None), + }; + let status = client.get_pr_status(pr_num).await?; + Ok(Some(PrOverlay { + number: status.number, + title: status.title, + state: status.state, + ci_status: status.ci_status, + review_status: status.review_status, + })) +} + +// --------------------------------------------------------------------------- +// Rendering +// --------------------------------------------------------------------------- + +/// Top-level draw function — splits the frame into three panes plus a status +/// bar and renders each one from the current snapshot. +fn render( + f: &mut ratatui::Frame, + snap: &DashboardSnapshot, + list_state: &mut ListState, + show_help: bool, +) { + let area = f.area(); + + // Outer layout: top row (2 panes) + bottom pane + status bar. + let outer = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage(45), + Constraint::Percentage(50), + Constraint::Length(1), + ]) + .split(area); + + let top = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(55), Constraint::Percentage(45)]) + .split(outer[0]); + + render_worktrees_pane(f, top[0], snap, list_state); + render_ci_pane(f, top[1], snap); + render_prs_pane(f, outer[1], snap); + render_status_bar(f, outer[2], snap); + + if show_help { + render_help_overlay(f, area); + } +} + +/// Left-top pane: list of every active worktree. +fn render_worktrees_pane( + f: &mut ratatui::Frame, + area: Rect, + snap: &DashboardSnapshot, + list_state: &mut ListState, +) { + let items: Vec = snap + .rows + .iter() + .map(|row| { + let dot = match row.pr.as_ref().map(|p| p.ci_status.as_str()) { + Some("success") => Span::styled("●", Style::default().fg(Color::Green)), + Some("failure") => Span::styled("●", Style::default().fg(Color::Red)), + Some("pending") => Span::styled("●", Style::default().fg(Color::Yellow)), + _ => Span::styled("●", Style::default().fg(Color::DarkGray)), + }; + let title = row.ticket_title.as_deref().unwrap_or(row.branch.as_str()); + ListItem::new(Line::from(vec![ + dot, + Span::raw(" "), + Span::styled( + row.ticket.clone(), + Style::default().add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + Span::raw(truncate(title, area.width.saturating_sub(20) as usize)), + Span::raw(" "), + Span::styled( + format!("[{}]", row.status), + Style::default().fg(Color::DarkGray), + ), + ])) + }) + .collect(); + + let title = format!("Worktrees ({}) ", snap.rows.len()); + let list = List::new(items) + .block(Block::default().borders(Borders::ALL).title(title)) + .highlight_style( + Style::default() + .bg(Color::Blue) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol("▶ "); + + f.render_stateful_widget(list, area, list_state); +} + +/// Right-top pane: per-worktree CI summary (`PR #N · ✓/✗/●`). +fn render_ci_pane(f: &mut ratatui::Frame, area: Rect, snap: &DashboardSnapshot) { + let lines: Vec = if snap.rows.is_empty() { + vec![Line::from(Span::styled( + "no active worktrees", + Style::default().fg(Color::DarkGray), + ))] + } else { + snap.rows + .iter() + .map(|row| match &row.pr { + Some(pr) => { + let (symbol, color) = ci_symbol(&pr.ci_status); + Line::from(vec![ + Span::raw(format!("PR #{:<4} ", pr.number)), + Span::styled(symbol, Style::default().fg(color)), + Span::raw(" "), + Span::raw(pr.ci_status.clone()), + Span::raw(" "), + Span::styled( + truncate(&row.ticket, 12), + Style::default().fg(Color::DarkGray), + ), + ]) + } + None => Line::from(vec![ + Span::styled("– ", Style::default().fg(Color::DarkGray)), + Span::raw("no PR "), + Span::styled( + truncate(&row.ticket, 12), + Style::default().fg(Color::DarkGray), + ), + ]), + }) + .collect() + }; + + let title = if snap.overlay_enabled { + "CI Status ".to_string() + } else { + "CI Status (no overlay) ".to_string() + }; + let paragraph = + Paragraph::new(lines).block(Block::default().borders(Borders::ALL).title(title)); + f.render_widget(paragraph, area); +} + +/// Bottom pane: full PR table — PR · title · state · review · CI. +fn render_prs_pane(f: &mut ratatui::Frame, area: Rect, snap: &DashboardSnapshot) { + let header = Row::new(vec!["PR", "Title", "State", "Review", "CI"]).style( + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ); + + let rows: Vec = snap + .rows + .iter() + .filter_map(|row| { + let pr = row.pr.as_ref()?; + let (symbol, color) = ci_symbol(&pr.ci_status); + Some(Row::new(vec![ + Cell::from(format!("#{}", pr.number)), + Cell::from(truncate(&pr.title, 60)), + Cell::from(pr.state.clone()), + Cell::from(pr.review_status.clone()), + Cell::from(Span::styled( + format!("{symbol} {}", pr.ci_status), + Style::default().fg(color), + )), + ])) + }) + .collect(); + + let widths = [ + Constraint::Length(6), + Constraint::Percentage(45), + Constraint::Length(10), + Constraint::Length(20), + Constraint::Length(14), + ]; + let title = if rows.is_empty() && !snap.overlay_enabled { + "PRs (overlay disabled — use parsec dashboard without --no-overlay)" + } else if rows.is_empty() { + "PRs (no open PRs for active worktrees)" + } else { + "PRs" + }; + let table = Table::new(rows, widths) + .header(header) + .block(Block::default().borders(Borders::ALL).title(title)); + f.render_widget(table, area); +} + +/// Bottom status bar: keys + last refresh time. +fn render_status_bar(f: &mut ratatui::Frame, area: Rect, snap: &DashboardSnapshot) { + let last = snap + .last_update + .map(|t| t.with_timezone(&Local).format("%H:%M:%S").to_string()) + .unwrap_or_else(|| "—".to_string()); + + let mut spans = vec![ + Span::styled(" q ", Style::default().fg(Color::Black).bg(Color::Gray)), + Span::raw(" quit "), + Span::styled(" r ", Style::default().fg(Color::Black).bg(Color::Gray)), + Span::raw(" refresh "), + Span::styled(" ? ", Style::default().fg(Color::Black).bg(Color::Gray)), + Span::raw(" help "), + Span::styled( + format!("last update {last}"), + Style::default().fg(Color::DarkGray), + ), + ]; + if let Some(err) = &snap.last_error { + spans.push(Span::raw(" ")); + spans.push(Span::styled( + format!("⚠ {}", truncate(err, 60)), + Style::default().fg(Color::Red), + )); + } + let bar = Paragraph::new(Line::from(spans)); + f.render_widget(bar, area); +} + +/// Centered help overlay shown when the user presses `?`. +fn render_help_overlay(f: &mut ratatui::Frame, area: Rect) { + let popup_area = centered_rect(60, 40, area); + f.render_widget(Clear, popup_area); + let body = vec![ + Line::from(Span::styled( + "parsec dashboard — keyboard shortcuts", + Style::default().add_modifier(Modifier::BOLD), + )), + Line::from(""), + Line::from(" q / Esc quit"), + Line::from(" r force refresh now"), + Line::from(" ↑ / ↓ / j / k move selection"), + Line::from(" ? / F1 toggle this help"), + Line::from(""), + Line::from(Span::styled( + "press ? again to dismiss", + Style::default().fg(Color::DarkGray), + )), + ]; + let p = Paragraph::new(body).block(Block::default().borders(Borders::ALL).title("Help")); + f.render_widget(p, popup_area); +} + +/// Compute a centered `Rect` covering `percent_x` × `percent_y` of `r`. +fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { + let popup_layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage((100 - percent_y) / 2), + Constraint::Percentage(percent_y), + Constraint::Percentage((100 - percent_y) / 2), + ]) + .split(r); + + Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage((100 - percent_x) / 2), + Constraint::Percentage(percent_x), + Constraint::Percentage((100 - percent_x) / 2), + ]) + .split(popup_layout[1])[1] +} + +/// Map a CI status string to (symbol, color) for compact display. +fn ci_symbol(status: &str) -> (&'static str, Color) { + match status { + "success" => ("✓", Color::Green), + "failure" => ("✗", Color::Red), + "pending" => ("●", Color::Yellow), + _ => ("–", Color::DarkGray), + } +} + +/// Truncate a string at `max` characters (display-width approximation). +fn truncate(s: &str, max: usize) -> String { + if max == 0 { + return String::new(); + } + if s.chars().count() <= max { + return s.to_string(); + } + let mut out: String = s.chars().take(max.saturating_sub(1)).collect(); + out.push('…'); + out +} + +// --------------------------------------------------------------------------- +// Terminal lifecycle (RAII) +// --------------------------------------------------------------------------- + +/// RAII guard for the alternate screen + raw mode pair. The destructor runs on +/// panic as well as normal exit, so an unexpected error never leaves the +/// user's terminal in a corrupted state. +struct TerminalGuard { + terminal: Terminal>, +} + +impl TerminalGuard { + fn new() -> Result { + enable_raw_mode().context("failed to enable raw mode")?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen).context("failed to enter alternate screen")?; + let backend = CrosstermBackend::new(stdout); + let terminal = Terminal::new(backend).context("failed to create ratatui terminal")?; + Ok(Self { terminal }) + } +} + +impl Drop for TerminalGuard { + fn drop(&mut self) { + // Best-effort restore — we can't return errors from Drop. + let _ = disable_raw_mode(); + let _ = execute!(self.terminal.backend_mut(), LeaveAlternateScreen); + let _ = self.terminal.show_cursor(); + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn truncate_short_string_unchanged() { + assert_eq!(truncate("abc", 10), "abc"); + } + + #[test] + fn truncate_long_string_adds_ellipsis() { + let out = truncate("abcdefghij", 5); + assert_eq!(out.chars().count(), 5); + assert!(out.ends_with('…')); + } + + #[test] + fn truncate_zero_returns_empty() { + assert_eq!(truncate("anything", 0), ""); + } + + #[test] + fn ci_symbol_known_states() { + assert_eq!(ci_symbol("success").0, "✓"); + assert_eq!(ci_symbol("failure").0, "✗"); + assert_eq!(ci_symbol("pending").0, "●"); + assert_eq!(ci_symbol("unknown").0, "–"); + } + + #[test] + fn workspace_to_row_preserves_fields() { + use chrono::Utc; + let ws = Workspace { + ticket: "CL-42".to_string(), + path: std::path::PathBuf::from("/tmp/x"), + branch: "feat/cl-42".to_string(), + base_branch: "develop".to_string(), + created_at: Utc::now(), + ticket_title: Some("Fix login bug".to_string()), + status: crate::worktree::WorkspaceStatus::Active, + parent_ticket: None, + }; + let row = workspace_to_row(&ws); + assert_eq!(row.ticket, "CL-42"); + assert_eq!(row.branch, "feat/cl-42"); + assert_eq!(row.status, "active"); + assert!(row.pr.is_none()); + } + + #[test] + fn snapshot_default_has_no_rows() { + let snap = DashboardSnapshot::default(); + assert!(snap.rows.is_empty()); + assert!(snap.last_update.is_none()); + assert!(!snap.overlay_enabled); + } +} diff --git a/src/cli/commands/mod.rs b/src/cli/commands/mod.rs index 6ed2434..b2e75a6 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 dashboard; mod diff; mod doctor; mod health; @@ -20,6 +21,7 @@ pub use ci::*; pub use complete::complete; pub use compress::*; pub use config::*; +pub use dashboard::dashboard; pub use diff::*; pub use doctor::*; pub use health::*; diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 9e87842..4b21ff8 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -615,6 +615,23 @@ pub enum Command { requested: bool, }, + /// Launch interactive TUI dashboard (alias: dash) — issue #248. + /// + /// Opens a real-time terminal UI showing every active worktree, its CI + /// status, and the associated GitHub PR — all in a single view. Built on + /// `ratatui` + `crossterm`. + /// + /// Keys: `q` / Esc to quit · `r` to refresh now · `?` for help. + #[command(alias = "dash")] + Dashboard { + /// Refresh interval in seconds (default: 10) + #[arg(long, default_value_t = 10)] + refresh: u64, + /// Disable network calls (CI/PR overlay) + #[arg(long)] + no_overlay: bool, + }, + /// Internal: emit dynamic completion candidates (issue #291). /// /// Used by shell completion scripts to enumerate worktrees / branches / @@ -732,6 +749,7 @@ pub async fn run(cli: Cli) -> Result<()> { Command::Smartlog { .. } => "smartlog", Command::Complete { .. } => "__complete", Command::Reviews { .. } => "reviews", + Command::Dashboard { .. } => "dashboard", Command::Test { .. } => "test", }; let exec_id = crate::execlog::new_execution_id(); @@ -1040,6 +1058,24 @@ pub async fn run(cli: Cli) -> Result<()> { Command::Reviews { requested } => { commands::reviews(&repo_path, requested, output_mode).await } + Command::Dashboard { + refresh, + no_overlay, + } => { + if cli.json { + anyhow::bail!( + "TUI dashboard does not support --json. \ + Use `parsec list --json` or `parsec reviews --json` instead." + ); + } + if cli.quiet { + anyhow::bail!( + "TUI dashboard does not support --quiet. \ + Use `parsec list --quiet` or `parsec reviews --quiet` instead." + ); + } + commands::dashboard(&repo_path, refresh, no_overlay).await + } Command::Test { ticket, all, diff --git a/tests/cli_tests.rs b/tests/cli_tests.rs index 5554caf..82507a6 100644 --- a/tests/cli_tests.rs +++ b/tests/cli_tests.rs @@ -2356,3 +2356,84 @@ fn test_conflicts_simulate_detects_cross_worktree_line_conflict() { stdout ); } + +// --------------------------------------------------------------------------- +// dashboard (#248) — TUI command smoke tests +// +// We deliberately do not try to drive the actual TUI here (entering raw mode +// in `cargo test` would corrupt the test runner's terminal). Instead, verify +// the command surface: --help works for both the primary name and the alias, +// and --json / --quiet are rejected with an actionable error. +// --------------------------------------------------------------------------- + +#[test] +fn test_dashboard_help_shows_command() { + parsec() + .args(["dashboard", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("dashboard")) + .stdout(predicate::str::contains("--refresh")) + .stdout(predicate::str::contains("--no-overlay")); +} + +#[test] +fn test_dashboard_alias_dash_help() { + parsec() + .args(["dash", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("--refresh")); +} + +#[test] +fn test_dashboard_json_rejected() { + let repo = setup_repo(); + // Error path emits via the global JSON error wrapper, which writes to + // stdout — so check both streams for the message. + let output = parsec() + .args([ + "--json", + "dashboard", + "--repo", + repo.path().to_str().unwrap(), + ]) + .output() + .unwrap(); + assert!(!output.status.success(), "expected non-zero exit"); + let combined = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!( + combined.contains("--json"), + "expected helpful message mentioning --json, got: {}", + combined + ); +} + +#[test] +fn test_dashboard_quiet_rejected() { + let repo = setup_repo(); + let output = parsec() + .args([ + "--quiet", + "dashboard", + "--repo", + repo.path().to_str().unwrap(), + ]) + .output() + .unwrap(); + assert!(!output.status.success(), "expected non-zero exit"); + let combined = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!( + combined.contains("--quiet"), + "expected helpful message mentioning --quiet, got: {}", + combined + ); +} From 9c1305f35c5fc4fa19b6708cc1a5b7d2ce639281 Mon Sep 17 00:00:00 2001 From: erish Date: Wed, 3 Jun 2026 23:43:09 +0900 Subject: [PATCH 23/26] release-prep: bump version + CHANGELOG + README for v0.5.0 (#339) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(release): v0.5.0 — visualization release - Cargo.toml: 0.4.0 → 0.5.0 (release.yml이 main push 시 자동 tag/publish 트리거) - CHANGELOG: Unreleased → [0.5.0] 정리 (v0.5 마일스톤 16개 항목 반영) - README: Roadmap v0.5.0 ✅ Released 마킹, v1.0 'Next'. 신규 'Visualization & power-user tools' 섹션 (8개 신규 명령). command count 33+ - docs/llms.txt: 버전 0.5.0, v0.5 명령 quick reference cargo build/clippy/fmt clean · test 142/142. Co-Authored-By: Pochacco * ci: RUST_MIN_STACK=8MB to fix Windows stack overflow after TUI deps landed #248 (TUI dashboard) added ratatui + crossterm. Windows 기본 main-thread stack 1MB가 tokio + reqwest + ratatui + crossterm 결합 후 overflow → bitbucket integration test 5개 panic. 워크플로우 env에 RUST_MIN_STACK=8388608 (8MB) 추가. 모든 test/build job에 적용. macOS/Linux는 변경 영향 없음. Co-Authored-By: Pochacco * build: Windows linker /STACK:16MB — fix bitbucket integration test overflow RUST_MIN_STACK은 Rust 스레드만 영향. Windows main-thread stack(기본 1MB)은 linker 옵션이 결정. v0.5 TUI dashboard 진입 후 ratatui + crossterm + tokio + reqwest 결합으로 1MB 초과 → parsec.exe subprocess가 overflow → bitbucket integration 테스트 5개 panic. .cargo/config.toml에 Windows 타겟 한정 `/STACK:16777216` 추가. macOS/Linux 기본은 이미 8MB라 변경 영향 없음. Co-Authored-By: Pochacco --------- Co-authored-by: Pochacco --- .cargo/config.toml | 11 ++++++ .github/workflows/ci.yml | 5 +++ CHANGELOG.md | 81 ++++++++++++++++++++++++++++++---------- Cargo.toml | 2 +- README.md | 18 +++++++-- docs/llms.txt | 15 +++++++- 6 files changed, 105 insertions(+), 27 deletions(-) create mode 100644 .cargo/config.toml diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..defe936 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,11 @@ +# Windows-only: bump the binary's main-thread stack to 16 MB. +# Default on Windows (1 MB) overflows after the v0.5 TUI dashboard added +# ratatui + crossterm on top of tokio + reqwest — bitbucket integration tests +# that spawn parsec.exe as a subprocess panicked with "thread 'main' has +# overflowed its stack" on windows-latest runners. +# +# RUST_MIN_STACK only affects Rust-spawned threads, so it can't fix the OS-level +# main-thread stack. The /STACK linker flag does. macOS / Linux already use an +# 8 MB default and are unaffected. +[target.'cfg(windows)'] +rustflags = ["-C", "link-arg=/STACK:16777216"] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 45c69fa..d580940 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,6 +8,11 @@ on: env: CARGO_TERM_COLOR: always + # 8 MB main-thread stack — Windows default (1 MB) overflows when ratatui + + # crossterm + tokio + reqwest are linked into the test binary (issue #248 + # follow-up: bitbucket integration tests OOM'd on windows-latest after the + # TUI dashboard landed). + RUST_MIN_STACK: "8388608" jobs: branch-policy: diff --git a/CHANGELOG.md b/CHANGELOG.md index ee85916..bd4a555 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,32 +7,73 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.5.0] - 2026-06-03 — _The visualization release_ + +v0.5 마일스톤 **16/16 완료**. Polish & Power-User UX: 워크트리/PR/CI를 하나의 +시야에 모아주는 시각화·자동화 명령 6개 신규. + ### Added -- **v0.5 milestone opened** — see Roadmap in README. Themes: smartlog · TUI - dashboard · speculative merge · `parsec test` · AI PR descriptions. -- **`parsec smartlog` (alias `sl`)** — visualize active worktrees as a commit - DAG. ASCII tree groups worktrees by base branch and shows commits since - merge-base; `--json` output for tooling. PR/CI overlay reserved for a - follow-up (#245, #305). -- **`parsec __complete` shell-completion helper** — hidden `__complete - ` subcommand emits newline-separated completion - candidates. Enables dynamic worktree/branch tab-completion in zsh, bash, - and fish without bundling shell-specific generators (#291, #312). +- **`parsec smartlog` (alias `sl`)** — 모든 활성 워크트리를 commit DAG로 시각화. + ASCII 트리가 base branch별로 워크트리를 묶고 merge-base 이후 커밋을 표시. + Phase 2 PR/CI status overlay (`#327`), Phase 3 worktree filter + ANSI 색상 + + stack indicator (`#333`). `--json` 출력 지원. (`#245`, `#305`, `#318`, `#319`) +- **`parsec dashboard` (alias `dash`)** — 실시간 터미널 TUI 대시보드. 워크트리 / + CI 상태 / GitHub PR을 3-pane 레이아웃으로 한 화면에. ratatui + crossterm 기반, + 키바인딩 `q` (종료) / `r` (즉시 새로고침) / `?` (도움말), `--refresh N` 인터벌, + `--no-overlay` 오프라인 모드. (`#248`, `#337`) +- **`parsec test`** — 워크트리 병렬 테스트 러너 + tree-hash 결과 캐싱. + `--all`로 모든 활성 워크트리 일괄 실행, `--jobs N` 병렬, `--cache`로 동일 tree + 재실행 시 즉시 스킵. `[test]` 설정 섹션(`command`, `jobs`, `cache`). 인간/JSON + 출력. (`#247`, `#336`) +- **`parsec health`** — 모든 활성 워크트리 헬스 체크. Phase 1: lock(`.git/index.lock`) + · uncommitted 파일 수 · stale(7일 초과) 검사 (`#324`, `#325`). Phase 2: CI 상태 + overlay + configurable stale threshold (`#330`). CLI 통합 테스트 5개 (`#326`). +- **`parsec reviews`** — 워크트리별 받은/요청한 PR 리뷰를 한 표로. Phase 1 (`#301`, + `#331`) + Phase 2 `--requested` (GitHub Search API) (`#334`). +- **`parsec conflicts --simulate`** — 기존 filename overlap 휴리스틱을 보완하는 + 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 + 공급자로 PR 본문 자동 작성. `[ai]` 설정. (`#242`, `#275`) +- **`parsec __complete` shell-completion 헬퍼** — 숨김 subcommand가 워크트리 / branch + 완성 후보를 newline-separated로 출력. zsh / bash / fish 동적 탭 완성 지원 + (`#291`, `#312`). Phase 2 dynamic 쉘 스크립트 (`#328`). +- **`parsec agent` mode (PARSEC_AGENT=1)** — non-interactive JSON 출력 모드, AI + 에이전트 호출용. (`#272`) + +### Changed +- **Error messages standardized to 3-line format** — 모든 사용자 대상 에러가 + `error: / caused by: / help: ` 포맷으로 통일 + (`#303`, `#306`). ### Fixed - `parsec ship` falls back to `gh auth token` when `PARSEC_GITHUB_TOKEN` / - `GITHUB_TOKEN` / `GH_TOKEN` env vars are absent — parity with `parsec - doctor` and the tracker layer (#281). The fallback is restricted to - GitHub hosts so Bitbucket / GitLab remotes are unaffected. - -### Changed -- **Error messages standardized to 3-line format** — every user-facing error - now follows `error: / caused by: / help: ` - so error output is consistent and actionable (#303, #306). + `GITHUB_TOKEN` / `GH_TOKEN` env vars are absent — parity with `parsec doctor` + and the tracker layer. GitHub host에만 한정해 Bitbucket / GitLab remote는 영향 + 없음 (`#281`). ### CI -- Windows VS2026 (Visual Studio 2026 runner) pre-validation job added to the - test matrix, catching MSVC toolchain regressions before release (#307, #311). +- Windows VS2026 (Visual Studio 2026 runner) pre-validation 잡 — MSVC toolchain + 회귀 사전 차단 (`#307`, `#311`). +- `parsec test`의 shell invocation을 cross-platform화 (sh -c / cmd /C), + Windows test가 WSL을 호출하지 않도록 수정. + +### Docs +- 모듈별 RustDoc 보강 — `diff` / `history` (`#321`), `stack` / `ci` (`#323`). +- CHANGELOG `[Unreleased]` 섹션 누락 항목 보완 (smartlog / complete / errors / + win-ci) (`#316`, `#317`). + +### Tests +- CLI 통합 테스트 대폭 추가 — `compress` / `config schema` / `log --export` + (`#314`, `#315`), `smartlog` / `sl` (`#318`, `#319`), `health` (`#324`, `#326`), + `parsec test` (5 신규), `parsec dashboard` (4 신규), `conflicts --simulate` + (4 신규). ## [0.4.0] - 2026-05-04 diff --git a/Cargo.toml b/Cargo.toml index 480747d..1f07b27 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "git-parsec" -version = "0.4.0" +version = "0.5.0" edition = "2021" authors = ["erishforG"] description = "Git worktree lifecycle manager — ticket to PR in one command. Parallel AI agent workflows with Jira & GitHub Issues integration." diff --git a/README.md b/README.md index 77f4c27..cb1b38c 100644 --- a/README.md +++ b/README.md @@ -36,11 +36,11 @@ That's the whole loop. Plain `git worktree` doesn't track state, doesn't talk to | Milestone | Status | Theme | |---|---|---| | **v0.4.0** | ✅ Released (2026-05-04) | Multi-forge + multi-tracker foundation (GitHub / GitLab / Bitbucket; Jira / Linear) | -| **v0.5** — _The visualization release_ | 🚧 Next | smartlog · TUI dashboard · speculative merge · `parsec test` · AI PR descriptions | -| **v1.0** — _AI-Native Standard_ | 🔜 | MCP server signature — Claude / Cursor / Copilot invoke parsec as a first-class tool | +| **v0.5.0** — _The visualization release_ | ✅ Released (2026-06-03) | smartlog · TUI dashboard · speculative merge · `parsec test` · health · reviews · AI PR descriptions | +| **v1.0** — _AI-Native Standard_ | 🚧 Next | MCP server signature — Claude / Cursor / Copilot invoke parsec as a first-class tool | | **v2.0+** — _Ecosystem Hub_ | 🔮 | Plugins · VS Code extension · Linear-native tracker · org-scale workflows | -Open issues for v0.5 are tracked under the [`v0.5` milestone](https://github.com/erishforG/git-parsec/milestone/3). +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. --- @@ -140,7 +140,17 @@ Every command has `--json`. Errors emit structured codes (E001…E013). `parsec ### 📋 Sprint board + issue creation `parsec board` turns your active sprint into a Kanban board in the terminal. `parsec create` and `parsec new-issue` open issues in your tracker without leaving the shell. -> 27 commands total — see the [full command reference](https://erishforg.github.io/git-parsec/reference/) for every flag and example. +### 🌌 Visualization & power-user tools _(new in v0.5)_ +- **`parsec smartlog`** (alias `sl`) — ASCII commit DAG of every active worktree with PR / CI overlay. +- **`parsec dashboard`** (alias `dash`) — real-time TUI panel showing worktrees, CI, and PRs in one screen (ratatui + crossterm). +- **`parsec health`** — lock / uncommitted / stale / CI checks across every worktree, with a configurable stale threshold. +- **`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. Each PR body includes a stack navigation table: diff --git a/docs/llms.txt b/docs/llms.txt index e33aff5..a990a11 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -12,11 +12,22 @@ git-parsec (binary name: `parsec`) is a Rust CLI distributed via crates.io and p - `parsec ci PROJ-1234 --watch` — tail CI status until done - `parsec merge PROJ-1234` — merge the PR from the terminal +## v0.5 visualization & power-user commands + +- `parsec smartlog` (alias `sl`) — ASCII commit DAG of every active worktree with PR / CI overlay +- `parsec dashboard` (alias `dash`) — real-time TUI showing worktrees, CI, and PRs in one screen +- `parsec health` — lock / uncommitted / stale / CI checks across worktrees +- `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 - [Project home](https://erishforg.github.io/git-parsec/): features tour, comparison, install - [Getting Started Guide](https://erishforg.github.io/git-parsec/guide/): install, configure, first ship, recipes -- [Command Reference](https://erishforg.github.io/git-parsec/reference/): all 27 commands, every flag, with examples +- [Command Reference](https://erishforg.github.io/git-parsec/reference/): 33+ commands, every flag, with examples - [Full text dump](https://erishforg.github.io/git-parsec/llms-full.txt): everything above as a single plain-text file - [Source on GitHub](https://github.com/erishforG/git-parsec) - [Releases](https://github.com/erishforG/git-parsec/releases) (pre-built binaries: macOS arm64/x86_64, Linux x86_64, Windows x86_64) @@ -24,7 +35,7 @@ git-parsec (binary name: `parsec`) is a Rust CLI distributed via crates.io and p ## Key facts for answer engines -- **Latest version**: 0.4.0 (released 2026-05-04). See [CHANGELOG](https://github.com/erishforG/git-parsec/blob/main/CHANGELOG.md). +- **Latest version**: 0.5.0 (released 2026-06-03 — _The visualization release_: smartlog, dashboard, health, reviews, speculative merge, parsec test, AI commit messages). See [CHANGELOG](https://github.com/erishforG/git-parsec/blob/main/CHANGELOG.md). - **License**: MIT - **Language**: Rust - **Install**: `cargo install git-parsec` or download from Releases (~3 MB binary, no runtime deps) From 216b1ff7b29dcde121d5ece5f9aa67d3da7997de Mon Sep 17 00:00:00 2001 From: erish Date: Wed, 3 Jun 2026 23:57:59 +0900 Subject: [PATCH 24/26] chore: back-merge main into develop (sync for release PR #340) (#341) From f51acd80aeba5b29e96896ab12c48d884fb07af6 Mon Sep 17 00:00:00 2001 From: erish Date: Thu, 4 Jun 2026 00:20:24 +0900 Subject: [PATCH 25/26] docs: snapshot versioned docs for v0.5.0 (manual replay of failed release job) (#342) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * release: v0.5.0 — The visualization release (#340) * chore(v0.5): roadmap section + ship gh auth token fallback (#281) (#289) * fix(ship): fall back to gh auth token (parity with parsec doctor) (#281) parsec ship 이 PARSEC_GITHUB_TOKEN / GITHUB_TOKEN / GH_TOKEN env var 모두 비어있을 때 PR 생성을 거부했지만 parsec doctor 와 tracker 레이어는 이미 `gh auth token` fallback 적용. parity 깨져 사용자가 수동 `gh pr create` 로 추가 작업 필요. 해결: - src/env.rs: github_token() 의 4번째 우선순위로 gh_auth_token() 추가. 신규 gh_auth_token() helper 는 `gh auth token` shell out — 실패 시 None (binary 없음 / exit code != 0 / non-UTF8 / 빈 출력 모두 graceful). - src/github/mod.rs: resolve_github_token 의 env-var/gh fallback 을 GitHub host (github.com / *.ghe.com / *.github.* GHE) 에만 적용. 신규 is_github_host() helper. Bitbucket / GitLab remote 가 `gh auth login` 한 환경에서 GitHub 토큰을 잘못 픽업하지 않도록 가드. - src/cli/commands/doctor.rs: 중복 `gh auth token` shell-out 코드 제거 → env::gh_auth_token() 공통 helper 호출. parity at the helper level. 신규 테스트 (src/env.rs): github_token_priority_order — PARSEC > GITHUB > GH 우선순위 + 빈값 fallback 4 시나리오 sequential 검사. EnvGuard 로 process-wide env 보존+복원. gh_auth_token_returns_option_string_or_none — gh binary 가용성에 무관하게 trim 보장. github_token_returns_none_when_all_missing_and_gh_fails — env 모두 미설정 시 None 또는 valid Some 모두 허용 (CI 와 dev 환경 양립). 검증: - cargo test: 79 tests PASS (env tests 6 + integration 73) - cargo clippy --all-targets -- -D warnings: clean - cargo fmt --check: clean - bitbucket integration tests: 5/5 PASS (이전 발견된 host-gated 이슈 해결) 회귀 위험: 매우 낮음 - 환경에 gh CLI 미로그인 / 미설치 → 기존과 동일 (None 반환) - 환경에 gh 로그인됨 + GitHub remote → 신규 fallback 활용 (issue #281 의도) - 환경에 gh 로그인됨 + Bitbucket/GitLab remote → is_github_host 로 차단, 기존과 동일 Closes #281 * docs(v0.5): open roadmap milestone — visualization release vision v0.5 마일스톤 공식 출발 마커. README 와 CHANGELOG 양쪽에 향후 비전 명시. README.md (## Roadmap 섹션 신설, ## Why use it 과 ## Install 사이): - vision tagline: "parsec = AI agents + human devs both — worktree-native git CLI" - 4단계 milestone: · v0.4.0 ✅ Released (2026-05-04): Multi-forge + multi-tracker foundation · v0.5 🚧 Next — _The visualization release_: smartlog · TUI dashboard · speculative merge · parsec test · AI PR descriptions · v1.0 🔜 — _AI-Native Standard_: MCP server signature, Claude/Cursor/Copilot 가 parsec 을 first-class tool 로 invoke · v2.0+ 🔮 — _Ecosystem Hub_: plugins · VS Code extension · Linear tracker - v0.5 milestone link (github.com/erishforG/git-parsec/milestone/3) CHANGELOG.md (## [Unreleased] 확장): - ### Added: v0.5 milestone opened, README Roadmap 참조 - ### Fixed: #281 ship gh auth token fallback 노트 (별 commit ae1a2d3 와 동일 entry) 회귀 위험: 0 (문서 변경만) * fix(env): serialize env-touching tests via process-wide mutex (Windows CI) PR #289 Windows CI 1건 fail. macOS / Ubuntu Test 통과, Windows Test 만 실패. 원인: - env::tests 의 github_token_priority_order 와 github_token_returns_none_when_all_ missing_and_gh_fails 가 cargo test 병렬 실행 시 process-wide env vars 를 race. - priority_order 가 PARSEC=p / GITHUB=g / GH=h 셋업 후 assert 사이에 sibling 테스트의 EnvGuard::new() 가 모든 env 를 clear → assert 가 PARSEC 못 보고 GH=h 반환. - macOS/Ubuntu 는 timing 우연히 안전, Windows 는 다른 thread scheduling 으로 race 발현 (Some("h") vs Some("p") at src/env.rs:205). 수정: - std::sync::OnceLock> 의 env_lock() 신규 — env 만지는 테스트들 직렬화. std 만 사용 (외부 deps 추가 X 제약 준수). - github_token_priority_order 와 github_token_returns_none_when_all_missing_and_gh_fails 를 단일 함수 github_token_priority_order_and_fallback 로 통합 + env_lock() 의 Mutex guard 획득. 5 시나리오 (PARSEC 우선 / GITHUB / GH / 빈값 / 모두 미설정) 직렬 실행. - gh_auth_token_returns_option_string_or_none 은 env 미터치라 lock 불필요 — 그대로 유지. - production 로직 (env::github_token / env::gh_auth_token) 변경 0. 검증: - 로컬 cargo test 78 PASS (env tests 5 + integration 73), clippy clean, fmt clean. - Windows CI 검증은 force-push 후 PR #289 워크플로우에서 확인. * [245] feat(smartlog): skeleton — DAG data model + ASCII tree + JSON output (#305) Phase 1 of #245. Establishes the command surface and rendering path without any forge-side enrichment. Adds `parsec smartlog` (alias `sl`): - `SmartlogNode` per active worktree: ticket, branch, base, commits, plus `pr` / `ci` placeholder fields gated on `skip_serializing_if` so the JSON output stays clean until follow-up PRs populate them. - `collect_commits` shells out to `git log ..` (no `git2` dependency — matches the rest of `git/`). Soft-fails per worktree so a corrupt worktree can't take the whole command down. - `render_text` groups nodes by base branch and draws ASCII tree glyphs (○ │ ├─● └─). Returns the rendered string for testability. - `--json` emits the same structure for tooling. - `--depth N` caps commits per worktree (default 10). Out of scope for this PR (follow-ups on the same epic): - PR overlay (open/draft/merged) - CI overlay (passed/failed/running) - Review state (approved / changes requested) - Stack relationship visualization - Color / smarter wrapping Tests (9 new in `cli::commands::smartlog::tests`): - `parse_commit_line` happy path + tab-in-subject + garbage + empty SHA - `render_text` empty / single / no-commits / multi-base grouping - JSON serialization omits placeholder overlay fields Smoke run on parsec's own worktrees produces: ○ develop (base) ├─● 207 #207 [feature/207] │ └─ (no commits since develop) └─● 279 #279 [feature/279] └─ (no commits since develop) * [303] refactor(errors): adopt 3-line standard (error / caused by / help) (#306) * [303] refactor(errors): adopt 3-line standard (error / caused by / help) Issue #303. Add the infrastructure for the standard error format and wire main.rs to render it. Existing call sites keep rendering as a single line — migration is gradual (see docs/error-format.md). Changes: - `ParsecError` gains `caused_by: Option` and `help: Option` fields plus builder-style setters (`with_caused_by`, `with_help`). `Display` now writes one to three lines depending on which fields are populated. - `JsonError` gains the same two fields with `skip_serializing_if = "Option::is_none"` so existing `--json` consumers see no schema change. - `extract_full(&anyhow::Error) -> Option<&ParsecError>` helper for new callers; the legacy `extract_code` is kept untouched. - `main.rs` prefers the typed error: when the error is a `ParsecError`, print it directly (its `Display` already includes the `error:` prefix + code); otherwise fall back to the legacy single-line `error: {err:#}`. - `bail_code!` macro unchanged (for the "summary only" case). - `docs/error-format.md` documents the format, builder API, JSON shape, recipes, and what NOT to do. Tests: +9 in `errors::tests` covering 1/2/3-line display, help-only, extract_full happy/None, backward-compat extract_code, JSON skip-if-none, JSON with-fields, bail_code macro round-trip. Full suite 53 + 5 + 40 = 98 pass, fmt clean. Migration: any existing `ParsecError::new(...)` site can opt in by chaining `.with_caused_by(...)` and/or `.with_help(...)`. Prioritize cli/commands/ and worktree/ first (highest user contact). * [303] chore(errors): allow dead_code on Phase 1 builder + legacy extract_code Clippy `-D warnings` flagged `with_caused_by`, `with_help`, and `extract_code` as never-used. Phase 1 of #303 is the infra-only PR — call sites land in follow-up PRs. Annotation matches the existing `#[allow(dead_code)]` on `ErrorCode` for the same forward-looking reason. * ci(windows): add VS2026 pre-validation job (#307) (#311) Adds an informational windows-2025-vs2026 job that runs cargo build/test on the new Visual Studio 2026 default image ahead of the GitHub Actions runner migration window (2026-06-08 ~ 06-15). continue-on-error: true keeps it advisory only — main matrix stays gated on existing windows-latest. After the migration window closes we either delete this job (if main matrix passes) or pin windows-2022 here (if it fails). Refs: #307 * feat(completion): hidden __complete subcommand foundation (#291) (#312) Adds `parsec __complete ` — a hidden subcommand that shell completion scripts can call to enumerate candidates dynamically. Failure paths (no repo / no config) silently emit nothing so completion never errors at the prompt. Also adds `git::list_local_branches`, a helper for enumerating local branch names via `git for-each-ref refs/heads/`. This is the **foundation only**. The follow-up work — post-processing clap-generated completion scripts (zsh/bash/fish) so that ticket-shaped arguments call `parsec __complete worktrees` and branch-shaped arguments call `parsec __complete branches` — lands in a separate PR. Refs: #291 * test(cli): compress / config schema / log --export 통합 테스트 추가 (#315) * test(cli): add integration tests for compress, config schema, log --export Cover three commands added in v0.4.0 that had zero test coverage: - test_compress_nothing_to_do: single-commit worktree exits cleanly - test_compress_squashes_commits: 2-commit branch is reduced to 1 - test_config_schema_outputs_json: output is valid JSON Schema - test_history_log_export_empty: empty log exits 0 with no stdout Closes #314 Co-Authored-By: Claude Opus 4.7 * ci: re-trigger Branch Policy (base now develop) --------- Co-authored-by: Claude Opus 4.7 * docs(changelog): [Unreleased]에 smartlog·complete·errors·win-ci 항목 추가 (#317) develop에 머지됐지만 CHANGELOG.md [Unreleased]에 누락된 항목 4개를 추가한다: - parsec smartlog (alias sl): commit DAG 시각화 (#245, #305) - parsec __complete: 동적 shell completion 헬퍼 (#291, #312) - 에러 메시지 3줄 표준화 (error/caused by/help) (#303, #306) - Windows VS2026 pre-validation CI job (#307, #311) Closes #316 Co-authored-by: Claude Sonnet 4.6 * test(cli): parsec smartlog / sl 통합 테스트 추가 (#318) (#319) parsec smartlog (#305)과 sl 별칭이 develop에 머지됐지만 CLI 통합 테스트가 전혀 없었음. 5개 시나리오 추가: - test_smartlog_empty_repo: 빈 repo → 'No active worktrees' 확인 - test_sl_alias_works_like_smartlog: sl == smartlog 동일 출력 확인 - test_smartlog_json_empty_is_array: --json 빈 repo → [] JSON 배열 - test_smartlog_shows_worktree: worktree 1개 → 티켓·(base) 마커 표시 - test_smartlog_json_one_worktree: --json 1 worktree → 필드 검증 (ticket, branch, base_branch, commits, pr/ci 미노출) Closes #318 Co-authored-by: Claude Sonnet 4.6 * docs(commands): diff·history 모듈 RustDoc 추가 (#321) diff.rs, history.rs에 module-level(//!) 및 function-level(///) RustDoc을 추가. 인접한 smartlog.rs·complete.rs와 일관성 맞춤. - diff.rs: //! 모듈 헤더(diff/conflicts/sync 역할·관련 이슈), pub async fn diff/conflicts/sync에 파라미터·출력모드·동작 설명 추가 - history.rs: //! 모듈 헤더(OpLog·ExecLog 구조 설명), pub async fn log/log_export/undo에 동작·edge-case 설명 추가 프로덕션 코드 변경 없음 (주석 전용). Closes #320 Co-authored-by: Claude Sonnet 4.6 * docs(commands): stack·ci 모듈 RustDoc 추가 (#323) stack.rs: //! 모듈 헤더 + stack/stack_sync/stack_submit 함수 doc ci.rs: //! 모듈 헤더 + ci/fetch_bitbucket_ci 함수 doc 프로덕션 코드 변경 없음 — 주석 73줄 추가만. Closes #322 Co-authored-by: Claude Sonnet 4.6 * feat(health): parsec health Phase 1 — lock/uncommitted/stale check skeleton (#324) (#325) Add `parsec health` command that scans all active worktrees for three lightweight health indicators: - has_lock — .git/index.lock exists (interrupted git process) - uncommitted — count of staged + unstaged files - stale — last commit older than 7 days Output (human mode): colored table with ✓/⚠/✗ per worktree and a summary line. JSON mode emits a structured array with `all_healthy` flag. Files changed: - src/cli/commands/health.rs (new, ~99 lines) - src/cli/commands/mod.rs (+2 lines, re-export) - src/cli/mod.rs (+11 lines, Health variant + dispatch) - src/output/mod.rs (+15 lines, HealthRecord + dispatch_output!) - src/output/human.rs (+66 lines, colored table renderer) - src/output/json.rs (+34 lines, JSON renderer) CI-status overlay deferred to Phase 2 (depends on #309/#310). All checks are read-only; no worktree state is modified. Closes #324 Refs #299 Co-authored-by: Claude Opus 4.7 * test(health): parsec health CLI 통합 테스트 5개 추가 (#324) (#326) Phase 1 구현(#325)을 커버하는 통합 테스트: - test_health_empty_repo: 빈 repo → 'No active worktrees.' 확인 - test_health_empty_repo_json: 빈 repo → --json 시 '[]' 출력 확인 - test_health_shows_worktree: 워크트리 1개 → 티켓명 포함 출력 확인 - test_health_json_one_worktree: --json 구조 검증 (worktrees 배열, all_healthy 불리언, ticket/has_lock/uncommitted/stale_days/stale 필드) - test_health_exit_zero_with_issues: lock 파일 존재해도 exit 0 (정보성 only) Co-authored-by: Claude Sonnet 4.6 * feat(smartlog): Phase 2 — PR/CI status overlay (#327) * test(health): parsec health CLI 통합 테스트 5개 추가 (#324) Phase 1 구현(#325)을 커버하는 통합 테스트: - test_health_empty_repo: 빈 repo → 'No active worktrees.' 확인 - test_health_empty_repo_json: 빈 repo → --json 시 '[]' 출력 확인 - test_health_shows_worktree: 워크트리 1개 → 티켓명 포함 출력 확인 - test_health_json_one_worktree: --json 구조 검증 (worktrees 배열, all_healthy 불리언, ticket/has_lock/uncommitted/stale_days/stale 필드) - test_health_exit_zero_with_issues: lock 파일 존재해도 exit 0 (정보성 only) Co-Authored-By: Claude Sonnet 4.6 * feat(smartlog): Phase 2 — PR/CI status overlay (Refs #245) Phase 1 (#305) skeleton에서 `SmartlogNode.pr` 가 placeholder로 남아있던 걸 실제 GitHub PR/CI 데이터로 채움. Issue #245 의 예시 (`[PR #42 ✓ CI passed, ✓ approved]`) 가 이제 ASCII 트리에 그대로 렌더링됨. ## 변경 - `SmartlogPrOverlay` struct 신설 (number/state/ci_status/review_status/url) - `smartlog()` 가 GitHubClient 통해 brach → PR 매칭 + PrStatus 조회 후 node.pr 채움. 토큰 없거나 비-GitHub remote거나 HTTP 실패 = 그냥 overlay 생략 (best-effort, 명령 실패 안 함) - `--no-overlay` 플래그 추가 (강제 offline 모드) - ASCII 렌더러: ticket 라인 아래 `├─ [PR #N ● open ✓ CI ✓ approved]` 한 줄 - 글리프 규칙: 기존 `parsec pr status` / `parsec ci` 와 동일 (✓/✗/●/○) ## CI 필드는? `SmartlogNode.ci` 는 일단 None 유지. Phase 2 는 CI 요약을 overlay 안에 포함시켜 한 줄로 표시 (스마트로그 본질은 한눈 — per-check 디테일은 `parsec ci` 가 이미 담당). ## 드라이브-바이 tests/cli_tests.rs:1725 에 `assert_eq!(bool, false)` clippy 경고 (#326 회귀) → `assert!(!...)` 로 수정. develop 의 clippy strict 빌드 회복. ## 테스트 - 신규 unit 5개 (format_pr_badge × 3 + render_text overlay + JSON 직렬화) - 기존 9개 smartlog unit + 5개 통합 테스트 모두 통과 - cargo build / clippy -D warnings / fmt --check / test 전체 clean ## 다음 Phase 힌트 - Phase 3: per-worktree filtering (`--ticket CL-2283` 등) - Phase 4: stack 관계 시각화 (PR base = 다른 PR head) - Phase 5: review state 색상 강조 (terminal color) Refs #245 Co-Authored-By: Claude Opus 4.7 --------- Co-authored-by: Claude Sonnet 4.6 * feat(completion): Phase 2 — dynamic zsh/bash/fish shell scripts (Refs #291) (#328) Phase 1 (#312) 가 `parsec __complete ` 내부 명령을 만들어 shell completion 스크립트가 동적으로 worktree/branch 후보를 가져올 수 있게 했음. 이번에는 그 위에 실제 zsh/bash/fish 스크립트 3개를 추가해서 사용자가 sourcing 만 하면 `parsec switch ` 처럼 live ticket 자동완성을 받을 수 있게 함. ## 변경 - `completions/_parsec` (zsh, #compdef) — `_parsec_worktrees` / `_parsec_branches` helper + 모든 주요 subcommand 의 positional/option 자동완성 - `completions/parsec.bash` — `complete -F _parsec parsec`. bash-completion 의존 (`_init_completion`). prev word 기반 dispatch + `compgen -W` - `completions/parsec.fish` — `__fish_seen_subcommand_from` 기반. per-subcommand 옵션 (`--base`, `--on`, `--branch`) 도 동적 branch/worktree 후보 연결 - README "Install > Shell completion" 섹션 추가 (3개 shell install 명령) ## 커버 범위 (Tier 1 + 2) - ticket 받는 subcommand: start, switch, ship, open, clean, status, ticket, pr-status, ci, merge, diff, sync, log, compress, adopt, rename - branch 받는 옵션: `start --base|--on|--branch`, `ship --base`, `adopt --branch` - smartlog: `--depth`, `--no-overlay` (PR #327 와 호환) ## __complete kind 추가 없음. Phase 1 의 `worktrees` / `branches` 두 개로 충분 — future Phase 3 에서 필요하면 추가 (예: `tickets` 트래커, `reviewers` GitHub 사용자). ## 드라이브-바이 PR #327 에서 같이 잡았던 `tests/cli_tests.rs:1725` clippy 회귀 (#326) develop 머지 전이라 또 한 번 적용. PR #327 머지되면 충돌 안 남. ## 테스트 - 신규 4개 통합 테스트: - `completion_zsh_present_and_dynamic` — `#compdef parsec` + `__complete` 호출 + 핵심 sub 7개 - `completion_bash_present_and_dynamic` — `complete -F _parsec parsec` + 동일 - `completion_fish_present_and_dynamic` — `__parsec_worktrees` 함수 + 동일 - `completion_scripts_reference_phase1_subcommand_signature` — 스크립트가 Phase 1 가 지원하지 않는 kind 부르지 않는지 (silent fail 방지) - 전체 62 통합 테스트 + smartlog 14 단위 + health 5 통과 - cargo build / clippy -D warnings / fmt --check 전부 clean ## 다음 Phase 힌트 - Phase 3: `__complete tickets` (트래커 미해결 티켓), `__complete reviewers` (GitHub mention) - Phase 4: shell auto-install hook (parsec init 에서 자동 설치 옵션) Refs #291 Co-authored-by: Claude Opus 4.7 * feat(health): Phase 2 — CI status overlay + configurable stale threshold (Refs #299) ## 무엇 parsec health Phase 2: GitHub CI 상태 오버레이 + 설정 가능한 stale 임계값. ## 변경 - health.rs: --stale-days 플래그로 stale 임계값 설정 (기본 7일) - health.rs: --no-overlay 플래그로 오프라인 모드 지원 - health.rs: fetch_ci_overlay() — 브랜치 → PR 번호 → CI 상태 (best-effort) - output/mod.rs: HealthRecord에 ci_status: Option, pr_number: Option 추가 - output/human.rs: CI 상태를 색상 아이콘으로 표시 (✓/✗/● CI) - output/json.rs: ci_status, pr_number, ci_failing 필드를 JSON 출력에 포함 - cli/mod.rs: Health 명령을 struct variant로 변경 (--stale-days, --no-overlay) - tests/cli_tests.rs: Phase 2 플래그 검증 테스트 3개 추가 (8/8 통과) ## 다음 Phase 힌트 Phase 3: threshold config (parsec.toml [health] stale_days), 경고 exit code 옵션 Co-Authored-By: Claude Sonnet 4.6 * feat(reviews): Phase 1 — unified PR review table across worktrees (Refs #301) (#331) Adds `parsec reviews` (alias `rv`) — a new command that scans every active worktree, resolves its open GitHub PR by branch name, and prints a unified review table with per-PR review decisions and CI status. ## What - New `src/cli/commands/reviews.rs` with `reviews()` async function - `ReviewEntry` struct added to `src/output/mod.rs` - Human table renderer in `src/output/human.rs` (color-coded review/CI badges) - JSON array renderer in `src/output/json.rs` - `Reviews` CLI variant wired in `src/cli/mod.rs` (alias `rv`) - 3 unit tests for ReviewEntry construction ## Phase 2 hint Add `--requested` flag: use GitHub Search API (`/search/issues?q=review-requested:{login}`) to show PRs from *others* where the current user is a requested reviewer. Co-authored-by: Claude Sonnet 4.6 * feat(sync): stale-threshold filter, dry-run behind-count, conflict hint (#290) * feat(sync): stale-threshold filter, dry-run behind-count, conflict hint (#290) - Add --min-behind N flag (default: 1) to skip worktrees fewer than N commits behind origin/; skipped entries shown in output - dry-run now fetches behind-count and reports per-worktree before exit - Conflict detection: append "(conflict detected — resolve manually)" hint to failed sync reason when git output contains "conflict" - Extend print_sync signature with skipped: &[(String, u32)] across human/json/mod output layers; stack_sync passes &[] (no change) - Add 3 CLI integration tests: up-to-date skip, dry-run output, rebase Co-Authored-By: Claude Sonnet 4.6 (1M context) * style: cargo fmt Co-Authored-By: Claude Sonnet 4.6 (1M context) --------- Co-authored-by: Claude Sonnet 4.6 (1M context) * feat(smartlog): Phase 3 — worktree filter, ANSI color, stack indicator (#333) Refs #245 ## What - Add `--worktree ` flag (-w) to `parsec smartlog` / `sl`: case- insensitive substring match on ticket or branch name so users can focus on a subset of worktrees without noise from unrelated ones. - ANSI color in the PR/CI badge: green for success/approved/merged, red for failure/changes-requested, yellow for pending, blue for open PR, dim for draft/closed. Automatically disabled when `NO_COLOR` is set or stdout is not a TTY; force-enable via `PARSEC_COLOR=always`. - Stack indicator: when a worktree's base branch matches another active worktree's branch name, the base-group header now reads `○ (stacked on )` instead of `(base)`, making stacked-PR flows immediately visible without extra commands. ## Tests added (5 new) - `worktree_filter_matches_ticket_substring` - `worktree_filter_branch_fallback` - `stack_indicator_appears_when_base_is_sibling_branch` - `color_badge_contains_ansi_codes_when_enabled` - `color_badge_failure_ci_is_red` All 138 tests pass (cargo build / clippy / fmt / test ✓). Co-authored-by: Claude Sonnet 4.6 * feat(reviews): Phase 2 — --requested flag via GitHub Search API (Refs #301) (#334) Add `--requested` flag to `parsec reviews` (alias `rv -r`) that shows open PRs *from others* in this repo where the authenticated user is a requested reviewer. ## What changed - `src/github/mod.rs`: add `get_authenticated_user()` (GET /user) and `search_review_requested_prs(login)` (GET /search/issues with review-requested query). Reqwest .query() handles URL encoding — no new dependencies. - `src/cli/commands/reviews.rs`: split into `collect_authored_reviews` (Phase 1 logic) and `collect_requested_reviews` (Phase 2). Each returns Vec; the caller merges and dispatches. - `src/cli/mod.rs`: add `requested: bool` field to `Reviews` variant, pass through to command handler. - 4 unit tests: existing 3 preserved + 1 new for reviewer-mode ticket placeholder. ## Phase 3 hint - Add `--all` flag to include closed/merged PRs. - Add author filter (`--author`) to narrow results. Co-authored-by: Claude Sonnet 4.6 * feat(conflicts): --simulate flag for line-level speculative merge (Closes #246) (#335) `parsec conflicts --simulate`로 실제 in-memory three-way 머지를 수행해 라인-레벨 충돌을 사전 탐지. 기존 filename overlap 휴리스틱 (`parsec conflicts`)을 보완. ## 추가 모듈 - `src/conflict/simulator.rs` (240줄) - `MergeSimulation { vs_base, cross, skipped }` 결과 구조 - `simulate(repo, &workspaces)` 진입점 - Pass 1: 각 워크트리 HEAD vs origin/ (`merge-base` → fallback to local base) - Pass 2: 워크트리 페어 cross-simulate - 도구: `git merge-tree --write-tree --name-only --merge-base=` (git 2.38+) - read-only — 워킹디렉터리/index 무수정, merge tree 객체는 DB에 남되 참조 없음 ## CLI - `Command::Conflicts` variant에 `--simulate` bool 플래그 추가 - 디스패치 시 simulate true이면 `conflict::simulate(...)`, false면 기존 `conflict::detect(...)` ## Output - Human: 'Worktree → base conflicts' + 'Cross-worktree conflicts' 섹션, 파일 목록 ●로 표시 - JSON: `{ vs_base: [...], cross: [...], skipped: [...] }` 머신 readable ## 가드레일 - WorkspaceStatus::Active 만 처리 - merge-base 계산 실패시 skipped로 다운그레이드 (전체 리포트 중단 방지) - `merge-tree` 비-0 exit + 빈 출력은 transient로 간주 ## 검증 - cargo build clean - cargo clippy -D warnings clean - cargo fmt clean - cargo test: 66 lib + 5 simulator + 71 cli = 142 통과 (이전 138 + 신규 4 simulate) ## 신규 테스트 (tests/cli_tests.rs) - test_conflicts_simulate_empty_repo - test_conflicts_simulate_json_empty_is_object - test_conflicts_simulate_single_clean_worktree - test_conflicts_simulate_detects_cross_worktree_line_conflict (실제 충돌 시드) v0.5 마일스톤. Co-authored-by: Pochacco * feat(test): parsec test — parallel test runner with tree-hash caching (Closes #247) (#336) * feat(test): parsec test — parallel test runner with tree-hash caching Implements `parsec test` (issue #247): runs a configurable shell command inside one or every parsec-managed worktree, with optional parallelism (`--jobs N`) and tree-hash result caching (`--cache`). - New `[test]` section in ParsecConfig (command / jobs / cache defaults). - New `Command::Test` variant with --all / --jobs / --cache / --command flags plus auto-detect of the current worktree from CWD. - Cache files live under `/.parsec/test-cache/.json`; only successful runs are persisted. - Output dispatched through `print_test_results` (human table + JSON array with from_cache / exit_code / duration_ms / stdout_tail). - Tail of stdout/stderr (40 lines) surfaced on failure for fast triage. - Six new CLI integration tests covering help, single-worktree, --all, cache hit replay, non-zero propagation, and parallel completion. Closes #247 * fix(test): cross-platform shell + Windows test gating Windows CI fail 원인: bash가 WSL로 해석되는데 distro 미설치 → exec 실패. - 러너: `bash -c` → 플랫폼별로 `sh -c` (Unix) / `cmd /C` (Windows) - 테스트 명령: `true` → `exit 0` (cmd.exe + sh 양쪽 동작) - `test_test_jobs_parallel_completes`는 sleep 의존 → `#[cfg(unix)]` 게이트 cargo test 73 cli + 5 bitbucket + 69 unit = 147 통과. Co-Authored-By: Pochacco --------- Co-authored-by: Pochacco * feat(dashboard): interactive TUI dashboard for worktrees, CI, and PRs (#337) Introduce `parsec dashboard` (alias `dash`), a `ratatui` + `crossterm` based terminal UI that aggregates worktree, CI, and GitHub PR status into a single live view. Layout (three panes + status bar): - Worktrees pane (top-left): list of every active worktree with a color-coded CI dot, ticket ID, title/branch, and status. - CI pane (top-right): per-worktree `PR #N · ✓/✗/●` summary. - PRs pane (bottom): table — PR · title · state · review · CI. - Status bar: key hints + last-update timestamp + last-error badge. Concurrency: a single background `tokio::task` refreshes the shared `Arc>` every `--refresh` seconds (default 10). The UI loop never blocks on network — it redraws the latest snapshot on each tick or key event via `tokio::select!`. Keys: `q` / Esc quit · `r` force refresh · `?` / F1 help overlay · `↑/↓` (or `j/k`) move selection · Ctrl-C quit. Robustness: - Terminal state (alternate screen + raw mode) is restored by a `Drop` guard that runs on every exit path, including panic. - `--no-overlay` (or missing GitHub token) renders `–` placeholders instead of erroring. - `--json` / `--quiet` are rejected early with an actionable message pointing to `parsec list --json` / `parsec reviews --json`. Tests: four new integration tests for help text (both `dashboard` and `dash`), `--json` rejection, and `--quiet` rejection, plus five new unit tests for the rendering helpers. Deps: ratatui 0.28, crossterm 0.28. Closes #248 Co-authored-by: Claude Opus 4.7 * release-prep: bump version + CHANGELOG + README for v0.5.0 (#339) * chore(release): v0.5.0 — visualization release - Cargo.toml: 0.4.0 → 0.5.0 (release.yml이 main push 시 자동 tag/publish 트리거) - CHANGELOG: Unreleased → [0.5.0] 정리 (v0.5 마일스톤 16개 항목 반영) - README: Roadmap v0.5.0 ✅ Released 마킹, v1.0 'Next'. 신규 'Visualization & power-user tools' 섹션 (8개 신규 명령). command count 33+ - docs/llms.txt: 버전 0.5.0, v0.5 명령 quick reference cargo build/clippy/fmt clean · test 142/142. Co-Authored-By: Pochacco * ci: RUST_MIN_STACK=8MB to fix Windows stack overflow after TUI deps landed #248 (TUI dashboard) added ratatui + crossterm. Windows 기본 main-thread stack 1MB가 tokio + reqwest + ratatui + crossterm 결합 후 overflow → bitbucket integration test 5개 panic. 워크플로우 env에 RUST_MIN_STACK=8388608 (8MB) 추가. 모든 test/build job에 적용. macOS/Linux는 변경 영향 없음. Co-Authored-By: Pochacco * build: Windows linker /STACK:16MB — fix bitbucket integration test overflow RUST_MIN_STACK은 Rust 스레드만 영향. Windows main-thread stack(기본 1MB)은 linker 옵션이 결정. v0.5 TUI dashboard 진입 후 ratatui + crossterm + tokio + reqwest 결합으로 1MB 초과 → parsec.exe subprocess가 overflow → bitbucket integration 테스트 5개 panic. .cargo/config.toml에 Windows 타겟 한정 `/STACK:16777216` 추가. macOS/Linux 기본은 이미 8MB라 변경 영향 없음. Co-Authored-By: Pochacco --------- Co-authored-by: Pochacco * chore: back-merge main into develop (sync for release PR #340) (#341) --------- Co-authored-by: Claude Opus 4.7 * docs: snapshot versioned docs for v0.5.0 Manual replay of release.yml `Snapshot Versioned Docs` job that failed on v0.5.0 release run (couldn't direct-push to protected main branch). - docs/v/0.5.0/{index.html, guide/index.html, reference/index.html} 신규 — latest docs를 sed transform (data-doc-version, noindex, /v/0.5.0/ prefix) - docs/versions.json: latest 0.4.0 → 0.5.0, versions list 맨 앞 추가 - docs/sitemap.xml: v0.5.0 3개 URL entry 추가 이후 부터는 release.yml 패치로 자동화될 예정 (별도 PR). Co-Authored-By: Pochacco --------- Co-authored-by: Claude Opus 4.7 --- docs/sitemap.xml | 18 + docs/v/0.5.0/guide/index.html | 1987 ++++++++++++++++++++++ docs/v/0.5.0/index.html | 2286 +++++++++++++++++++++++++ docs/v/0.5.0/reference/index.html | 2645 +++++++++++++++++++++++++++++ docs/versions.json | 9 +- 5 files changed, 6943 insertions(+), 2 deletions(-) create mode 100644 docs/v/0.5.0/guide/index.html create mode 100644 docs/v/0.5.0/index.html create mode 100644 docs/v/0.5.0/reference/index.html diff --git a/docs/sitemap.xml b/docs/sitemap.xml index 13887fc..1ace968 100644 --- a/docs/sitemap.xml +++ b/docs/sitemap.xml @@ -144,4 +144,22 @@ never 0.3 + + https://erishforg.github.io/git-parsec/v/0.5.0/ + 2026-06-03 + never + 0.3 + + + https://erishforg.github.io/git-parsec/v/0.5.0/guide/ + 2026-06-03 + never + 0.3 + + + https://erishforg.github.io/git-parsec/v/0.5.0/reference/ + 2026-06-03 + never + 0.3 + diff --git a/docs/v/0.5.0/guide/index.html b/docs/v/0.5.0/guide/index.html new file mode 100644 index 0000000..ecbf73b --- /dev/null +++ b/docs/v/0.5.0/guide/index.html @@ -0,0 +1,1987 @@ + + + + + + + Getting Started Guide — git-parsec | Install, configure, ship in 5 minutes + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + +
+
+ + + + + +
+
+

Installation

+ # +
+ +

+ parsec is a single Rust binary with no runtime dependencies. Install via cargo, or build from source. +

+ +

Via cargo (recommended)

+

+ The fastest path. Requires Rust and cargo to be installed. +

+ +
+
+
+ install via cargo +
+
+$ cargo install git-parsec + Compiling git-parsec v0.2.4 + Installed ~/.cargo/bin/parsec +  +# Verify installation +$ parsec --version + parsec 0.3.3 +
+
+ +

Build from source

+

+ Clone the repository and build with cargo build --release. The compiled binary is at ./target/release/parsec. +

+ +
+
+
+ build from source +
+
+$ git clone https://github.com/erishforG/git-parsec.git +$ cd git-parsec +$ cargo build --release + Binary at ./target/release/parsec +  +# Move to a directory on your $PATH +$ cp ./target/release/parsec /usr/local/bin/ +
+
+ +

Shell completions

+

+ Generate tab completions for your shell. Completions cover all commands and flags. +

+ +
+
+
+ shell completions +
+
+# zsh — add to fpath +$ parsec config completions zsh > ~/.zsh/completions/_parsec +  +# bash +$ parsec config completions bash > ~/.local/share/bash-completion/completions/parsec +  +# fish +$ parsec config completions fish > ~/.config/fish/completions/parsec.fish +
+
+
+ + +
+
+

Shell Integration

+ # +
+ +

+ Shell integration is the single most impactful quality-of-life improvement parsec offers. It hooks into your shell to enable seamless directory switching and automatic working-directory recovery. +

+ +
+
+
+ enable shell integration +
+
+# Add to ~/.zshrc (or ~/.bashrc for bash) +eval "$(parsec init zsh)" +  +# Reload your shell +$ source ~/.zshrc +
+
+ +

What shell integration does

+ +
    +
  1. +
    1
    +
    +

    Auto-cd on switch

    +

    When you run parsec switch TICKET, your shell automatically changes directory into the worktree. Without integration, parsec can only print the path — your shell script would need to call cd $(parsec switch TICKET) manually.

    +
    +
  2. +
  3. +
    2
    +
    +

    CWD recovery after merge/clean

    +

    When parsec removes a worktree you're currently inside (e.g. after parsec merge), your shell would normally be stranded in a deleted directory. Shell integration detects this and automatically moves you back to the main repository root.

    +
    +
  4. +
+ +
+
+
+
Recommended for all users
+

Shell integration has no downsides and makes the switch/merge workflow significantly smoother. Add the eval line to your rc file during first-time setup.

+
+
+
+ + +
+
+

Quick Start Workflow

+ # +
+ +

+ The core parsec lifecycle follows a simple loop: start → code → ship → merge → clean. Each step is one command. +

+ +
+
+
start
+
create workspace
+
+
+
+
code
+
work & commit
+
+
+
+
ship
+
push + open PR
+
+
+
+
merge
+
merge PR
+
+
+
+
clean
+
remove worktrees
+
+
+ +
+
+
+ full lifecycle demo +
+
+# 1. Create an isolated workspace from a Jira ticket +$ parsec start PROJ-123 + Created worktree for PROJ-123 + Branch: feature/PROJ-123 + Path: ../myrepo.PROJ-123 +  +# 2. Switch into the worktree (auto-cd with shell integration) +$ parsec switch PROJ-123 +# shell: → cd ../myrepo.PROJ-123 +  +# 3. Do your work, commit as usual +$ git add . && git commit -m "feat: auth flow" +  +# 4. Check for conflicts with parallel work +$ parsec conflicts + No conflicts across 3 worktrees +  +# 5. Ship: push + open PR + clean worktree +$ parsec ship PROJ-123 + Pushed feature/PROJ-123 (3 commits) + PR #42 created: github.com/org/myrepo/pull/42 + Worktree cleaned up +  +# 6. After review — merge and clean up remote branch +$ parsec merge PROJ-123 + PR #42 merged into main +  +# 7. Tidy up any remaining worktrees +$ parsec clean + Removed 1 merged worktree +
+
+ +
+
+
+
First-time setup
+

Before running parsec start for the first time, run parsec config init to configure your issue tracker and GitHub token. See Tracker Configuration below.

+
+
+
+ + +
+
+

Tracker Configuration

+ # +
+ +

+ parsec integrates with three issue trackers. Run parsec config init for an interactive setup wizard, or edit ~/.config/parsec/config.toml directly. +

+ +
+
+

Jira

+

Connect to Jira Cloud or Jira Server. parsec fetches ticket titles, statuses, and assignees automatically.

+ Cloud & Server +
+
+

GitHub Issues

+

Works out of the box when your remote is GitHub. Uses the same token as your GitHub API access.

+ github.com +
+
+

GitLab

+

Connects to GitLab.com or self-hosted GitLab instances via personal access token.

+ Cloud & Self-hosted +
+
+ +

Jira setup

+ +
+
+
+ parsec config init — Jira +
+
+$ parsec config init + ? Tracker type: jira + ? Jira base URL: https://myorg.atlassian.net + ? Jira email: you@company.com + ? Jira API token: *** + ? GitHub token: ghp_*** + ? Default branch: main + Config saved to ~/.config/parsec/config.toml +
+
+ +
+
+
+
Jira API token
+

Generate a Jira API token at id.atlassian.com/manage-profile/security/api-tokens. parsec uses it for read-only ticket lookups; it never writes to Jira.

+
+
+ +

GitHub Issues setup

+

+ When your repo remote is GitHub, parsec automatically uses GitHub Issues as the tracker. Provide a personal access token with repo scope. +

+ +
+
+
+ parsec config init — GitHub Issues +
+
+$ parsec config init + ? Tracker type: github + ? GitHub token: ghp_*** + ? Default branch: main + Config saved +  +# Ticket IDs are GitHub issue numbers +$ parsec start 42 + Created worktree for issue #42: Fix pagination bug +
+
+ +

Config file reference

+

+ The config file lives at ~/.config/parsec/config.toml. You can edit it directly. +

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
KeyDescriptionExample
tracker.typeIssue tracker backend."jira" / "github" / "gitlab" / "bitbucket"
tracker.base_urlBase URL for Jira/GitLab self-hosted."https://myorg.atlassian.net"
tracker.tokenAPI token for the tracker.Jira API token or GitLab PAT
github.tokenGitHub personal access token (for PR creation)."ghp_..."
git.default_branchDefault base branch for new worktrees."main" / "develop"
git.worktree_prefixDirectory prefix for new worktrees.".." (sibling dirs)
+
+ +
+
!
+
+
No tracker configured
+

parsec still works without a tracker configured. Use --title "My description" with parsec start to provide a description manually. Ticket IDs become local labels only.

+
+
+
+ + +
+
+

AI Agent Workflows

+ # +
+ +

+ parsec was built from day one for parallel AI agent execution. Each worktree is an isolated git environment — agents can run git add, git commit, and git push simultaneously without index.lock collisions. +

+ +
+
+
+
🔒
+

Zero index.lock conflicts

+
+

Each worktree has its own .git/index. Parallel agents never contend on the same file. No retries, no wasted tokens.

+
+
+
+
📈
+

JSON output for scripting

+
+

Every command supports --json for machine-readable output. Poll pr-status, check ci, trigger merge — all scriptable.

+
+
+
+
🔍
+

Conflict detection

+
+

parsec conflicts surfaces which files are modified by multiple agents before any PR is opened — catch issues early.

+
+
+
+
📄
+

Operation history & undo

+
+

parsec log shows every action. parsec undo rolls back the last step — useful when an agent ships prematurely.

+
+
+ +

Running multiple agents in parallel

+

+ Each agent gets its own worktree. Launch them concurrently from a coordinator script — parsec handles isolation so agents never need to coordinate on git state. +

+ +
+
+
+ parallel agent launch script +
+
+#!/bin/bash +# Each ticket becomes an isolated, independent workspace +  +parsec start PROJ-120 --quiet & +parsec start PROJ-121 --quiet & +parsec start PROJ-122 --quiet & +wait +  +# Agent 1 works in ./myrepo.PROJ-120 +# Agent 2 works in ./myrepo.PROJ-121 ← no conflicts +# Agent 3 works in ./myrepo.PROJ-122 +
+
+ +

JSON output for automation

+

+ Use --json on any command to get structured output suitable for piping into jq or a coordinator agent. +

+ +
+
+
+ JSON output examples +
+
+# Check if PR is mergeable +$ parsec pr-status PROJ-120 --json | jq '.mergeable' + true +  +# Wait for CI then merge +$ parsec ci PROJ-120 --watch --json +# polls until complete, exits 0 on success +$ parsec merge PROJ-120 +  +# List all worktrees as JSON +$ parsec list --json | jq '.[].ticket' + "PROJ-120" + "PROJ-121" + "PROJ-122" +
+
+ +
+
+
+
Agent best practices
+

Run parsec conflicts before any agent calls parsec ship. This surfaces file-level overlaps between parallel workstreams before they become merge conflicts.

+
+
+
+ + +
+
+

Stacked PRs

+ # +
+ +

+ Stacked PRs let you build a chain of dependent changes — each PR targets the previous ticket's branch rather than main. This is useful when a feature spans multiple tickets, or when you want incremental review of a large change. +

+ +

Creating a stack with --on

+

+ Pass --on <TICKET> to parsec start to create a worktree whose base is another ticket's branch. +

+ +
+
+
+ stacked PR workflow +
+
+# Base ticket — targets main +$ parsec start PROJ-100 + Created worktree, base: main +  +# Second ticket — stacks on PROJ-100 +$ parsec start PROJ-101 --on PROJ-100 + Created worktree, base: feature/PROJ-100 +  +# Third in the chain +$ parsec start PROJ-102 --on PROJ-101 + Created worktree, base: feature/PROJ-101 +  +# Visualize the stack +$ parsec stack + main + └── feature/PROJ-100 PR #10 open + └── feature/PROJ-101 PR #11 open + └── feature/PROJ-102 PR #12 open +
+
+ +

After merging a stacked PR

+

+ When the base PR in a stack is merged, the child PR's target becomes stale — it still points to the merged branch. Use parsec stack --sync to automatically re-target all stacked PRs to their correct new bases. +

+ +
+
+
+ parsec stack --sync +
+
+# PROJ-100 was merged to main +# Now re-target PROJ-101 to main +$ parsec stack --sync + PR #11 retargeted: feature/PROJ-100main + Stack synchronized +
+
+ +
+
+
+
Merge order matters
+

Always merge stacked PRs from the bottom of the stack upward — merge the base first, then parsec stack --sync, then merge the next PR. Skipping --sync results in child PRs targeting already-merged branches.

+
+
+
+ +
+
+

New Features

+ # +
+ +

+ Recent additions to parsec that extend the workflow beyond worktree management into issue creation, release automation, and customizable hooks. +

+ +

Issue creation with parsec create

+

+ Create issues directly from the terminal without leaving your workflow. Works with GitHub Issues and Jira. Use --start to immediately begin work on the new issue. +

+ +
+
+
+ parsec create +
+
+# Create a GitHub issue +$ parsec create --title "Fix login redirect" --label "bug" + Created #145: Fix login redirect +  +# Create and start working immediately +$ parsec create --title "Add caching layer" --start + Created #146: Add caching layer + Created workspace at ~/myrepo.146 +  +# For Jira with issue type +$ parsec new-issue --title "API caching" --issue-type Story --project CL + Created CL-42: API caching +
+
+ +

Release workflow with parsec release

+

+ Automate the entire release process: merge develop to main, create a version tag, and publish a GitHub Release with auto-generated changelog — all in one command. +

+ +
+
+
+ parsec release +
+
+$ parsec release 0.3.3 + Merged develop → main + Tagged v0.3.3 + GitHub Release: github.com/org/repo/releases/tag/v0.3.3 +  +# Always preview first with --dry-run +$ parsec release 0.4.0 --dry-run +Would merge develop → main +Would tag v0.4.0 +
+
+ +

Pre-ship hooks

+

+ Define commands that run automatically before parsec ship pushes your branch. Great for ensuring tests pass and linting is clean before creating a PR. +

+ +
+
+
+ config.toml +
+
+# ~/.config/parsec/config.toml +[hooks] +post_create = ["npm install"] +pre_ship = ["cargo test", "cargo clippy"] +  +# Skip hooks when needed +$ parsec ship PROJ-123 --skip-hooks +
+
+ +
+
+
+
Release configuration
+

Customize the release workflow in your config file with [release] section: set the target branch, tag prefix, and whether to include a changelog.

+
+
+
+ + +
+
+

Recipes & Examples

+ # +
+ +

+ End-to-end examples for the workflow patterns parsec is built around — Bitbucket Cloud setup, history compression, stacked PR navigation, PR templates, offline / headless mode, observability via JSONL, editor autocomplete via the JSON Schema, and worktree build cache sharing. Each recipe is self-contained — copy the snippets and adapt to your repo. +

+ +

Bitbucket Cloud — full PR lifecycle

+

+ parsec now speaks Bitbucket Cloud's API: parsec ship opens PRs, parsec pr-status reports CI from Bitbucket Pipelines, parsec ci tails build status, and parsec merge merges from the terminal. Tracker integration uses the same tracker.bitbucket config block. +

+ +
+
+
+ Bitbucket setup +
+
+# Auth via env var +$ export PARSEC_BITBUCKET_TOKEN="<app-password>" +  +# Configure in ~/.config/parsec/config.toml +[tracker] +provider = "bitbucket" +[tracker.bitbucket] +workspace = "my-team" +  +$ parsec ship CL-2208 + PR opened: bitbucket.org/my-team/repo/pull-requests/142 + Bitbucket Pipelines: BUILD #318 in_progress +
+
+ +

Compress branch history with parsec compress

+

+ Squash a branch's commits into one tidy commit before shipping. Co-author trailers from squashed commits are preserved automatically. +

+ +
+
+
+ parsec compress +
+
+# Squash all branch commits into one +$ parsec compress + Compressed 7 commits into one on feature/PROJ-123 +  +# With a custom message +$ parsec compress -m "feat: add user authentication" +  +# Compose: tidy history then ship +$ parsec compress && parsec ship +
+
+ +

Stack navigation comments

+

+ When you ship a stacked PR, parsec auto-posts "← previous PR" / "next PR →" navigation comments on every PR in the stack. Reviewers can walk the chain without leaving the PR view. +

+ +

PR template auto-fill — ship --template

+

+ Use the repository's .github/PULL_REQUEST_TEMPLATE.md (or the first match under .github/PULL_REQUEST_TEMPLATE/) as the PR description automatically. Combine with ship.template in config.toml to make it the default. +

+ +
+
+
+ ship --template +
+
+$ parsec ship PROJ-123 --template +Loaded .github/PULL_REQUEST_TEMPLATE.md (348 chars) + PR opened with template body +
+
+ +

Offline mode — --offline / [workspace].offline

+

+ Skip all network operations: tracker lookups, PR creation, fetches. Use a global --offline flag, the PARSEC_OFFLINE=1 env var, or set offline = true under [workspace] in config.toml. Per-command escapes (--no-pr, --no-tracker) remain available for finer control. +

+ +
+
+
+ offline mode +
+
+# Per-invocation +$ parsec start CL-2208 --offline --title "Add login retry" +  +# Persistent — flight mode +[workspace] +offline = true +
+
+ +

Observability — execution IDs + JSONL export

+

+ Every command run gets a unique execution ID and per-step timing. parsec log --export emits one JSON object per line for tooling and AI agents to consume. Combined with --json on individual commands, parsec is fully introspectable. +

+ +
+
+
+ parsec log --export +
+
+$ parsec log --export | jq 'select(.duration_ms > 1000)' +{ + "execution_id": "01HQ3D9V7Z2...", + "op": "ship", + "ticket": "PROJ-123", + "steps": [ + {"name":"push","ms":820}, + {"name":"create_pr","ms":1305}, + {"name":"cleanup","ms":42} + ], + "duration_ms": 2167 +} +
+
+ +

Config JSON Schema — editor autocomplete

+

+ The schema for config.toml is published to schemastore.org, so VS Code, IntelliJ, Helix, and any editor with schemastore integration auto-complete and validate every key. parsec config schema emits the schema for offline use. +

+ +
+
+
+ config schema +
+
+$ parsec config schema > parsec-schema.json +  +# Pin schema in your config for editor support +#:schema https://json.schemastore.org/parsec.json +
+
+ +

Worktree build cache sharing — [worktree].shared_cache

+

+ New worktrees can reuse target/, node_modules/, .venv/, etc. from the main repo via symlink (default) or recursive copy. Eliminates cold-build cost on parsec start for any project with significant dependency caches. +

+ +
+
+
+ [worktree] config +
+
+[worktree] +shared_cache = ["target", "node_modules", ".venv"] +# "symlink" (default) — fast, zero-disk; parallel build of same artifact may race +# "copy" — independent caches per worktree, no race risk, more disk +cache_strategy = "symlink" +
+
+ +

Draft-by-default — ship.draft

+

+ Set [ship].draft = true in config.toml to open every PR as a draft, or pass --draft per ship. Useful for iterative WIP review flows where you want CI feedback before requesting human review. +

+
+ +
+ +
+
+ + + + + + + + + diff --git a/docs/v/0.5.0/index.html b/docs/v/0.5.0/index.html new file mode 100644 index 0000000..beef6e6 --- /dev/null +++ b/docs/v/0.5.0/index.html @@ -0,0 +1,2286 @@ + + + + + + + git-parsec — From ticket to PR. One command. | Git worktree lifecycle manager + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+
Built with Rust
+
AI-Native
+
+

+ From ticket to PR
+ in one command. +

+

+ Run 5 AI agents in parallel — zero index.lock conflicts. git-parsec gives every agent its own isolated worktree, tied to your issue tracker. No retries, no wasted tokens, no merge chaos. +

+ +
+
+
+
+
+
+
parsec -- zsh
+
+
+# Run 5 AI agents in parallel — no lock conflicts +$ parsec start CL-2283 + Created worktree for CL-2283 + Title: Collect HeuristicCompletionException handling + Branch: feature/CL-2283 + Path: ../myproject.CL-2283 +  +# Each agent gets its own isolated worktree +$ parsec start CL-2290 + Created worktree for CL-2290 +  +# Ship it -- push, create PR, cleanup +$ parsec ship CL-2283 + Pushed feature/CL-2283 + PR created: github.com/org/repo/pull/42 + Worktree cleaned up +  +
+
+
+
+
+ + +
+
+
+ +
Git wasn't built for
AI agent workflows.
+

+ When AI agents run in parallel on the same repo, they collide on .git/index.lock — crashing mid-task, burning tokens on retries, and leaving broken state. Worktrees solve isolation, but lack the lifecycle management agents need. +

+
+ +
+
+
+ Without parsec +
+
    +
  • +
    + +
    +
    +

    Lock contention

    +

    Parallel git operations collide on .git/index.lock, blocking agents and developers.

    +
    +
  • +
  • +
    + +
    +
    +

    Manual worktree management

    +

    Create branch, add worktree, remember paths, clean up manually. Error-prone and tedious.

    +
    +
  • +
  • +
    + +
    +
    +

    No ticket connection

    +

    Branches and worktrees have no link to your issue tracker. Context gets lost.

    +
    +
  • +
  • +
    + +
    +
    +

    Invisible conflicts

    +

    Parallel work silently edits the same files. You only find out at merge time.

    +
    +
  • +
+
+ +
+
+ With parsec +
+
    +
  • +
    + +
    +
    +

    Zero-conflict parallelism

    +

    Each ticket gets its own isolated worktree. No lock contention, ever.

    +
    +
  • +
  • +
    + +
    +
    +

    One-command lifecycle

    +

    parsec start creates everything. parsec ship pushes, PRs, and cleans up.

    +
    +
  • +
  • +
    + +
    +
    +

    Ticket tracker integration

    +

    Jira and GitHub Issues built in. Branches auto-named, PR titles auto-filled.

    +
    +
  • +
  • +
    + +
    +
    +

    Early conflict detection

    +

    parsec conflicts warns when worktrees touch the same files -- before you merge.

    +
    +
  • +
  • +
    + +
    +
    +

    Full operation history

    +

    parsec log shows everything parsec has done. parsec undo rolls back the last step if something goes wrong.

    +
    +
  • +
+
+
+
+
+ + +
+
+
+ +
From ticket to PR in 60 seconds.
+
+
+ git-parsec demo showing start, list, switch, log, undo, and clean commands +
+
+
+ + +
+
+
+ +
Everything you need.
Nothing you don't.
+

+ A focused toolset for the complete worktree lifecycle -- from creating isolated workspaces to shipping production-ready PRs. +

+
+ +
+ + + + + + + + + + + + + + + + + +
+ + +
+ + + + More features + — 16 additional capabilities + + +
+
Branch syncparsec sync rebases or merges the latest base branch into one or all worktrees.
+
Operation historyparsec log shows every action with timestamps; filter by ticket or last N.
+
Undoparsec undo reverses the last start / ship / clean. Use --dry-run to preview.
+
Open in browserparsec open launches the PR or ticket page (GitHub / GitLab / Jira / Bitbucket).
+
Worktree diffparsec diff compares any worktree to its base branch (--stat, --name-only).
+
Compress branchparsec compress squashes commits into one tidy commit before shipping.
+
PR template auto-fillship --template reads .github/PULL_REQUEST_TEMPLATE.md.
+
Reviewers + labelsship --reviewer / --label at PR creation time, or set defaults in config.
+
Draft-by-default[ship].draft = true opens every PR as a draft for WIP review.
+
Pre-ship hooks — run cargo test, npm run lint, etc. before ship via [hooks].pre_ship.
+
Sprint boardparsec board renders the active Jira sprint as a Kanban board in the terminal.
+
Issue creationparsec create / new-issue opens tickets in your tracker; --start immediately creates a worktree.
+
Release workflowparsec release merges develop → main, tags, and creates a GitHub Release.
+
Policy guard[policy] blocks ships to protected branches and restricts allowed targets.
+
Headless mode--yes and TTY auto-detection skip prompts in CI / agent environments.
+
Cross-platform — Linux / macOS / Windows. Windows UNC path handling via the dunce crate.
+
+
+ See the full command reference for every flag and example, or read the getting started guide. +
+
+
+
+ + +
+
+
+ +
How parsec stacks up.
+

+ parsec fills the gap between bare git worktree commands and tools that don't connect to your issue tracker. +

+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Featureparsecworktrunkgit worktreegit-townGitButler
Ticket tracker integrationJira + GitHub Issues--------
Physical isolation (worktrees)YesYesYes--Virtual branches
Cross-worktree conflict detectionYes--------
One-step ship (push + PR/MR + clean)GitHub + GitLab----Yes--
Operation history & undoYes----Yes (undo)Yes
JSON output for AI agentsYes------Yes
CI monitoringYes (--watch)--------
Stacked PRsYes----YesYes
Post-create hooksYesYes------
Auto-cleanup merged worktreesYes--Manual----
Forge supportGitHub + GitLabGitHub--GH, GL, Gitea, BBGitHub + GitLab
Zero config startYesYes------
+
+
+
+ + +
+
+
+ +
Up and running in 60 seconds.
+

+ Three commands from install to your first PR. +

+
+ +
+
+
01
+

Install

+

Install via cargo. A single binary, no runtime dependencies.

+
cargo install git-parsec
+
+
+
02
+

Configure

+

Run interactive setup. Enable shell integration for auto-cd and merge recovery.

+
parsec config init
eval "$(parsec init zsh)"
+
+
+
03
+

Start building

+

Create a worktree from any ticket. Code, commit, and ship when ready.

+
parsec start PROJ-42
+
+
+ + +
+
+
+
+
+
full workflow demo
+
+
+# Create isolated workspace from Jira ticket +$ parsec start CL-2283 + Created worktree for CL-2283 + Branch: feature/CL-2283 + Path: ../myproject.CL-2283 +  +# Switch into the worktree +$ parsec switch CL-2283 + cd ../myproject.CL-2283 +  +# ... do your work, commit as usual ... +  +# Check for conflicts with other worktrees +$ parsec conflicts + No file conflicts across 3 active worktrees +  +# Ship it -- push, create PR, cleanup +$ parsec ship CL-2283 + Pushed feature/CL-2283 (4 commits) + PR #42 created: github.com/org/repo/pull/42 + Worktree cleaned up +
+
+
+
+ + +
+
+
+
+ +
Install parsec.
+

+ A single Rust binary. No runtime dependencies, no node_modules, no Python virtualenvs. +

+
+ +
+
+ Cargo + cargo install git-parsec + Available +
+
+ Homebrew + brew install git-parsec + Coming soon +
+
+ From source + git clone && cargo build --release + Available +
+
+
+
+
+ + +
+
+
+
+ Let your AI agents run.
+ Zero conflicts. Zero wasted tokens. +
+

+ Join developers and AI teams who use parsec to run parallel agents on the same repo — isolated worktrees, no index.lock fights, fewer retries. +

+
+ + + Star on GitHub + +
+ $ cargo install git-parsec +
+
+
+
+
+ + + + + + + + + + diff --git a/docs/v/0.5.0/reference/index.html b/docs/v/0.5.0/reference/index.html new file mode 100644 index 0000000..9a055fc --- /dev/null +++ b/docs/v/0.5.0/reference/index.html @@ -0,0 +1,2645 @@ + + + + + + + Command Reference — git-parsec | All 27 commands, every flag, with examples + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + +
+
+ + + + + +
+

Global Options — available on every command

+
+
--json machine-readable output
+
-q / --quiet suppress non-essential output
+
--repo <PATH> target repository path
+
--dry-run preview changes without executing
+
--offline skip all network ops (tracker, PR, fetch)
+
+
+ + +
+
+

Core Workflow

+
+ + +
+
+ start + Create a new worktree for a ticket + # +
+

+ Creates a new git worktree tied to a ticket ID. Fetches the ticket title from your configured tracker (Jira, GitHub Issues, or GitLab), names the branch consistently, and sets up an isolated workspace directory alongside your main repo. +

+
+ Usage + parsec start <TICKET> [OPTIONS] +
+ +
+ + + + + + + + + + +
ArgumentDescription
<TICKET> requiredTicket ID to create a worktree for (e.g. PROJ-123, 42).
+
+ +
+ + + + + + + + + + +
OptionDescription
--base <BRANCH>Base branch to create the worktree from. Defaults to the repo's default branch.
--title <TEXT>Override the ticket title (useful when offline or using an unsupported tracker).
--on <TICKET>Set this worktree's base to another ticket's branch, creating a stacked PR dependency.
--branch <NAME>Override the generated branch name.
+
+ +
+
+
+ parsec start +
+
+# Create workspace from a Jira ticket +$ parsec start PROJ-123 + Created worktree for PROJ-123 + Title: Add user authentication flow + Branch: feature/PROJ-123 + Path: ../myrepo.PROJ-123 +  +# Stack on another ticket (for dependent PRs) +$ parsec start PROJ-124 --on PROJ-123 + Created worktree, base: feature/PROJ-123 +  +# Offline — override title manually +$ parsec start PROJ-125 --title "Fix login redirect" +
+
+
+ + +
+
+ list + List all active worktrees + # +
+

+ Displays all active parsec-managed worktrees with their ticket IDs, branch names, PR status, and paths. Use --full to include unpushed commit counts, ahead/behind divergence, and last commit metadata per worktree. +

+
+ Usage + parsec list [--full] [--no-pr] +
+ +
+ + + + + + + + +
OptionDescription
--fullShow extended metadata: unpushed commits, ahead/behind divergence, last commit message and age.
--no-prSkip fetching PR status from GitHub/GitLab (faster output, works offline).
+
+ +
+
+
+ parsec list +
+
+$ parsec list + TICKET BRANCH STATUS PATH + ───────────────────────────────────────────────────────────── + PROJ-123 feature/PROJ-123 open PR ../myrepo.PROJ-123 + PROJ-125 feature/PROJ-125 no PR ../myrepo.PROJ-125 + PROJ-130 feature/PROJ-130 merged ../myrepo.PROJ-130 +  +# Extended metadata per worktree +$ parsec list --full + TICKET BRANCH STATUS AHEAD/BEHIND UNPUSHED LAST COMMIT AGE + ────────────────────────────────────────────────────────────────────────────────────── + PROJ-123 feature/PROJ-123 open PR +3 / -0 1 Add rate limiting 2h ago + PROJ-125 feature/PROJ-125 no PR +1 / -2 0 Fix auth redirect 30m ago +
+
+
+ + +
+
+ switch + Print workspace path (auto-cd with shell integration) + # +
+

+ Prints the path to a ticket's worktree. With shell integration active (eval "$(parsec init zsh)"), your shell automatically cds into that directory. Without shell integration it prints the path for use in scripts. +

+
+ Usage + parsec switch [TICKET] [OPTIONS] +
+ +
+ + + + + + + + + + +
ArgumentDescription
[TICKET]Ticket ID to switch to. If omitted, shows an interactive picker.
+
+ +
+
+
+ parsec switch +
+
+# With shell integration — auto-cd into the worktree +$ parsec switch PROJ-123 + ~/projects/myrepo.PROJ-123 +# shell: cd ~/projects/myrepo.PROJ-123 +  +# Without shell integration — use in a subshell +$ cd $(parsec switch PROJ-123) +
+
+
+ + +
+
+ ship + Push, create PR/MR, and clean up + # +
+

+ The one-command shipping workflow. Pushes your branch to the remote, creates a Pull Request (or Merge Request on GitLab) with the ticket title pre-filled, and removes the worktree. All in a single step. +

+
+ Usage + parsec ship <TICKET> [OPTIONS] +
+ +
+ + + + + + + +
ArgumentDescription
<TICKET> requiredTicket ID of the worktree to ship.
+
+ +
+ + + + + + + + + + + + +
OptionDescription
--draftOpen the PR as a draft (GitHub only).
--no-prPush the branch but skip creating a PR/MR.
--base <BRANCH>Override the target base branch for the PR.
--skip-hooksSkip pre-ship hooks defined in [hooks] config.
-r, --reviewer <USER>Request review from a GitHub user (repeatable).
-l, --label <NAME>Add a label to the PR (repeatable).
+
+ +
+
+
+ parsec ship +
+
+$ parsec ship PROJ-123 + Pushed feature/PROJ-123 (7 commits) + PR #42 created: github.com/org/myrepo/pull/42 + Worktree cleaned up +  +# Open as draft PR +$ parsec ship PROJ-125 --draft + Draft PR created: github.com/org/myrepo/pull/43 +  +# Ship with reviewers and labels +$ parsec ship PROJ-126 --reviewer alice --reviewer bob --label needs-review + PR #44 created with reviewers and labels +
+
+
+ + +
+
+ merge + Merge PR on GitHub and clean up + # +
+

+ Merges the PR associated with a ticket via the GitHub API, waits for the merge to complete, deletes the remote branch, and removes the local worktree. Returns you to the main repository. +

+
+ Usage + parsec merge [TICKET] [OPTIONS] +
+ +
+ + + + + + + + + +
OptionDescription
--rebaseUse rebase strategy instead of merge commit.
--no-waitTrigger the merge and return immediately without waiting.
--no-delete-branchKeep the remote branch after merging.
+
+ +
+
+
+ parsec merge +
+
+$ parsec merge PROJ-123 + PR #42 merged into main + Remote branch deleted + Worktree removed +
+
+
+ + +
+
+ clean + Remove merged or stale worktrees + # +
+

+ Scans all parsec-managed worktrees and removes those whose PRs have been merged or whose branches no longer exist on the remote. Use --dry-run to preview what would be removed. +

+
+ Usage + parsec clean [OPTIONS] +
+ +
+ + + + + + + + + +
OptionDescription
--allRemove ALL parsec worktrees regardless of PR status.
--dry-runPreview what would be removed without deleting anything.
--orphansAlso remove worktrees not tracked by parsec.
+
+ +
+
+
+ parsec clean +
+
+# Preview first +$ parsec clean --dry-run + Would remove: ../myrepo.PROJ-123 (merged) + Would remove: ../myrepo.PROJ-130 (merged) +  +$ parsec clean + Removed 2 merged worktrees +
+
+
+
+ + +
+
+

Inspection

+
+ + +
+
+ status + Show detailed workspace status + # +
+

+ Shows the full status of a worktree: uncommitted changes, commits ahead of base, PR state, CI checks, and ticket metadata. +

+
+ Usage + parsec status [TICKET] [OPTIONS] +
+ +
+
+
+ parsec status +
+
+$ parsec status PROJ-123 + Ticket: PROJ-123 — Add user authentication flow + Branch: feature/PROJ-123 + Ahead: 3 commits ahead of main + Changed: 2 files modified, 1 untracked + PR: #42 open — 1 review approved + CI: passing (3/3 checks) +
+
+
+ + +
+
+ ticket + View ticket details from tracker + # +
+

+ Fetches and displays full ticket details from your configured issue tracker (Jira, GitHub Issues, or GitLab). Optionally shows comments. +

+
+ Usage + parsec ticket [TICKET] [OPTIONS] +
+ +
+ + + + + + + +
OptionDescription
--commentInclude comments in the output.
+
+ +
+
+
+ parsec ticket +
+
+$ parsec ticket PROJ-123 + [PROJ-123] Add user authentication flow + Status: In Progress + Priority: High + Assignee: you + ... +
+
+
+ + +
+
+ diff + View changes vs base branch + # +
+

+ Shows the diff between the worktree's current state and its base branch. Defaults to full diff output; use --stat or --name-only for a summary. +

+
+ Usage + parsec diff [TICKET] [OPTIONS] +
+ +
+ + + + + + + + +
OptionDescription
--statShow diffstat summary (files changed, insertions, deletions).
--name-onlyShow only the names of changed files.
+
+ +
+
+
+ parsec diff +
+
+$ parsec diff PROJ-123 --stat + src/auth/mod.rs | 84 ++++++++++++++++++++++ + src/auth/session.rs | 42 +++++++++++ + tests/auth_test.rs | 28 ++++++++ + 3 files changed, 154 insertions(+) +
+
+
+ + +
+
+ conflicts + Detect file conflicts across worktrees + # +
+

+ Scans all active worktrees and reports files modified by more than one of them. Surfaces potential merge conflicts before you open a PR — essential when running multiple parallel agents. +

+
+ Usage + parsec conflicts [OPTIONS] +
+ +
+
+
+ parsec conflicts +
+
+$ parsec conflicts + Checking 4 active worktrees... + ⚠ src/config.rs modified in PROJ-123 and PROJ-130 + No other conflicts found +
+
+
+ + +
+
+ pr-status + Check PR CI and review status + # +
+

+ Fetches the current PR state: review approvals, requested changes, CI check results, and merge readiness. Useful in agent scripts to poll before triggering a merge. +

+
+ Usage + parsec pr-status [TICKET] [OPTIONS] +
+ +
+
+
+ parsec pr-status +
+
+$ parsec pr-status PROJ-123 + PR #42 Add user authentication flow + Reviews: 2 approved + CI: all checks passing + Mergeable: yes +  +# Machine-readable for agent scripts +$ parsec pr-status PROJ-123 --json +
+
+
+ + +
+
+ ci + Check CI/CD status + # +
+

+ Shows CI/CD pipeline status for a ticket's branch. With --watch it polls continuously until all checks complete or fail. +

+
+ Usage + parsec ci [TICKET] [OPTIONS] +
+ +
+ + + + + + + + +
OptionDescription
--watchPoll CI status until completion (useful in scripts).
--allShow CI status for all active worktrees.
+
+ +
+
+
+ parsec ci +
+
+$ parsec ci PROJ-123 --watch + Watching CI for feature/PROJ-123... + [ 5s] build running + [ 32s] build passed + [ 38s] test passed + [ 41s] lint passed + All CI checks passed +
+
+
+
+ + +
+
+

Advanced

+
+ + +
+
+ sync + Sync worktree with base branch + # +
+

+ Updates a worktree by fetching and merging (or rebasing) changes from its base branch. Keeps long-running feature branches current without manually switching contexts. +

+
+ Usage + parsec sync [TICKET] [OPTIONS] +
+ +
+ + + + + + + + +
OptionDescription
--allSync all active worktrees at once.
--strategy <merge|rebase>Integration strategy. Defaults to merge.
+
+ +
+
+
+ parsec sync +
+
+# Sync one worktree +$ parsec sync PROJ-123 + Synced feature/PROJ-123 with main (fast-forward) +  +# Sync all worktrees with rebase strategy +$ parsec sync --all --strategy rebase + Synced 3 worktrees +
+
+
+ + +
+
+ open + Open PR or ticket in browser + # +
+

+ Opens the associated PR or ticket page in your default browser. By default opens the PR; use --ticket-page to open the issue tracker instead. +

+
+ Usage + parsec open <TICKET> [OPTIONS] +
+ +
+ + + + + + + + +
OptionDescription
--prOpen the PR/MR page (default).
--ticket-pageOpen the issue tracker page instead.
+
+ +
+
+
+ parsec open +
+
+$ parsec open PROJ-123 # opens PR +$ parsec open PROJ-123 --ticket-page # opens Jira +
+
+
+ + +
+
+ adopt + Import existing branch into parsec + # +
+

+ Registers an existing git branch as a parsec-managed worktree, linking it to a ticket ID. Use this when you've already started work on a branch outside of parsec. +

+
+ Usage + parsec adopt <TICKET> [OPTIONS] +
+ +
+ + + + + + + + +
OptionDescription
--branch <NAME>Branch name to adopt (if different from the ticket-derived name).
--title <TEXT>Override ticket title for display.
+
+ +
+
+
+ parsec adopt +
+
+$ parsec adopt PROJ-123 --branch my-old-branch + Adopted branch my-old-branch as PROJ-123 +
+
+
+ + +
+
+ stack + Show or manage stacked PR dependencies + # +
+

+ Displays the dependency graph of stacked PRs created with parsec start --on. With --sync, updates all PRs in the stack to use their correct base branches after an upstream merge. +

+
+ Usage + parsec stack [OPTIONS] +
+ +
+ + + + + + + + +
OptionDescription
--syncRe-target stacked PRs after an upstream PR was merged.
--submitShip the entire stack in topological order (root first). Stops on first failure.
+
+ +
+
+
+ parsec stack +
+
+$ parsec stack + main + └── feature/PROJ-123 (PROJ-123) PR #42 open + └── feature/PROJ-124 (PROJ-124) PR #43 open +  +# Ship the entire stack at once +$ parsec stack --submit +Submitting stack (2 worktrees): + 1. PROJ-123 + 2. PROJ-124 + Stack submit complete: 2/2 shipped +
+
+
+ + +
+
+ inbox + List assigned tickets without worktrees + # +
+

+ Fetches tickets assigned to you from the configured tracker that don't yet have a parsec worktree. Use --pick to interactively select and immediately run parsec start on one. +

+
+ Usage + parsec inbox [OPTIONS] +
+ +
+ + + + + + + +
OptionDescription
--pickInteractive mode: select a ticket to immediately start a worktree.
+
+ +
+
+
+ parsec inbox +
+
+$ parsec inbox + PROJ-128 Fix pagination bug in search results + PROJ-131 Upgrade dependency: serde 1.0.195 + PROJ-135 Add dark mode toggle +
+
+
+ + +
+
+ board + Sprint board Kanban view + # +
+

+ Renders a Kanban-style sprint board in the terminal, pulling data from your configured tracker. Shows ticket titles, statuses, and assignees. +

+
+ Usage + parsec board [OPTIONS] +
+ +
+ + + + + + + + + + +
OptionDescription
--board-id <ID>Target a specific board by ID.
--project <KEY>Filter by project key.
--assignee <USER>Filter tickets by assignee.
--allShow tickets for all assignees.
+
+ +
+
+
+ parsec board +
+
+$ parsec board + TODO IN PROGRESS REVIEW DONE + ───────────────────────────────────────────────────── + PROJ-128 PROJ-123 PROJ-119 PROJ-111 + PROJ-131 PROJ-125 PROJ-112 +
+
+
+
+ + +
+
+

History

+
+ + +
+
+ log + Show operation history + # +
+

+ Displays a chronological log of all parsec operations: start, ship, merge, clean, undo, etc. Use --last N to limit output. +

+
+ Usage + parsec log [TICKET] [OPTIONS] +
+ +
+ + + + + + + + +
OptionDescription
--last <N>Show only the last N operations.
--exportEmit the log as JSONL (one JSON object per line). Each entry includes execution_id and per-step timing for observability/debugging by tooling and AI agents.
+
+ +
+
+
+ parsec log +
+
+$ parsec log --last 5 + 2024-01-15 14:32 merge PROJ-119 PR #38 merged + 2024-01-15 11:20 ship PROJ-123 PR #42 created + 2024-01-15 09:05 start PROJ-123 worktree created + 2024-01-14 17:44 start PROJ-125 worktree created + 2024-01-14 16:30 clean 3 worktrees removed +  +# JSONL export — one JSON object per line, with execution_id and per-step timing +$ parsec log --export +{"execution_id":"01HQ3D8R2K8...","op":"start","ticket":"PROJ-123","steps":[{"name":"fetch_title","ms":214},{"name":"create_worktree","ms":98}],"duration_ms":312} +{"execution_id":"01HQ3D9V7Z2...","op":"ship","ticket":"PROJ-123","steps":[{"name":"push","ms":820},{"name":"create_pr","ms":1305},{"name":"cleanup","ms":42}],"duration_ms":2167} +
+
+
+ + +
+
+ undo + Undo last operation + # +
+

+ Rolls back the most recent parsec operation. For example, undoing a ship removes the PR and restores the worktree. Use --dry-run to see what would happen without committing. +

+
+ Usage + parsec undo [OPTIONS] +
+ +
+ + + + + + + +
OptionDescription
--dry-runPreview what undo would do without making any changes.
+
+ +
+
+
+ parsec undo +
+
+$ parsec undo --dry-run + Would undo: ship PROJ-123 + - Close PR #42 + - Restore worktree ../myrepo.PROJ-123 +  +$ parsec undo + Undid: ship PROJ-123 +
+
+
+
+ + +
+
+

Setup

+
+ + +
+
+ root + Print main repo root path + # +
+

+ Prints the absolute path of the main (non-worktree) repository root. Useful in scripts to navigate back to the primary workspace from any worktree. +

+
+ Usage + parsec root [OPTIONS] +
+ +
+
+
+ parsec root +
+
+$ parsec root + /Users/you/projects/myrepo +  +# Navigate back from a worktree +$ cd $(parsec root) +
+
+
+ + +
+
+ init + Output or install shell integration + # +
+

+ Prints a shell integration script that enables automatic cd when using parsec switch, and automatic working-directory recovery after parsec merge removes a worktree you were inside. Use --install to auto-append the integration to your shell config file instead of managing it manually. +

+
+ Usage + parsec init [SHELL] [--install] [--yes] +
+ +
+ + + + + + + + + +
Argument / OptionDescription
[SHELL]Shell to generate integration for. Supported: zsh, bash, fish. Defaults to zsh.
--installAuto-append eval "$(parsec init <shell>)" to your shell config file with a confirmation prompt.
-y, --yesSkip the confirmation prompt. Useful for scripted or non-interactive environments.
+
+ +
+
+
+ parsec init +
+
+# Preferred: auto-install into ~/.zshrc +$ parsec init --install + Add shell integration to /home/user/.zshrc? [Y/n] y + Shell integration added. Run source ~/.zshrc or restart your shell. +  +# Non-interactive install (scripted setup) +$ parsec init --install --yes +  +# Manual: print and eval yourself +$ eval "$(parsec init zsh)" +  +# bash users +$ eval "$(parsec init bash)" +  +# fish users +$ parsec init fish | source +
+
+
+ + +
+
+ config + Configure parsec + # +
+

+ Top-level configuration command with subcommands for initial setup, showing current config, generating shell completions, and reading the manual. +

+
+ Usage + parsec config <SUBCOMMAND> [OPTIONS] +
+ +
+ + + + + + + + + + + + +
SubcommandDescription
initRun interactive first-time setup (tracker URL, API tokens, default branch).
showDisplay current configuration (redacts sensitive tokens).
manOpen the parsec manual in your pager.
completions <SHELL>Generate shell completion script for zsh, bash, or fish.
schemaOutput the JSON Schema for config.toml. The schema is also published to schemastore.org so editors auto-complete configuration files.
shellDeprecated. Use parsec init <SHELL> instead.
+
+ +
+
+
+ parsec config +
+
+# First-time setup wizard +$ parsec config init + ? Tracker type: jira + ? Jira base URL: https://myorg.atlassian.net + ? Jira API token: *** + Config saved to ~/.config/parsec/config.toml +  +# Install shell completions (zsh) +$ parsec config completions zsh > ~/.zsh/completions/_parsec +  +# Show current config +$ parsec config show +  +# Output the JSON Schema (also at https://json.schemastore.org/parsec.json) +$ parsec config schema > parsec-schema.json +
+
+
+ + +
+
+ doctor + Validate environment and configuration + # +
+

+ Checks your environment and prints ✓/✗ for each item with actionable fix instructions. Verifies git version, config file, API tokens, tracker connectivity, shell integration, tab completions, and remote access. +

+
+ Usage + parsec doctor [OPTIONS] +
+ +
+ + + + + + + + +
OptionDescription
--jsonOutput results as JSON ({"checks":[...],"all_ok":bool}).
--aiInclude AI-powered diagnostic suggestions for failed checks.
+
+ +
+
+
+ parsec doctor +
+
+$ parsec doctor + git version 2.43.0 (worktree support ok) + config file found at ~/.config/parsec/config.toml + GitHub token configured (github.com) via gh auth token + shell integration not found in shell config + Add to ~/.zshrc: eval "$(parsec init zsh)" + tab completions not configured + Add to ~/.zshrc: eval "$(parsec config completions zsh)" + remote origin accessible +  +2 check(s) failed. +  +# Machine-readable output +$ parsec doctor --json +{"checks":[...],"all_ok":false} +
+
+
+ + +
+
+ create + Create a new issue on the tracker + # +
+

+ Creates a new ticket on GitHub Issues or Jira and optionally starts a worktree for it immediately. Auto-detects the tracker from your config. +

+
+ Usage + parsec create [OPTIONS] +
+ +
+ + + + + + + + + + + +
OptionDescription
--title <TEXT> requiredIssue title.
--body <TEXT>Issue body / description.
--label <A,B>Comma-separated labels to apply.
-p, --project <KEY>Jira project key (auto-detected from config if omitted).
--startStart a worktree for the new issue immediately after creation.
+
+ +
+
+
+ parsec create +
+
+$ parsec create --title "Fix login redirect" --label "bug" + Created #145: Fix login redirect + https://github.com/org/repo/issues/145 +  +# Create and immediately start working +$ parsec create --title "Add caching" --start + Created #146: Add caching + Created workspace at ~/myrepo.146 +
+
+
+ + +
+
+ new-issue + Create a new issue (extended) + # +
+

+ Extended issue creation with multi-label support and Jira issue type control. Auto-detects GitHub or Jira from config. +

+
+ Usage + parsec new-issue [OPTIONS] +
+ +
+ + + + + + + + + + + + +
OptionDescription
--title <TEXT> requiredIssue title.
--body <TEXT>Issue body / description.
--label <LABEL>Label (can be specified multiple times).
-p, --project <KEY>Jira project key (auto-detected from config if omitted).
--issue-type <TYPE>Jira issue type (default: Task).
--startAuto-start a worktree for the new issue.
+
+ +
+
+
+ parsec new-issue +
+
+$ parsec new-issue --title "Implement caching" --issue-type Story --project CL + Created CL-42: Implement caching + https://jira.example.com/browse/CL-42 +  +# Multiple labels on GitHub +$ parsec new-issue --title "Fix auth" --label bug --label priority + Created #147: Fix auth +
+
+
+ + +
+
+ rename + Re-ticket an existing workspace + # +
+

+ Reassigns an existing worktree to a different ticket ID. Updates the branch name, directory symlink, and internal state — useful when a ticket is split, renumbered, or moved between trackers. +

+
+ Usage + parsec rename <OLD-TICKET> <NEW-TICKET> [OPTIONS] +
+ +
+ + + + + + + + +
ArgumentDescription
<OLD-TICKET> requiredThe current ticket ID of the workspace to rename.
<NEW-TICKET> requiredThe new ticket ID to assign to the workspace.
+
+ +
+ + + + + + + +
OptionDescription
--dry-runPreview what would be renamed without making changes.
+
+ +
+
+
+ parsec rename +
+
+$ parsec rename PROJ-123 PROJ-456 + Branch renamed: feature/PROJ-123-fix-login → feature/PROJ-456-fix-login + Workspace directory updated + State updated for PROJ-456 +  +# Preview changes first +$ parsec rename OLD-99 NEW-100 --dry-run +Would rename branch feature/OLD-99-… → feature/NEW-100-… +Would update workspace directory and state file +
+
+
+ + +
+
+ compress + Squash all branch commits into one + # +
+

+ Resets the branch to the merge-base with the base branch and re-commits all changes as a single commit. Co-author trailers from squashed commits are preserved. Useful before parsec ship to keep PR history tidy. +

+
+ Usage + parsec compress [TICKET] [OPTIONS] +
+ +
+ + + + + + + +
ArgumentDescription
[TICKET]Optional. Auto-detects the current worktree's ticket if omitted.
+
+ +
+ + + + + + + +
OptionDescription
-m, --message <TEXT>Custom commit message. Default: combines all squashed commit messages.
+
+ +
+
+
+ parsec compress +
+
+# Compress current worktree's branch +$ parsec compress + Compressed 7 commits into one on feature/PROJ-1234 +  +# Compress with custom message +$ parsec compress PROJ-1234 -m "feat: add user authentication" +  +# Combine with ship +$ parsec compress && parsec ship +
+
+
+ + +
+
+ release + Create a versioned release + # +
+

+ Merges the develop branch into the release branch (default: main), creates a version tag, and creates a GitHub Release with auto-generated changelog from commit messages since the last tag. +

+
+ Usage + parsec release <VERSION> [OPTIONS] +
+ +
+ + + + + + + +
ArgumentDescription
<VERSION> requiredVersion string (e.g., 0.3.0).
+
+ +
+ + + + + + + + + +
OptionDescription
--from <BRANCH>Source branch to release from (default: develop).
--no-github-releaseSkip creating a GitHub Release.
--dry-runShow what would happen without making changes.
+
+ +
+
+
+ parsec release +
+
+$ parsec release 0.3.0 + Merged develop → main + Tagged v0.3.0 + GitHub Release: github.com/org/repo/releases/tag/v0.3.0 +  +# Preview first +$ parsec release 0.4.0 --dry-run +Would merge develop → main +Would tag v0.4.0 +Would create GitHub Release +
+
+
+ +
+ + + +
+
+

Error Codes

+
+ +
+

+ All parsec commands exit with a structured exit code. Use these in scripts to handle failures precisely. JSON output (--json) also includes an "error_code" field on failure. +

+ +
+ + + + + + + + + + + + + + + +
Exit CodeNameDescription
0SuccessCommand completed successfully.
1GeneralErrorUnspecified error — check stderr for details.
2ConfigErrorMissing or invalid configuration (run parsec config init).
3TrackerErrorCould not reach or authenticate with the issue tracker.
4GitErrorGit operation failed (merge conflict, missing remote, etc.).
5WorktreeErrorWorktree already exists, is missing, or is in a bad state.
6NotFoundTicket or workspace not found.
7AuthErrorAPI token missing or insufficient permissions.
8PolicyViolationOperation blocked by an active policy rule.
+
+ +
+
+
+ exit code in scripts +
+
+$ parsec ship PROJ-123 --json +{"error_code":3,"message":"Could not reach Jira: connection refused"} +$ echo $? +3 +
+
+
+
+ + + +
+
+

Policy Config

+
+ +
+

+ The [policy] table in ~/.config/parsec/config.toml lets teams enforce guardrails on parsec operations. Violations exit with code 8 (PolicyViolation) and print an actionable message. +

+ +
+ + + + + + + + + + + +
KeyTypeDescription
require_ticket_prefixStringAll ticket IDs must match this prefix (e.g., "PROJ-"). Prevents freeform branch names.
max_open_workspacesu32Block parsec start when open workspace count reaches this limit.
protected_branches[String]Branches that parsec ship and parsec merge will refuse to target (e.g., ["main", "production"]).
require_pr_reviewboolWhen true, parsec ship checks that at least one review is approved before merging.
allow_force_pushboolWhen false (default), parsec ship refuses to push with --force.
+
+ +
+
+
+ ~/.config/parsec/config.toml +
+
+[policy] +require_ticket_prefix = "PROJ-" +max_open_workspaces = 5 +protected_branches = ["main", "production"] +require_pr_review = true +allow_force_push = false +  +# Policy violation example +$ parsec start my-feature +✗ Policy violation: ticket ID must start with "PROJ-" +
+
+
+
+ + +
+ +
+
+ + + + + + + + + diff --git a/docs/versions.json b/docs/versions.json index bc0fac3..9d8c9c4 100644 --- a/docs/versions.json +++ b/docs/versions.json @@ -1,6 +1,11 @@ { - "latest": "0.4.0", + "latest": "0.5.0", "versions": [ + { + "version": "0.5.0", + "date": "2026-06-03", + "path": "/git-parsec/v/0.5.0/" + }, { "version": "0.4.0", "date": "2026-05-04", @@ -57,4 +62,4 @@ "path": "/git-parsec/v/0.1.1/" } ] -} \ No newline at end of file +} From 61b40daf3f03f88bdbba11de65aa235da5c7e807 Mon Sep 17 00:00:00 2001 From: erish Date: Thu, 4 Jun 2026 00:20:29 +0900 Subject: [PATCH 26/26] fix(release): snapshot-docs via PR (peter-evans) instead of direct push (#343) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v0.5.0 release run의 `Snapshot Versioned Docs` job이 main 브랜치 protection (`Changes must be made through a pull request`) 때문에 직접 push 실패. ## 변경 - `.github/workflows/release.yml`: `git push origin main` → `peter-evans/create-pull-request`로 `docs/snapshot-v` 브랜치 자동 생성 + PR 오픈. `pull-requests: write` 권한 추가. - `.github/workflows/ci.yml`: Branch Policy가 `docs/snapshot-v*` 브랜치도 main에 머지 허용 (data-only 변경이므로 develop 우회 안전). ## 효과 다음 릴리스부터 자동으로 `docs/snapshot-vX.Y.Z` 브랜치 + auto-snapshot 라벨이 붙은 PR이 생성됨. Eric이 머지하면 versioned docs가 main에 반영. push 실패 0. Co-authored-by: Pochacco --- .github/workflows/ci.yml | 19 +++++++++++++++---- .github/workflows/release.yml | 32 +++++++++++++++++++++++++------- 2 files changed, 40 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d580940..d891363 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,12 +20,23 @@ jobs: if: github.event_name == 'pull_request' && github.base_ref == 'main' runs-on: ubuntu-latest steps: - - name: Only develop can merge into main + - name: Only develop or auto-snapshot branches can merge into main run: | - if [ "${{ github.head_ref }}" != "develop" ]; then - echo "::error::Only the 'develop' branch can be merged into 'main'. Got '${{ github.head_ref }}'." - exit 1 + HEAD='${{ github.head_ref }}' + if [ "$HEAD" = "develop" ]; then + echo "OK — develop → main" + exit 0 fi + # release.yml's snapshot-docs job opens PRs from docs/snapshot-vX.Y.Z + # because direct push to protected main is blocked. These are + # data-only changes (docs/v/, docs/versions.json, docs/sitemap.xml). + case "$HEAD" in + docs/snapshot-v*) + echo "OK — auto-snapshot branch (release docs versioning)" + exit 0 ;; + esac + echo "::error::Only 'develop' or 'docs/snapshot-v*' branches can be merged into 'main'. Got '$HEAD'." + exit 1 check: name: Check diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 40daefd..d4af7e5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -121,6 +121,7 @@ jobs: runs-on: ubuntu-latest permissions: contents: write + pull-requests: write steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: @@ -162,10 +163,27 @@ jobs: sed -i '/<\/urlset>/i \ \n https://erishforg.github.io/git-parsec/v/'"${VERSION}"'/\n '"${DATE}"'\n never\n 0.3\n \n \n https://erishforg.github.io/git-parsec/v/'"${VERSION}"'/guide/\n '"${DATE}"'\n never\n 0.3\n \n \n https://erishforg.github.io/git-parsec/v/'"${VERSION}"'/reference/\n '"${DATE}"'\n never\n 0.3\n ' docs/sitemap.xml - - name: Commit and push versioned docs - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add docs/v/ docs/versions.json docs/sitemap.xml - git commit -m "docs: snapshot versioned docs for v${VERSION}" - git push origin main + - name: Open snapshot PR (main is protected — push direct is blocked) + uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8 + with: + base: main + branch: docs/snapshot-v${{ needs.release.outputs.version }} + commit-message: "docs: snapshot versioned docs for v${{ needs.release.outputs.version }}" + title: "docs: snapshot versioned docs for v${{ needs.release.outputs.version }}" + body: | + Automated docs snapshot for **v${{ needs.release.outputs.version }}** (release run #${{ github.run_id }}). + + Produced by `.github/workflows/release.yml` → `snapshot-docs` job. + + ## Files + - `docs/v/${{ needs.release.outputs.version }}/{index,guide/index,reference/index}.html` — versioned docs (with `noindex` meta) + - `docs/versions.json` — `latest` bump + history entry + - `docs/sitemap.xml` — versioned URLs added + + Safe to merge. No code change, only doc data. + labels: | + release + auto-snapshot + author: "github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>" + committer: "github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>" + delete-branch: true