ito_core/audit/
worktree.rs1use std::path::{Path, PathBuf};
7
8use ito_domain::audit::event::{AuditEvent, WorktreeInfo};
9
10use super::reader::read_audit_events;
11use super::writer::audit_log_path;
12
13pub fn discover_worktrees(_ito_path: &Path) -> Vec<WorktreeInfo> {
18 let output = std::process::Command::new("git")
19 .args(["worktree", "list", "--porcelain"])
20 .output();
21
22 let Ok(output) = output else {
23 return Vec::new();
24 };
25
26 if !output.status.success() {
27 return Vec::new();
28 }
29
30 let stdout = String::from_utf8_lossy(&output.stdout);
31 parse_worktree_list(&stdout)
32}
33
34fn parse_worktree_list(output: &str) -> Vec<WorktreeInfo> {
36 let mut worktrees = Vec::new();
37 let mut current_path: Option<PathBuf> = None;
38 let mut current_branch: Option<String> = None;
39 let mut is_bare = false;
40
41 for line in output.lines() {
42 if let Some(path) = line.strip_prefix("worktree ") {
43 if let Some(path) = current_path.take() {
45 if !is_bare {
46 let wt_ito_path = path.join(".ito");
47 let log = audit_log_path(&wt_ito_path);
48 let has_ito = wt_ito_path.exists();
50 worktrees.push(WorktreeInfo {
51 path,
52 branch: current_branch.take(),
53 is_main: worktrees.is_empty(), });
55 if !has_ito {
56 let _ = log;
58 }
59 }
60 current_branch = None;
61 is_bare = false;
62 }
63 current_path = Some(PathBuf::from(path));
64 } else if let Some(branch_ref) = line.strip_prefix("branch ") {
65 current_branch = branch_ref.strip_prefix("refs/heads/").map(String::from);
67 } else if line == "bare" {
68 is_bare = true;
69 } else if line.is_empty() {
70 if let Some(path) = current_path.take() {
72 if !is_bare {
73 worktrees.push(WorktreeInfo {
74 path,
75 branch: current_branch.take(),
76 is_main: worktrees.is_empty(),
77 });
78 }
79 current_branch = None;
80 is_bare = false;
81 }
82 }
83 }
84
85 if let Some(path) = current_path
87 && !is_bare
88 {
89 worktrees.push(WorktreeInfo {
90 path,
91 branch: current_branch,
92 is_main: worktrees.is_empty(),
93 });
94 }
95
96 worktrees
97}
98
99pub fn find_worktree_for_branch(branch: &str) -> Option<PathBuf> {
107 let output = std::process::Command::new("git")
108 .args(["worktree", "list", "--porcelain"])
109 .output()
110 .ok()?;
111
112 if !output.status.success() {
113 return None;
114 }
115
116 let stdout = String::from_utf8_lossy(&output.stdout);
117 find_worktree_for_branch_in_output(&stdout, branch)
118}
119
120fn find_worktree_for_branch_in_output(output: &str, branch: &str) -> Option<PathBuf> {
124 let worktrees = parse_worktree_list(output);
125 worktrees
126 .into_iter()
127 .find(|wt| wt.branch.as_deref() == Some(branch))
128 .map(|wt| wt.path)
129}
130
131pub fn worktree_audit_log_path(worktree: &WorktreeInfo) -> PathBuf {
133 audit_log_path(&worktree.path.join(".ito"))
134}
135
136pub fn aggregate_worktree_events(
141 worktrees: &[WorktreeInfo],
142) -> Vec<(WorktreeInfo, Vec<AuditEvent>)> {
143 let mut results = Vec::new();
144
145 for wt in worktrees {
146 let wt_ito_path = wt.path.join(".ito");
147 let log_path = audit_log_path(&wt_ito_path);
148 if !log_path.exists() {
149 continue;
150 }
151
152 let events = read_audit_events(&wt_ito_path);
153 if !events.is_empty() {
154 results.push((wt.clone(), events));
155 }
156 }
157
158 results
159}
160
161#[cfg(test)]
162mod tests {
163 use super::*;
164
165 #[test]
166 fn parse_single_worktree() {
167 let output = "worktree /home/user/project\nHEAD abc1234\nbranch refs/heads/main\n\n";
168 let wts = parse_worktree_list(output);
169 assert_eq!(wts.len(), 1);
170 assert_eq!(wts[0].path, PathBuf::from("/home/user/project"));
171 assert_eq!(wts[0].branch, Some("main".to_string()));
172 assert!(wts[0].is_main);
173 }
174
175 #[test]
176 fn parse_multiple_worktrees() {
177 let output = "\
178worktree /home/user/project
179HEAD abc1234
180branch refs/heads/main
181
182worktree /home/user/wt-feature
183HEAD def5678
184branch refs/heads/feature-x
185
186";
187 let wts = parse_worktree_list(output);
188 assert_eq!(wts.len(), 2);
189 assert!(wts[0].is_main);
190 assert!(!wts[1].is_main);
191 assert_eq!(wts[1].branch, Some("feature-x".to_string()));
192 }
193
194 #[test]
195 fn parse_bare_worktree_excluded() {
196 let output = "\
197worktree /home/user/project.git
198bare
199
200worktree /home/user/wt-main
201HEAD abc1234
202branch refs/heads/main
203
204";
205 let wts = parse_worktree_list(output);
206 assert_eq!(wts.len(), 1);
207 assert_eq!(wts[0].path, PathBuf::from("/home/user/wt-main"));
208 }
209
210 #[test]
211 fn parse_detached_head() {
212 let output = "worktree /home/user/project\nHEAD abc1234\ndetached\n\n";
213 let wts = parse_worktree_list(output);
214 assert_eq!(wts.len(), 1);
215 assert!(wts[0].branch.is_none());
216 }
217
218 #[test]
219 fn worktree_audit_log_path_resolves() {
220 let wt = WorktreeInfo {
221 path: PathBuf::from("/project/wt-feature"),
222 branch: Some("feature".to_string()),
223 is_main: false,
224 };
225 let path = worktree_audit_log_path(&wt);
226 assert_eq!(
227 path,
228 PathBuf::from("/project/wt-feature/.ito/.state/audit/events.jsonl")
229 );
230 }
231
232 #[test]
233 fn find_worktree_matching_branch() {
234 let output = "\
235worktree /home/user/project
236HEAD abc1234
237branch refs/heads/main
238
239worktree /home/user/wt-feature
240HEAD def5678
241branch refs/heads/002-16_ralph-worktree-awareness
242
243";
244 let result = find_worktree_for_branch_in_output(output, "002-16_ralph-worktree-awareness");
245 assert_eq!(result, Some(PathBuf::from("/home/user/wt-feature")));
246 }
247
248 #[test]
249 fn find_worktree_no_match() {
250 let output = "\
251worktree /home/user/project
252HEAD abc1234
253branch refs/heads/main
254
255";
256 let result = find_worktree_for_branch_in_output(output, "nonexistent-branch");
257 assert!(result.is_none());
258 }
259
260 #[test]
261 fn find_worktree_bare_excluded() {
262 let output = "\
263worktree /home/user/project.git
264bare
265
266worktree /home/user/wt-main
267HEAD abc1234
268branch refs/heads/main
269
270";
271 let result = find_worktree_for_branch_in_output(output, "main");
273 assert_eq!(result, Some(PathBuf::from("/home/user/wt-main")));
274 }
275
276 #[test]
277 fn find_worktree_multiple_returns_first_match() {
278 let output = "\
279worktree /home/user/project.git
280bare
281
282worktree /home/user/wt-main
283HEAD abc1234
284branch refs/heads/main
285
286worktree /home/user/wt-feature-a
287HEAD def5678
288branch refs/heads/feature-a
289
290worktree /home/user/wt-feature-b
291HEAD 9ab0123
292branch refs/heads/feature-b
293
294";
295 let result = find_worktree_for_branch_in_output(output, "feature-b");
296 assert_eq!(result, Some(PathBuf::from("/home/user/wt-feature-b")));
297
298 let result = find_worktree_for_branch_in_output(output, "feature-c");
300 assert!(result.is_none());
301 }
302
303 #[test]
304 fn aggregate_empty_worktrees() {
305 let results = aggregate_worktree_events(&[]);
306 assert!(results.is_empty());
307 }
308
309 #[test]
310 fn aggregate_worktree_with_events() {
311 let tmp = tempfile::tempdir().expect("tempdir");
312 let wt_path = tmp.path().join("wt");
313 std::fs::create_dir_all(&wt_path).expect("create wt dir");
314
315 let wt_ito_path = wt_path.join(".ito");
317 let writer = crate::audit::writer::FsAuditWriter::new(&wt_ito_path);
318 let event = ito_domain::audit::event::AuditEvent {
319 v: 1,
320 ts: "2026-02-08T14:30:00.000Z".to_string(),
321 entity: "task".to_string(),
322 entity_id: "1.1".to_string(),
323 scope: Some("ch".to_string()),
324 op: "create".to_string(),
325 from: None,
326 to: Some("pending".to_string()),
327 actor: "cli".to_string(),
328 by: "@test".to_string(),
329 meta: None,
330 ctx: ito_domain::audit::event::EventContext {
331 session_id: "test".to_string(),
332 harness_session_id: None,
333 branch: None,
334 worktree: None,
335 commit: None,
336 },
337 };
338 ito_domain::audit::writer::AuditWriter::append(&writer, &event).unwrap();
339
340 let wt_info = WorktreeInfo {
341 path: wt_path,
342 branch: Some("main".to_string()),
343 is_main: true,
344 };
345
346 let results = aggregate_worktree_events(&[wt_info]);
347 assert_eq!(results.len(), 1);
348 assert_eq!(results[0].1.len(), 1);
349 }
350}