use anyhow::{Context, Result};
use git2::Repository;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone)]
pub struct WorktreeInfo {
pub name: String,
pub path: PathBuf,
pub head_ref: Option<String>,
pub is_main: bool,
pub is_dirty: bool,
pub agent: Option<AgentInfo>,
pub head_time: i64,
}
#[derive(Debug, Clone)]
pub struct AgentInfo {
pub agent_type: AgentType,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AgentType {
ClaudeCode,
Cursor,
Aider,
Copilot,
Other,
}
impl AgentType {
pub fn label(&self) -> &'static str {
match self {
AgentType::ClaudeCode => "Claude",
AgentType::Cursor => "Cursor",
AgentType::Aider => "Aider",
AgentType::Copilot => "Copilot",
AgentType::Other => "AI",
}
}
}
pub fn list_worktrees(repo_path: &Path) -> Result<Vec<WorktreeInfo>> {
let repo = Repository::discover(repo_path).context("Not a git repository")?;
let mut worktrees = Vec::new();
if let Some(workdir) = repo.workdir() {
let head_ref = repo.head().ok().and_then(|h| {
if h.is_branch() {
h.shorthand().map(|s| s.to_string())
} else {
h.target().map(|oid| format!("{:.7}", oid))
}
});
let is_dirty = repo_is_dirty(&repo);
let head_time = head_commit_time(&repo);
let mut info = WorktreeInfo {
name: "main".to_string(),
path: workdir.to_path_buf(),
head_ref,
is_main: true,
is_dirty,
agent: None,
head_time,
};
info.agent = detect_agent(&info.path);
worktrees.push(info);
}
let wt_names = repo.worktrees()?;
for name in wt_names.iter() {
let Some(name) = name else { continue };
let Ok(wt) = repo.find_worktree(name) else {
continue;
};
let wt_path = wt.path().to_path_buf();
let (head_ref, is_dirty, head_time) = match Repository::open(&wt_path) {
Ok(wt_repo) => {
let head = wt_repo.head().ok().and_then(|h| {
if h.is_branch() {
h.shorthand().map(|s| s.to_string())
} else {
h.target().map(|oid| format!("{:.7}", oid))
}
});
let dirty = repo_is_dirty(&wt_repo);
let time = head_commit_time(&wt_repo);
(head, dirty, time)
}
Err(_) => (None, false, 0),
};
let mut info = WorktreeInfo {
name: name.to_string(),
path: wt_path,
head_ref,
is_main: false,
is_dirty,
agent: None,
head_time,
};
info.agent = detect_agent(&info.path);
worktrees.push(info);
}
worktrees.sort_by(|a, b| b.head_time.cmp(&a.head_time));
Ok(worktrees)
}
fn head_commit_time(repo: &Repository) -> i64 {
repo.head()
.ok()
.and_then(|h| h.peel_to_commit().ok())
.map(|c| c.time().seconds())
.unwrap_or(0)
}
fn repo_is_dirty(repo: &Repository) -> bool {
let mut opts = git2::StatusOptions::new();
opts.include_untracked(true).recurse_untracked_dirs(false);
match repo.statuses(Some(&mut opts)) {
Ok(statuses) => !statuses.is_empty(),
Err(_) => false,
}
}
fn detect_agent(path: &Path) -> Option<AgentInfo> {
if path.join(".claude").is_dir() {
return Some(AgentInfo {
agent_type: AgentType::ClaudeCode,
});
}
if path.join(".cursorrules").is_file() || path.join(".cursor").is_dir() {
return Some(AgentInfo {
agent_type: AgentType::Cursor,
});
}
if path.join(".aider.conf.yml").is_file() || path.join(".aider").is_dir() {
return Some(AgentInfo {
agent_type: AgentType::Aider,
});
}
if path.join(".github/copilot").is_dir() {
return Some(AgentInfo {
agent_type: AgentType::Copilot,
});
}
let dir_name = path.file_name()?.to_string_lossy().to_lowercase();
for keyword in &["claude", "cursor", "aider", "copilot", "agent"] {
if dir_name.contains(keyword) {
let agent_type = match *keyword {
"claude" => AgentType::ClaudeCode,
"cursor" => AgentType::Cursor,
"aider" => AgentType::Aider,
"copilot" => AgentType::Copilot,
_ => AgentType::Other,
};
return Some(AgentInfo { agent_type });
}
}
None
}