1use std::collections::HashSet;
4use std::fs;
5use std::path::{Path, PathBuf};
6use std::time::{SystemTime, UNIX_EPOCH};
7
8use crate::process::{ProcessRequest, ProcessRunner, SystemProcessRunner};
9
10use super::writer::audit_log_path;
11
12#[derive(Debug, thiserror::Error)]
14#[error("{message}")]
15pub struct AuditMirrorError {
16 message: String,
17}
18
19impl AuditMirrorError {
20 fn new(message: impl Into<String>) -> Self {
21 Self {
22 message: message.into(),
23 }
24 }
25}
26
27pub fn sync_audit_mirror(
32 repo_root: &Path,
33 ito_path: &Path,
34 branch: &str,
35) -> Result<(), AuditMirrorError> {
36 let runner = SystemProcessRunner;
37 sync_audit_mirror_with_runner(&runner, repo_root, ito_path, branch)
38}
39
40pub(crate) fn sync_audit_mirror_with_runner(
41 runner: &dyn ProcessRunner,
42 repo_root: &Path,
43 ito_path: &Path,
44 branch: &str,
45) -> Result<(), AuditMirrorError> {
46 if !is_git_worktree(runner, repo_root) {
47 return Ok(());
48 }
49
50 let local_log = audit_log_path(ito_path);
51 if !local_log.exists() {
52 return Ok(());
53 }
54
55 let worktree_path = unique_temp_worktree_path();
56 add_detached_worktree(runner, repo_root, &worktree_path)?;
57 let cleanup = WorktreeCleanup {
58 repo_root: repo_root.to_path_buf(),
59 worktree_path: worktree_path.clone(),
60 };
61
62 let result = (|| -> 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 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 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 let _ = runner.run(
469 &ProcessRequest::new("git")
470 .args(["rm", "-rf", "."]) .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
624fn 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 let _ = fs::remove_dir_all(&self.worktree_path);
746 Ok(())
747 }
748}
749
750impl Drop for WorktreeCleanup {
751 fn drop(&mut self) {
752 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}