Skip to main content

git_worktree_manager/operations/
pr_cache.rs

1//! Batched PR-status cache for `gw list`.
2//!
3//! Calls `gh pr list` once per `gw` invocation (instead of `gh pr view` per
4//! worktree) and persists the result under
5//! `~/.cache/gw/pr-status-<repo-hash>.json` with a 60-second TTL. On any
6//! failure (gh missing, disk error, corrupt file), `PrCache::load_or_fetch`
7//! returns an empty cache so callers fall back to `git branch --merged`.
8
9use std::collections::HashMap;
10use std::path::{Path, PathBuf};
11use std::sync::atomic::{AtomicU64, Ordering};
12#[cfg(not(test))]
13use std::sync::OnceLock;
14use std::time::{SystemTime, UNIX_EPOCH};
15
16/// Per-process counter appended to tmp filenames for nano-collision safety
17/// when two threads or processes write the same repo cache within the same nanosecond.
18static TMP_COUNTER: AtomicU64 = AtomicU64::new(0);
19
20/// Ensures the orphan-sweep runs at most once per process. Running it on every
21/// write would add a `read_dir` syscall to every cache update; once per process
22/// is sufficient because tmp files from prior runs are already old enough to sweep
23/// and new ones created within this run will be renamed away or cleaned up inline.
24#[cfg(not(test))]
25static SWEEP_DONE: OnceLock<()> = OnceLock::new();
26
27use serde::{Deserialize, Serialize};
28use sha2::{Digest, Sha256};
29
30/// 60-second TTL — balances freshness against gh rate limits.
31const CACHE_TTL_SECS: u64 = 60;
32
33/// Cap on PRs fetched per `gh pr list` call. Repos with more PRs will see the
34/// oldest fall back to git-only merge detection.
35///
36/// If `prs.len() == GH_FETCH_LIMIT` we may be missing older entries; consider
37/// paginating in a follow-up.
38const GH_FETCH_LIMIT: usize = 500;
39
40/// Typed PR state as returned by `gh pr list`.
41///
42/// The `#[serde(other)]` variant catches any future states GitHub may add
43/// without breaking deserialization.
44#[non_exhaustive]
45#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
46#[serde(rename_all = "UPPERCASE")]
47pub enum PrState {
48    Open,
49    Merged,
50    Closed,
51    #[serde(other)]
52    Other,
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize)]
56struct CacheFile {
57    fetched_at: u64,
58    repo: String,
59    prs: HashMap<String, PrState>,
60}
61
62#[derive(Debug, Default, Clone)]
63pub struct PrCache {
64    map: HashMap<String, PrState>,
65}
66
67impl PrCache {
68    /// Return the PR state for `branch`, if known.
69    ///
70    /// `branch` must be in the same form that `gh pr list` returns for
71    /// `headRefName` — i.e. **without** a `refs/heads/` prefix. Callers in
72    /// `display.rs` pass `branch_name` which comes from
73    /// `git::normalize_branch_name`, which strips `refs/heads/` so the form
74    /// matches `gh`'s output.
75    pub fn state(&self, branch: &str) -> Option<&PrState> {
76        self.map.get(branch)
77    }
78
79    /// Try loading a fresh cache entry from disk. Returns `None` if the file
80    /// is missing, expired, corrupt, or in the future (clock skew guard).
81    pub fn from_disk(repo: &Path) -> Option<Self> {
82        load_from_disk(repo).map(|map| PrCache { map })
83    }
84
85    /// Fetch PR state via `gh pr list` and persist to disk. Returns an empty
86    /// cache on any failure so callers' fallback path still works.
87    ///
88    /// On failure, the on-disk cache (if any) is left untouched — only a
89    /// successful fetch triggers a write.
90    pub fn fetch_and_persist(repo: &Path) -> Self {
91        match fetch_from_gh(repo) {
92            Some(map) => {
93                write_to_disk(repo, &map);
94                PrCache { map }
95            }
96            None => PrCache::default(),
97        }
98    }
99
100    /// Load from disk if fresh (and `no_cache` is false), else fetch via
101    /// `gh pr list` and persist. Returns an empty cache on any failure so
102    /// the caller's fallback path still works.
103    ///
104    /// When `no_cache=true` and `gh` is down, the previous on-disk cache is
105    /// preserved but not consulted; the next non-bypass call may serve stale
106    /// data until `gh` recovers.
107    pub fn load_or_fetch(repo: &Path, no_cache: bool) -> Self {
108        if !no_cache {
109            if let Some(c) = Self::from_disk(repo) {
110                return c;
111            }
112        }
113        Self::fetch_and_persist(repo)
114    }
115}
116
117/// Compute a stable short hash for a repository path.
118/// Canonicalizes so `/foo/../foo` hashes the same as `/foo`.
119///
120/// If canonicalization fails (transient FS issue), fall back to the raw path.
121/// Caches keyed on raw vs canonical paths will be different but self-consistent.
122///
123/// 16 hex chars / 64 bits — collision-free in practice for per-user repo counts.
124fn repo_hash(repo: &Path) -> String {
125    let canon = repo.canonicalize().unwrap_or_else(|_| repo.to_path_buf());
126    let mut hasher = Sha256::new();
127    hasher.update(canon.to_string_lossy().as_bytes());
128    let digest = hasher.finalize();
129    hex_short(&digest[..8])
130}
131
132fn hex_short(bytes: &[u8]) -> String {
133    use std::fmt::Write;
134    let mut out = String::with_capacity(bytes.len() * 2);
135    for b in bytes {
136        let _ = write!(out, "{:02x}", b);
137    }
138    out
139}
140
141/// Return the on-disk cache path for a given repo.
142/// Returns None if we cannot determine a cache directory on this platform.
143fn cache_path_for(repo: &Path) -> Option<PathBuf> {
144    #[cfg(test)]
145    if let Ok(dir) = std::env::var("GW_TEST_CACHE_DIR") {
146        return Some(
147            PathBuf::from(dir)
148                .join("gw")
149                .join(format!("pr-status-{}.json", repo_hash(repo))),
150        );
151    }
152
153    let base = dirs::cache_dir()?.join("gw");
154    Some(base.join(format!("pr-status-{}.json", repo_hash(repo))))
155}
156
157#[derive(Debug, Deserialize)]
158struct GhPr {
159    #[serde(rename = "headRefName")]
160    head_ref_name: String,
161    state: PrState,
162}
163
164/// Run `gh pr list --state all --json headRefName,state --limit N` and parse.
165/// Returns None on any failure (gh missing, non-zero exit, JSON parse error).
166///
167/// Parse failure swallows the error per spec's silent-fallback contract.
168fn fetch_from_gh(repo: &Path) -> Option<HashMap<String, PrState>> {
169    #[cfg(test)]
170    {
171        // #37: GW_TEST_GH_FAIL takes precedence over GW_TEST_GH_JSON — a test
172        // that sets both will always get None, never the JSON payload.
173        if std::env::var("GW_TEST_GH_FAIL").ok().as_deref() == Some("1") {
174            return None;
175        }
176        if let Ok(json) = std::env::var("GW_TEST_GH_JSON") {
177            let prs: Vec<GhPr> = serde_json::from_str(json.trim()).ok()?;
178            let mut map = HashMap::with_capacity(prs.len());
179            for pr in prs {
180                map.insert(pr.head_ref_name, pr.state);
181            }
182            return Some(map);
183        }
184    }
185
186    if !crate::git::has_command("gh") {
187        return None;
188    }
189    let limit = GH_FETCH_LIMIT.to_string();
190    let result = crate::git::run_command(
191        &[
192            "gh",
193            "pr",
194            "list",
195            "--state",
196            "all",
197            "--json",
198            "headRefName,state",
199            "--limit",
200            &limit,
201        ],
202        Some(repo),
203        false,
204        true,
205    )
206    .ok()?;
207    if result.returncode != 0 {
208        return None;
209    }
210
211    let prs: Vec<GhPr> = serde_json::from_str(result.stdout.trim()).ok()?;
212    let mut map = HashMap::with_capacity(prs.len());
213    for pr in prs {
214        map.insert(pr.head_ref_name, pr.state);
215    }
216    Some(map)
217}
218
219/// Returns the current Unix timestamp in seconds, or `None` if the system
220/// clock is broken (pre-epoch or overflow). `None` means: skip caching
221/// entirely — broken clock = no TTL we can trust.
222fn now_secs() -> Option<u64> {
223    SystemTime::now()
224        .duration_since(UNIX_EPOCH)
225        .ok()
226        .map(|d| d.as_secs())
227}
228
229/// Read cache file if it exists and is still within TTL. Any error → None.
230/// Returns None if `now_secs()` is None (broken clock — safer to refetch).
231fn load_from_disk(repo: &Path) -> Option<HashMap<String, PrState>> {
232    let path = cache_path_for(repo)?;
233    let data = std::fs::read_to_string(&path).ok()?;
234    let file: CacheFile = serde_json::from_str(&data).ok()?;
235    // broken clock (None) → refuse to serve possibly-stale cache
236    let now = now_secs()?;
237    // Reject entries from the future (clock skew guard).
238    if file.fetched_at > now {
239        return None;
240    }
241    let age = now.saturating_sub(file.fetched_at);
242    if age > CACHE_TTL_SECS {
243        return None;
244    }
245    Some(file.prs)
246}
247
248/// Remove orphaned `.tmp.` files from `parent` that are older than `cutoff`.
249///
250/// Orphans accumulate when a prior `gw` process crashed between the `fs::write`
251/// and `fs::rename` steps. Best-effort: any I/O error is silently ignored.
252fn sweep_orphans(parent: &Path, cutoff: SystemTime) {
253    let Ok(entries) = std::fs::read_dir(parent) else {
254        return;
255    };
256    for entry in entries.flatten() {
257        let name = entry.file_name();
258        let name_str = name.to_string_lossy();
259        if !(name_str.starts_with("pr-status-") && name_str.contains(".tmp.")) {
260            continue;
261        }
262        let Ok(meta) = entry.metadata() else {
263            continue;
264        };
265        let Ok(modified) = meta.modified() else {
266            continue;
267        };
268        if modified < cutoff {
269            let _ = std::fs::remove_file(entry.path());
270        }
271    }
272}
273
274/// Best-effort write. Failures are silently ignored — the in-memory result is
275/// still returned to the caller.
276///
277/// On failure, the on-disk cache (if any) is left untouched.
278///
279/// #14/#23: `prs` is borrowed (&HashMap) and cloned into `CacheFile`. Taking
280/// ownership would require `fetch_and_persist` to pass the map by value,
281/// complicating the return-value path for no meaningful perf gain at typical
282/// worktree counts (~10s of PRs). Kept as a borrow for clarity.
283fn write_to_disk(repo: &Path, prs: &HashMap<String, PrState>) {
284    let Some(path) = cache_path_for(repo) else {
285        return;
286    };
287    if let Some(parent) = path.parent() {
288        let _ = std::fs::create_dir_all(parent);
289    }
290    // #10: compute secs and nanos from a single SystemTime::now() call so
291    // both share the same instant — avoids a second clock call for nanos.
292    // #13: broken clock means we cannot set a meaningful fetched_at timestamp
293    // — skip persistence entirely; in-memory result is still returned to the
294    // caller via fetch_and_persist.
295    let now_instant = SystemTime::now();
296    let dur = match now_instant.duration_since(UNIX_EPOCH) {
297        Ok(d) => d,
298        Err(_) => return, // broken clock
299    };
300    let now = dur.as_secs();
301    let nanos = dur.subsec_nanos();
302
303    let file = CacheFile {
304        fetched_at: now,
305        repo: repo.to_string_lossy().into_owned(),
306        prs: prs.clone(),
307    };
308    let Ok(json) = serde_json::to_string(&file) else {
309        return;
310    };
311
312    // Sweep orphans from prior failed runs (older than 60s) so the cache dir
313    // doesn't accumulate cruft. Best-effort: any error is silently ignored.
314    // Gated on SWEEP_DONE so the read_dir syscall runs at most once per process —
315    // orphans from prior runs are already old; ones from this run are handled inline.
316    // In test builds the gate is skipped so each test gets a deterministic sweep.
317    #[cfg(not(test))]
318    let do_sweep = SWEEP_DONE.set(()).is_ok();
319    #[cfg(test)]
320    let do_sweep = true;
321    if do_sweep {
322        if let Some(parent) = path.parent() {
323            // On systems with a clock < 60s past epoch, this collapses to "no orphans
324            // older than `now`", effectively skipping the sweep — acceptable degenerate.
325            let cutoff = SystemTime::now()
326                .checked_sub(std::time::Duration::from_secs(60))
327                .unwrap_or_else(SystemTime::now);
328            sweep_orphans(parent, cutoff);
329        }
330    }
331
332    // Atomic write: write to <path>.tmp.<pid>.<nanos>.<counter>, then rename.
333    // Using pid + nanoseconds + per-process counter avoids collisions when:
334    //   - multiple gw processes write concurrently (different pid), or
335    //   - two writes happen within the same nanosecond (counter breaks the tie).
336    // On Windows, std::fs::rename fails if the target exists; we retry with a
337    // remove-then-rename fallback (best-effort, second failure is silently ignored).
338    // #11/#8: use file_stem to strip .json before the tmp suffix, giving
339    // "pr-status-<hash>.tmp.PID.NANOS.COUNTER" — shorter and groups cleanly
340    // with the final file. Using file_name would produce "pr-status-<hash>.json.tmp…".
341    let counter = TMP_COUNTER.fetch_add(1, Ordering::Relaxed);
342    let tmp = path.with_file_name(format!(
343        "{}.tmp.{}.{}.{}",
344        path.file_stem().unwrap_or_default().to_string_lossy(),
345        std::process::id(),
346        nanos,
347        counter,
348    ));
349    // #15/#34: clean up the tmp file on initial write failure. `remove_file`
350    // is best-effort — if write never created the file (e.g. permission error
351    // before any bytes were written) the ENOENT is silently ignored via `.ok()`.
352    if std::fs::write(&tmp, &json).is_err() {
353        let _ = std::fs::remove_file(&tmp); // ENOENT is harmless here
354        return;
355    }
356    if std::fs::rename(&tmp, &path).is_err() {
357        // #12: Windows fallback: target may already exist; best-effort remove then
358        // retry. If the second rename also fails, remove the orphaned tmp file.
359        // Best-effort: another process can race the rename and leave an orphan tmp
360        // file, but those will be reaped on the next successful write.
361        let _ = std::fs::remove_file(&path);
362        if std::fs::rename(&tmp, &path).is_err() {
363            let _ = std::fs::remove_file(&tmp); // cleanup orphan
364        }
365    }
366}
367
368#[cfg(test)]
369mod tests {
370    use super::*;
371    use std::path::PathBuf;
372    use std::sync::{Mutex, MutexGuard};
373
374    // Tests mutate process-global env vars; the mutex serializes them to avoid
375    // races. Production code does not consult these vars (see #[cfg(test)]
376    // gates above).
377    static ENV_LOCK: Mutex<()> = Mutex::new(());
378
379    /// Serializes env-var mutations across tests. Tests pair this with EnvGuard
380    /// for panic-safe restoration.
381    fn env_lock() -> MutexGuard<'static, ()> {
382        ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner())
383    }
384
385    /// Sanity-check that now_secs() works on a normal system.
386    // Note: the `None` branch (broken clock) is not exercised by tests; it requires
387    // time manipulation. Coverage gap accepted.
388    #[test]
389    fn now_secs_returns_some_on_normal_system() {
390        assert!(now_secs().is_some());
391    }
392
393    #[test]
394    fn repo_hash_is_stable_and_short() {
395        let p = PathBuf::from("/tmp/some-repo-that-does-not-exist-xyz");
396        let h1 = repo_hash(&p);
397        let h2 = repo_hash(&p);
398        assert_eq!(h1, h2);
399        assert_eq!(h1.len(), 16);
400    }
401
402    #[test]
403    fn repo_hash_differs_per_path() {
404        let a = repo_hash(&PathBuf::from("/tmp/repo-a-xyz"));
405        let b = repo_hash(&PathBuf::from("/tmp/repo-b-xyz"));
406        assert_ne!(a, b);
407    }
408
409    #[test]
410    fn cache_path_contains_repo_hash() {
411        let p = PathBuf::from("/tmp/repo-xyz");
412        let cp = cache_path_for(&p).expect("cache dir available");
413        let s = cp.to_string_lossy();
414        assert!(s.contains("gw"));
415        assert!(s.contains("pr-status-"));
416        assert!(s.ends_with(".json"));
417    }
418
419    #[test]
420    fn fetch_parses_gh_json_from_env() {
421        let _g = env_lock();
422        let _env = EnvGuard::capture(&["GW_TEST_GH_FAIL", "GW_TEST_GH_JSON"]);
423        std::env::set_var(
424            "GW_TEST_GH_JSON",
425            r#"[{"headRefName":"feat/foo","state":"OPEN"},{"headRefName":"fix/bar","state":"MERGED"}]"#,
426        );
427        let prs = fetch_from_gh(std::path::Path::new(".")).expect("parsed");
428        assert_eq!(prs.get("feat/foo"), Some(&PrState::Open));
429        assert_eq!(prs.get("fix/bar"), Some(&PrState::Merged));
430    }
431
432    #[test]
433    fn fetch_returns_none_on_forced_failure() {
434        let _g = env_lock();
435        let _env = EnvGuard::capture(&["GW_TEST_GH_FAIL", "GW_TEST_GH_JSON"]);
436        std::env::set_var("GW_TEST_GH_FAIL", "1");
437        let result = fetch_from_gh(std::path::Path::new("."));
438        assert!(result.is_none());
439    }
440
441    use tempfile::tempdir;
442
443    // #16: generic env-var save/restore guard. Captures the current values of
444    // the given keys and restores them on drop — panic-safe. Handles
445    // GW_TEST_CACHE_DIR, GW_TEST_GH_FAIL, GW_TEST_GH_JSON and any future vars.
446    struct EnvGuard {
447        saved: Vec<(&'static str, Option<std::ffi::OsString>)>,
448    }
449
450    impl EnvGuard {
451        fn capture(keys: &[&'static str]) -> Self {
452            let saved = keys.iter().map(|k| (*k, std::env::var_os(k))).collect();
453            Self { saved }
454        }
455    }
456
457    impl Drop for EnvGuard {
458        fn drop(&mut self) {
459            for (k, v) in self.saved.drain(..) {
460                match v {
461                    Some(val) => std::env::set_var(k, val),
462                    None => std::env::remove_var(k),
463                }
464            }
465        }
466    }
467
468    /// Set `GW_TEST_CACHE_DIR` for the duration of `f`. Restores the previous
469    /// value (or removes the var) via an `EnvGuard`, so the env is cleaned up
470    /// even if `f` panics.
471    fn with_cache_dir<F: FnOnce()>(dir: &std::path::Path, f: F) {
472        let _g = EnvGuard::capture(&["GW_TEST_CACHE_DIR"]);
473        std::env::set_var("GW_TEST_CACHE_DIR", dir);
474        f();
475    }
476
477    #[test]
478    fn load_from_disk_returns_fresh_entry() {
479        let _g = env_lock();
480        let dir = tempdir().unwrap();
481        with_cache_dir(dir.path(), || {
482            let repo = std::path::Path::new("/tmp/repo-xyz");
483            let path = cache_path_for(repo).unwrap();
484            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
485            let now = SystemTime::now()
486                .duration_since(UNIX_EPOCH)
487                .unwrap()
488                .as_secs();
489            let file = CacheFile {
490                fetched_at: now,
491                repo: repo.to_string_lossy().into_owned(),
492                prs: [("feat/a".to_string(), PrState::Open)]
493                    .into_iter()
494                    .collect(),
495            };
496            std::fs::write(&path, serde_json::to_string(&file).unwrap()).unwrap();
497
498            let loaded = load_from_disk(repo).expect("fresh cache");
499            assert_eq!(loaded.get("feat/a"), Some(&PrState::Open));
500        });
501    }
502
503    #[test]
504    fn load_from_disk_rejects_expired_entry() {
505        let _g = env_lock();
506        let dir = tempdir().unwrap();
507        with_cache_dir(dir.path(), || {
508            let repo = std::path::Path::new("/tmp/repo-expired-xyz");
509            let path = cache_path_for(repo).unwrap();
510            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
511            let file = CacheFile {
512                fetched_at: 0, // ancient
513                repo: repo.to_string_lossy().into_owned(),
514                prs: HashMap::new(),
515            };
516            std::fs::write(&path, serde_json::to_string(&file).unwrap()).unwrap();
517
518            assert!(load_from_disk(repo).is_none());
519        });
520    }
521
522    #[test]
523    fn load_from_disk_rejects_future_entry() {
524        let _g = env_lock();
525        let dir = tempdir().unwrap();
526        with_cache_dir(dir.path(), || {
527            let repo = std::path::Path::new("/tmp/repo-future-xyz");
528            let path = cache_path_for(repo).unwrap();
529            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
530            let far_future = now_secs().unwrap() + 9999;
531            let file = CacheFile {
532                fetched_at: far_future,
533                repo: repo.to_string_lossy().into_owned(),
534                prs: HashMap::new(),
535            };
536            std::fs::write(&path, serde_json::to_string(&file).unwrap()).unwrap();
537
538            assert!(load_from_disk(repo).is_none());
539        });
540    }
541
542    #[test]
543    fn load_from_disk_rejects_corrupt_file() {
544        let _g = env_lock();
545        let dir = tempdir().unwrap();
546        with_cache_dir(dir.path(), || {
547            let repo = std::path::Path::new("/tmp/repo-corrupt-xyz");
548            let path = cache_path_for(repo).unwrap();
549            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
550            std::fs::write(&path, "not json").unwrap();
551
552            assert!(load_from_disk(repo).is_none());
553        });
554    }
555
556    #[test]
557    fn load_or_fetch_uses_disk_when_fresh() {
558        let _g = env_lock();
559        let _env = EnvGuard::capture(&["GW_TEST_CACHE_DIR", "GW_TEST_GH_FAIL", "GW_TEST_GH_JSON"]);
560        let dir = tempdir().unwrap();
561        with_cache_dir(dir.path(), || {
562            let repo = std::path::Path::new("/tmp/repo-disk-hit-xyz");
563            let path = cache_path_for(repo).unwrap();
564            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
565            let file = CacheFile {
566                fetched_at: now_secs().unwrap(),
567                repo: repo.to_string_lossy().into_owned(),
568                prs: [("feat/cached".to_string(), PrState::Merged)]
569                    .into_iter()
570                    .collect(),
571            };
572            std::fs::write(&path, serde_json::to_string(&file).unwrap()).unwrap();
573
574            // No GW_TEST_GH_JSON set. gh must not be consulted; if it were
575            // called in CI without a repo, it would fail — instead we get
576            // the disk value.
577            std::env::set_var("GW_TEST_GH_FAIL", "1");
578            let cache = PrCache::load_or_fetch(repo, false);
579            assert_eq!(cache.state("feat/cached"), Some(&PrState::Merged));
580        });
581    }
582
583    #[test]
584    fn load_or_fetch_bypasses_disk_when_no_cache_true() {
585        let _g = env_lock();
586        let _env = EnvGuard::capture(&["GW_TEST_CACHE_DIR", "GW_TEST_GH_FAIL", "GW_TEST_GH_JSON"]);
587        let dir = tempdir().unwrap();
588        with_cache_dir(dir.path(), || {
589            let repo = std::path::Path::new("/tmp/repo-bypass-xyz");
590            let path = cache_path_for(repo).unwrap();
591            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
592            let file = CacheFile {
593                fetched_at: now_secs().unwrap(),
594                repo: repo.to_string_lossy().into_owned(),
595                prs: [("feat/old".to_string(), PrState::Open)]
596                    .into_iter()
597                    .collect(),
598            };
599            std::fs::write(&path, serde_json::to_string(&file).unwrap()).unwrap();
600
601            std::env::set_var(
602                "GW_TEST_GH_JSON",
603                r#"[{"headRefName":"feat/new","state":"OPEN"}]"#,
604            );
605            let cache = PrCache::load_or_fetch(repo, true);
606            assert_eq!(cache.state("feat/new"), Some(&PrState::Open));
607            assert_eq!(cache.state("feat/old"), None);
608        });
609    }
610
611    #[test]
612    fn load_or_fetch_empty_when_gh_fails_and_no_cache_file() {
613        let _g = env_lock();
614        let _env = EnvGuard::capture(&["GW_TEST_CACHE_DIR", "GW_TEST_GH_FAIL", "GW_TEST_GH_JSON"]);
615        let dir = tempdir().unwrap();
616        with_cache_dir(dir.path(), || {
617            let repo = std::path::Path::new("/tmp/repo-empty-xyz");
618            std::env::set_var("GW_TEST_GH_FAIL", "1");
619            let cache = PrCache::load_or_fetch(repo, false);
620            assert!(cache.state("anything").is_none());
621        });
622    }
623
624    #[test]
625    fn write_to_disk_cleans_up_tmp_file() {
626        let _g = env_lock();
627        let dir = tempdir().unwrap();
628        with_cache_dir(dir.path(), || {
629            let repo = std::path::Path::new("/tmp/repo-atomic-xyz");
630            let mut prs = HashMap::new();
631            prs.insert("feat/x".to_string(), PrState::Open);
632            write_to_disk(repo, &prs);
633
634            let final_path = cache_path_for(repo).unwrap();
635            assert!(final_path.exists(), "final cache file exists");
636
637            // The .tmp.<pid>.<nanos> file should have been renamed away.
638            let parent = final_path.parent().unwrap();
639            let entries: Vec<_> = std::fs::read_dir(parent).unwrap().flatten().collect();
640            for entry in &entries {
641                let name = entry.file_name();
642                let name_str = name.to_string_lossy();
643                assert!(
644                    !name_str.contains(".tmp."),
645                    "no tmp file should remain: {}",
646                    name_str
647                );
648            }
649        });
650    }
651
652    #[test]
653    fn from_disk_and_fetch_and_persist_split() {
654        let _g = env_lock();
655        let _env = EnvGuard::capture(&["GW_TEST_CACHE_DIR", "GW_TEST_GH_FAIL", "GW_TEST_GH_JSON"]);
656        let dir = tempdir().unwrap();
657        with_cache_dir(dir.path(), || {
658            let repo = std::path::Path::new("/tmp/repo-split-xyz");
659            // from_disk returns None when no file exists
660            assert!(PrCache::from_disk(repo).is_none());
661
662            // fetch_and_persist falls back to empty on gh failure
663            std::env::set_var("GW_TEST_GH_FAIL", "1");
664            let empty = PrCache::fetch_and_persist(repo);
665            assert!(empty.state("anything").is_none());
666            // Transition to success phase: clear FAIL so GH_JSON is consulted.
667            std::env::remove_var("GW_TEST_GH_FAIL");
668
669            // fetch_and_persist writes to disk on success
670            std::env::set_var(
671                "GW_TEST_GH_JSON",
672                r#"[{"headRefName":"main","state":"OPEN"}]"#,
673            );
674            let _ = PrCache::fetch_and_persist(repo);
675            // from_disk now returns the written file
676            let loaded = PrCache::from_disk(repo).expect("written to disk");
677            assert_eq!(loaded.state("main"), Some(&PrState::Open));
678        });
679    }
680
681    /// Verify that write_to_disk removes orphaned .tmp.* files older than 60s.
682    #[cfg(unix)]
683    #[test]
684    fn write_to_disk_sweeps_old_orphan_tmp_files() {
685        let _g = env_lock();
686        let _env = EnvGuard::capture(&["GW_TEST_CACHE_DIR"]);
687        let dir = tempdir().unwrap();
688        with_cache_dir(dir.path(), || {
689            let repo = std::path::Path::new("/tmp/repo-sweep-xyz");
690            let final_path = cache_path_for(repo).unwrap();
691            let parent = final_path.parent().unwrap();
692            std::fs::create_dir_all(parent).unwrap();
693
694            // Plant an old orphan tmp file and backdate its mtime to epoch
695            // (clearly older than the 60s sweep cutoff).
696            let orphan = parent.join("pr-status-orphan.tmp.99999.123456789.0");
697            std::fs::write(&orphan, "stale").unwrap();
698            // Set mtime to Unix epoch via libc::utimes (available on unix).
699            {
700                use std::ffi::CString;
701                let c_path = CString::new(orphan.to_string_lossy().as_bytes()).unwrap();
702                let times = [libc::timeval {
703                    tv_sec: 0,
704                    tv_usec: 0,
705                }; 2];
706                // SAFETY: c_path is valid, times array is correctly sized.
707                unsafe { libc::utimes(c_path.as_ptr(), times.as_ptr()) };
708            }
709
710            // Plant a fresh tmp file with current mtime — the sweep must NOT remove it.
711            let fresh_tmp = parent.join("pr-status-fresh.tmp.123.456.0");
712            std::fs::write(&fresh_tmp, "fresh").unwrap();
713
714            // Trigger a write — the sweep runs before writing the tmp file.
715            let mut prs = HashMap::new();
716            prs.insert("feat/sweep".to_string(), PrState::Open);
717            write_to_disk(repo, &prs);
718
719            assert!(
720                !orphan.exists(),
721                "old orphan tmp file should have been swept"
722            );
723            assert!(fresh_tmp.exists(), "fresh tmp file should not be swept");
724            assert!(final_path.exists(), "final cache file should exist");
725        });
726    }
727
728    /// Canary: ensures every PrState variant is enumerated. A new variant
729    /// breaks compilation here, forcing the author to inspect callers.
730    #[test]
731    fn pr_state_variants_are_handled() {
732        // The value is the exhaustiveness check; runtime asserts are noise.
733        fn _must_handle(s: &PrState) {
734            match s {
735                PrState::Open => {}
736                PrState::Merged => {}
737                PrState::Closed => {}
738                PrState::Other => {}
739            }
740        }
741    }
742}