1use serde::Serialize;
7use std::collections::BTreeMap;
8use std::path::{Path, PathBuf};
9use std::sync::{Mutex, OnceLock};
10
11#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
12pub struct SessionGit {
13 pub workspace_id: String,
14 pub instance_id: u64,
15 pub provider: String,
16 pub branch: String,
17 pub head: String,
18 pub dirty: u32,
20 pub root: String,
21}
22
23#[derive(Clone, Debug)]
24struct Live {
25 pid: u32,
26 provider: String,
27}
28
29static LIVE: OnceLock<Mutex<BTreeMap<(String, u64), Live>>> = OnceLock::new();
30
31fn live() -> std::sync::MutexGuard<'static, BTreeMap<(String, u64), Live>> {
32 LIVE.get_or_init(Default::default).lock().unwrap_or_else(|p| p.into_inner())
33}
34
35pub fn running(workspace: &str, instance: u64, provider: &str, pid: u32) {
37 live().insert((workspace.to_owned(), instance), Live { pid, provider: provider.to_owned() });
38}
39
40pub fn provider_hint(workspace: &str, instance: u64, provider: &str) {
42 if let Some(l) = live().get_mut(&(workspace.to_owned(), instance)) {
43 if l.provider != provider {
44 l.provider = provider.to_owned();
45 }
46 }
47}
48
49pub fn exited(workspace: &str, instance: u64) {
51 live().remove(&(workspace.to_owned(), instance));
52}
53
54fn cwd_of(pid: u32) -> Option<PathBuf> {
55 if cfg!(unix) {
56 std::fs::read_link(format!("/proc/{pid}/cwd")).ok()
57 } else {
58 None
59 }
60}
61
62fn git(dir: &Path, args: &[&str]) -> Option<String> {
63 let mut cmd = std::process::Command::new("git");
64 cmd.arg("-C").arg(dir).args(args).stdin(std::process::Stdio::null()).stderr(std::process::Stdio::null());
65 crate::windows_process::hide_console(&mut cmd);
66 let out = cmd.output().ok()?;
67 out.status.success().then(|| String::from_utf8_lossy(&out.stdout).into_owned())
68}
69
70pub fn read_repo(dir: &Path) -> Option<(String, String, u32, String)> {
72 let root = git(dir, &["rev-parse", "--show-toplevel"])?.trim().to_owned();
73 let branch = git(dir, &["rev-parse", "--abbrev-ref", "HEAD"]).map(|s| s.trim().to_owned()).unwrap_or_default();
74 let head = git(dir, &["rev-parse", "--short=12", "HEAD"]).map(|s| s.trim().to_owned()).unwrap_or_default();
75 let dirty = git(dir, &["status", "--porcelain", "--untracked-files=normal"]).map(|s| s.lines().take(10_000).count() as u32).unwrap_or(0);
76 Some((branch, head, dirty, root))
77}
78
79pub fn snapshot() -> Vec<SessionGit> {
81 let sessions: Vec<_> = live().iter().map(|(k, v)| (k.clone(), v.clone())).collect();
82 sessions
83 .into_iter()
84 .filter_map(|((ws, inst), l)| {
85 let dir = cwd_of(l.pid)?;
86 let (branch, head, dirty, root) = read_repo(&dir)?;
87 Some(SessionGit { workspace_id: ws, instance_id: inst, provider: l.provider, branch, head, dirty, root })
88 })
89 .collect()
90}
91
92#[cfg(test)]
93mod tests {
94 use super::*;
95
96 #[test]
97 fn reads_branch_and_dirty() {
98 let d = std::env::temp_dir().join(format!("gw-{}", std::process::id()));
99 let _ = std::fs::remove_dir_all(&d);
100 std::fs::create_dir_all(&d).unwrap();
101 let run = |a: &[&str]| assert!(std::process::Command::new("git").arg("-C").arg(&d).args(a).output().unwrap().status.success());
102 run(&["init", "-q", "-b", "feat-x"]);
103 run(&["-c", "core.hooksPath=/dev/null", "-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "--allow-empty", "-m", "i"]);
104 std::fs::write(d.join("a.txt"), "x").unwrap();
105 let (branch, head, dirty, _) = read_repo(&d).unwrap();
106 assert_eq!(branch, "feat-x");
107 assert_eq!(head.len(), 12);
108 assert_eq!(dirty, 1);
109 let _ = std::fs::remove_dir_all(d);
110 }
111
112 #[test]
113 fn live_session_snapshot() {
114 running("w", 77, "claude", std::process::id());
115 let here = std::env::current_dir().unwrap();
116 let in_repo = read_repo(&here).is_some();
117 assert_eq!(snapshot().iter().any(|s| s.instance_id == 77), in_repo && cfg!(target_os = "linux"));
118 exited("w", 77);
119 assert!(!snapshot().iter().any(|s| s.instance_id == 77));
120 }
121}