1use std::collections::HashSet;
4use std::fs;
5use std::path::{Path, PathBuf};
6use std::time::{SystemTime, UNIX_EPOCH};
7
8use crate::process::{ProcessRequest, ProcessRunner, SystemProcessRunner};
9
10use super::writer::audit_log_path;
11
12#[derive(Debug, thiserror::Error)]
14#[error("{message}")]
15pub struct AuditMirrorError {
16 message: String,
17}
18
19impl AuditMirrorError {
20 fn new(message: impl Into<String>) -> Self {
21 Self {
22 message: message.into(),
23 }
24 }
25}
26
27pub fn sync_audit_mirror(
32 repo_root: &Path,
33 ito_path: &Path,
34 branch: &str,
35) -> Result<(), AuditMirrorError> {
36 let runner = SystemProcessRunner;
37 sync_audit_mirror_with_runner(&runner, repo_root, ito_path, branch)
38}
39
40pub(crate) fn sync_audit_mirror_with_runner(
41 runner: &dyn ProcessRunner,
42 repo_root: &Path,
43 ito_path: &Path,
44 branch: &str,
45) -> Result<(), AuditMirrorError> {
46 if !is_git_worktree(runner, repo_root) {
47 return Ok(());
48 }
49
50 let local_log = audit_log_path(ito_path);
51 if !local_log.exists() {
52 return Ok(());
53 }
54
55 let worktree_path = unique_temp_worktree_path();
56 add_detached_worktree(runner, repo_root, &worktree_path)?;
57 let cleanup = WorktreeCleanup {
58 repo_root: repo_root.to_path_buf(),
59 worktree_path: worktree_path.clone(),
60 };
61
62 let result = (|| {
63 let fetched = fetch_branch(runner, repo_root, branch);
64 match fetched {
65 Ok(()) => checkout_detached_remote_branch(runner, &worktree_path, branch)?,
66 Err(FetchError::RemoteMissing) => checkout_orphan_branch(runner, &worktree_path)?,
67 Err(FetchError::Other(msg)) => return Err(AuditMirrorError::new(msg)),
68 }
69
70 write_merged_audit_log(
71 &local_log,
72 &worktree_path.join(".ito/.state/audit/events.jsonl"),
73 )?;
74 stage_audit_log(runner, &worktree_path)?;
75
76 if !has_staged_changes(runner, &worktree_path)? {
77 return Ok(());
78 }
79
80 commit_audit_log(runner, &worktree_path)?;
81 if push_branch(runner, &worktree_path, branch)? {
82 return Ok(());
83 }
84
85 let fetched = fetch_branch(runner, repo_root, branch);
87 match fetched {
88 Ok(()) => checkout_detached_remote_branch(runner, &worktree_path, branch)?,
89 Err(FetchError::RemoteMissing) => checkout_orphan_branch(runner, &worktree_path)?,
90 Err(FetchError::Other(msg)) => return Err(AuditMirrorError::new(msg)),
91 }
92 write_merged_audit_log(
93 &local_log,
94 &worktree_path.join(".ito/.state/audit/events.jsonl"),
95 )?;
96 stage_audit_log(runner, &worktree_path)?;
97 if has_staged_changes(runner, &worktree_path)? {
98 commit_audit_log(runner, &worktree_path)?;
99 }
100 if push_branch(runner, &worktree_path, branch)? {
101 return Ok(());
102 }
103
104 Err(AuditMirrorError::new(format!(
105 "audit mirror push to '{branch}' failed due to a remote conflict; try 'git fetch origin {branch}' and re-run, or disable mirroring with 'ito config set audit.mirror.enabled false'"
106 )))
107 })();
108
109 let cleanup_err = cleanup.cleanup_with_runner(runner);
110 if let Err(err) = cleanup_err {
111 eprintln!(
112 "Warning: failed to remove temporary audit mirror worktree '{}': {}",
113 cleanup.worktree_path.display(),
114 err
115 );
116 }
117 result
118}
119
120fn write_merged_audit_log(local_log: &Path, target_log: &Path) -> Result<(), AuditMirrorError> {
121 let local = fs::read_to_string(local_log)
122 .map_err(|e| AuditMirrorError::new(format!("failed to read local audit log: {e}")))?;
123 let remote = fs::read_to_string(target_log).unwrap_or_default();
124
125 let merged = merge_jsonl_lines(&remote, &local);
126
127 if let Some(parent) = target_log.parent() {
128 fs::create_dir_all(parent).map_err(|e| {
129 AuditMirrorError::new(format!("failed to create audit mirror dir: {e}"))
130 })?;
131 }
132 fs::write(target_log, merged)
133 .map_err(|e| AuditMirrorError::new(format!("failed to write audit mirror log: {e}")))?;
134 Ok(())
135}
136
137fn merge_jsonl_lines(remote: &str, local: &str) -> String {
138 let mut out: Vec<String> = Vec::new();
139 let mut seen: HashSet<String> = HashSet::new();
140
141 for line in remote.lines() {
142 if line.trim().is_empty() {
143 continue;
144 }
145 out.push(line.to_string());
146 seen.insert(line.to_string());
147 }
148
149 for line in local.lines() {
150 if line.trim().is_empty() {
151 continue;
152 }
153 if seen.contains(line) {
154 continue;
155 }
156 out.push(line.to_string());
157 seen.insert(line.to_string());
158 }
159
160 if out.is_empty() {
161 return String::new();
162 }
163 format!("{}\n", out.join("\n"))
165}
166
167fn add_detached_worktree(
168 runner: &dyn ProcessRunner,
169 repo_root: &Path,
170 worktree_path: &Path,
171) -> Result<(), AuditMirrorError> {
172 let out = runner
173 .run(
174 &ProcessRequest::new("git")
175 .args([
176 "worktree",
177 "add",
178 "--detach",
179 worktree_path.to_string_lossy().as_ref(),
180 ])
181 .current_dir(repo_root),
182 )
183 .map_err(|e| AuditMirrorError::new(format!("git worktree add failed: {e}")))?;
184 if out.success {
185 return Ok(());
186 }
187 Err(AuditMirrorError::new(format!(
188 "git worktree add failed ({})",
189 render_output(&out)
190 )))
191}
192
193#[derive(Debug)]
194enum FetchError {
195 RemoteMissing,
196 Other(String),
197}
198
199fn fetch_branch(
200 runner: &dyn ProcessRunner,
201 repo_root: &Path,
202 branch: &str,
203) -> Result<(), FetchError> {
204 let out = runner
205 .run(
206 &ProcessRequest::new("git")
207 .args(["fetch", "origin", branch])
208 .current_dir(repo_root),
209 )
210 .map_err(|e| FetchError::Other(format!("git fetch origin {branch} failed to run: {e}")))?;
211 if out.success {
212 return Ok(());
213 }
214 let detail = render_output(&out);
215 if detail.contains("couldn't find remote ref") {
216 return Err(FetchError::RemoteMissing);
217 }
218 Err(FetchError::Other(format!(
219 "git fetch origin {branch} failed ({detail})"
220 )))
221}
222
223fn checkout_detached_remote_branch(
224 runner: &dyn ProcessRunner,
225 worktree_path: &Path,
226 branch: &str,
227) -> Result<(), AuditMirrorError> {
228 let target = format!("origin/{branch}");
229 let out = runner
230 .run(
231 &ProcessRequest::new("git")
232 .args(["checkout", "--detach", &target])
233 .current_dir(worktree_path),
234 )
235 .map_err(|e| AuditMirrorError::new(format!("git checkout failed: {e}")))?;
236 if out.success {
237 return Ok(());
238 }
239 Err(AuditMirrorError::new(format!(
240 "failed to checkout audit mirror branch '{branch}' ({})",
241 render_output(&out)
242 )))
243}
244
245fn checkout_orphan_branch(
246 runner: &dyn ProcessRunner,
247 worktree_path: &Path,
248) -> Result<(), AuditMirrorError> {
249 let orphan = unique_orphan_branch_name();
250 let out = runner
251 .run(
252 &ProcessRequest::new("git")
253 .args(["checkout", "--orphan", orphan.as_str()])
254 .current_dir(worktree_path),
255 )
256 .map_err(|e| AuditMirrorError::new(format!("git checkout --orphan failed: {e}")))?;
257 if !out.success {
258 return Err(AuditMirrorError::new(format!(
259 "failed to create orphan audit mirror worktree ({})",
260 render_output(&out)
261 )));
262 }
263
264 let _ = runner.run(
266 &ProcessRequest::new("git")
267 .args(["rm", "-rf", "."]) .current_dir(worktree_path),
269 );
270 Ok(())
271}
272
273fn stage_audit_log(
274 runner: &dyn ProcessRunner,
275 worktree_path: &Path,
276) -> Result<(), AuditMirrorError> {
277 let relative = ".ito/.state/audit/events.jsonl";
278 let out = runner
279 .run(
280 &ProcessRequest::new("git")
281 .args(["add", "-f", relative])
282 .current_dir(worktree_path),
283 )
284 .map_err(|e| AuditMirrorError::new(format!("git add failed: {e}")))?;
285 if out.success {
286 return Ok(());
287 }
288 Err(AuditMirrorError::new(format!(
289 "failed to stage audit mirror log ({})",
290 render_output(&out)
291 )))
292}
293
294fn has_staged_changes(
295 runner: &dyn ProcessRunner,
296 worktree_path: &Path,
297) -> Result<bool, AuditMirrorError> {
298 let relative = ".ito/.state/audit/events.jsonl";
299 let out = runner
300 .run(
301 &ProcessRequest::new("git")
302 .args(["diff", "--cached", "--quiet", "--", relative])
303 .current_dir(worktree_path),
304 )
305 .map_err(|e| AuditMirrorError::new(format!("git diff --cached failed: {e}")))?;
306 if out.success {
307 return Ok(false);
308 }
309 if out.exit_code == 1 {
310 return Ok(true);
311 }
312 Err(AuditMirrorError::new(format!(
313 "failed to inspect staged audit mirror changes ({})",
314 render_output(&out)
315 )))
316}
317
318fn commit_audit_log(
319 runner: &dyn ProcessRunner,
320 worktree_path: &Path,
321) -> Result<(), AuditMirrorError> {
322 let message = "chore(audit): mirror events";
323 let out = runner
324 .run(
325 &ProcessRequest::new("git")
326 .args(["commit", "-m", message])
327 .current_dir(worktree_path),
328 )
329 .map_err(|e| AuditMirrorError::new(format!("git commit failed: {e}")))?;
330 if out.success {
331 return Ok(());
332 }
333 Err(AuditMirrorError::new(format!(
334 "failed to commit audit mirror update ({})",
335 render_output(&out)
336 )))
337}
338
339fn push_branch(
343 runner: &dyn ProcessRunner,
344 worktree_path: &Path,
345 branch: &str,
346) -> Result<bool, AuditMirrorError> {
347 let refspec = format!("HEAD:refs/heads/{branch}");
348 let out = runner
349 .run(
350 &ProcessRequest::new("git")
351 .args(["push", "origin", &refspec])
352 .current_dir(worktree_path),
353 )
354 .map_err(|e| AuditMirrorError::new(format!("git push failed to run: {e}")))?;
355 if out.success {
356 return Ok(true);
357 }
358
359 let detail = render_output(&out);
360 if detail.contains("non-fast-forward") {
361 return Ok(false);
362 }
363
364 Err(AuditMirrorError::new(format!(
365 "audit mirror push failed ({detail})"
366 )))
367}
368
369fn is_git_worktree(runner: &dyn ProcessRunner, repo_root: &Path) -> bool {
370 let out = runner.run(
371 &ProcessRequest::new("git")
372 .args(["rev-parse", "--is-inside-work-tree"])
373 .current_dir(repo_root),
374 );
375 let Ok(out) = out else {
376 return false;
377 };
378 out.success && out.stdout.trim() == "true"
379}
380
381fn render_output(out: &crate::process::ProcessOutput) -> String {
382 let stdout = out.stdout.trim();
383 let stderr = out.stderr.trim();
384
385 if !stderr.is_empty() {
386 return stderr.to_string();
387 }
388 if !stdout.is_empty() {
389 return stdout.to_string();
390 }
391 "no command output".to_string()
392}
393
394fn unique_temp_worktree_path() -> PathBuf {
395 let pid = std::process::id();
396 let nanos = match SystemTime::now().duration_since(UNIX_EPOCH) {
397 Ok(duration) => duration.as_nanos(),
398 Err(_) => 0,
399 };
400 std::env::temp_dir().join(format!("ito-audit-mirror-{pid}-{nanos}"))
401}
402
403fn unique_orphan_branch_name() -> String {
404 let pid = std::process::id();
405 let nanos = match SystemTime::now().duration_since(UNIX_EPOCH) {
406 Ok(duration) => duration.as_nanos(),
407 Err(_) => 0,
408 };
409 format!("ito-audit-mirror-orphan-{pid}-{nanos}")
410}
411
412struct WorktreeCleanup {
413 repo_root: PathBuf,
414 worktree_path: PathBuf,
415}
416
417impl WorktreeCleanup {
418 fn cleanup_with_runner(&self, runner: &dyn ProcessRunner) -> Result<(), String> {
419 let out = runner.run(
420 &ProcessRequest::new("git")
421 .args([
422 "worktree",
423 "remove",
424 "--force",
425 self.worktree_path.to_string_lossy().as_ref(),
426 ])
427 .current_dir(&self.repo_root),
428 );
429 if let Err(err) = out {
430 return Err(format!("git worktree remove failed: {err}"));
431 }
432
433 let _ = fs::remove_dir_all(&self.worktree_path);
435 Ok(())
436 }
437}
438
439impl Drop for WorktreeCleanup {
440 fn drop(&mut self) {
441 let _ = fs::remove_dir_all(&self.worktree_path);
444 }
445}
446
447#[cfg(test)]
448mod tests {
449 use super::*;
450
451 #[test]
452 fn merge_jsonl_dedupes_and_appends_local_lines() {
453 let remote = "{\"a\":1}\n{\"b\":2}\n";
454 let local = "{\"b\":2}\n{\"c\":3}\n";
455 let merged = merge_jsonl_lines(remote, local);
456 assert_eq!(merged, "{\"a\":1}\n{\"b\":2}\n{\"c\":3}\n");
457 }
458
459 #[test]
460 fn merge_jsonl_ignores_blank_lines() {
461 let remote = "\n{\"a\":1}\n\n";
462 let local = "\n\n";
463 let merged = merge_jsonl_lines(remote, local);
464 assert_eq!(merged, "{\"a\":1}\n");
465 }
466}