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 6ba2807..d891363 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: @@ -15,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 @@ -96,3 +112,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 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index e99b10b..bd4a555 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,74 @@ 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 +- **`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. GitHub host에만 한정해 Bitbucket / GitLab remote는 영향 + 없음 (`#281`). + +### CI +- 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 ### Added diff --git a/Cargo.toml b/Cargo.toml index 933c91f..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." @@ -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/README.md b/README.md index 6685b72..cb1b38c 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.0** — _The visualization release_ | ✅ Released (2026-06-03) | smartlog · TUI dashboard · speculative merge · `parsec test` · health · reviews · AI PR descriptions | +| **v1.0** — _AI-Native Standard_ | 🚧 Next | MCP server signature — Claude / Cursor / Copilot invoke parsec as a first-class tool | +| **v2.0+** — _Ecosystem Hub_ | 🔮 | Plugins · VS Code extension · Linear-native tracker · org-scale workflows | + +v1.0 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. + +--- + ## Install ```bash @@ -42,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 @@ -108,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/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/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/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) 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 +} 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/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/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/diff.rs b/src/cli/commands/diff.rs index 80dcdd6..9eb73a3 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,22 +92,56 @@ pub async fn diff( Ok(()) } -pub async fn conflicts(repo: &Path, mode: Mode) -> Result<()> { +/// Detect files that are modified in multiple active worktrees simultaneously. +/// +/// 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(()) } +/// 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. +/// +/// `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/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()?; @@ -99,7 +156,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 @@ -112,20 +168,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) => { @@ -135,11 +221,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/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/cli/commands/health.rs b/src/cli/commands/health.rs new file mode 100644 index 0000000..d3efca3 --- /dev/null +++ b/src/cli/commands/health.rs @@ -0,0 +1,154 @@ +//! `parsec health` — quick sanity-check for all active worktrees (#299). +//! +//! 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-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. +//! +//! 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::github::GitHubClient; +use crate::output::{self, HealthRecord, Mode}; +use crate::worktree::WorktreeManager; + +/// Run health checks for all active worktrees and print a summary. +/// +/// # 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 +/// +/// 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). +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()?; + + if workspaces.is_empty() { + if mode == Mode::Human { + println!("No active worktrees."); + } else if mode == Mode::Json { + println!("[]"); + } + 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 ------------------------------------------------- + let git_dir = ws.path.join(".git"); + let lock_path = if git_dir.is_file() { + std::fs::read_to_string(&git_dir) + .ok() + .and_then(|s| { + s.strip_prefix("gitdir: ") + .map(|p| std::path::PathBuf::from(p.trim())) + }) + .unwrap_or_else(|| git_dir.clone()) + .join("index.lock") + } else { + git_dir.join("index.lock") + }; + let has_lock = lock_path.exists(); + + // --- uncommitted ----------------------------------------------- + let uncommitted = git::get_uncommitted_files(&ws.path) + .unwrap_or_default() + .len(); + + // --- stale (days since last commit) ---------------------------- + let stale_days_val = 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 + }); + + // --- 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_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/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))?; diff --git a/src/cli/commands/mod.rs b/src/cli/commands/mod.rs index 5f7a5b0..b2e75a6 100644 --- a/src/cli/commands/mod.rs +++ b/src/cli/commands/mod.rs @@ -1,25 +1,37 @@ mod ci; +mod complete; mod compress; mod config; +mod dashboard; mod diff; mod doctor; +mod health; mod history; mod pr; mod release; +mod reviews; mod ship; +pub mod smartlog; mod stack; +mod test; mod tracker_cmds; mod workspace; 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::*; pub use history::*; pub use pr::*; pub use release::*; +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/reviews.rs b/src/cli/commands/reviews.rs new file mode 100644 index 0000000..5610e88 --- /dev/null +++ b/src/cli/commands/reviews.rs @@ -0,0 +1,196 @@ +//! `parsec reviews` — unified PR review status across all active worktrees (#301). +//! +//! 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. +//! +//! 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. +//! - Both views (author + requested) share the same `ReviewEntry` table output. + +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. +/// +/// 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. +pub async fn reviews(repo: &Path, requested: bool, mode: Mode) -> Result<()> { + let config = ParsecConfig::load()?; + + 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 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 { + let pr_number = match gh.find_pr_by_branch(&ws.branch).await { + Ok(Some(n)) => n, + Ok(None) | Err(_) => continue, + }; + let status = match gh.get_pr_status(pr_number).await { + Ok(s) => s, + Err(_) => continue, + }; + 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(), + }); + } + Ok(entries) +} + +/// 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) +} + +// --------------------------------------------------------------------------- +// 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"); + } + + #[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/commands/smartlog.rs b/src/cli/commands/smartlog.rs new file mode 100644 index 0000000..463bcc1 --- /dev/null +++ b/src/cli/commands/smartlog.rs @@ -0,0 +1,691 @@ +//! `parsec smartlog` (alias `sl`) — visualize active worktrees as a commit DAG. +//! +//! 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 +//! +//! 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. +//! +//! 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; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use crate::config::ParsecConfig; +use crate::git; +use crate::github::GitHubClient; +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 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 — 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 { + 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, + no_overlay: bool, + worktree_filter: Option<&str>, + 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, + }); + } + + // 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, color)); + } + } + 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 +/// `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()) +} + +/// 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` (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" => 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, state_str, ci); + let review = match pr.review_status.as_str() { + "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 { + // 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`. +/// 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. +/// +/// `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); + } + + let mut out = String::new(); + let base_count = by_base.len(); + for (base_idx, (base, group)) in by_base.iter().enumerate() { + // 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; + 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 { "│ " }; + // PR overlay (Phase 2): one line above commits when overlay set. + // Phase 3: badge is now optionally colorized. + if let Some(pr) = &node.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)); + } 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 +} + +// --------------------------------------------------------------------------- +// 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 +// --------------------------------------------------------------------------- + +#[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(&[], false); + 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, false); + 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, false); + 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], false); + 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\"")); + } + + 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"), 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"), 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"), false); + // `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], 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"); + // 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"); + } + + // ----------------------------------------------------------------------- + // 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/commands/stack.rs b/src/cli/commands/stack.rs index 4004f3d..f8e2d40 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)?; @@ -113,11 +147,18 @@ 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(()) } /// 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)?; 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 94f71f8..4b21ff8 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 /// @@ -255,6 +263,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 +277,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 @@ -428,6 +443,23 @@ pub enum Command { ai: bool, }, + /// Check all active worktrees for common issues + /// + /// 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 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 /// /// Merges the current develop branch into the release branch (default: main), @@ -501,6 +533,123 @@ 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, + + /// 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, + + /// 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, + }, + + /// 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 + /// prints a unified review table showing review decisions and CI status. + /// + /// 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 { + /// 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, + }, + + /// 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 / + /// 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)] @@ -574,7 +723,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", @@ -592,10 +741,16 @@ 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", Command::Compress { .. } => "compress", + Command::Smartlog { .. } => "smartlog", + Command::Complete { .. } => "__complete", + Command::Reviews { .. } => "reviews", + Command::Dashboard { .. } => "dashboard", + Command::Test { .. } => "test", }; let exec_id = crate::execlog::new_execution_id(); let exec_started_at = chrono::Utc::now(); @@ -693,20 +848,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, @@ -771,7 +924,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 } @@ -830,6 +985,10 @@ pub async fn run(cli: Cli) -> Result<()> { commands::doctor(&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, @@ -882,6 +1041,60 @@ 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, + worktree, + } => { + commands::smartlog( + &repo_path, + depth, + no_overlay, + worktree.as_deref(), + output_mode, + ) + .await + } + 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, + 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, }; // Record execution entry (best-effort, never fail the command) 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/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/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/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/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/src/github/mod.rs b/src/github/mod.rs index 45e953e..d153471 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() } // --------------------------------------------------------------------------- @@ -296,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 // --------------------------------------------------------------------------- @@ -527,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( 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()); diff --git a/src/output/human.rs b/src/output/human.rs index d8e6ce3..b94e225 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()); @@ -446,7 +525,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 +542,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 +563,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."); } } @@ -1001,3 +1095,284 @@ 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(), + }; + + // 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 || ci_issue { + "✗".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, + ci_tag, + ); + } + println!(); + if issues == 0 { + println!("{}", "All worktrees healthy.".green().bold()); + } else { + println!( + "{}", + format!("{}/{} worktrees need attention.", issues, records.len()) + .yellow() + .bold() + ); + } +} + +/// 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() + ); + } +} + +/// 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 fee760d..616cf26 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); @@ -125,11 +129,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); @@ -351,3 +361,84 @@ 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); + 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(); + 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) + && !matches!(r.ci_status.as_deref(), Some("failing") | Some("failure")) + }); + println!( + "{}", + json!({ + "worktrees": items, + "all_healthy": all_healthy, + }) + ); +} + +/// 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()) + ); +} + +/// 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 2dcbc26..e17e3a7 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; @@ -43,6 +43,57 @@ 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, + /// 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, +} + +/// 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. + 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): @@ -84,6 +135,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]); @@ -92,6 +144,7 @@ dispatch_output!(print_undo_preview, entry: &OpEntry); dispatch_output!( print_sync, synced: &[String], + skipped: &[(String, u32)], failed: &[(String, String)], strategy: &str ); @@ -117,12 +170,15 @@ 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], pr_map: &std::collections::HashMap ); +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 3bc79ee..82507a6 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 // --------------------------------------------------------------------------- @@ -1166,6 +1229,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 // --------------------------------------------------------------------------- @@ -1193,3 +1337,1103 @@ 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 + ); +} + +// --------------------------------------------------------------------------- +// 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" + ); +} + +// --------------------------------------------------------------------------- +// 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!( + !entry["has_lock"].as_bool().unwrap(), + "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(); +} + +// --------------------------------------------------------------------------- +// 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 + ); + } + } + } +} + +// --------------------------------------------------------------------------- +// 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" + ); +} + +// --------------------------------------------------------------------------- +// 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) +// --------------------------------------------------------------------------- + +#[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 + ); +} + +// --------------------------------------------------------------------------- +// 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 + ); +}