worktrunk 0.50.0

A CLI for Git worktree management, designed for parallel AI agent workflows
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
//! CI status detection for GitHub, GitLab, Gitea, and Azure DevOps.
//!
//! This module provides CI status detection by querying GitHub PRs/workflows,
//! GitLab MRs/pipelines, Gitea PRs/commit-statuses, and Azure DevOps
//! PRs/pipelines using their respective CLI tools (`gh`, `glab`, `tea`, and
//! `az`).

mod azure;
mod cache;
mod gitea;
mod github;
mod gitlab;
mod platform;

use std::process::Output;

use anstyle::{AnsiColor, Color, Style};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize, de::DeserializeOwned};
use worktrunk::git::{BranchRef, Repository, parse_owner_repo};
use worktrunk::shell_exec::Cmd;
use worktrunk::utils::epoch_now;

/// A parsed branch name for CI status detection.
///
/// CI tools like `gh` and `glab` expect bare branch names (e.g., `"feature"`),
/// not remote-prefixed refs (e.g., `"origin/feature"`). This type holds
/// parsed branch components:
/// 1. `name` - bare branch name for CI tool API calls
/// 2. `remote` - remote name for URL lookups (if remote branch)
/// 3. `full_name` - original name for cache keys
#[derive(Debug, Clone)]
pub struct CiBranchName {
    /// The original full name (e.g., "origin/feature" or "feature")
    pub full_name: String,
    /// For remote branches: the remote name (e.g., "origin")
    pub remote: Option<String>,
    /// The bare branch name (e.g., "feature")
    pub name: String,
}

impl CiBranchName {
    /// Create from a [`BranchRef`], using its short name and remote/local kind.
    ///
    /// For remote branches (e.g., "origin/feature"), splits at the first `/`
    /// to extract the remote name and bare branch name.
    /// For local branches, the name is already bare.
    ///
    /// Returns `None` for detached HEAD (no short name).
    pub fn from_branch_ref(branch_ref: &BranchRef) -> Option<Self> {
        let short = branch_ref.short_name()?;
        if branch_ref.is_remote() {
            // Remote branch — split "origin/feature" into remote + bare name.
            if let Some((remote, name)) = short.split_once('/') {
                return Some(Self {
                    full_name: short.to_string(),
                    remote: Some(remote.to_string()),
                    name: name.to_string(),
                });
            }
        }
        // Local branch — name is already bare
        Some(Self {
            full_name: short.to_string(),
            remote: None,
            name: short.to_string(),
        })
    }

    /// Returns true if this is a remote branch reference.
    pub fn is_remote(&self) -> bool {
        self.remote.is_some()
    }

    /// Check if this branch has upstream (remote tracking) configured.
    ///
    /// Remote branches inherently "have upstream" since they ARE the upstream.
    /// Local branches need tracking config to have upstream.
    pub fn has_upstream(&self, repo: &Repository) -> bool {
        self.is_remote() || repo.branch(&self.name).upstream().ok().flatten().is_some()
    }
}

// Re-export public types
pub(crate) use cache::CachedCiStatus;

/// Maximum number of PRs/MRs to fetch when filtering by source repository.
///
/// We fetch multiple results because the same branch name may exist in
/// multiple forks. 20 should be sufficient for most cases.
///
/// # Limitation
///
/// If more than 20 PRs/MRs exist for the same branch name, we only search the
/// first page. This means in extremely busy repos with many forks, our PR/MR
/// could be on page 2+ and not be found. This is a trade-off: pagination would
/// require multiple API calls and slow down status detection. In practice, 20
/// is sufficient for most workflows.
const MAX_PRS_TO_FETCH: u8 = 20;

/// Create a Cmd configured for non-interactive batch execution.
///
/// This prevents tools like `gh` and `glab` from:
/// - Prompting for user input
/// - Using TTY-specific output formatting
/// - Opening browsers for authentication
fn non_interactive_cmd(program: &str) -> Cmd {
    Cmd::new(program)
        .env_remove("CLICOLOR_FORCE")
        .env_remove("GH_FORCE_TTY")
        .env("NO_COLOR", "1")
        .env("CLICOLOR", "0")
        .env("GH_PROMPT_DISABLED", "1")
}

/// Check if a CLI tool is available
///
/// On Windows, CreateProcessW (via Cmd) searches PATH for .exe files.
/// We provide .exe mocks in tests via mock-stub, so this works consistently.
fn tool_available(tool: &str, args: &[&str]) -> bool {
    Cmd::new(tool)
        .args(args.iter().copied())
        .run()
        .map(|o| o.status.success())
        .unwrap_or(false)
}

/// Parse JSON output from CLI tools
fn parse_json<T: DeserializeOwned>(stdout: &[u8], command: &str, branch: &str) -> Option<T> {
    serde_json::from_slice(stdout)
        .map_err(|e| log::warn!("Failed to parse {} JSON for {}: {}", command, branch, e))
        .ok()
}

/// Combine stderr and stdout for retriable-error sniffing.
///
/// Some CLIs (notably `tea`) report API errors as JSON on stdout while
/// transport errors land on stderr — checking both avoids missing retriable
/// errors when the tool routes them differently.
fn output_error_text(output: &Output) -> String {
    format!(
        "{}{}",
        String::from_utf8_lossy(&output.stderr),
        String::from_utf8_lossy(&output.stdout)
    )
}

/// If a non-success `Output` indicates a retriable failure, surface it as a
/// PR-status warning. Returns `None` for non-retriable failures so callers
/// can `?` it to fall through to "no CI status".
fn retriable_pr_error(output: &Output) -> Option<PrStatus> {
    is_retriable_error(&output_error_text(output)).then(PrStatus::error)
}

/// Resolve `(owner, repo)` for a branch's effective remote.
///
/// Thin wrapper over [`branch_remote_url`] + [`parse_owner_repo`]. The
/// platform was already chosen upstream by [`Repository::ci_platform`] —
/// either from explicit `forge.platform` config or from the URL host —
/// so backends don't re-filter here. Re-checking via the host heuristic
/// (`is_gitea` / `is_github`) would silently drop legitimate hosts that
/// rely on the explicit override (e.g. `codeberg.org` for Forgejo,
/// `git.mycompany.com` for self-hosted GHE).
fn branch_owner_repo(repo: &Repository, branch: &CiBranchName) -> Option<(String, String)> {
    parse_owner_repo(&branch_remote_url(repo, branch)?)
}

/// Resolve the effective URL for a branch's remote without parsing.
///
/// Resolution chain:
/// - Remote-branch refs (`origin/feature`) read from the branch's own
///   remote via [`Repository::effective_remote_url`] (honors
///   `url.insteadOf` rewrites).
/// - Local branches prefer the branch's push destination
///   (`branch.<n>.pushRemote` → `remote.pushDefault` → tracking remote),
///   falling back to the repo's primary remote so a tracking-less branch
///   still resolves.
///
/// Backends with their own URL parser (e.g. Azure DevOps' org/project shape)
/// compose this directly; backends that want `(owner, repo)` use
/// [`branch_owner_repo`].
fn branch_remote_url(repo: &Repository, branch: &CiBranchName) -> Option<String> {
    if let Some(remote_name) = &branch.remote {
        repo.effective_remote_url(remote_name)
    } else {
        repo.branch(&branch.name).push_remote_url().or_else(|| {
            let remote = repo.primary_remote().ok()?;
            repo.effective_remote_url(&remote)
        })
    }
}

/// Check if stderr indicates a retriable error (rate limit, network issues)
fn is_retriable_error(stderr: &str) -> bool {
    let lower = stderr.to_ascii_lowercase();
    [
        "rate limit",
        "api rate",
        "403",
        "429",
        "timeout",
        "connection",
        "network",
    ]
    .iter()
    .any(|p| lower.contains(p))
}

/// Status of CI tools availability
#[derive(Debug, Clone, Copy)]
pub struct CiToolsStatus {
    /// gh is installed (can run --version)
    pub gh_installed: bool,
    /// gh is installed and authenticated
    pub gh_authenticated: bool,
    /// glab is installed (can run --version)
    pub glab_installed: bool,
    /// glab is installed and authenticated
    pub glab_authenticated: bool,
    /// tea is installed (can run --version)
    pub tea_installed: bool,
    /// tea is installed and has a login configured
    pub tea_authenticated: bool,
    /// az is installed (can run --version)
    pub az_installed: bool,
    /// az is installed and authenticated (logged in)
    pub az_authenticated: bool,
}

impl CiToolsStatus {
    /// Check which CI tools are available
    ///
    /// If `gitlab_host` is provided, checks glab auth status against that specific
    /// host instead of the default. This is important for self-hosted GitLab instances
    /// where the default host (gitlab.com) may be unreachable.
    pub fn detect(gitlab_host: Option<&str>) -> Self {
        let gh_installed = tool_available("gh", &["--version"]);
        let gh_authenticated = gh_installed && tool_available("gh", &["auth", "status"]);
        let glab_installed = tool_available("glab", &["--version"]);
        let glab_authenticated = glab_installed
            && if let Some(host) = gitlab_host {
                tool_available("glab", &["auth", "status", "--hostname", host])
            } else {
                tool_available("glab", &["auth", "status"])
            };
        let tea_installed = tool_available("tea", &["--version"]);
        // `tea` stores logins in its config file; reading it (rather than
        // invoking `tea`) avoids the OAuth-refresh side effect a `tea` lookup
        // can trigger. See `git::remote_ref::gitea`.
        let tea_authenticated = tea_installed && worktrunk::git::remote_ref::gitea::has_any_login();
        let az_installed = tool_available("az", &["--version"]);
        // `az account show` exits non-zero when logged out — works whether or not
        // the azure-devops extension is installed.
        let az_authenticated = az_installed && tool_available("az", &["account", "show"]);
        Self {
            gh_installed,
            gh_authenticated,
            glab_installed,
            glab_authenticated,
            tea_installed,
            tea_authenticated,
            az_installed,
            az_authenticated,
        }
    }
}

/// CI status from GitHub/GitLab checks
/// Matches the statusline.sh color scheme:
/// - Passed: Green (all checks passed)
/// - Running: Blue (checks in progress)
/// - Failed: Red (checks failed)
/// - Conflicts: Yellow (merge conflicts)
/// - NoCI: Gray (no PR/checks)
/// - Error: Yellow (CI fetch failed, e.g., rate limit)
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, strum::IntoStaticStr)]
#[serde(rename_all = "kebab-case")]
#[strum(serialize_all = "kebab-case")]
pub enum CiStatus {
    Passed,
    Running,
    Failed,
    Conflicts,
    NoCI,
    /// CI status could not be fetched (rate limit, network error, etc.)
    Error,
}

/// Source of CI status (PR/MR vs branch workflow)
///
/// Serialized to JSON as "pr" or "branch" for programmatic consumers.
#[derive(
    Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, strum::IntoStaticStr, JsonSchema,
)]
#[strum(serialize_all = "kebab-case")]
pub enum CiSource {
    /// Pull request or merge request
    #[serde(rename = "pr", alias = "pull-request")]
    PullRequest,
    /// Branch workflow/pipeline (no PR/MR)
    #[serde(rename = "branch")]
    Branch,
}

/// CI status from PR/MR or branch workflow
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PrStatus {
    pub ci_status: CiStatus,
    /// Source of the CI status (PR/MR or branch workflow)
    pub source: CiSource,
    /// True if local HEAD differs from remote HEAD (unpushed changes)
    pub is_stale: bool,
    /// URL to the PR/MR (if available)
    #[serde(skip_serializing_if = "Option::is_none")]
    pub url: Option<String>,
}

impl CiStatus {
    /// Get the ANSI color for this CI status.
    ///
    /// - Passed: Green
    /// - Running: Blue
    /// - Failed: Red
    /// - Conflicts: Yellow
    /// - NoCI: BrightBlack (dimmed)
    /// - Error: Yellow (warning color)
    pub fn color(&self) -> AnsiColor {
        match self {
            Self::Passed => AnsiColor::Green,
            Self::Running => AnsiColor::Blue,
            Self::Failed => AnsiColor::Red,
            Self::Conflicts | Self::Error => AnsiColor::Yellow,
            Self::NoCI => AnsiColor::BrightBlack,
        }
    }
}

impl PrStatus {
    /// Get the style for this PR status (color + optional dimming for stale)
    pub fn style(&self) -> Style {
        let style = Style::new().fg_color(Some(Color::Ansi(self.ci_status.color())));
        if self.is_stale { style.dimmed() } else { style }
    }

    /// Get the indicator symbol for this status
    ///
    /// - Error: ⚠ (warning indicator)
    /// - All others: ● (filled circle)
    pub fn indicator(&self) -> &'static str {
        if matches!(self.ci_status, CiStatus::Error) {
            ""
        } else {
            ""
        }
    }

    /// Format CI status with control over link inclusion.
    ///
    /// When `include_link` is false, the indicator is colored but not clickable.
    /// Used for environments that don't support OSC 8 hyperlinks (e.g., Claude Code).
    pub fn format_indicator(&self, include_link: bool) -> String {
        let indicator = self.indicator();
        if let (true, Some(url)) = (include_link, &self.url) {
            let style = self.style().underline();
            format!(
                "{}{}{}{}{}",
                style,
                osc8::Hyperlink::new(url),
                indicator,
                osc8::Hyperlink::END,
                style.render_reset()
            )
        } else {
            let style = self.style();
            format!("{style}{indicator}{style:#}")
        }
    }

    /// Create an error status for retriable failures (rate limit, network errors)
    fn error() -> Self {
        Self {
            ci_status: CiStatus::Error,
            source: CiSource::Branch,
            is_stale: false,
            url: None,
        }
    }

    /// Detect CI status for a branch using the forge CLI (`gh`/`glab`/`tea`/`az`)
    /// First tries to find PR/MR status, then falls back to workflow/pipeline runs
    /// Returns None if no CI found or CLI tools unavailable
    ///
    /// # Caching
    /// Results (including None) are cached in `.git/wt/cache/ci-status/<branch>.json`
    /// for 30-60 seconds to avoid hitting GitHub API rate limits. TTL uses deterministic jitter
    /// based on repo path to spread cache expirations across concurrent statuslines. Invalidated
    /// when HEAD changes.
    ///
    /// # Fork Support
    /// Runs gh commands from the repository directory to enable auto-detection of
    /// upstream repositories for forks. This ensures PRs opened against upstream
    /// repos are properly detected.
    ///
    /// # Arguments
    /// * `branch` - The parsed branch name (may be local or remote).
    /// * `local_head` - The commit SHA to check CI status for.
    pub fn detect(repo: &Repository, branch: &CiBranchName, local_head: &str) -> Option<Self> {
        let has_upstream = branch.has_upstream(repo);
        let repo_path = repo.current_worktree().root().ok()?;

        // Check cache first to avoid hitting API rate limits
        // Use full_name as cache key to distinguish local "feature" from remote "origin/feature"
        let now_secs = epoch_now();

        if let Some(cached) = CachedCiStatus::read(repo, &branch.full_name) {
            if cached.is_valid(local_head, now_secs, &repo_path) {
                log::debug!(
                    "Using cached CI status for {} (age={}s, ttl={}s, status={:?})",
                    branch.full_name,
                    now_secs - cached.checked_at,
                    CachedCiStatus::ttl_for_repo(&repo_path),
                    cached.status.as_ref().map(|s| &s.ci_status)
                );
                return cached.status;
            }
            log::debug!(
                "Cache expired for {} (age={}s, ttl={}s, head_match={})",
                branch.full_name,
                now_secs - cached.checked_at,
                CachedCiStatus::ttl_for_repo(&repo_path),
                cached.head == local_head
            );
        }

        // Cache miss or expired - fetch fresh status
        let status = Self::detect_uncached(repo, branch, local_head, has_upstream);

        // Cache the result (including None - means no CI found for this branch)
        let cached = CachedCiStatus {
            status: status.clone(),
            checked_at: now_secs,
            head: local_head.to_string(),
            branch: branch.full_name.clone(),
        };
        cached.write(repo, &branch.full_name);

        status
    }

    /// Detect CI status without caching (internal implementation)
    ///
    /// Platform is determined from project config (`forge.platform`), falling
    /// back to the remote URL host. Returns `None` if the platform cannot be
    /// determined (user should set `forge.platform` for non-standard hostnames).
    /// PR/MR detection always runs. Workflow/pipeline fallback only runs if `has_upstream`.
    fn detect_uncached(
        repo: &Repository,
        branch: &CiBranchName,
        local_head: &str,
        has_upstream: bool,
    ) -> Option<Self> {
        // Determine platform (project config, branch's remote, or primary remote URL).
        match repo.ci_platform(branch.remote.as_deref()) {
            Some(p) => platform::detect_ci(p, repo, branch, local_head, has_upstream),
            None => {
                // Unknown platform — user should set forge.platform in project config
                log::debug!(
                    "Could not detect CI platform from remote URL; \
                     set forge.platform in .config/wt.toml for CI status"
                );
                None
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_is_retriable_error() {
        // Rate limit errors
        assert!(is_retriable_error("API rate limit exceeded"));
        assert!(is_retriable_error("rate limit exceeded for requests"));
        assert!(is_retriable_error("Error 403: forbidden"));
        assert!(is_retriable_error("HTTP 429 Too Many Requests"));

        // Network errors
        assert!(is_retriable_error("connection timed out"));
        assert!(is_retriable_error("network error"));
        assert!(is_retriable_error("timeout waiting for response"));

        // Case insensitivity
        assert!(is_retriable_error("RATE LIMIT"));
        assert!(is_retriable_error("Connection Reset"));

        // Non-retriable errors
        assert!(!is_retriable_error("branch not found"));
        assert!(!is_retriable_error("invalid credentials"));
        assert!(!is_retriable_error("permission denied"));
        assert!(!is_retriable_error(""));
    }

    #[test]
    fn test_ci_status_color() {
        use anstyle::AnsiColor;

        assert_eq!(CiStatus::Passed.color(), AnsiColor::Green);
        assert_eq!(CiStatus::Running.color(), AnsiColor::Blue);
        assert_eq!(CiStatus::Failed.color(), AnsiColor::Red);
        assert_eq!(CiStatus::Conflicts.color(), AnsiColor::Yellow);
        assert_eq!(CiStatus::Error.color(), AnsiColor::Yellow);
        assert_eq!(CiStatus::NoCI.color(), AnsiColor::BrightBlack);
    }

    #[test]
    fn test_pr_status_indicator() {
        let pr_passed = PrStatus {
            ci_status: CiStatus::Passed,
            source: CiSource::PullRequest,
            is_stale: false,
            url: None,
        };
        assert_eq!(pr_passed.indicator(), "");

        let branch_running = PrStatus {
            ci_status: CiStatus::Running,
            source: CiSource::Branch,
            is_stale: false,
            url: None,
        };
        assert_eq!(branch_running.indicator(), "");

        let error_status = PrStatus {
            ci_status: CiStatus::Error,
            source: CiSource::PullRequest,
            is_stale: false,
            url: None,
        };
        assert_eq!(error_status.indicator(), "");
    }

    #[test]
    fn test_format_indicator() {
        use insta::assert_snapshot;

        let with_url = PrStatus {
            ci_status: CiStatus::Passed,
            source: CiSource::PullRequest,
            is_stale: false,
            url: Some("https://github.com/owner/repo/pull/123".to_string()),
        };
        let no_url = PrStatus {
            ci_status: CiStatus::Passed,
            source: CiSource::PullRequest,
            is_stale: false,
            url: None,
        };

        // With URL + include_link=true → has OSC 8 hyperlink
        assert_snapshot!(with_url.format_indicator(true), @r"]8;;https://github.com/owner/repo/pull/123\●]8;;\");
        // With URL + include_link=false → no OSC 8
        assert_snapshot!(with_url.format_indicator(false), @"●");
        // No URL + include_link=true → no OSC 8
        assert_snapshot!(no_url.format_indicator(true), @"●");
    }

    #[test]
    fn test_pr_status_error_constructor() {
        let error = PrStatus::error();
        assert_eq!(error.ci_status, CiStatus::Error);
        assert_eq!(error.source, CiSource::Branch);
        assert!(!error.is_stale);
        assert!(error.url.is_none());
    }

    #[test]
    fn test_ci_branch_name_from_local_branch_ref() {
        let branch_ref = BranchRef::local_branch("feature", "abc123");
        let ci = CiBranchName::from_branch_ref(&branch_ref).expect("local has short_name");
        assert_eq!(ci.full_name, "feature");
        assert_eq!(ci.name, "feature");
        assert_eq!(ci.remote, None);
        assert!(!ci.is_remote());
    }

    #[test]
    fn test_ci_branch_name_from_remote_branch_ref() {
        let branch_ref = BranchRef::remote_branch("origin/feature", "abc123");
        let ci = CiBranchName::from_branch_ref(&branch_ref).expect("remote has short_name");
        assert_eq!(ci.full_name, "origin/feature");
        assert_eq!(ci.name, "feature");
        assert_eq!(ci.remote.as_deref(), Some("origin"));
        assert!(ci.is_remote());
    }

    #[test]
    fn test_ci_branch_name_from_detached_head() {
        let detached = BranchRef {
            full_ref: None,
            commit_sha: "abc123".to_string(),
            worktree_path: None,
        };
        assert!(CiBranchName::from_branch_ref(&detached).is_none());
    }

    #[test]
    fn test_pr_status_style() {
        // Stale status gets dimmed
        let stale = PrStatus {
            ci_status: CiStatus::Running,
            source: CiSource::Branch,
            is_stale: true,
            url: None,
        };
        let style = stale.style();
        // Just verify it doesn't panic and returns a style
        let _ = format!("{style}test{style:#}");
    }

    /// Build a synthetic non-success `Output` with the given stderr/stdout
    /// bodies — `Command::output()` is the only "real" constructor and
    /// would require spawning a process. Status uses `ExitStatus::default()`
    /// (success), but the retriable-error helpers ignore status and only
    /// look at the bytes.
    fn fake_output(stderr: &str, stdout: &str) -> Output {
        Output {
            status: Default::default(),
            stdout: stdout.as_bytes().to_vec(),
            stderr: stderr.as_bytes().to_vec(),
        }
    }

    /// `output_error_text` must combine both streams — `tea` routes API
    /// errors to stdout while transport errors land on stderr, so a
    /// stderr-only sniff would miss rate-limit messages from the JSON body.
    #[test]
    fn test_output_error_text_combines_streams() {
        let out = fake_output("transport: connection reset", r#"{"message":"rate limit"}"#);
        let text = output_error_text(&out);
        assert!(text.contains("transport: connection reset"));
        assert!(text.contains("rate limit"));
    }

    /// `retriable_pr_error` returns `Some(PrStatus::error())` when either
    /// stream contains a retriable marker, and `None` otherwise — the
    /// fall-through case where `?` propagates "no CI status".
    #[test]
    fn test_retriable_pr_error_routing() {
        // Retriable from stderr.
        let out = fake_output("HTTP 429 Too Many Requests", "");
        let status = retriable_pr_error(&out).expect("retriable should yield Some");
        assert_eq!(status.ci_status, CiStatus::Error);

        // Retriable from stdout (the `tea` shape).
        let out = fake_output("", r#"{"message":"rate limit exceeded"}"#);
        assert!(retriable_pr_error(&out).is_some());

        // Non-retriable failure → None, so caller's `?` falls through.
        let out = fake_output("not found", "");
        assert!(retriable_pr_error(&out).is_none());

        // No body at all → None.
        let out = fake_output("", "");
        assert!(retriable_pr_error(&out).is_none());
    }
}