1use crate::doctor::{CheckStatus, DoctorReport};
19use crate::error::Result;
20use crate::worktree::{self, BranchStatus, WorktreeInfo};
21use serde::{Deserialize, Serialize};
22
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
26pub struct JsonStatus {
27 pub is_dirty: bool,
28 pub has_upstream: bool,
29 pub ahead: usize,
30 pub behind: usize,
31 pub unknown: bool,
33}
34
35impl From<&BranchStatus> for JsonStatus {
36 fn from(s: &BranchStatus) -> Self {
37 Self {
38 is_dirty: s.is_dirty,
39 has_upstream: s.has_upstream,
40 ahead: s.ahead,
41 behind: s.behind,
42 unknown: s.unknown,
43 }
44 }
45}
46
47#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
51pub struct JsonWorktree {
52 pub name: String,
54 pub id: String,
57 pub path: String,
59 pub branch: Option<String>,
60 pub head: Option<String>,
64 pub is_main: bool,
65 pub is_locked: bool,
66 pub is_prunable: bool,
67 pub status: JsonStatus,
68 pub age_seconds: Option<u64>,
71 pub issue: Option<u64>,
73 pub pr: Option<u64>,
75 #[serde(default, skip_serializing_if = "Option::is_none")]
80 pub agents: Option<JsonWorktreeAgents>,
81}
82
83#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
85pub struct JsonWorktreeAgents {
86 pub top: JsonAgentSession,
88 pub sessions: Vec<JsonAgentSession>,
90}
91
92#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
94pub struct JsonAgentSession {
95 pub kind: String,
97 pub freshness: String,
99 pub last_activity: u64,
101 pub id: String,
103 #[serde(default, skip_serializing_if = "Option::is_none")]
106 pub name: Option<String>,
107}
108
109impl JsonWorktreeAgents {
110 pub fn from_summary(agents: &crate::agent_sessions::WorktreeAgents, now: std::time::SystemTime) -> Option<Self> {
113 let to_wire = |s: &crate::agent_sessions::AgentSession| JsonAgentSession {
114 kind: s.kind.display().to_string(),
115 freshness: match crate::agent_sessions::Freshness::classify(s.last_activity, s.ended, now) {
116 crate::agent_sessions::Freshness::Active => "active".to_string(),
117 crate::agent_sessions::Freshness::Idle => "idle".to_string(),
118 },
119 last_activity: s
120 .last_activity
121 .duration_since(std::time::SystemTime::UNIX_EPOCH)
122 .map(|d| d.as_secs())
123 .unwrap_or(0),
124 id: s.id.clone(),
125 name: s.name.clone(),
126 };
127 let top = agents.top()?;
128 Some(Self {
129 top: to_wire(top),
130 sessions: agents.sessions.iter().map(to_wire).collect(),
131 })
132 }
133}
134
135impl From<&WorktreeInfo> for JsonWorktree {
136 fn from(w: &WorktreeInfo) -> Self {
137 Self {
138 name: w.name.clone(),
139 id: w.id.clone(),
140 path: w.path.to_string_lossy().into_owned(),
146 branch: w.branch.clone(),
147 head: w.head.clone(),
148 is_main: w.is_main,
149 is_locked: w.is_locked,
150 is_prunable: w.is_prunable,
151 status: JsonStatus::from(&w.status),
152 age_seconds: w.age.map(|d| d.as_secs()),
153 issue: w.link.issue,
154 pr: w.link.pr,
155 agents: None,
158 }
159 }
160}
161
162#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
164pub struct JsonPath {
165 pub name: String,
166 pub path: String,
167 pub branch: Option<String>,
168}
169
170impl From<&WorktreeInfo> for JsonPath {
171 fn from(w: &WorktreeInfo) -> Self {
172 Self {
173 name: w.name.clone(),
174 path: w.path.to_string_lossy().into_owned(),
175 branch: w.branch.clone(),
176 }
177 }
178}
179
180pub fn check_status_str(status: &CheckStatus) -> &'static str {
183 match status {
184 CheckStatus::Ok => "ok",
185 CheckStatus::Warning => "warning",
186 CheckStatus::Failed => "failed",
187 }
188}
189
190#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
192pub struct JsonCheck {
193 pub name: String,
194 pub status: String,
196 pub detail: String,
197 pub fix_hint: Option<String>,
198}
199
200#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
204pub struct JsonDoctorReport {
205 pub checks: Vec<JsonCheck>,
206 pub severity: String,
208 pub exit_code: i32,
209}
210
211impl From<&DoctorReport> for JsonDoctorReport {
212 fn from(r: &DoctorReport) -> Self {
213 Self {
214 checks: r
215 .checks
216 .iter()
217 .map(|c| JsonCheck {
218 name: c.name.clone(),
219 status: check_status_str(&c.status).to_string(),
220 detail: c.detail.clone(),
221 fix_hint: c.fix_hint.clone(),
222 })
223 .collect(),
224 severity: check_status_str(&r.severity()).to_string(),
225 exit_code: r.exit_code(),
226 }
227 }
228}
229
230pub fn worktrees(repo: &git2::Repository) -> Result<Vec<JsonWorktree>> {
234 let trees = worktree::list(repo)?;
235 let mut rows: Vec<JsonWorktree> = trees.iter().map(JsonWorktree::from).collect();
236 let reals: Vec<std::path::PathBuf> = trees.iter().map(|w| w.path.clone()).collect();
237 let pins = agent_pins_for_rows(repo, &trees);
238 attach_agents(&mut rows, &reals, &pins);
239 Ok(rows)
240}
241
242pub fn agent_pins_for_rows(repo: &git2::Repository, trees: &[crate::worktree::WorktreeInfo]) -> Vec<(String, String)> {
246 trees
247 .iter()
248 .flat_map(|w| {
249 let pins = crate::github::pinnable_branch(w.branch.as_deref())
250 .map(|branch| crate::github::agent_pins(repo, branch).unwrap_or_default())
251 .unwrap_or_default();
252 let key = crate::agent_sessions::path_display_key(&w.path);
255 pins.into_iter().map(move |sid| (key.clone(), sid))
256 })
257 .collect()
258}
259
260pub fn attach_agents(rows: &mut [JsonWorktree], reals: &[std::path::PathBuf], pins: &[(String, String)]) {
269 attach_agents_inner(rows, reals, pins, false);
270}
271
272pub fn attach_agents_with_pool(
278 rows: &mut [JsonWorktree],
279 reals: &[std::path::PathBuf],
280 pins: &[(String, String)],
281) -> Vec<crate::agent_sessions::AgentSession> {
282 attach_agents_inner(rows, reals, pins, true)
283}
284
285fn attach_agents_inner(
286 rows: &mut [JsonWorktree],
287 reals: &[std::path::PathBuf],
288 pins: &[(String, String)],
289 want_pool: bool,
290) -> Vec<crate::agent_sessions::AgentSession> {
291 let Some(home) = crate::agent_sessions::agents_home() else {
292 return Vec::new();
293 };
294 let now = std::time::SystemTime::now();
295 debug_assert_eq!(rows.len(), reals.len());
301 let keyed: Vec<(String, std::path::PathBuf)> = reals
302 .iter()
303 .map(|p| (crate::agent_sessions::path_display_key(p), p.clone()))
304 .collect();
305 let (summary, pool) = detect_cached(&home, &keyed, pins, now, want_pool);
306 for (row, real) in rows.iter_mut().zip(reals) {
307 row.agents = summary
308 .get(&crate::agent_sessions::path_display_key(real))
309 .and_then(|a| JsonWorktreeAgents::from_summary(a, now));
310 }
311 pool
312}
313
314fn detect_cached(
327 home: &std::path::Path,
328 keyed: &[(String, std::path::PathBuf)],
329 pins: &[(String, String)],
330 now: std::time::SystemTime,
331 want_pool: bool,
332) -> (
333 std::collections::BTreeMap<String, crate::agent_sessions::WorktreeAgents>,
334 Vec<crate::agent_sessions::AgentSession>,
335) {
336 const TTL: std::time::Duration = std::time::Duration::from_secs(30);
337 type CacheKey = (std::path::PathBuf, Vec<(String, String)>, Vec<String>);
338 type Detection = (
339 std::collections::BTreeMap<String, crate::agent_sessions::WorktreeAgents>,
340 Vec<crate::agent_sessions::AgentSession>,
341 );
342 type CacheSlot = Option<(std::time::Instant, CacheKey, bool, Detection)>;
343 static CACHE: std::sync::Mutex<CacheSlot> = std::sync::Mutex::new(None);
344
345 let key: CacheKey = (
346 home.to_path_buf(),
347 pins.to_vec(),
348 keyed.iter().map(|(k, _)| k.clone()).collect(),
349 );
350 let mut slot = CACHE.lock().unwrap_or_else(|e| e.into_inner());
353 if let Some((at, cached_key, has_pool, detection)) = slot.as_ref() {
354 if *cached_key == key && at.elapsed() < TTL && (*has_pool || !want_pool) {
355 return detection.clone();
356 }
357 }
358 let detection = if want_pool {
359 crate::agent_sessions::detect_with_sessions(home, keyed, pins, now)
360 } else {
361 (crate::agent_sessions::detect_all(home, keyed, pins, now), Vec::new())
362 };
363 *slot = Some((std::time::Instant::now(), key, want_pool, detection.clone()));
364 detection
365}