Skip to main content

ito_core/audit/
mirror.rs

1//! Best-effort synchronization of the local audit log to a dedicated remote branch.
2
3use 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/// Failure details from an audit mirror sync attempt.
21#[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
35/// Attempt to sync the local audit log to `origin/<branch>`.
36///
37/// This function returns an error when a sync attempt fails; callers that
38/// require best-effort semantics should log/print the error and continue.
39pub 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        // Retry once on non-fast-forward by refetching and re-merging.
94        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    // Ensure trailing newline so subsequent appends are line-oriented.
368    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    // Remove tracked files from the index to keep the mirror branch focused.
556    let _ = runner.run(
557        &ProcessRequest::new("git")
558            .args(["rm", "-rf", "."]) // best-effort; may fail on empty trees
559            .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
712/// Push `HEAD` to `origin/<branch>`.
713///
714/// Returns `Ok(true)` when push succeeded, `Ok(false)` when the push was rejected due to non-fast-forward.
715fn 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        // Ensure the directory is gone even if git left it behind.
835        let _ = fs::remove_dir_all(&self.worktree_path);
836        Ok(())
837    }
838}
839
840impl Drop for WorktreeCleanup {
841    fn drop(&mut self) {
842        // Best-effort panic-safety: if callers unwind before `git worktree remove`
843        // runs, still remove the directory to avoid littering temp.
844        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}