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 = (|| -> Result<(), AuditMirrorError> {
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
120pub(crate) fn append_jsonl_to_internal_branch(
121    repo_root: &Path,
122    branch: &str,
123    jsonl: &str,
124) -> Result<(), AuditMirrorError> {
125    let runner = SystemProcessRunner;
126    append_jsonl_to_internal_branch_with_runner(&runner, repo_root, branch, jsonl)
127}
128
129pub(crate) fn append_jsonl_to_internal_branch_with_runner(
130    runner: &dyn ProcessRunner,
131    repo_root: &Path,
132    branch: &str,
133    jsonl: &str,
134) -> Result<(), AuditMirrorError> {
135    if !is_git_worktree(runner, repo_root) {
136        return Err(AuditMirrorError::new(
137            "internal audit branch unavailable outside a git worktree",
138        ));
139    }
140
141    let mut allow_retry = true;
142    loop {
143        match append_jsonl_to_internal_branch_attempt(runner, repo_root, branch, jsonl)? {
144            AppendBranchResult::Appended => return Ok(()),
145            AppendBranchResult::Conflict if allow_retry => {
146                allow_retry = false;
147            }
148            AppendBranchResult::Conflict => {
149                return Err(AuditMirrorError::new(format!(
150                    "failed to update internal audit branch '{branch}' due to concurrent writes; retry the command"
151                )));
152            }
153        }
154    }
155}
156
157enum AppendBranchResult {
158    Appended,
159    Conflict,
160}
161
162fn append_jsonl_to_internal_branch_attempt(
163    runner: &dyn ProcessRunner,
164    repo_root: &Path,
165    branch: &str,
166    jsonl: &str,
167) -> Result<AppendBranchResult, AuditMirrorError> {
168    let expected_old = current_branch_oid(runner, repo_root, branch)?;
169
170    let worktree_path = unique_temp_worktree_path();
171    add_detached_worktree(runner, repo_root, &worktree_path)?;
172    let cleanup = WorktreeCleanup {
173        repo_root: repo_root.to_path_buf(),
174        worktree_path: worktree_path.clone(),
175    };
176
177    let result = (|| -> Result<AppendBranchResult, AuditMirrorError> {
178        if expected_old.is_some() {
179            checkout_detached_local_branch(runner, &worktree_path, branch)?;
180        } else {
181            checkout_orphan_branch(runner, &worktree_path)?;
182        }
183
184        write_merged_jsonl(&worktree_path.join(".ito/.state/audit/events.jsonl"), jsonl)?;
185        stage_audit_log(runner, &worktree_path)?;
186
187        if !has_staged_changes(runner, &worktree_path)? {
188            return Ok(AppendBranchResult::Appended);
189        }
190
191        commit_internal_audit_log(runner, &worktree_path)?;
192        match update_branch_ref(runner, &worktree_path, branch, expected_old.as_deref())? {
193            UpdateRefResult::Updated => Ok(AppendBranchResult::Appended),
194            UpdateRefResult::Conflict => Ok(AppendBranchResult::Conflict),
195        }
196    })();
197
198    let cleanup_err = cleanup.cleanup_with_runner(runner);
199    if let Err(err) = cleanup_err {
200        eprintln!(
201            "Warning: failed to remove temporary audit worktree '{}': {}",
202            cleanup.worktree_path.display(),
203            err
204        );
205    }
206    result
207}
208
209fn current_branch_oid(
210    runner: &dyn ProcessRunner,
211    repo_root: &Path,
212    branch: &str,
213) -> Result<Option<String>, AuditMirrorError> {
214    let out = runner
215        .run(
216            &ProcessRequest::new("git")
217                .args(["rev-parse", "--verify", &format!("refs/heads/{branch}")])
218                .current_dir(repo_root),
219        )
220        .map_err(|e| AuditMirrorError::new(format!("git rev-parse failed: {e}")))?;
221    if out.success {
222        let oid = out.stdout.trim();
223        return Ok((!oid.is_empty()).then(|| oid.to_string()));
224    }
225    let detail = render_output(&out).to_ascii_lowercase();
226    if detail.contains("unknown revision") || detail.contains("needed a single revision") {
227        return Ok(None);
228    }
229    Err(AuditMirrorError::new(format!(
230        "failed to inspect internal audit branch '{branch}' ({})",
231        render_output(&out)
232    )))
233}
234
235pub(crate) fn read_internal_branch_log(
236    repo_root: &Path,
237    branch: &str,
238) -> Result<InternalBranchLogRead, AuditMirrorError> {
239    let runner = SystemProcessRunner;
240    read_internal_branch_log_with_runner(&runner, repo_root, branch)
241}
242
243#[derive(Debug, Clone, PartialEq, Eq)]
244pub(crate) enum InternalBranchLogRead {
245    BranchMissing,
246    LogMissing,
247    Contents(String),
248}
249
250pub(crate) fn read_internal_branch_log_with_runner(
251    runner: &dyn ProcessRunner,
252    repo_root: &Path,
253    branch: &str,
254) -> Result<InternalBranchLogRead, AuditMirrorError> {
255    if !is_git_worktree(runner, repo_root) {
256        return Err(AuditMirrorError::new(
257            "internal audit branch unavailable outside a git worktree",
258        ));
259    }
260
261    if !local_branch_exists(runner, repo_root, branch)? {
262        return Ok(InternalBranchLogRead::BranchMissing);
263    }
264
265    let pathspec = format!("refs/heads/{branch}:.ito/.state/audit/events.jsonl");
266    let out = runner
267        .run(
268            &ProcessRequest::new("git")
269                .args(["show", &pathspec])
270                .current_dir(repo_root),
271        )
272        .map_err(|e| AuditMirrorError::new(format!("git show failed: {e}")))?;
273    if out.success {
274        return Ok(InternalBranchLogRead::Contents(out.stdout));
275    }
276
277    let detail = render_output(&out).to_ascii_lowercase();
278    if detail.contains("does not exist in")
279        || detail.contains("path '.ito/.state/audit/events.jsonl' does not exist")
280    {
281        return Ok(InternalBranchLogRead::LogMissing);
282    }
283
284    Err(AuditMirrorError::new(format!(
285        "failed to read internal audit branch '{branch}' ({})",
286        render_output(&out)
287    )))
288}
289
290fn write_merged_audit_log(local_log: &Path, target_log: &Path) -> Result<(), AuditMirrorError> {
291    let local = fs::read_to_string(local_log)
292        .map_err(|e| AuditMirrorError::new(format!("failed to read local audit log: {e}")))?;
293    let remote = fs::read_to_string(target_log).unwrap_or_default();
294
295    let merged = merge_jsonl_lines(&remote, &local);
296
297    write_jsonl(target_log, &merged)
298}
299
300fn write_merged_jsonl(target_log: &Path, jsonl: &str) -> Result<(), AuditMirrorError> {
301    let existing = fs::read_to_string(target_log).unwrap_or_default();
302    let merged = merge_jsonl_lines(&existing, jsonl);
303
304    write_jsonl(target_log, &merged)
305}
306
307fn write_jsonl(target_log: &Path, contents: &str) -> Result<(), AuditMirrorError> {
308    if let Some(parent) = target_log.parent() {
309        fs::create_dir_all(parent).map_err(|e| {
310            AuditMirrorError::new(format!("failed to create audit mirror dir: {e}"))
311        })?;
312    }
313    fs::write(target_log, contents)
314        .map_err(|e| AuditMirrorError::new(format!("failed to write audit mirror log: {e}")))?;
315    Ok(())
316}
317
318fn merge_jsonl_lines(remote: &str, local: &str) -> String {
319    let mut out: Vec<String> = Vec::new();
320    let mut seen: HashSet<String> = HashSet::new();
321
322    for line in remote.lines() {
323        if line.trim().is_empty() {
324            continue;
325        }
326        out.push(line.to_string());
327        seen.insert(line.to_string());
328    }
329
330    for line in local.lines() {
331        if line.trim().is_empty() {
332            continue;
333        }
334        if seen.contains(line) {
335            continue;
336        }
337        out.push(line.to_string());
338        seen.insert(line.to_string());
339    }
340
341    if out.is_empty() {
342        return String::new();
343    }
344    // Ensure trailing newline so subsequent appends are line-oriented.
345    format!("{}\n", out.join("\n"))
346}
347
348fn add_detached_worktree(
349    runner: &dyn ProcessRunner,
350    repo_root: &Path,
351    worktree_path: &Path,
352) -> Result<(), AuditMirrorError> {
353    let out = runner
354        .run(
355            &ProcessRequest::new("git")
356                .args([
357                    "worktree",
358                    "add",
359                    "--detach",
360                    worktree_path.to_string_lossy().as_ref(),
361                ])
362                .current_dir(repo_root),
363        )
364        .map_err(|e| AuditMirrorError::new(format!("git worktree add failed: {e}")))?;
365    if out.success {
366        return Ok(());
367    }
368    Err(AuditMirrorError::new(format!(
369        "git worktree add failed ({})",
370        render_output(&out)
371    )))
372}
373
374#[derive(Debug)]
375enum FetchError {
376    RemoteMissing,
377    Other(String),
378}
379
380fn fetch_branch(
381    runner: &dyn ProcessRunner,
382    repo_root: &Path,
383    branch: &str,
384) -> Result<(), FetchError> {
385    let out = runner
386        .run(
387            &ProcessRequest::new("git")
388                .args(["fetch", "origin", branch])
389                .current_dir(repo_root),
390        )
391        .map_err(|e| FetchError::Other(format!("git fetch origin {branch} failed to run: {e}")))?;
392    if out.success {
393        return Ok(());
394    }
395    let detail = render_output(&out);
396    if detail.contains("couldn't find remote ref") {
397        return Err(FetchError::RemoteMissing);
398    }
399    Err(FetchError::Other(format!(
400        "git fetch origin {branch} failed ({detail})"
401    )))
402}
403
404fn checkout_detached_remote_branch(
405    runner: &dyn ProcessRunner,
406    worktree_path: &Path,
407    branch: &str,
408) -> Result<(), AuditMirrorError> {
409    let target = format!("origin/{branch}");
410    let out = runner
411        .run(
412            &ProcessRequest::new("git")
413                .args(["checkout", "--detach", &target])
414                .current_dir(worktree_path),
415        )
416        .map_err(|e| AuditMirrorError::new(format!("git checkout failed: {e}")))?;
417    if out.success {
418        return Ok(());
419    }
420    Err(AuditMirrorError::new(format!(
421        "failed to checkout audit mirror branch '{branch}' ({})",
422        render_output(&out)
423    )))
424}
425
426fn checkout_detached_local_branch(
427    runner: &dyn ProcessRunner,
428    worktree_path: &Path,
429    branch: &str,
430) -> Result<(), AuditMirrorError> {
431    let target = format!("refs/heads/{branch}");
432    let out = runner
433        .run(
434            &ProcessRequest::new("git")
435                .args(["checkout", "--detach", &target])
436                .current_dir(worktree_path),
437        )
438        .map_err(|e| AuditMirrorError::new(format!("git checkout failed: {e}")))?;
439    if out.success {
440        return Ok(());
441    }
442    Err(AuditMirrorError::new(format!(
443        "failed to checkout internal audit branch '{branch}' ({})",
444        render_output(&out)
445    )))
446}
447
448fn checkout_orphan_branch(
449    runner: &dyn ProcessRunner,
450    worktree_path: &Path,
451) -> Result<(), AuditMirrorError> {
452    let orphan = unique_orphan_branch_name();
453    let out = runner
454        .run(
455            &ProcessRequest::new("git")
456                .args(["checkout", "--orphan", orphan.as_str()])
457                .current_dir(worktree_path),
458        )
459        .map_err(|e| AuditMirrorError::new(format!("git checkout --orphan failed: {e}")))?;
460    if !out.success {
461        return Err(AuditMirrorError::new(format!(
462            "failed to create orphan audit mirror worktree ({})",
463            render_output(&out)
464        )));
465    }
466
467    // Remove tracked files from the index to keep the mirror branch focused.
468    let _ = runner.run(
469        &ProcessRequest::new("git")
470            .args(["rm", "-rf", "."]) // best-effort; may fail on empty trees
471            .current_dir(worktree_path),
472    );
473    Ok(())
474}
475
476fn stage_audit_log(
477    runner: &dyn ProcessRunner,
478    worktree_path: &Path,
479) -> Result<(), AuditMirrorError> {
480    let relative = ".ito/.state/audit/events.jsonl";
481    let out = runner
482        .run(
483            &ProcessRequest::new("git")
484                .args(["add", "-f", relative])
485                .current_dir(worktree_path),
486        )
487        .map_err(|e| AuditMirrorError::new(format!("git add failed: {e}")))?;
488    if out.success {
489        return Ok(());
490    }
491    Err(AuditMirrorError::new(format!(
492        "failed to stage audit mirror log ({})",
493        render_output(&out)
494    )))
495}
496
497fn has_staged_changes(
498    runner: &dyn ProcessRunner,
499    worktree_path: &Path,
500) -> Result<bool, AuditMirrorError> {
501    let relative = ".ito/.state/audit/events.jsonl";
502    let out = runner
503        .run(
504            &ProcessRequest::new("git")
505                .args(["diff", "--cached", "--quiet", "--", relative])
506                .current_dir(worktree_path),
507        )
508        .map_err(|e| AuditMirrorError::new(format!("git diff --cached failed: {e}")))?;
509    if out.success {
510        return Ok(false);
511    }
512    if out.exit_code == 1 {
513        return Ok(true);
514    }
515    Err(AuditMirrorError::new(format!(
516        "failed to inspect staged audit mirror changes ({})",
517        render_output(&out)
518    )))
519}
520
521fn commit_audit_log(
522    runner: &dyn ProcessRunner,
523    worktree_path: &Path,
524) -> Result<(), AuditMirrorError> {
525    let message = "chore(audit): mirror events";
526    let out = runner
527        .run(
528            &ProcessRequest::new("git")
529                .args(["commit", "-m", message])
530                .current_dir(worktree_path),
531        )
532        .map_err(|e| AuditMirrorError::new(format!("git commit failed: {e}")))?;
533    if out.success {
534        return Ok(());
535    }
536    Err(AuditMirrorError::new(format!(
537        "failed to commit audit mirror update ({})",
538        render_output(&out)
539    )))
540}
541
542fn commit_internal_audit_log(
543    runner: &dyn ProcessRunner,
544    worktree_path: &Path,
545) -> Result<(), AuditMirrorError> {
546    let message = "chore(audit): update internal log";
547    let out = runner
548        .run(
549            &ProcessRequest::new("git")
550                .args(["commit", "-m", message])
551                .current_dir(worktree_path),
552        )
553        .map_err(|e| AuditMirrorError::new(format!("git commit failed: {e}")))?;
554    if out.success {
555        return Ok(());
556    }
557    Err(AuditMirrorError::new(format!(
558        "failed to commit internal audit log ({})",
559        render_output(&out)
560    )))
561}
562
563enum UpdateRefResult {
564    Updated,
565    Conflict,
566}
567
568fn update_branch_ref(
569    runner: &dyn ProcessRunner,
570    worktree_path: &Path,
571    branch: &str,
572    expected_old: Option<&str>,
573) -> Result<UpdateRefResult, AuditMirrorError> {
574    let target = format!("refs/heads/{branch}");
575    let new_oid = branch_head_oid(runner, worktree_path)?;
576    let expected = expected_old.unwrap_or("0000000000000000000000000000000000000000");
577    let out = runner
578        .run(
579            &ProcessRequest::new("git")
580                .args(["update-ref", &target, &new_oid, expected])
581                .current_dir(worktree_path),
582        )
583        .map_err(|e| AuditMirrorError::new(format!("git update-ref failed: {e}")))?;
584    if out.success {
585        return Ok(UpdateRefResult::Updated);
586    }
587    let detail = render_output(&out);
588    let lower = detail.to_ascii_lowercase();
589    if lower.contains("cannot lock ref")
590        || lower.contains("is at ")
591        || lower.contains("reference already exists")
592    {
593        return Ok(UpdateRefResult::Conflict);
594    }
595    Err(AuditMirrorError::new(format!(
596        "failed to update internal audit branch '{branch}' ({})",
597        detail
598    )))
599}
600
601fn branch_head_oid(
602    runner: &dyn ProcessRunner,
603    worktree_path: &Path,
604) -> Result<String, AuditMirrorError> {
605    let out = runner
606        .run(
607            &ProcessRequest::new("git")
608                .args(["rev-parse", "HEAD"])
609                .current_dir(worktree_path),
610        )
611        .map_err(|e| AuditMirrorError::new(format!("git rev-parse HEAD failed: {e}")))?;
612    if out.success {
613        let oid = out.stdout.trim();
614        if !oid.is_empty() {
615            return Ok(oid.to_string());
616        }
617    }
618    Err(AuditMirrorError::new(format!(
619        "failed to resolve internal audit commit ({})",
620        render_output(&out)
621    )))
622}
623
624/// Push `HEAD` to `origin/<branch>`.
625///
626/// Returns `Ok(true)` when push succeeded, `Ok(false)` when the push was rejected due to non-fast-forward.
627fn push_branch(
628    runner: &dyn ProcessRunner,
629    worktree_path: &Path,
630    branch: &str,
631) -> Result<bool, AuditMirrorError> {
632    let refspec = format!("HEAD:refs/heads/{branch}");
633    let out = runner
634        .run(
635            &ProcessRequest::new("git")
636                .args(["push", "origin", &refspec])
637                .current_dir(worktree_path),
638        )
639        .map_err(|e| AuditMirrorError::new(format!("git push failed to run: {e}")))?;
640    if out.success {
641        return Ok(true);
642    }
643
644    let detail = render_output(&out);
645    if detail.contains("non-fast-forward") {
646        return Ok(false);
647    }
648
649    Err(AuditMirrorError::new(format!(
650        "audit mirror push failed ({detail})"
651    )))
652}
653
654fn local_branch_exists(
655    runner: &dyn ProcessRunner,
656    repo_root: &Path,
657    branch: &str,
658) -> Result<bool, AuditMirrorError> {
659    let target = format!("refs/heads/{branch}");
660    let out = runner
661        .run(
662            &ProcessRequest::new("git")
663                .args(["show-ref", "--verify", "--quiet", &target])
664                .current_dir(repo_root),
665        )
666        .map_err(|e| AuditMirrorError::new(format!("git show-ref failed: {e}")))?;
667    if out.success {
668        return Ok(true);
669    }
670    if out.exit_code == 1 {
671        return Ok(false);
672    }
673
674    Err(AuditMirrorError::new(format!(
675        "failed to inspect internal audit branch '{branch}' ({})",
676        render_output(&out)
677    )))
678}
679
680fn is_git_worktree(runner: &dyn ProcessRunner, repo_root: &Path) -> bool {
681    let out = runner.run(
682        &ProcessRequest::new("git")
683            .args(["rev-parse", "--is-inside-work-tree"])
684            .current_dir(repo_root),
685    );
686    let Ok(out) = out else {
687        return false;
688    };
689    out.success && out.stdout.trim() == "true"
690}
691
692fn render_output(out: &crate::process::ProcessOutput) -> String {
693    let stdout = out.stdout.trim();
694    let stderr = out.stderr.trim();
695
696    if !stderr.is_empty() {
697        return stderr.to_string();
698    }
699    if !stdout.is_empty() {
700        return stdout.to_string();
701    }
702    "no command output".to_string()
703}
704
705fn unique_temp_worktree_path() -> PathBuf {
706    let pid = std::process::id();
707    let nanos = match SystemTime::now().duration_since(UNIX_EPOCH) {
708        Ok(duration) => duration.as_nanos(),
709        Err(_) => 0,
710    };
711    std::env::temp_dir().join(format!("ito-audit-mirror-{pid}-{nanos}"))
712}
713
714fn unique_orphan_branch_name() -> String {
715    let pid = std::process::id();
716    let nanos = match SystemTime::now().duration_since(UNIX_EPOCH) {
717        Ok(duration) => duration.as_nanos(),
718        Err(_) => 0,
719    };
720    format!("ito-audit-mirror-orphan-{pid}-{nanos}")
721}
722
723struct WorktreeCleanup {
724    repo_root: PathBuf,
725    worktree_path: PathBuf,
726}
727
728impl WorktreeCleanup {
729    fn cleanup_with_runner(&self, runner: &dyn ProcessRunner) -> Result<(), String> {
730        let out = runner.run(
731            &ProcessRequest::new("git")
732                .args([
733                    "worktree",
734                    "remove",
735                    "--force",
736                    self.worktree_path.to_string_lossy().as_ref(),
737                ])
738                .current_dir(&self.repo_root),
739        );
740        if let Err(err) = out {
741            return Err(format!("git worktree remove failed: {err}"));
742        }
743
744        // Ensure the directory is gone even if git left it behind.
745        let _ = fs::remove_dir_all(&self.worktree_path);
746        Ok(())
747    }
748}
749
750impl Drop for WorktreeCleanup {
751    fn drop(&mut self) {
752        // Best-effort panic-safety: if callers unwind before `git worktree remove`
753        // runs, still remove the directory to avoid littering temp.
754        let _ = fs::remove_dir_all(&self.worktree_path);
755    }
756}
757
758#[cfg(test)]
759mod tests {
760    use super::*;
761
762    #[test]
763    fn merge_jsonl_dedupes_and_appends_local_lines() {
764        let remote = "{\"a\":1}\n{\"b\":2}\n";
765        let local = "{\"b\":2}\n{\"c\":3}\n";
766        let merged = merge_jsonl_lines(remote, local);
767        assert_eq!(merged, "{\"a\":1}\n{\"b\":2}\n{\"c\":3}\n");
768    }
769
770    #[test]
771    fn merge_jsonl_ignores_blank_lines() {
772        let remote = "\n{\"a\":1}\n\n";
773        let local = "\n\n";
774        let merged = merge_jsonl_lines(remote, local);
775        assert_eq!(merged, "{\"a\":1}\n");
776    }
777}