Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 5 additions & 12 deletions src/cli/commands/doctor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down
158 changes: 156 additions & 2 deletions src/env.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,12 @@ pub fn jira_token(config_token: Option<&str>) -> Option<String> {
.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<String> {
for var in [PARSEC_GITHUB_TOKEN, GITHUB_TOKEN, GH_TOKEN] {
if let Ok(token) = std::env::var(var) {
Expand All @@ -56,7 +61,30 @@ pub fn github_token() -> Option<String> {
}
}
}
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<String> {
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
Expand Down Expand Up @@ -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<Mutex<()>> = 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<String>)>,
}
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");
}
}
}
}
28 changes: 19 additions & 9 deletions src/github/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -190,14 +190,25 @@ pub fn parse_github_remote(url: &str) -> Option<GitHubRemote> {
})
}

/// 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.<host>.token` — host-specific config
/// 2. `PARSEC_GITHUB_TOKEN` env var — explicit override
/// 3. `GITHUB_TOKEN` / `GH_TOKEN` — generic fallback
/// 1. `config.github.<host>.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<String> {
// 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() {
Expand All @@ -206,12 +217,11 @@ pub fn resolve_github_token(host: &str, config: &ParsecConfig) -> Option<String>
}
}

// 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()
}

// ---------------------------------------------------------------------------
Expand Down
Loading