diff --git a/CHANGELOG.md b/CHANGELOG.md index e99b10b..ea5bc79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **v0.5 milestone opened** — see Roadmap in README. Themes: smartlog · TUI + dashboard · speculative merge · `parsec test` · AI PR descriptions. + +### Fixed +- `parsec ship` falls back to `gh auth token` when `PARSEC_GITHUB_TOKEN` / + `GITHUB_TOKEN` / `GH_TOKEN` env vars are absent — parity with `parsec + doctor` and the tracker layer (#281). The fallback is restricted to + GitHub hosts so Bitbucket / GitLab remotes are unaffected. + ## [0.4.0] - 2026-05-04 ### Added diff --git a/README.md b/README.md index 6685b72..c48e4f8 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,21 @@ That's the whole loop. Plain `git worktree` doesn't track state, doesn't talk to --- +## Roadmap + +> **Vision**: parsec = AI agents + human devs both — worktree-native git CLI. + +| Milestone | Status | Theme | +|---|---|---| +| **v0.4.0** | ✅ Released (2026-05-04) | Multi-forge + multi-tracker foundation (GitHub / GitLab / Bitbucket; Jira / Linear) | +| **v0.5** — _The visualization release_ | 🚧 Next | smartlog · TUI dashboard · speculative merge · `parsec test` · AI PR descriptions | +| **v1.0** — _AI-Native Standard_ | 🔜 | MCP server signature — Claude / Cursor / Copilot invoke parsec as a first-class tool | +| **v2.0+** — _Ecosystem Hub_ | 🔮 | Plugins · VS Code extension · Linear-native tracker · org-scale workflows | + +Open issues for v0.5 are tracked under the [`v0.5` milestone](https://github.com/erishforG/git-parsec/milestone/3). + +--- + ## Install ```bash diff --git a/src/cli/commands/doctor.rs b/src/cli/commands/doctor.rs index a2082cd..7109e9f 100644 --- a/src/cli/commands/doctor.rs +++ b/src/cli/commands/doctor.rs @@ -86,15 +86,14 @@ pub async fn doctor(repo: &Path, mode: Mode) -> Result<()> { // ------------------------------------------------------------------ { let config_result = crate::config::ParsecConfig::load(); + // issue #281: gh auth token fallback 은 lib (`crate::env::gh_auth_token`) 에서 + // 단일 정의 — `ship` / tracker 와 parity. doctor 는 SOURCE 를 사람이 읽기 위한 + // 진단 메시지로 분기하므로 별도 매핑 유지. + let from_gh = crate::env::gh_auth_token().is_some(); + let from_env = std::env::var("GITHUB_TOKEN").is_ok(); let github_token_found = match &config_result { Ok(cfg) => { let from_config = cfg.github.values().any(|h| h.token.is_some()); - let from_env = std::env::var("GITHUB_TOKEN").is_ok(); - let from_gh = StdCommand::new("gh") - .args(["auth", "token"]) - .output() - .map(|o| o.status.success()) - .unwrap_or(false); if from_config { Some("config file") } else if from_env { @@ -106,12 +105,6 @@ pub async fn doctor(repo: &Path, mode: Mode) -> Result<()> { } } Err(_) => { - let from_env = std::env::var("GITHUB_TOKEN").is_ok(); - let from_gh = StdCommand::new("gh") - .args(["auth", "token"]) - .output() - .map(|o| o.status.success()) - .unwrap_or(false); if from_env { Some("GITHUB_TOKEN env var") } else if from_gh { diff --git a/src/env.rs b/src/env.rs index de824fa..73df3bf 100644 --- a/src/env.rs +++ b/src/env.rs @@ -47,7 +47,12 @@ pub fn jira_token(config_token: Option<&str>) -> Option { .map(|t| t.to_string()) } -/// Resolve GitHub token. Priority: PARSEC_GITHUB_TOKEN > GITHUB_TOKEN > GH_TOKEN +/// Resolve GitHub token. Priority: +/// 1. `PARSEC_GITHUB_TOKEN` +/// 2. `GITHUB_TOKEN` +/// 3. `GH_TOKEN` +/// 4. `gh auth token` shell fallback (issue #281 — parity with `parsec doctor` / +/// tracker layer; `parsec ship` previously rejected this path) pub fn github_token() -> Option { for var in [PARSEC_GITHUB_TOKEN, GITHUB_TOKEN, GH_TOKEN] { if let Ok(token) = std::env::var(var) { @@ -56,7 +61,30 @@ pub fn github_token() -> Option { } } } - None + gh_auth_token() +} + +/// Shell out to `gh auth token` and capture stdout. Returns `None` on failure: +/// binary not found, exit code != 0, non-UTF8 stdout, or empty token. +/// +/// Used as the final fallback in [`github_token`] (issue #281 — parity with +/// `parsec doctor` and the tracker layer). Cross-platform: relies on `gh` +/// being on PATH; failures are silent so callers present a unified "no token +/// found" message. +pub fn gh_auth_token() -> Option { + let out = std::process::Command::new("gh") + .args(["auth", "token"]) + .output() + .ok()?; + if !out.status.success() { + return None; + } + let token = String::from_utf8(out.stdout).ok()?.trim().to_string(); + if token.is_empty() { + None + } else { + Some(token) + } } /// Resolve GitLab token. Priority: PARSEC_GITLAB_TOKEN > GITLAB_TOKEN @@ -113,3 +141,129 @@ pub fn is_offline() -> bool { .map(|v| v == "1" || v == "true") .unwrap_or(false) } + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- +#[cfg(test)] +mod tests { + use super::*; + use std::sync::{Mutex, OnceLock}; + + /// Process-wide mutex to serialize env-touching tests. cargo test runs + /// tests in parallel by default, so any test that mutates env vars must + /// hold this lock — otherwise sibling tests racing through `set_var` / + /// `remove_var` clobber each other (Windows CI hit this with priority_order + /// reading PARSEC=p but seeing GH=h because another test cleared PARSEC + /// mid-assertion). + fn env_lock() -> &'static Mutex<()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + } + + /// Helper: snapshot/clear env vars affecting github_token, then restore. + /// std::env::set_var/remove_var is unsafe in Rust 2024. Tests holding + /// `env_lock()` only run serially, so the snapshot+restore is sufficient. + struct EnvGuard { + orig: Vec<(&'static str, Option)>, + } + impl EnvGuard { + fn new(vars: &[&'static str]) -> Self { + let orig = vars.iter().map(|v| (*v, std::env::var(v).ok())).collect(); + for v in vars { + // SAFETY: tests run serially within a module by default in Rust 2024. + #[allow(unused_unsafe)] + unsafe { + std::env::remove_var(v) + }; + } + Self { orig } + } + fn set(&self, key: &str, val: &str) { + #[allow(unused_unsafe)] + unsafe { + std::env::set_var(key, val) + }; + } + } + impl Drop for EnvGuard { + fn drop(&mut self) { + for (k, v) in &self.orig { + #[allow(unused_unsafe)] + unsafe { + if let Some(val) = v { + std::env::set_var(k, val); + } else { + std::env::remove_var(k); + } + } + } + } + } + + /// 우선순위 + 빈값 fallback + 모두 미설정 시나리오를 한 함수에서 sequential 검사. + /// `env_lock()` 으로 process-wide 직렬화 (cargo test 병렬 실행 환경에서 sibling + /// 테스트가 env 를 클로버하지 않도록). Windows CI 에서 race 발견 (#289). + #[test] + fn github_token_priority_order_and_fallback() { + let _guard = env_lock().lock().unwrap_or_else(|p| p.into_inner()); + // 1. PARSEC_GITHUB_TOKEN 우선 + { + let g = EnvGuard::new(&[PARSEC_GITHUB_TOKEN, GITHUB_TOKEN, GH_TOKEN]); + g.set(PARSEC_GITHUB_TOKEN, "p"); + g.set(GITHUB_TOKEN, "g"); + g.set(GH_TOKEN, "h"); + assert_eq!(github_token().as_deref(), Some("p")); + drop(g); + } + // 2. PARSEC_GITHUB_TOKEN 미설정 → GITHUB_TOKEN + { + let g = EnvGuard::new(&[PARSEC_GITHUB_TOKEN, GITHUB_TOKEN, GH_TOKEN]); + g.set(GITHUB_TOKEN, "g"); + g.set(GH_TOKEN, "h"); + assert_eq!(github_token().as_deref(), Some("g")); + drop(g); + } + // 3. PARSEC_GITHUB_TOKEN / GITHUB_TOKEN 미설정 → GH_TOKEN + { + let g = EnvGuard::new(&[PARSEC_GITHUB_TOKEN, GITHUB_TOKEN, GH_TOKEN]); + g.set(GH_TOKEN, "h"); + assert_eq!(github_token().as_deref(), Some("h")); + drop(g); + } + // 4. 빈 PARSEC_GITHUB_TOKEN 은 무시 → GITHUB_TOKEN + { + let g = EnvGuard::new(&[PARSEC_GITHUB_TOKEN, GITHUB_TOKEN, GH_TOKEN]); + g.set(PARSEC_GITHUB_TOKEN, ""); + g.set(GITHUB_TOKEN, "g"); + assert_eq!(github_token().as_deref(), Some("g")); + drop(g); + } + // 5. 모두 미설정 + gh 실패 → None. CI 환경 (gh 로그인 X) 이 일반. + // local dev 에서 gh auth login 돼있으면 Some(token) 도 허용 (smoke). + { + let g = EnvGuard::new(&[PARSEC_GITHUB_TOKEN, GITHUB_TOKEN, GH_TOKEN]); + match github_token() { + None => {} + Some(t) => assert!( + !t.is_empty(), + "if gh auth token is available, it must not be empty" + ), + } + drop(g); + } + } + + #[test] + fn gh_auth_token_returns_option_string_or_none() { + // 외부 gh binary 에 의존 — CI 환경 (로그인 X) 에서는 None 기대. + // local dev 에서 gh auth login 돼있으면 Some(token). 둘 다 허용 (smoke check only). + match gh_auth_token() { + None => {} + Some(t) => { + assert!(!t.is_empty()); + assert!(!t.contains('\n'), "trimmed"); + } + } + } +} diff --git a/src/github/mod.rs b/src/github/mod.rs index 45e953e..f85a74f 100644 --- a/src/github/mod.rs +++ b/src/github/mod.rs @@ -190,14 +190,25 @@ pub fn parse_github_remote(url: &str) -> Option { }) } +/// Returns true when `host` looks like a GitHub host. github.com and any host +/// with `.github.` (GHE) substring qualifies. Used to gate env-var and +/// `gh auth token` fallbacks so they don't leak into other forges. +pub fn is_github_host(host: &str) -> bool { + let h = host.trim().to_ascii_lowercase(); + h == "github.com" || h.contains(".github.") || h.ends_with(".ghe.com") +} + /// Resolve a GitHub token for the given host. /// /// Resolution priority: -/// 1. `config.github..token` — host-specific config -/// 2. `PARSEC_GITHUB_TOKEN` env var — explicit override -/// 3. `GITHUB_TOKEN` / `GH_TOKEN` — generic fallback +/// 1. `config.github..token` — host-specific config (any host) +/// 2. `PARSEC_GITHUB_TOKEN` / `GITHUB_TOKEN` / `GH_TOKEN` env vars (GitHub host only) +/// 3. `gh auth token` shell fallback (GitHub host only) — issue #281 parity +/// +/// 2 & 3 are gated on host being a GitHub host so that bitbucket / gitlab remotes +/// don't accidentally pick up a GitHub token via `gh auth login`. pub fn resolve_github_token(host: &str, config: &ParsecConfig) -> Option { - // 1. Host-specific config token + // 1. Host-specific config token (any host — opt-in via config) if let Some(host_cfg) = config.github.get(host) { if let Some(ref token) = host_cfg.token { if !token.is_empty() { @@ -206,12 +217,11 @@ pub fn resolve_github_token(host: &str, config: &ParsecConfig) -> Option } } - // 2 & 3. Environment variables (PARSEC_GITHUB_TOKEN > GITHUB_TOKEN > GH_TOKEN) - if let Some(token) = crate::env::github_token() { - return Some(token); + // 2 & 3: env / gh CLI fallback — only for actual GitHub hosts. + if !is_github_host(host) { + return None; } - - None + crate::env::github_token() } // ---------------------------------------------------------------------------