Skip to main content

ito_core/audit/
worktree.rs

1//! Worktree discovery for audit event aggregation and streaming.
2//!
3//! Uses `git worktree list --porcelain` to enumerate all worktrees and
4//! resolves their audit event file paths.
5
6use 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
13/// Discover all git worktrees that have an audit events file.
14///
15/// Returns an empty vec if git is unavailable, not in a repo, or no
16/// worktrees have audit logs.
17pub 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
34/// Parse `git worktree list --porcelain` output into `WorktreeInfo` entries.
35fn 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            // Save previous worktree
44            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                    // Only include worktrees that have an audit log or .ito dir
49                    let has_ito = wt_ito_path.exists();
50                    worktrees.push(WorktreeInfo {
51                        path,
52                        branch: current_branch.take(),
53                        is_main: worktrees.is_empty(), // First worktree is main
54                    });
55                    if !has_ito {
56                        // Still include it but note the log path may not exist yet
57                        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            // refs/heads/main -> main
66            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            // Block separator — flush current
71            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    // Flush last entry
86    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
99/// Find the worktree path for a branch by parsing `git worktree list --porcelain`.
100///
101/// Returns the worktree root directory if a non-bare worktree exists whose
102/// branch name matches `branch`. Bare worktrees are excluded.
103///
104/// Used by Ralph to resolve the effective working directory for a change
105/// when worktree-based workflows are active.
106pub 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
120/// Parse porcelain worktree output and find the path for a given branch name.
121///
122/// This is the testable core of [`find_worktree_for_branch`].
123fn 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
131/// Get the audit log path for a worktree.
132pub fn worktree_audit_log_path(worktree: &WorktreeInfo) -> PathBuf {
133    audit_log_path(&worktree.path.join(".ito"))
134}
135
136/// Read and aggregate events from all worktrees.
137///
138/// Returns events grouped by worktree. Only worktrees with existing
139/// event files are included.
140pub 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        // Even though the bare repo is listed first, it should be excluded
272        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        // Non-matching returns None
299        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        // Write an event to this worktree's audit log
316        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}