use std::collections::HashSet;
use std::path::{Path, PathBuf};
use ito_domain::audit::event::{AuditEvent, WorktreeInfo};
use super::store::{audit_storage_location_key, default_audit_store};
use super::writer::audit_log_path;
pub fn discover_worktrees(_ito_path: &Path) -> Vec<WorktreeInfo> {
let output = std::process::Command::new("git")
.args(["worktree", "list", "--porcelain"])
.output();
let Ok(output) = output else {
return Vec::new();
};
if !output.status.success() {
return Vec::new();
}
let stdout = String::from_utf8_lossy(&output.stdout);
parse_worktree_list(&stdout)
}
fn parse_worktree_list(output: &str) -> Vec<WorktreeInfo> {
let mut worktrees = Vec::new();
let mut current_path: Option<PathBuf> = None;
let mut current_branch: Option<String> = None;
let mut is_bare = false;
for line in output.lines() {
if let Some(path) = line.strip_prefix("worktree ") {
if let Some(path) = current_path.take() {
if !is_bare {
worktrees.push(WorktreeInfo {
path,
branch: current_branch.take(),
is_main: worktrees.is_empty(), });
}
current_branch = None;
is_bare = false;
}
current_path = Some(PathBuf::from(path));
} else if let Some(branch_ref) = line.strip_prefix("branch ") {
current_branch = branch_ref.strip_prefix("refs/heads/").map(String::from);
} else if line == "bare" {
is_bare = true;
} else if line.is_empty() {
if let Some(path) = current_path.take() {
if !is_bare {
worktrees.push(WorktreeInfo {
path,
branch: current_branch.take(),
is_main: worktrees.is_empty(),
});
}
current_branch = None;
is_bare = false;
}
}
}
if let Some(path) = current_path
&& !is_bare
{
worktrees.push(WorktreeInfo {
path,
branch: current_branch,
is_main: worktrees.is_empty(),
});
}
worktrees
}
pub fn find_worktree_for_branch(branch: &str) -> Option<PathBuf> {
let output = std::process::Command::new("git")
.args(["worktree", "list", "--porcelain"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let stdout = String::from_utf8_lossy(&output.stdout);
find_worktree_for_branch_in_output(&stdout, branch)
}
fn find_worktree_for_branch_in_output(output: &str, branch: &str) -> Option<PathBuf> {
let worktrees = parse_worktree_list(output);
worktrees
.into_iter()
.find(|wt| wt.branch.as_deref() == Some(branch))
.map(|wt| wt.path)
}
pub fn worktree_audit_log_path(worktree: &WorktreeInfo) -> PathBuf {
audit_log_path(&worktree.path.join(".ito"))
}
pub fn aggregate_worktree_events(
worktrees: &[WorktreeInfo],
) -> Vec<(WorktreeInfo, Vec<AuditEvent>)> {
let mut results = Vec::new();
let mut seen_locations = HashSet::new();
for wt in worktrees {
let wt_ito_path = wt.path.join(".ito");
if !wt_ito_path.exists() {
continue;
}
let store = default_audit_store(&wt_ito_path);
let key = audit_storage_location_key(&store.location());
if !seen_locations.insert(key) {
continue;
}
let events = store.read_all();
if !events.is_empty() {
results.push((wt.clone(), events));
}
}
results
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_single_worktree() {
let output = "worktree /home/user/project\nHEAD abc1234\nbranch refs/heads/main\n\n";
let wts = parse_worktree_list(output);
assert_eq!(wts.len(), 1);
assert_eq!(wts[0].path, PathBuf::from("/home/user/project"));
assert_eq!(wts[0].branch, Some("main".to_string()));
assert!(wts[0].is_main);
}
#[test]
fn parse_multiple_worktrees() {
let output = "\
worktree /home/user/project
HEAD abc1234
branch refs/heads/main
worktree /home/user/wt-feature
HEAD def5678
branch refs/heads/feature-x
";
let wts = parse_worktree_list(output);
assert_eq!(wts.len(), 2);
assert!(wts[0].is_main);
assert!(!wts[1].is_main);
assert_eq!(wts[1].branch, Some("feature-x".to_string()));
}
#[test]
fn parse_bare_worktree_excluded() {
let output = "\
worktree /home/user/project.git
bare
worktree /home/user/wt-main
HEAD abc1234
branch refs/heads/main
";
let wts = parse_worktree_list(output);
assert_eq!(wts.len(), 1);
assert_eq!(wts[0].path, PathBuf::from("/home/user/wt-main"));
}
#[test]
fn parse_detached_head() {
let output = "worktree /home/user/project\nHEAD abc1234\ndetached\n\n";
let wts = parse_worktree_list(output);
assert_eq!(wts.len(), 1);
assert!(wts[0].branch.is_none());
}
#[test]
fn worktree_audit_log_path_resolves() {
let wt = WorktreeInfo {
path: PathBuf::from("/project/wt-feature"),
branch: Some("feature".to_string()),
is_main: false,
};
let path = worktree_audit_log_path(&wt);
assert_eq!(
path,
PathBuf::from("/project/wt-feature/.ito/.state/audit/events.jsonl")
);
}
#[test]
fn find_worktree_matching_branch() {
let output = "\
worktree /home/user/project
HEAD abc1234
branch refs/heads/main
worktree /home/user/wt-feature
HEAD def5678
branch refs/heads/002-16_ralph-worktree-awareness
";
let result = find_worktree_for_branch_in_output(output, "002-16_ralph-worktree-awareness");
assert_eq!(result, Some(PathBuf::from("/home/user/wt-feature")));
}
#[test]
fn find_worktree_no_match() {
let output = "\
worktree /home/user/project
HEAD abc1234
branch refs/heads/main
";
let result = find_worktree_for_branch_in_output(output, "nonexistent-branch");
assert!(result.is_none());
}
#[test]
fn find_worktree_bare_excluded() {
let output = "\
worktree /home/user/project.git
bare
worktree /home/user/wt-main
HEAD abc1234
branch refs/heads/main
";
let result = find_worktree_for_branch_in_output(output, "main");
assert_eq!(result, Some(PathBuf::from("/home/user/wt-main")));
}
#[test]
fn find_worktree_multiple_returns_first_match() {
let output = "\
worktree /home/user/project.git
bare
worktree /home/user/wt-main
HEAD abc1234
branch refs/heads/main
worktree /home/user/wt-feature-a
HEAD def5678
branch refs/heads/feature-a
worktree /home/user/wt-feature-b
HEAD 9ab0123
branch refs/heads/feature-b
";
let result = find_worktree_for_branch_in_output(output, "feature-b");
assert_eq!(result, Some(PathBuf::from("/home/user/wt-feature-b")));
let result = find_worktree_for_branch_in_output(output, "feature-c");
assert!(result.is_none());
}
#[test]
fn aggregate_empty_worktrees() {
let results = aggregate_worktree_events(&[]);
assert!(results.is_empty());
}
#[test]
fn aggregate_worktree_with_events() {
let tmp = tempfile::tempdir().expect("tempdir");
let wt_path = tmp.path().join("wt");
std::fs::create_dir_all(&wt_path).expect("create wt dir");
let wt_ito_path = wt_path.join(".ito");
let writer = crate::audit::writer::FsAuditWriter::new(&wt_ito_path);
let event = ito_domain::audit::event::AuditEvent {
v: 1,
ts: "2026-02-08T14:30:00.000Z".to_string(),
entity: "task".to_string(),
entity_id: "1.1".to_string(),
scope: Some("ch".to_string()),
op: "create".to_string(),
from: None,
to: Some("pending".to_string()),
actor: "cli".to_string(),
by: "@test".to_string(),
meta: None,
count: 1,
ctx: ito_domain::audit::event::EventContext {
session_id: "test".to_string(),
harness_session_id: None,
branch: None,
worktree: None,
commit: None,
},
};
ito_domain::audit::writer::AuditWriter::append(&writer, &event).unwrap();
let wt_info = WorktreeInfo {
path: wt_path,
branch: Some("main".to_string()),
is_main: true,
};
let results = aggregate_worktree_events(&[wt_info]);
assert_eq!(results.len(), 1);
assert_eq!(results[0].1.len(), 1);
}
}