use crate::doctor::{CheckStatus, DoctorReport};
use crate::error::Result;
use crate::worktree::{self, BranchStatus, WorktreeInfo};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct JsonStatus {
pub is_dirty: bool,
pub has_upstream: bool,
pub ahead: usize,
pub behind: usize,
pub unknown: bool,
}
impl From<&BranchStatus> for JsonStatus {
fn from(s: &BranchStatus) -> Self {
Self {
is_dirty: s.is_dirty,
has_upstream: s.has_upstream,
ahead: s.ahead,
behind: s.behind,
unknown: s.unknown,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct JsonWorktree {
pub name: String,
pub id: String,
pub path: String,
pub branch: Option<String>,
pub head: Option<String>,
pub is_main: bool,
pub is_locked: bool,
pub is_prunable: bool,
pub status: JsonStatus,
pub age_seconds: Option<u64>,
pub issue: Option<u64>,
pub pr: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agents: Option<JsonWorktreeAgents>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct JsonWorktreeAgents {
pub top: JsonAgentSession,
pub sessions: Vec<JsonAgentSession>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct JsonAgentSession {
pub kind: String,
pub freshness: String,
pub last_activity: u64,
pub id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
}
impl JsonWorktreeAgents {
pub fn from_summary(agents: &crate::agent_sessions::WorktreeAgents, now: std::time::SystemTime) -> Option<Self> {
let to_wire = |s: &crate::agent_sessions::AgentSession| JsonAgentSession {
kind: s.kind.display().to_string(),
freshness: match crate::agent_sessions::Freshness::classify(s.last_activity, s.ended, now) {
crate::agent_sessions::Freshness::Active => "active".to_string(),
crate::agent_sessions::Freshness::Idle => "idle".to_string(),
},
last_activity: s
.last_activity
.duration_since(std::time::SystemTime::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0),
id: s.id.clone(),
name: s.name.clone(),
};
let top = agents.top()?;
Some(Self {
top: to_wire(top),
sessions: agents.sessions.iter().map(to_wire).collect(),
})
}
}
impl From<&WorktreeInfo> for JsonWorktree {
fn from(w: &WorktreeInfo) -> Self {
Self {
name: w.name.clone(),
id: w.id.clone(),
path: w.path.to_string_lossy().into_owned(),
branch: w.branch.clone(),
head: w.head.clone(),
is_main: w.is_main,
is_locked: w.is_locked,
is_prunable: w.is_prunable,
status: JsonStatus::from(&w.status),
age_seconds: w.age.map(|d| d.as_secs()),
issue: w.link.issue,
pr: w.link.pr,
agents: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct JsonPath {
pub name: String,
pub path: String,
pub branch: Option<String>,
}
impl From<&WorktreeInfo> for JsonPath {
fn from(w: &WorktreeInfo) -> Self {
Self {
name: w.name.clone(),
path: w.path.to_string_lossy().into_owned(),
branch: w.branch.clone(),
}
}
}
pub fn check_status_str(status: &CheckStatus) -> &'static str {
match status {
CheckStatus::Ok => "ok",
CheckStatus::Warning => "warning",
CheckStatus::Failed => "failed",
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct JsonCheck {
pub name: String,
pub status: String,
pub detail: String,
pub fix_hint: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct JsonDoctorReport {
pub checks: Vec<JsonCheck>,
pub severity: String,
pub exit_code: i32,
}
impl From<&DoctorReport> for JsonDoctorReport {
fn from(r: &DoctorReport) -> Self {
Self {
checks: r
.checks
.iter()
.map(|c| JsonCheck {
name: c.name.clone(),
status: check_status_str(&c.status).to_string(),
detail: c.detail.clone(),
fix_hint: c.fix_hint.clone(),
})
.collect(),
severity: check_status_str(&r.severity()).to_string(),
exit_code: r.exit_code(),
}
}
}
pub fn worktrees(repo: &git2::Repository) -> Result<Vec<JsonWorktree>> {
let trees = worktree::list(repo)?;
let mut rows: Vec<JsonWorktree> = trees.iter().map(JsonWorktree::from).collect();
let reals: Vec<std::path::PathBuf> = trees.iter().map(|w| w.path.clone()).collect();
let pins = agent_pins_for_rows(repo, &trees);
attach_agents(&mut rows, &reals, &pins);
Ok(rows)
}
pub fn agent_pins_for_rows(repo: &git2::Repository, trees: &[crate::worktree::WorktreeInfo]) -> Vec<(String, String)> {
trees
.iter()
.flat_map(|w| {
let pins = crate::github::pinnable_branch(w.branch.as_deref())
.map(|branch| crate::github::agent_pins(repo, branch).unwrap_or_default())
.unwrap_or_default();
let key = crate::agent_sessions::path_display_key(&w.path);
pins.into_iter().map(move |sid| (key.clone(), sid))
})
.collect()
}
pub fn attach_agents(rows: &mut [JsonWorktree], reals: &[std::path::PathBuf], pins: &[(String, String)]) {
attach_agents_inner(rows, reals, pins, false);
}
pub fn attach_agents_with_pool(
rows: &mut [JsonWorktree],
reals: &[std::path::PathBuf],
pins: &[(String, String)],
) -> Vec<crate::agent_sessions::AgentSession> {
attach_agents_inner(rows, reals, pins, true)
}
fn attach_agents_inner(
rows: &mut [JsonWorktree],
reals: &[std::path::PathBuf],
pins: &[(String, String)],
want_pool: bool,
) -> Vec<crate::agent_sessions::AgentSession> {
let Some(home) = crate::agent_sessions::agents_home() else {
return Vec::new();
};
let now = std::time::SystemTime::now();
debug_assert_eq!(rows.len(), reals.len());
let keyed: Vec<(String, std::path::PathBuf)> = reals
.iter()
.map(|p| (crate::agent_sessions::path_display_key(p), p.clone()))
.collect();
let (summary, pool) = detect_cached(&home, &keyed, pins, now, want_pool);
for (row, real) in rows.iter_mut().zip(reals) {
row.agents = summary
.get(&crate::agent_sessions::path_display_key(real))
.and_then(|a| JsonWorktreeAgents::from_summary(a, now));
}
pool
}
fn detect_cached(
home: &std::path::Path,
keyed: &[(String, std::path::PathBuf)],
pins: &[(String, String)],
now: std::time::SystemTime,
want_pool: bool,
) -> (
std::collections::BTreeMap<String, crate::agent_sessions::WorktreeAgents>,
Vec<crate::agent_sessions::AgentSession>,
) {
const TTL: std::time::Duration = std::time::Duration::from_secs(30);
type CacheKey = (std::path::PathBuf, Vec<(String, String)>, Vec<String>);
type Detection = (
std::collections::BTreeMap<String, crate::agent_sessions::WorktreeAgents>,
Vec<crate::agent_sessions::AgentSession>,
);
type CacheSlot = Option<(std::time::Instant, CacheKey, bool, Detection)>;
static CACHE: std::sync::Mutex<CacheSlot> = std::sync::Mutex::new(None);
let key: CacheKey = (
home.to_path_buf(),
pins.to_vec(),
keyed.iter().map(|(k, _)| k.clone()).collect(),
);
let mut slot = CACHE.lock().unwrap_or_else(|e| e.into_inner());
if let Some((at, cached_key, has_pool, detection)) = slot.as_ref() {
if *cached_key == key && at.elapsed() < TTL && (*has_pool || !want_pool) {
return detection.clone();
}
}
let detection = if want_pool {
crate::agent_sessions::detect_with_sessions(home, keyed, pins, now)
} else {
(crate::agent_sessions::detect_all(home, keyed, pins, now), Vec::new())
};
*slot = Some((std::time::Instant::now(), key, want_pool, detection.clone()));
detection
}