1use std::collections::HashSet;
4use std::fs;
5use std::path::{Path, PathBuf};
6use std::sync::atomic::{AtomicU64, Ordering};
7use std::time::{SystemTime, UNIX_EPOCH};
8
9use chrono::{DateTime, Duration, Utc};
10use ito_domain::audit::event::{AuditEvent, ops};
11
12use crate::process::{ProcessRequest, ProcessRunner, SystemProcessRunner};
13
14use super::writer::audit_log_path;
15
16const MAX_GIT_AUDIT_EVENTS: usize = 1000;
17const MAX_GIT_AUDIT_AGE_DAYS: i64 = 30;
18static TEMP_NAME_COUNTER: AtomicU64 = AtomicU64::new(0);
19
20#[derive(Debug, thiserror::Error)]
22#[error("{message}")]
23pub struct AuditMirrorError {
24 message: String,
25}
26
27impl AuditMirrorError {
28 fn new(message: impl Into<String>) -> Self {
29 Self {
30 message: message.into(),
31 }
32 }
33}
34
35pub fn sync_audit_mirror(
40 repo_root: &Path,
41 ito_path: &Path,
42 branch: &str,
43) -> Result<(), AuditMirrorError> {
44 let runner = SystemProcessRunner;
45 sync_audit_mirror_with_runner(&runner, repo_root, ito_path, branch)
46}
47
48pub(crate) fn sync_audit_mirror_with_runner(
49 runner: &dyn ProcessRunner,
50 repo_root: &Path,
51 ito_path: &Path,
52 branch: &str,
53) -> Result<(), AuditMirrorError> {
54 if !is_git_worktree(runner, repo_root) {
55 return Ok(());
56 }
57
58 let local_log = audit_log_path(ito_path);
59 if !local_log.exists() {
60 return Ok(());
61 }
62
63 let worktree_path = unique_temp_worktree_path();
64 add_detached_worktree(runner, repo_root, &worktree_path)?;
65 let cleanup = WorktreeCleanup {
66 repo_root: repo_root.to_path_buf(),
67 worktree_path: worktree_path.clone(),
68 };
69
70 let result = (|| -> Result<(), AuditMirrorError> {
71 let fetched = fetch_branch(runner, repo_root, branch);
72 match fetched {
73 Ok(()) => checkout_detached_remote_branch(runner, &worktree_path, branch)?,
74 Err(FetchError::RemoteMissing) => checkout_orphan_branch(runner, &worktree_path)?,
75 Err(FetchError::Other(msg)) => return Err(AuditMirrorError::new(msg)),
76 }
77
78 write_merged_audit_log(
79 &local_log,
80 &worktree_path.join(".ito/.state/audit/events.jsonl"),
81 )?;
82 stage_audit_log(runner, &worktree_path)?;
83
84 if !has_staged_changes(runner, &worktree_path)? {
85 return Ok(());
86 }
87
88 commit_audit_log(runner, &worktree_path)?;
89 if push_branch(runner, &worktree_path, branch)? {
90 return Ok(());
91 }
92
93 let fetched = fetch_branch(runner, repo_root, branch);
95 match fetched {
96 Ok(()) => checkout_detached_remote_branch(runner, &worktree_path, branch)?,
97 Err(FetchError::RemoteMissing) => checkout_orphan_branch(runner, &worktree_path)?,
98 Err(FetchError::Other(msg)) => return Err(AuditMirrorError::new(msg)),
99 }
100 write_merged_audit_log(
101 &local_log,
102 &worktree_path.join(".ito/.state/audit/events.jsonl"),
103 )?;
104 stage_audit_log(runner, &worktree_path)?;
105 if has_staged_changes(runner, &worktree_path)? {
106 commit_audit_log(runner, &worktree_path)?;
107 }
108 if push_branch(runner, &worktree_path, branch)? {
109 return Ok(());
110 }
111
112 Err(AuditMirrorError::new(format!(
113 "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'"
114 )))
115 })();
116
117 let cleanup_err = cleanup.cleanup_with_runner(runner);
118 if let Err(err) = cleanup_err {
119 eprintln!(
120 "Warning: failed to remove temporary audit mirror worktree '{}': {}",
121 cleanup.worktree_path.display(),
122 err
123 );
124 }
125 result
126}
127
128pub(crate) fn append_jsonl_to_internal_branch(
129 repo_root: &Path,
130 branch: &str,
131 jsonl: &str,
132) -> Result<(), AuditMirrorError> {
133 let runner = SystemProcessRunner;
134 append_jsonl_to_internal_branch_with_runner(&runner, repo_root, branch, jsonl)
135}
136
137pub(crate) fn append_jsonl_to_internal_branch_with_runner(
138 runner: &dyn ProcessRunner,
139 repo_root: &Path,
140 branch: &str,
141 jsonl: &str,
142) -> Result<(), AuditMirrorError> {
143 if !is_git_worktree(runner, repo_root) {
144 return Err(AuditMirrorError::new(
145 "internal audit branch unavailable outside a git worktree",
146 ));
147 }
148
149 let mut allow_retry = true;
150 loop {
151 match append_jsonl_to_internal_branch_attempt(runner, repo_root, branch, jsonl)? {
152 AppendBranchResult::Appended => return Ok(()),
153 AppendBranchResult::Conflict if allow_retry => {
154 allow_retry = false;
155 }
156 AppendBranchResult::Conflict => {
157 return Err(AuditMirrorError::new(format!(
158 "failed to update internal audit branch '{branch}' due to concurrent writes; retry the command"
159 )));
160 }
161 }
162 }
163}
164
165enum AppendBranchResult {
166 Appended,
167 Conflict,
168}
169
170fn append_jsonl_to_internal_branch_attempt(
171 runner: &dyn ProcessRunner,
172 repo_root: &Path,
173 branch: &str,
174 jsonl: &str,
175) -> Result<AppendBranchResult, AuditMirrorError> {
176 let expected_old = current_branch_oid(runner, repo_root, branch)?;
177
178 let worktree_path = unique_temp_worktree_path();
179 add_detached_worktree(runner, repo_root, &worktree_path)?;
180 let cleanup = WorktreeCleanup {
181 repo_root: repo_root.to_path_buf(),
182 worktree_path: worktree_path.clone(),
183 };
184
185 let result = (|| -> Result<AppendBranchResult, AuditMirrorError> {
186 if expected_old.is_some() {
187 checkout_detached_local_branch(runner, &worktree_path, branch)?;
188 } else {
189 checkout_orphan_branch(runner, &worktree_path)?;
190 }
191
192 write_merged_jsonl(&worktree_path.join(".ito/.state/audit/events.jsonl"), jsonl)?;
193 stage_audit_log(runner, &worktree_path)?;
194
195 if !has_staged_changes(runner, &worktree_path)? {
196 return Ok(AppendBranchResult::Appended);
197 }
198
199 commit_internal_audit_log(runner, &worktree_path)?;
200 match update_branch_ref(runner, &worktree_path, branch, expected_old.as_deref())? {
201 UpdateRefResult::Updated => Ok(AppendBranchResult::Appended),
202 UpdateRefResult::Conflict => Ok(AppendBranchResult::Conflict),
203 }
204 })();
205
206 let cleanup_err = cleanup.cleanup_with_runner(runner);
207 if let Err(err) = cleanup_err {
208 eprintln!(
209 "Warning: failed to remove temporary audit worktree '{}': {}",
210 cleanup.worktree_path.display(),
211 err
212 );
213 }
214 result
215}
216
217fn current_branch_oid(
218 runner: &dyn ProcessRunner,
219 repo_root: &Path,
220 branch: &str,
221) -> Result<Option<String>, AuditMirrorError> {
222 let out = runner
223 .run(
224 &ProcessRequest::new("git")
225 .args(["rev-parse", "--verify", &format!("refs/heads/{branch}")])
226 .current_dir(repo_root),
227 )
228 .map_err(|e| AuditMirrorError::new(format!("git rev-parse failed: {e}")))?;
229 if out.success {
230 let oid = out.stdout.trim();
231 return Ok((!oid.is_empty()).then(|| oid.to_string()));
232 }
233 let detail = render_output(&out).to_ascii_lowercase();
234 if detail.contains("unknown revision") || detail.contains("needed a single revision") {
235 return Ok(None);
236 }
237 Err(AuditMirrorError::new(format!(
238 "failed to inspect internal audit branch '{branch}' ({})",
239 render_output(&out)
240 )))
241}
242
243pub(crate) fn read_internal_branch_log(
244 repo_root: &Path,
245 branch: &str,
246) -> Result<InternalBranchLogRead, AuditMirrorError> {
247 let runner = SystemProcessRunner;
248 read_internal_branch_log_with_runner(&runner, repo_root, branch)
249}
250
251#[derive(Debug, Clone, PartialEq, Eq)]
252pub(crate) enum InternalBranchLogRead {
253 BranchMissing,
254 LogMissing,
255 Contents(String),
256}
257
258pub(crate) fn read_internal_branch_log_with_runner(
259 runner: &dyn ProcessRunner,
260 repo_root: &Path,
261 branch: &str,
262) -> Result<InternalBranchLogRead, AuditMirrorError> {
263 if !is_git_worktree(runner, repo_root) {
264 return Err(AuditMirrorError::new(
265 "internal audit branch unavailable outside a git worktree",
266 ));
267 }
268
269 if !local_branch_exists(runner, repo_root, branch)? {
270 return Ok(InternalBranchLogRead::BranchMissing);
271 }
272
273 let pathspec = format!("refs/heads/{branch}:.ito/.state/audit/events.jsonl");
274 let out = runner
275 .run(
276 &ProcessRequest::new("git")
277 .args(["show", &pathspec])
278 .current_dir(repo_root),
279 )
280 .map_err(|e| AuditMirrorError::new(format!("git show failed: {e}")))?;
281 if out.success {
282 return Ok(InternalBranchLogRead::Contents(out.stdout));
283 }
284
285 let detail = render_output(&out).to_ascii_lowercase();
286 if detail.contains("does not exist in")
287 || detail.contains("path '.ito/.state/audit/events.jsonl' does not exist")
288 {
289 return Ok(InternalBranchLogRead::LogMissing);
290 }
291
292 Err(AuditMirrorError::new(format!(
293 "failed to read internal audit branch '{branch}' ({})",
294 render_output(&out)
295 )))
296}
297
298fn write_merged_audit_log(local_log: &Path, target_log: &Path) -> Result<(), AuditMirrorError> {
299 let local = fs::read_to_string(local_log)
300 .map_err(|e| AuditMirrorError::new(format!("failed to read local audit log: {e}")))?;
301 let remote = fs::read_to_string(target_log).unwrap_or_default();
302
303 let merged = merge_jsonl_lines(&remote, &local);
304
305 write_jsonl(target_log, &merged)
306}
307
308fn write_merged_jsonl(target_log: &Path, jsonl: &str) -> Result<(), AuditMirrorError> {
309 let existing = fs::read_to_string(target_log).unwrap_or_default();
310 let merged = merge_jsonl_lines(&existing, jsonl);
311
312 write_jsonl(target_log, &merged)
313}
314
315fn write_jsonl(target_log: &Path, contents: &str) -> Result<(), AuditMirrorError> {
316 if let Some(parent) = target_log.parent() {
317 fs::create_dir_all(parent).map_err(|e| {
318 AuditMirrorError::new(format!("failed to create audit mirror dir: {e}"))
319 })?;
320 }
321 fs::write(target_log, contents)
322 .map_err(|e| AuditMirrorError::new(format!("failed to write audit mirror log: {e}")))?;
323 Ok(())
324}
325
326fn merge_jsonl_lines(remote: &str, local: &str) -> String {
327 let mut out: Vec<String> = Vec::new();
328 let mut seen: HashSet<String> = HashSet::new();
329 let mut last_reconcile_key: Option<String> = None;
330
331 for line in remote.lines() {
332 if line.trim().is_empty() {
333 continue;
334 }
335 let reconcile_key = stable_reconcile_key(line);
336 if reconcile_key.is_some() && reconcile_key == last_reconcile_key {
337 increment_last_reconcile_count(&mut out);
338 continue;
339 }
340 out.push(line.to_string());
341 seen.insert(line.to_string());
342 last_reconcile_key = reconcile_key;
343 }
344
345 for line in local.lines() {
346 if line.trim().is_empty() {
347 continue;
348 }
349 let reconcile_key = stable_reconcile_key(line);
350 if reconcile_key.is_some() && reconcile_key == last_reconcile_key {
351 increment_last_reconcile_count(&mut out);
352 continue;
353 }
354 if seen.contains(line) {
355 continue;
356 }
357 out.push(line.to_string());
358 seen.insert(line.to_string());
359 last_reconcile_key = reconcile_key;
360 }
361
362 let out = truncate_git_audit_events(out);
363
364 if out.is_empty() {
365 return String::new();
366 }
367 format!("{}\n", out.join("\n"))
369}
370
371fn truncate_git_audit_events(lines: Vec<String>) -> Vec<String> {
372 let mut events: Vec<(usize, DateTime<Utc>, String)> = Vec::new();
373 for (index, line) in lines.into_iter().enumerate() {
374 let Some(ts) = audit_event_timestamp(&line) else {
375 continue;
376 };
377 events.push((index, ts, line));
378 }
379 let Some((_, newest, _)) = events.iter().max_by_key(|(_, ts, _)| *ts) else {
380 return Vec::new();
381 };
382 let cutoff = *newest - Duration::days(MAX_GIT_AUDIT_AGE_DAYS);
383 let mut events: Vec<(usize, DateTime<Utc>, String)> = events
384 .into_iter()
385 .filter(|(_, ts, _)| *ts >= cutoff)
386 .collect();
387 if events.len() > MAX_GIT_AUDIT_EVENTS {
388 events.sort_by_key(|(index, ts, _)| (*ts, *index));
389 events.drain(0..events.len() - MAX_GIT_AUDIT_EVENTS);
390 }
391 events.sort_by_key(|(index, _, _)| *index);
392 events.into_iter().map(|(_, _, line)| line).collect()
393}
394
395fn audit_event_timestamp(line: &str) -> Option<DateTime<Utc>> {
396 let Ok(event) = serde_json::from_str::<AuditEvent>(line) else {
397 return None;
398 };
399 let Ok(ts) = DateTime::parse_from_rfc3339(&event.ts) else {
400 return None;
401 };
402 Some(ts.with_timezone(&Utc))
403}
404
405fn increment_last_reconcile_count(out: &mut [String]) {
406 let Some(last) = out.last_mut() else {
407 return;
408 };
409 let Ok(mut event) = serde_json::from_str::<AuditEvent>(last) else {
410 return;
411 };
412 if event.op != ops::RECONCILED {
413 return;
414 }
415 event.count = event.count.saturating_add(1);
416 let Ok(serialized) = serde_json::to_string(&event) else {
417 return;
418 };
419 *last = serialized;
420}
421
422fn stable_reconcile_key(line: &str) -> Option<String> {
423 let Ok(event) = serde_json::from_str::<AuditEvent>(line) else {
424 return None;
425 };
426 if event.op != ops::RECONCILED {
427 return None;
428 }
429
430 Some(format!(
431 "{}\u{1f}{}\u{1f}{:?}\u{1f}{:?}\u{1f}{:?}\u{1f}{}\u{1f}{}",
432 event.entity, event.entity_id, event.scope, event.from, event.to, event.actor, event.by
433 ))
434}
435
436fn add_detached_worktree(
437 runner: &dyn ProcessRunner,
438 repo_root: &Path,
439 worktree_path: &Path,
440) -> Result<(), AuditMirrorError> {
441 let out = runner
442 .run(
443 &ProcessRequest::new("git")
444 .args([
445 "worktree",
446 "add",
447 "--detach",
448 worktree_path.to_string_lossy().as_ref(),
449 ])
450 .current_dir(repo_root),
451 )
452 .map_err(|e| AuditMirrorError::new(format!("git worktree add failed: {e}")))?;
453 if out.success {
454 return Ok(());
455 }
456 Err(AuditMirrorError::new(format!(
457 "git worktree add failed ({})",
458 render_output(&out)
459 )))
460}
461
462#[derive(Debug)]
463enum FetchError {
464 RemoteMissing,
465 Other(String),
466}
467
468fn fetch_branch(
469 runner: &dyn ProcessRunner,
470 repo_root: &Path,
471 branch: &str,
472) -> Result<(), FetchError> {
473 let out = runner
474 .run(
475 &ProcessRequest::new("git")
476 .args(["fetch", "origin", branch])
477 .current_dir(repo_root),
478 )
479 .map_err(|e| FetchError::Other(format!("git fetch origin {branch} failed to run: {e}")))?;
480 if out.success {
481 return Ok(());
482 }
483 let detail = render_output(&out);
484 if detail.contains("couldn't find remote ref") {
485 return Err(FetchError::RemoteMissing);
486 }
487 Err(FetchError::Other(format!(
488 "git fetch origin {branch} failed ({detail})"
489 )))
490}
491
492fn checkout_detached_remote_branch(
493 runner: &dyn ProcessRunner,
494 worktree_path: &Path,
495 branch: &str,
496) -> Result<(), AuditMirrorError> {
497 let target = format!("origin/{branch}");
498 let out = runner
499 .run(
500 &ProcessRequest::new("git")
501 .args(["checkout", "--detach", &target])
502 .current_dir(worktree_path),
503 )
504 .map_err(|e| AuditMirrorError::new(format!("git checkout failed: {e}")))?;
505 if out.success {
506 return Ok(());
507 }
508 Err(AuditMirrorError::new(format!(
509 "failed to checkout audit mirror branch '{branch}' ({})",
510 render_output(&out)
511 )))
512}
513
514fn checkout_detached_local_branch(
515 runner: &dyn ProcessRunner,
516 worktree_path: &Path,
517 branch: &str,
518) -> Result<(), AuditMirrorError> {
519 let target = format!("refs/heads/{branch}");
520 let out = runner
521 .run(
522 &ProcessRequest::new("git")
523 .args(["checkout", "--detach", &target])
524 .current_dir(worktree_path),
525 )
526 .map_err(|e| AuditMirrorError::new(format!("git checkout failed: {e}")))?;
527 if out.success {
528 return Ok(());
529 }
530 Err(AuditMirrorError::new(format!(
531 "failed to checkout internal audit branch '{branch}' ({})",
532 render_output(&out)
533 )))
534}
535
536fn checkout_orphan_branch(
537 runner: &dyn ProcessRunner,
538 worktree_path: &Path,
539) -> Result<(), AuditMirrorError> {
540 let orphan = unique_orphan_branch_name();
541 let out = runner
542 .run(
543 &ProcessRequest::new("git")
544 .args(["checkout", "--orphan", orphan.as_str()])
545 .current_dir(worktree_path),
546 )
547 .map_err(|e| AuditMirrorError::new(format!("git checkout --orphan failed: {e}")))?;
548 if !out.success {
549 return Err(AuditMirrorError::new(format!(
550 "failed to create orphan audit mirror worktree ({})",
551 render_output(&out)
552 )));
553 }
554
555 let _ = runner.run(
557 &ProcessRequest::new("git")
558 .args(["rm", "-rf", "."]) .current_dir(worktree_path),
560 );
561 Ok(())
562}
563
564fn stage_audit_log(
565 runner: &dyn ProcessRunner,
566 worktree_path: &Path,
567) -> Result<(), AuditMirrorError> {
568 let relative = ".ito/.state/audit/events.jsonl";
569 let out = runner
570 .run(
571 &ProcessRequest::new("git")
572 .args(["add", "-f", relative])
573 .current_dir(worktree_path),
574 )
575 .map_err(|e| AuditMirrorError::new(format!("git add failed: {e}")))?;
576 if out.success {
577 return Ok(());
578 }
579 Err(AuditMirrorError::new(format!(
580 "failed to stage audit mirror log ({})",
581 render_output(&out)
582 )))
583}
584
585fn has_staged_changes(
586 runner: &dyn ProcessRunner,
587 worktree_path: &Path,
588) -> Result<bool, AuditMirrorError> {
589 let relative = ".ito/.state/audit/events.jsonl";
590 let out = runner
591 .run(
592 &ProcessRequest::new("git")
593 .args(["diff", "--cached", "--quiet", "--", relative])
594 .current_dir(worktree_path),
595 )
596 .map_err(|e| AuditMirrorError::new(format!("git diff --cached failed: {e}")))?;
597 if out.success {
598 return Ok(false);
599 }
600 if out.exit_code == 1 {
601 return Ok(true);
602 }
603 Err(AuditMirrorError::new(format!(
604 "failed to inspect staged audit mirror changes ({})",
605 render_output(&out)
606 )))
607}
608
609fn commit_audit_log(
610 runner: &dyn ProcessRunner,
611 worktree_path: &Path,
612) -> Result<(), AuditMirrorError> {
613 let message = "chore(audit): mirror events";
614 let out = runner
615 .run(
616 &ProcessRequest::new("git")
617 .args(["commit", "-m", message])
618 .current_dir(worktree_path),
619 )
620 .map_err(|e| AuditMirrorError::new(format!("git commit failed: {e}")))?;
621 if out.success {
622 return Ok(());
623 }
624 Err(AuditMirrorError::new(format!(
625 "failed to commit audit mirror update ({})",
626 render_output(&out)
627 )))
628}
629
630fn commit_internal_audit_log(
631 runner: &dyn ProcessRunner,
632 worktree_path: &Path,
633) -> Result<(), AuditMirrorError> {
634 let message = "chore(audit): update internal log";
635 let out = runner
636 .run(
637 &ProcessRequest::new("git")
638 .args(["commit", "-m", message])
639 .current_dir(worktree_path),
640 )
641 .map_err(|e| AuditMirrorError::new(format!("git commit failed: {e}")))?;
642 if out.success {
643 return Ok(());
644 }
645 Err(AuditMirrorError::new(format!(
646 "failed to commit internal audit log ({})",
647 render_output(&out)
648 )))
649}
650
651enum UpdateRefResult {
652 Updated,
653 Conflict,
654}
655
656fn update_branch_ref(
657 runner: &dyn ProcessRunner,
658 worktree_path: &Path,
659 branch: &str,
660 expected_old: Option<&str>,
661) -> Result<UpdateRefResult, AuditMirrorError> {
662 let target = format!("refs/heads/{branch}");
663 let new_oid = branch_head_oid(runner, worktree_path)?;
664 let expected = expected_old.unwrap_or("0000000000000000000000000000000000000000");
665 let out = runner
666 .run(
667 &ProcessRequest::new("git")
668 .args(["update-ref", &target, &new_oid, expected])
669 .current_dir(worktree_path),
670 )
671 .map_err(|e| AuditMirrorError::new(format!("git update-ref failed: {e}")))?;
672 if out.success {
673 return Ok(UpdateRefResult::Updated);
674 }
675 let detail = render_output(&out);
676 let lower = detail.to_ascii_lowercase();
677 if lower.contains("cannot lock ref")
678 || lower.contains("is at ")
679 || lower.contains("reference already exists")
680 {
681 return Ok(UpdateRefResult::Conflict);
682 }
683 Err(AuditMirrorError::new(format!(
684 "failed to update internal audit branch '{branch}' ({})",
685 detail
686 )))
687}
688
689fn branch_head_oid(
690 runner: &dyn ProcessRunner,
691 worktree_path: &Path,
692) -> Result<String, AuditMirrorError> {
693 let out = runner
694 .run(
695 &ProcessRequest::new("git")
696 .args(["rev-parse", "HEAD"])
697 .current_dir(worktree_path),
698 )
699 .map_err(|e| AuditMirrorError::new(format!("git rev-parse HEAD failed: {e}")))?;
700 if out.success {
701 let oid = out.stdout.trim();
702 if !oid.is_empty() {
703 return Ok(oid.to_string());
704 }
705 }
706 Err(AuditMirrorError::new(format!(
707 "failed to resolve internal audit commit ({})",
708 render_output(&out)
709 )))
710}
711
712fn push_branch(
716 runner: &dyn ProcessRunner,
717 worktree_path: &Path,
718 branch: &str,
719) -> Result<bool, AuditMirrorError> {
720 let refspec = format!("HEAD:refs/heads/{branch}");
721 let out = runner
722 .run(
723 &ProcessRequest::new("git")
724 .args(["push", "origin", &refspec])
725 .current_dir(worktree_path),
726 )
727 .map_err(|e| AuditMirrorError::new(format!("git push failed to run: {e}")))?;
728 if out.success {
729 return Ok(true);
730 }
731
732 let detail = render_output(&out);
733 if detail.contains("non-fast-forward") {
734 return Ok(false);
735 }
736
737 Err(AuditMirrorError::new(format!(
738 "audit mirror push failed ({detail})"
739 )))
740}
741
742fn local_branch_exists(
743 runner: &dyn ProcessRunner,
744 repo_root: &Path,
745 branch: &str,
746) -> Result<bool, AuditMirrorError> {
747 let target = format!("refs/heads/{branch}");
748 let out = runner
749 .run(
750 &ProcessRequest::new("git")
751 .args(["show-ref", "--verify", "--quiet", &target])
752 .current_dir(repo_root),
753 )
754 .map_err(|e| AuditMirrorError::new(format!("git show-ref failed: {e}")))?;
755 if out.success {
756 return Ok(true);
757 }
758 if out.exit_code == 1 {
759 return Ok(false);
760 }
761
762 Err(AuditMirrorError::new(format!(
763 "failed to inspect internal audit branch '{branch}' ({})",
764 render_output(&out)
765 )))
766}
767
768fn is_git_worktree(runner: &dyn ProcessRunner, repo_root: &Path) -> bool {
769 let out = runner.run(
770 &ProcessRequest::new("git")
771 .args(["rev-parse", "--is-inside-work-tree"])
772 .current_dir(repo_root),
773 );
774 let Ok(out) = out else {
775 return false;
776 };
777 out.success && out.stdout.trim() == "true"
778}
779
780fn render_output(out: &crate::process::ProcessOutput) -> String {
781 let stdout = out.stdout.trim();
782 let stderr = out.stderr.trim();
783
784 if !stderr.is_empty() {
785 return stderr.to_string();
786 }
787 if !stdout.is_empty() {
788 return stdout.to_string();
789 }
790 "no command output".to_string()
791}
792
793fn unique_temp_worktree_path() -> PathBuf {
794 let pid = std::process::id();
795 let sequence = TEMP_NAME_COUNTER.fetch_add(1, Ordering::Relaxed);
796 let nanos = match SystemTime::now().duration_since(UNIX_EPOCH) {
797 Ok(duration) => duration.as_nanos(),
798 Err(_) => 0,
799 };
800 std::env::temp_dir().join(format!("ito-audit-mirror-{pid}-{nanos}-{sequence}"))
801}
802
803fn unique_orphan_branch_name() -> String {
804 let pid = std::process::id();
805 let sequence = TEMP_NAME_COUNTER.fetch_add(1, Ordering::Relaxed);
806 let nanos = match SystemTime::now().duration_since(UNIX_EPOCH) {
807 Ok(duration) => duration.as_nanos(),
808 Err(_) => 0,
809 };
810 format!("ito-audit-mirror-orphan-{pid}-{nanos}-{sequence}")
811}
812
813struct WorktreeCleanup {
814 repo_root: PathBuf,
815 worktree_path: PathBuf,
816}
817
818impl WorktreeCleanup {
819 fn cleanup_with_runner(&self, runner: &dyn ProcessRunner) -> Result<(), String> {
820 let out = runner.run(
821 &ProcessRequest::new("git")
822 .args([
823 "worktree",
824 "remove",
825 "--force",
826 self.worktree_path.to_string_lossy().as_ref(),
827 ])
828 .current_dir(&self.repo_root),
829 );
830 if let Err(err) = out {
831 return Err(format!("git worktree remove failed: {err}"));
832 }
833
834 let _ = fs::remove_dir_all(&self.worktree_path);
836 Ok(())
837 }
838}
839
840impl Drop for WorktreeCleanup {
841 fn drop(&mut self) {
842 let _ = fs::remove_dir_all(&self.worktree_path);
845 }
846}
847
848#[cfg(test)]
849mod tests {
850 use super::*;
851
852 #[test]
853 fn merge_jsonl_dedupes_and_appends_local_lines() {
854 let a = task_event_id("2026-04-26T11:28:00.000Z", "1", "create", "pending");
855 let b = task_event_id("2026-04-26T11:28:01.000Z", "2", "create", "pending");
856 let c = task_event_id("2026-04-26T11:28:02.000Z", "3", "create", "pending");
857 let remote = format!("{a}\n{b}\n");
858 let local = format!("{b}\n{c}\n");
859 let merged = merge_jsonl_lines(&remote, &local);
860 assert_eq!(merged, format!("{a}\n{b}\n{c}\n"));
861 }
862
863 #[test]
864 fn merge_jsonl_ignores_blank_lines() {
865 let event = task_event("2026-04-26T11:28:00.000Z", "create", "pending");
866 let remote = format!("\n{event}\n\n");
867 let local = "\n\n";
868 let merged = merge_jsonl_lines(&remote, local);
869 assert_eq!(merged, format!("{event}\n"));
870 }
871
872 #[test]
873 fn merge_jsonl_aggregates_adjacent_equivalent_reconciled_events() {
874 let remote = format!(
875 "{}\n",
876 reconcile_event("2026-04-26T11:28:01.873Z", "pending")
877 );
878 let local = format!(
879 "{}\n",
880 reconcile_event("2026-04-26T11:28:21.643Z", "pending")
881 );
882
883 let merged = merge_jsonl_lines(&remote, &local);
884 let mut lines = merged.lines();
885 let event: serde_json::Value = serde_json::from_str(lines.next().unwrap()).unwrap();
886 assert_eq!(event["count"], serde_json::json!(2));
887 assert_eq!(lines.next(), None);
888 }
889
890 #[test]
891 fn merge_jsonl_keeps_reconciled_events_after_different_event() {
892 let remote = format!(
893 "{}\n{}\n",
894 reconcile_event("2026-04-26T11:28:01.873Z", "pending"),
895 task_event("2026-04-26T11:28:10.000Z", "status_change", "complete")
896 );
897 let local = format!(
898 "{}\n",
899 reconcile_event("2026-04-26T11:28:21.643Z", "pending")
900 );
901
902 let merged = merge_jsonl_lines(&remote, &local);
903 assert_eq!(merged, format!("{}{}", remote, local));
904 }
905
906 #[test]
907 fn merge_jsonl_drops_events_older_than_one_month_from_newest_event() {
908 let old = task_event_id("2026-03-01T00:00:00.000Z", "old", "create", "pending");
909 let recent = task_event_id("2026-03-28T00:00:00.000Z", "recent", "create", "pending");
910 let newest = task_event_id("2026-04-26T00:00:00.000Z", "newest", "create", "pending");
911 let remote = format!("{old}\n{recent}\n");
912 let local = format!("{newest}\n");
913
914 let merged = merge_jsonl_lines(&remote, &local);
915 assert_eq!(merged, format!("{recent}\n{newest}\n"));
916 }
917
918 #[test]
919 fn merge_jsonl_caps_git_log_to_newest_1000_events() {
920 let mut local = String::new();
921 for i in 0..1005 {
922 local.push_str(&task_event_id(
923 "2026-04-26T00:00:00.000Z",
924 &i.to_string(),
925 "create",
926 "pending",
927 ));
928 local.push('\n');
929 }
930
931 let merged = merge_jsonl_lines("", &local);
932 let lines: Vec<&str> = merged.lines().collect();
933 assert_eq!(lines.len(), 1000);
934 assert!(lines[0].contains("\"entity_id\":\"5\""));
935 assert!(lines[999].contains("\"entity_id\":\"1004\""));
936 }
937
938 #[test]
939 fn merge_jsonl_count_cap_uses_timestamp_not_input_position() {
940 let old = task_event_id("2026-04-01T00:00:00.000Z", "old", "create", "pending");
941 let mut local = format!("{old}\n");
942 for i in 0..1000 {
943 local.push_str(&task_event_id(
944 "2026-04-26T00:00:00.000Z",
945 &i.to_string(),
946 "create",
947 "pending",
948 ));
949 local.push('\n');
950 }
951
952 let merged = merge_jsonl_lines("", &local);
953 let lines: Vec<&str> = merged.lines().collect();
954 assert_eq!(lines.len(), 1000);
955 assert!(!merged.contains("\"entity_id\":\"old\""));
956 }
957
958 fn reconcile_event(ts: &str, from: &str) -> String {
959 serde_json::json!({
960 "v": 1,
961 "ts": ts,
962 "entity": "task",
963 "entity_id": "3.2",
964 "scope": "001-33_enhance-spec-driven-workflow-validation",
965 "op": "reconciled",
966 "from": from,
967 "actor": "reconcile",
968 "by": "@reconcile",
969 "meta": {
970 "reason": "task '3.2' has audit status 'pending' but no file entry"
971 },
972 "ctx": {
973 "session_id": "test",
974 "branch": "main",
975 "worktree": "main",
976 "commit": "abc123"
977 }
978 })
979 .to_string()
980 }
981
982 fn task_event(ts: &str, op: &str, to: &str) -> String {
983 task_event_id(ts, "3.2", op, to)
984 }
985
986 fn task_event_id(ts: &str, entity_id: &str, op: &str, to: &str) -> String {
987 serde_json::json!({
988 "v": 1,
989 "ts": ts,
990 "entity": "task",
991 "entity_id": entity_id,
992 "scope": "001-33_enhance-spec-driven-workflow-validation",
993 "op": op,
994 "to": to,
995 "actor": "cli",
996 "by": "@test",
997 "ctx": {
998 "session_id": "test"
999 }
1000 })
1001 .to_string()
1002 }
1003}