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::time::{SystemTime, UNIX_EPOCH};
7
8use crate::process::{ProcessRequest, ProcessRunner, SystemProcessRunner};
9
10use super::writer::audit_log_path;
11
12/// Failure details from an audit mirror sync attempt.
13#[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
27/// Attempt to sync the local audit log to `origin/<branch>`.
28///
29/// This function returns an error when a sync attempt fails; callers that
30/// require best-effort semantics should log/print the error and continue.
31pub 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        // Retry once on non-fast-forward by refetching and re-merging.
86        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    // Ensure trailing newline so subsequent appends are line-oriented.
164    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    // Remove tracked files from the index to keep the mirror branch focused.
265    let _ = runner.run(
266        &ProcessRequest::new("git")
267            .args(["rm", "-rf", "."]) // best-effort; may fail on empty trees
268            .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
339/// Push `HEAD` to `origin/<branch>`.
340///
341/// Returns `Ok(true)` when push succeeded, `Ok(false)` when the push was rejected due to non-fast-forward.
342fn 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        // Ensure the directory is gone even if git left it behind.
434        let _ = fs::remove_dir_all(&self.worktree_path);
435        Ok(())
436    }
437}
438
439impl Drop for WorktreeCleanup {
440    fn drop(&mut self) {
441        // Best-effort panic-safety: if callers unwind before `git worktree remove`
442        // runs, still remove the directory to avoid littering temp.
443        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}