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 ahead: u32,
22 pub root: String,
23}
24
25#[derive(Clone, Debug)]
26struct Live {
27 pid: u32,
28 provider: String,
29}
30
31static LIVE: OnceLock<Mutex<BTreeMap<(String, u64), Live>>> = OnceLock::new();
32
33fn live() -> std::sync::MutexGuard<'static, BTreeMap<(String, u64), Live>> {
34 LIVE.get_or_init(Default::default).lock().unwrap_or_else(|p| p.into_inner())
35}
36
37pub fn running(workspace: &str, instance: u64, provider: &str, pid: u32) {
39 live().insert((workspace.to_owned(), instance), Live { pid, provider: provider.to_owned() });
40}
41
42pub fn provider_hint(workspace: &str, instance: u64, provider: &str) {
44 if let Some(l) = live().get_mut(&(workspace.to_owned(), instance)) {
45 if l.provider != provider {
46 l.provider = provider.to_owned();
47 }
48 }
49}
50
51pub fn exited(workspace: &str, instance: u64) {
53 live().remove(&(workspace.to_owned(), instance));
54}
55
56fn cwd_of(pid: u32) -> Option<PathBuf> {
57 if cfg!(unix) {
58 std::fs::read_link(format!("/proc/{pid}/cwd")).ok()
59 } else {
60 None
61 }
62}
63
64fn git(dir: &Path, args: &[&str]) -> Option<String> {
65 let mut cmd = std::process::Command::new("git");
66 cmd.arg("-C").arg(dir).args(args).stdin(std::process::Stdio::null()).stderr(std::process::Stdio::null());
67 crate::windows_process::hide_console(&mut cmd);
68 let out = cmd.output().ok()?;
69 out.status.success().then(|| String::from_utf8_lossy(&out.stdout).into_owned())
70}
71
72pub fn read_repo(dir: &Path) -> Option<(String, String, u32, String)> {
74 read_repo_full(dir).map(|(b, h, d, _, r)| (b, h, d, r))
75}
76
77pub fn read_repo_full(dir: &Path) -> Option<(String, String, u32, u32, String)> {
79 let (branch, head, dirty, root) = read_repo_basic(dir)?;
80 let base = ["main", "master"].into_iter().find(|b| git(dir, &["rev-parse", "--verify", "--quiet", &format!("refs/heads/{b}")]).is_some());
81 let ahead = base
82 .filter(|b| *b != branch)
83 .and_then(|b| git(dir, &["rev-list", "--count", "HEAD", &format!("^{b}")]))
84 .and_then(|s| s.trim().parse().ok())
85 .unwrap_or(0);
86 Some((branch, head, dirty, ahead, root))
87}
88
89fn read_repo_basic(dir: &Path) -> Option<(String, String, u32, String)> {
90 let root = git(dir, &["rev-parse", "--show-toplevel"])?.trim().to_owned();
91 let branch = git(dir, &["rev-parse", "--abbrev-ref", "HEAD"]).map(|s| s.trim().to_owned()).unwrap_or_default();
92 let head = git(dir, &["rev-parse", "--short=12", "HEAD"]).map(|s| s.trim().to_owned()).unwrap_or_default();
93 let dirty = git(dir, &["status", "--porcelain", "--untracked-files=normal"]).map(|s| s.lines().take(10_000).count() as u32).unwrap_or(0);
94 Some((branch, head, dirty, root))
95}
96
97fn task_repo_below(dir: &Path) -> Option<(String, String, u32, u32, String)> {
100 let mut entries: Vec<_> = std::fs::read_dir(dir).ok()?.flatten().take(256).map(|e| e.path()).filter(|p| p.join(".git").exists()).collect();
101 entries.sort();
102 entries.into_iter().find_map(|p| read_repo_full(&p).filter(|r| r.0.starts_with("task-")))
103}
104
105pub fn snapshot() -> Vec<SessionGit> {
107 let sessions: Vec<_> = live().iter().map(|(k, v)| (k.clone(), v.clone())).collect();
108 sessions
109 .into_iter()
110 .filter_map(|((ws, inst), l)| {
111 let dir = cwd_of(l.pid)?;
112 let (branch, head, dirty, ahead, root) = read_repo_full(&dir).or_else(|| task_repo_below(&dir))?;
113 Some(SessionGit { workspace_id: ws, instance_id: inst, provider: l.provider, branch, head, dirty, ahead, root })
114 })
115 .collect()
116}
117
118#[cfg(test)]
119mod tests {
120 use super::*;
121
122 #[test]
123 fn reads_branch_and_dirty() {
124 let d = std::env::temp_dir().join(format!("gw-{}", std::process::id()));
125 let _ = std::fs::remove_dir_all(&d);
126 std::fs::create_dir_all(&d).unwrap();
127 let run = |a: &[&str]| assert!(std::process::Command::new("git").arg("-C").arg(&d).args(a).output().unwrap().status.success());
128 run(&["init", "-q", "-b", "feat-x"]);
129 run(&["-c", "core.hooksPath=/dev/null", "-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "--allow-empty", "-m", "i"]);
130 std::fs::write(d.join("a.txt"), "x").unwrap();
131 let (branch, head, dirty, _) = read_repo(&d).unwrap();
132 run(&["branch", "main"]);
133 run(&["-c", "core.hooksPath=/dev/null", "-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "--allow-empty", "-m", "j"]);
134 assert_eq!(read_repo_full(&d).unwrap().3, 1);
135 assert_eq!(branch, "feat-x");
136 assert_eq!(head.len(), 12);
137 assert_eq!(dirty, 1);
138 let _ = std::fs::remove_dir_all(d);
139 }
140
141 #[test]
142 fn finds_task_repo_below_workspace() {
143 let ws = std::env::temp_dir().join(format!("gw-ws-{}", std::process::id()));
144 let _ = std::fs::remove_dir_all(&ws);
145 let r = ws.join("repo");
146 std::fs::create_dir_all(&r).unwrap();
147 let run = |a: &[&str]| assert!(std::process::Command::new("git").arg("-C").arg(&r).args(a).output().unwrap().status.success());
148 run(&["init", "-q", "-b", "main"]);
149 run(&["-c", "core.hooksPath=/dev/null", "-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "--allow-empty", "-m", "i"]);
150 assert!(task_repo_below(&ws).is_none());
151 run(&["checkout", "-q", "-b", "task-9"]);
152 run(&["-c", "core.hooksPath=/dev/null", "-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "--allow-empty", "-m", "w"]);
153 let f = task_repo_below(&ws).unwrap();
154 assert_eq!((f.0.as_str(), f.2, f.3), ("task-9", 0, 1));
155 let _ = std::fs::remove_dir_all(ws);
156 }
157
158 #[test]
159 fn live_session_snapshot() {
160 running("w", 77, "claude", std::process::id());
161 let here = std::env::current_dir().unwrap();
162 let in_repo = read_repo(&here).is_some();
163 assert_eq!(snapshot().iter().any(|s| s.instance_id == 77), in_repo && cfg!(target_os = "linux"));
164 exited("w", 77);
165 assert!(!snapshot().iter().any(|s| s.instance_id == 77));
166 }
167}