1use std::collections::HashSet;
9use std::path::Path;
10
11use crate::diagnostic::DiagnosticCollector;
12use crate::error::MarsError;
13use crate::reconcile::fs_ops;
14use crate::sync::apply::{ActionOutcome, ActionTaken};
15use crate::types::ContentHash;
16
17#[derive(Debug, Clone)]
19pub struct ManagedTarget {
20 pub path: String,
22}
23
24#[derive(Debug, Clone)]
26pub struct TargetSyncOutcome {
27 pub target: String,
29 pub items_synced: usize,
31 pub items_removed: usize,
33 pub errors: Vec<String>,
35}
36
37pub fn sync_managed_targets(
46 project_root: &Path,
47 mars_dir: &Path,
48 targets: &[String],
49 outcomes: &[ActionOutcome],
50 previous_managed_paths: &HashSet<String>,
51 force: bool,
52 diag: &mut DiagnosticCollector,
53) -> Vec<TargetSyncOutcome> {
54 let mut results = Vec::new();
55
56 for target_name in targets {
57 let target_root = project_root.join(target_name);
58 match sync_one_target(
59 mars_dir,
60 &target_root,
61 target_name,
62 outcomes,
63 previous_managed_paths,
64 force,
65 diag,
66 ) {
67 Ok(outcome) => {
68 if !outcome.errors.is_empty() {
69 for err in &outcome.errors {
70 diag.warn(
71 "target-sync-error",
72 format!("target `{target_name}`: {err}"),
73 );
74 }
75 }
76 results.push(outcome);
77 }
78 Err(e) => {
79 diag.warn(
80 "target-sync-failed",
81 format!("target `{target_name}` sync failed: {e}"),
82 );
83 results.push(TargetSyncOutcome {
84 target: target_name.clone(),
85 items_synced: 0,
86 items_removed: 0,
87 errors: vec![e.to_string()],
88 });
89 }
90 }
91 }
92
93 results
94}
95
96fn sync_one_target(
98 mars_dir: &Path,
99 target_root: &Path,
100 target_name: &str,
101 outcomes: &[ActionOutcome],
102 previous_managed_paths: &HashSet<String>,
103 force: bool,
104 diag: &mut DiagnosticCollector,
105) -> Result<TargetSyncOutcome, MarsError> {
106 let mut items_synced = 0;
107 let mut items_removed = 0;
108 let mut errors = Vec::new();
109
110 std::fs::create_dir_all(target_root)?;
112
113 let mut expected_paths: HashSet<String> = HashSet::new();
115 let target_registry = crate::target::TargetRegistry::new();
116 let target_adapter = target_registry.get(target_name);
117 let native_skill_variant_key = target_adapter
118 .and_then(|adapter| adapter.skill_variant_key())
119 .map(str::to_owned);
120 let target_accepts_canonical_agents = target_adapter
121 .map(|adapter| {
122 adapter
123 .default_dest_path(crate::lock::ItemKind::Agent, "__mars_probe__")
124 .is_some()
125 })
126 .unwrap_or(true);
127
128 for outcome in outcomes {
129 if outcome.item_id.kind == crate::lock::ItemKind::BootstrapDoc {
130 continue;
134 }
135 let dest_rel = outcome.dest_path.as_str();
136 if outcome.item_id.kind == crate::lock::ItemKind::Agent && !target_accepts_canonical_agents
137 {
138 if matches!(outcome.action, ActionTaken::Removed) {
139 let target_path = target_root.join(dest_rel);
140 if target_path.exists() || target_path.symlink_metadata().is_ok() {
141 if let Err(e) = fs_ops::safe_remove(&target_path) {
142 errors.push(format!("failed to remove {dest_rel}: {e}"));
143 } else {
144 items_removed += 1;
145 }
146 }
147 }
148 continue;
149 }
150 match &outcome.action {
151 ActionTaken::Removed => {
152 let target_path = target_root.join(dest_rel);
154 if target_path.exists() || target_path.symlink_metadata().is_ok() {
155 if let Err(e) = fs_ops::safe_remove(&target_path) {
156 errors.push(format!("failed to remove {dest_rel}: {e}"));
157 } else {
158 items_removed += 1;
159 }
160 }
161 }
162 ActionTaken::Skipped => {
163 expected_paths.insert(dest_rel.to_string());
165 let source = mars_dir.join(dest_rel);
166 let dest = target_root.join(dest_rel);
167 if source.exists() || source.symlink_metadata().is_ok() {
168 let should_refresh_native_skill = outcome.item_id.kind
169 == crate::lock::ItemKind::Skill
170 && native_skill_variant_key.is_some();
171 if force || !dest.exists() || should_refresh_native_skill {
172 let previous_target_hash = if should_refresh_native_skill && dest.exists() {
173 crate::hash::compute_hash(&dest, outcome.item_id.kind).ok()
174 } else {
175 None
176 };
177 match copy_item_to_target(
178 &source,
179 &dest,
180 outcome.item_id.kind,
181 outcome.item_id.name.as_str(),
182 native_skill_variant_key.as_deref(),
183 diag,
184 ) {
185 Ok(()) => {
186 items_synced += 1;
187 if let Some(previous_target_hash) = previous_target_hash
188 && let Ok(current_target_hash) =
189 crate::hash::compute_hash(&dest, outcome.item_id.kind)
190 && previous_target_hash != current_target_hash
191 {
192 diag.warn(
193 "target-native-projection-repaired",
194 format!(
195 "repaired diverged native projection: {target_name}/{dest_rel}/SKILL.md"
196 ),
197 );
198 }
199 }
200 Err(e) => errors.push(format!("failed to copy {dest_rel}: {e}")),
201 }
202 } else if native_skill_variant_key.is_none()
203 && let Some(expected_checksum) = &outcome.installed_checksum
204 {
205 match crate::hash::compute_hash(&dest, outcome.item_id.kind) {
206 Ok(actual) => {
207 let actual = ContentHash::from(actual);
208 if &actual != expected_checksum {
209 diag.warn(
210 "target-divergent",
211 format!(
212 "target `{target_name}` item `{}` diverged from `.mars` (preserved local content; run `mars sync --force` or `mars repair` to reset)",
213 dest_rel
214 ),
215 );
216 }
217 }
218 Err(e) => {
219 errors.push(format!("failed to verify {dest_rel} checksum: {e}"))
220 }
221 }
222 }
223 }
224 }
225 _ => {
226 expected_paths.insert(dest_rel.to_string());
229 let source = mars_dir.join(dest_rel);
230 let dest = target_root.join(dest_rel);
231 if source.exists() || source.symlink_metadata().is_ok() {
232 match copy_item_to_target(
233 &source,
234 &dest,
235 outcome.item_id.kind,
236 outcome.item_id.name.as_str(),
237 native_skill_variant_key.as_deref(),
238 diag,
239 ) {
240 Ok(()) => items_synced += 1,
241 Err(e) => errors.push(format!("failed to copy {dest_rel}: {e}")),
242 }
243 }
244 }
245 }
246 }
247
248 let orphan_removed = cleanup_orphans(
250 target_root,
251 &expected_paths,
252 previous_managed_paths,
253 &mut errors,
254 );
255 items_removed += orphan_removed;
256
257 Ok(TargetSyncOutcome {
258 target: target_name.to_string(),
259 items_synced,
260 items_removed,
261 errors,
262 })
263}
264
265fn copy_item_to_target(
270 source: &Path,
271 dest: &Path,
272 kind: crate::lock::ItemKind,
273 item_name: &str,
274 native_skill_variant_key: Option<&str>,
275 diag: &mut DiagnosticCollector,
276) -> Result<(), MarsError> {
277 if kind == crate::lock::ItemKind::Skill && native_skill_variant_key.is_some() {
278 crate::compiler::variants::validate_skill_variants(source, item_name, diag);
279 return crate::compiler::variants::project_skill_for_target(
280 source,
281 dest,
282 native_skill_variant_key,
283 diag,
284 item_name,
285 );
286 }
287
288 if let Some(parent) = dest.parent() {
290 std::fs::create_dir_all(parent)?;
291 }
292
293 let metadata = std::fs::metadata(source)?;
295
296 if metadata.is_dir() {
297 fs_ops::atomic_copy_dir(source, dest)?;
298 } else if metadata.is_file() {
299 fs_ops::atomic_copy_file(source, dest)?;
300 }
301
302 Ok(())
303}
304
305fn cleanup_orphans(
314 target_root: &Path,
315 expected: &HashSet<String>,
316 previous_managed_paths: &HashSet<String>,
317 errors: &mut Vec<String>,
318) -> usize {
319 let mut removed = 0;
320
321 for managed_path in previous_managed_paths {
324 if expected.contains(managed_path) {
325 continue;
326 }
327
328 let full_path = target_root.join(managed_path);
329
330 if !full_path.exists() && full_path.symlink_metadata().is_err() {
332 continue;
333 }
334
335 if full_path
337 .symlink_metadata()
338 .map(|m| m.file_type().is_symlink())
339 .unwrap_or(false)
340 {
341 continue;
342 }
343
344 if let Err(e) = fs_ops::safe_remove(&full_path) {
345 errors.push(format!("failed to remove orphan {managed_path}: {e}"));
346 } else {
347 removed += 1;
348 }
349 }
350
351 removed
352}
353
354#[cfg(test)]
355mod tests {
356 use super::*;
357 use crate::diagnostic::DiagnosticCollector;
358 use crate::hash;
359 use crate::sync::apply::{ActionOutcome, ActionTaken};
360 use crate::types::{DestPath, ItemName};
361 use tempfile::TempDir;
362
363 fn make_outcome(dest: &str, action: ActionTaken) -> ActionOutcome {
364 ActionOutcome {
365 item_id: crate::lock::ItemId {
366 kind: crate::lock::ItemKind::Agent,
367 name: ItemName::from("test"),
368 },
369 action,
370 dest_path: DestPath::from(dest),
371 source_name: "test-source".into(),
372 source_checksum: None,
373 installed_checksum: None,
374 }
375 }
376
377 fn managed_paths(paths: &[&str]) -> HashSet<String> {
378 paths
379 .iter()
380 .map(|p| (*p).to_string())
381 .collect::<HashSet<String>>()
382 }
383
384 fn make_skipped_with_checksum(dest: &str, checksum: &str) -> ActionOutcome {
385 let mut outcome = make_outcome(dest, ActionTaken::Skipped);
386 outcome.installed_checksum = Some(checksum.into());
387 outcome
388 }
389
390 #[test]
391 fn sync_copies_installed_items_to_target() {
392 let dir = TempDir::new().unwrap();
393 let mars_dir = dir.path().join(".mars");
394 let target = dir.path().join(".agents");
395
396 std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
398 std::fs::write(mars_dir.join("agents/coder.md"), "# Coder").unwrap();
399
400 let outcomes = vec![make_outcome("agents/coder.md", ActionTaken::Installed)];
401 let mut diag = DiagnosticCollector::new();
402
403 let results = sync_managed_targets(
404 dir.path(),
405 &mars_dir,
406 &[".agents".to_string()],
407 &outcomes,
408 &managed_paths(&[]),
409 false,
410 &mut diag,
411 );
412
413 assert_eq!(results.len(), 1);
414 assert_eq!(results[0].items_synced, 1);
415 assert!(results[0].errors.is_empty());
416 assert!(target.join("agents/coder.md").exists());
417 assert_eq!(
418 std::fs::read_to_string(target.join("agents/coder.md")).unwrap(),
419 "# Coder"
420 );
421 }
422
423 #[test]
424 fn sync_removes_items_from_target() {
425 let dir = TempDir::new().unwrap();
426 let mars_dir = dir.path().join(".mars");
427 let target = dir.path().join(".agents");
428
429 std::fs::create_dir_all(&mars_dir).unwrap();
430 std::fs::create_dir_all(target.join("agents")).unwrap();
431 std::fs::write(target.join("agents/old.md"), "# Old").unwrap();
432
433 let outcomes = vec![make_outcome("agents/old.md", ActionTaken::Removed)];
434 let mut diag = DiagnosticCollector::new();
435
436 let results = sync_managed_targets(
437 dir.path(),
438 &mars_dir,
439 &[".agents".to_string()],
440 &outcomes,
441 &managed_paths(&["agents/old.md"]),
442 false,
443 &mut diag,
444 );
445
446 assert_eq!(results[0].items_removed, 1);
447 assert!(!target.join("agents/old.md").exists());
448 }
449
450 #[test]
451 fn sync_cleans_up_previous_managed_orphans() {
452 let dir = TempDir::new().unwrap();
453 let mars_dir = dir.path().join(".mars");
454 let target = dir.path().join(".agents");
455
456 std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
458 std::fs::write(mars_dir.join("agents/coder.md"), "# Coder").unwrap();
459
460 std::fs::create_dir_all(target.join("agents")).unwrap();
462 std::fs::write(target.join("agents/orphan.md"), "# Orphan").unwrap();
463
464 let outcomes = vec![make_outcome("agents/coder.md", ActionTaken::Installed)];
465 let mut diag = DiagnosticCollector::new();
466
467 let results = sync_managed_targets(
468 dir.path(),
469 &mars_dir,
470 &[".agents".to_string()],
471 &outcomes,
472 &managed_paths(&["agents/orphan.md"]),
473 false,
474 &mut diag,
475 );
476
477 assert!(target.join("agents/coder.md").exists());
478 assert!(!target.join("agents/orphan.md").exists());
479 assert_eq!(results[0].items_removed, 1);
480 }
481
482 #[test]
483 fn sync_preserves_unmanaged_files_in_target() {
484 let dir = TempDir::new().unwrap();
485 let mars_dir = dir.path().join(".mars");
486 let target = dir.path().join(".agents");
487
488 std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
489 std::fs::write(mars_dir.join("agents/coder.md"), "# Coder").unwrap();
490
491 std::fs::create_dir_all(target.join("agents")).unwrap();
492 std::fs::write(target.join("agents/custom.md"), "# User custom").unwrap();
493
494 let outcomes = vec![make_outcome("agents/coder.md", ActionTaken::Installed)];
495 let mut diag = DiagnosticCollector::new();
496
497 let results = sync_managed_targets(
498 dir.path(),
499 &mars_dir,
500 &[".agents".to_string()],
501 &outcomes,
502 &managed_paths(&[]),
503 false,
504 &mut diag,
505 );
506
507 assert!(target.join("agents/coder.md").exists());
508 assert!(target.join("agents/custom.md").exists());
509 assert_eq!(results[0].items_removed, 0);
510 }
511
512 #[test]
513 fn sync_removed_agent_outcome_removes_existing_target_agent_without_copying() {
514 let dir = TempDir::new().unwrap();
515 let mars_dir = dir.path().join(".mars");
516 let target = dir.path().join(".agents");
517
518 std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
519 std::fs::write(mars_dir.join("agents/coder.md"), "# Canonical").unwrap();
520 std::fs::create_dir_all(target.join("agents")).unwrap();
521 std::fs::write(target.join("agents/coder.md"), "# Existing target copy").unwrap();
522
523 let outcomes = vec![make_outcome("agents/coder.md", ActionTaken::Removed)];
524 let mut diag = DiagnosticCollector::new();
525
526 let results = sync_managed_targets(
527 dir.path(),
528 &mars_dir,
529 &[".agents".to_string()],
530 &outcomes,
531 &managed_paths(&["agents/coder.md"]),
532 false,
533 &mut diag,
534 );
535
536 assert_eq!(results[0].items_synced, 0);
537 assert_eq!(results[0].items_removed, 1);
538 assert!(!target.join("agents/coder.md").exists());
539 assert!(results[0].errors.is_empty());
540 }
541
542 #[test]
543 fn sync_multiple_targets() {
544 let dir = TempDir::new().unwrap();
545 let mars_dir = dir.path().join(".mars");
546
547 std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
548 std::fs::write(mars_dir.join("agents/coder.md"), "# Coder").unwrap();
549
550 let outcomes = vec![make_outcome("agents/coder.md", ActionTaken::Installed)];
551 let mut diag = DiagnosticCollector::new();
552
553 let results = sync_managed_targets(
554 dir.path(),
555 &mars_dir,
556 &[".agents".to_string(), ".custom-target".to_string()],
557 &outcomes,
558 &managed_paths(&[]),
559 false,
560 &mut diag,
561 );
562
563 assert_eq!(results.len(), 2);
564 assert!(dir.path().join(".agents/agents/coder.md").exists());
565 assert!(dir.path().join(".custom-target/agents/coder.md").exists());
566 }
567
568 #[test]
569 fn sync_native_targets_skip_canonical_agent_markdown_copies() {
570 let dir = TempDir::new().unwrap();
571 let mars_dir = dir.path().join(".mars");
572
573 std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
574 std::fs::write(mars_dir.join("agents/coder.md"), "# Coder").unwrap();
575
576 let outcomes = vec![make_outcome("agents/coder.md", ActionTaken::Installed)];
577 let mut diag = DiagnosticCollector::new();
578
579 let results = sync_managed_targets(
580 dir.path(),
581 &mars_dir,
582 &[
583 ".claude".to_string(),
584 ".codex".to_string(),
585 ".opencode".to_string(),
586 ".pi".to_string(),
587 ],
588 &outcomes,
589 &managed_paths(&[]),
590 false,
591 &mut diag,
592 );
593
594 assert_eq!(results.len(), 4);
595 assert!(results.iter().all(|outcome| outcome.items_synced == 0));
596 assert!(!dir.path().join(".claude/agents/coder.md").exists());
597 assert!(!dir.path().join(".codex/agents/coder.md").exists());
598 assert!(!dir.path().join(".opencode/agents/coder.md").exists());
599 assert!(!dir.path().join(".pi/agents/coder.md").exists());
600 }
601
602 #[test]
603 fn sync_unknown_target_still_copies_canonical_agents() {
604 let dir = TempDir::new().unwrap();
605 let mars_dir = dir.path().join(".mars");
606
607 std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
608 std::fs::write(mars_dir.join("agents/coder.md"), "# Coder").unwrap();
609
610 let outcomes = vec![make_outcome("agents/coder.md", ActionTaken::Installed)];
611 let mut diag = DiagnosticCollector::new();
612
613 let results = sync_managed_targets(
614 dir.path(),
615 &mars_dir,
616 &[".custom-target".to_string()],
617 &outcomes,
618 &managed_paths(&[]),
619 false,
620 &mut diag,
621 );
622
623 assert_eq!(results[0].items_synced, 1);
624 assert!(dir.path().join(".custom-target/agents/coder.md").exists());
625 }
626
627 #[test]
628 fn sync_skill_directory() {
629 let dir = TempDir::new().unwrap();
630 let mars_dir = dir.path().join(".mars");
631 let target = dir.path().join(".agents");
632
633 std::fs::create_dir_all(mars_dir.join("skills/planning")).unwrap();
634 std::fs::write(mars_dir.join("skills/planning/SKILL.md"), "# Planning").unwrap();
635
636 let mut outcome = make_outcome("skills/planning", ActionTaken::Installed);
637 outcome.item_id.kind = crate::lock::ItemKind::Skill;
638 let outcomes = vec![outcome];
639 let mut diag = DiagnosticCollector::new();
640
641 let results = sync_managed_targets(
642 dir.path(),
643 &mars_dir,
644 &[".agents".to_string()],
645 &outcomes,
646 &managed_paths(&[]),
647 false,
648 &mut diag,
649 );
650
651 assert_eq!(results[0].items_synced, 1);
652 assert!(target.join("skills/planning/SKILL.md").exists());
653 }
654
655 #[test]
656 fn sync_projects_skills_for_native_harness_targets() {
657 let dir = TempDir::new().unwrap();
658 let mars_dir = dir.path().join(".mars");
659 let target = dir.path().join(".claude");
660
661 std::fs::create_dir_all(mars_dir.join("skills/planning/resources")).unwrap();
662 std::fs::create_dir_all(mars_dir.join("skills/planning/variants/claude")).unwrap();
663 std::fs::create_dir_all(target.join("skills")).unwrap();
664 std::fs::write(target.join("skills/orphan"), "# Orphan").unwrap();
665 std::fs::write(mars_dir.join("skills/planning/SKILL.md"), "# Base").unwrap();
666 std::fs::write(
667 mars_dir.join("skills/planning/resources/BOOTSTRAP.md"),
668 "# Bootstrap",
669 )
670 .unwrap();
671 std::fs::write(
672 mars_dir.join("skills/planning/variants/claude/SKILL.md"),
673 "# Claude",
674 )
675 .unwrap();
676
677 let mut outcome = make_outcome("skills/planning", ActionTaken::Installed);
678 outcome.item_id.kind = crate::lock::ItemKind::Skill;
679 let outcomes = vec![outcome];
680 let mut diag = DiagnosticCollector::new();
681
682 let results = sync_managed_targets(
683 dir.path(),
684 &mars_dir,
685 &[".claude".to_string()],
686 &outcomes,
687 &managed_paths(&["skills/planning", "skills/orphan"]),
688 false,
689 &mut diag,
690 );
691
692 assert_eq!(results[0].items_synced, 1);
693 assert_eq!(
694 std::fs::read_to_string(target.join("skills/planning/SKILL.md")).unwrap(),
695 "# Claude"
696 );
697 assert_eq!(
698 std::fs::read_to_string(target.join("skills/planning/resources/BOOTSTRAP.md")).unwrap(),
699 "# Bootstrap"
700 );
701 assert!(!target.join("skills/planning/variants").exists());
702 assert!(!target.join("skills/orphan").exists());
703 }
704
705 #[test]
706 fn cleanup_orphans_uses_forward_slash_keys_for_expected_paths() {
707 let dir = TempDir::new().unwrap();
708 let target_root = dir.path().join(".agents");
709 std::fs::create_dir_all(target_root.join("agents")).unwrap();
710 std::fs::write(target_root.join("agents/coder.md"), "# Managed").unwrap();
711 std::fs::write(target_root.join("agents/orphan.md"), "# Orphan").unwrap();
712
713 let mut expected = HashSet::new();
714 expected.insert(
715 DestPath::new(r"agents\coder.md")
716 .unwrap()
717 .as_str()
718 .to_string(),
719 );
720
721 let removed = cleanup_orphans(
722 &target_root,
723 &expected,
724 &managed_paths(&["agents/coder.md", "agents/orphan.md"]),
725 &mut Vec::new(),
726 );
727
728 assert_eq!(removed, 1);
729 assert!(target_root.join("agents/coder.md").exists());
730 assert!(!target_root.join("agents/orphan.md").exists());
731 }
732
733 #[test]
734 fn sync_convergence_on_rerun() {
735 let dir = TempDir::new().unwrap();
736 let mars_dir = dir.path().join(".mars");
737 let target = dir.path().join(".agents");
738
739 std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
740 std::fs::write(mars_dir.join("agents/coder.md"), "# Coder").unwrap();
741
742 let outcomes = vec![make_outcome("agents/coder.md", ActionTaken::Installed)];
743 let mut diag = DiagnosticCollector::new();
744
745 sync_managed_targets(
747 dir.path(),
748 &mars_dir,
749 &[".agents".to_string()],
750 &outcomes,
751 &managed_paths(&[]),
752 false,
753 &mut diag,
754 );
755
756 let outcomes2 = vec![make_outcome("agents/coder.md", ActionTaken::Skipped)];
758 let results = sync_managed_targets(
759 dir.path(),
760 &mars_dir,
761 &[".agents".to_string()],
762 &outcomes2,
763 &managed_paths(&["agents/coder.md"]),
764 false,
765 &mut diag,
766 );
767
768 assert!(target.join("agents/coder.md").exists());
769 assert_eq!(results[0].items_synced, 0);
771 }
772
773 #[test]
774 fn sync_force_refreshes_skipped_target_content() {
775 let dir = TempDir::new().unwrap();
776 let mars_dir = dir.path().join(".mars");
777 let target = dir.path().join(".agents");
778
779 std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
780 std::fs::write(mars_dir.join("agents/coder.md"), "# Canonical").unwrap();
781
782 std::fs::create_dir_all(target.join("agents")).unwrap();
783 std::fs::write(target.join("agents/coder.md"), "# Tampered").unwrap();
784
785 let outcomes = vec![make_outcome("agents/coder.md", ActionTaken::Skipped)];
786 let mut diag = DiagnosticCollector::new();
787 let results = sync_managed_targets(
788 dir.path(),
789 &mars_dir,
790 &[".agents".to_string()],
791 &outcomes,
792 &managed_paths(&["agents/coder.md"]),
793 true,
794 &mut diag,
795 );
796
797 assert_eq!(results[0].items_synced, 1);
798 assert_eq!(
799 std::fs::read_to_string(target.join("agents/coder.md")).unwrap(),
800 "# Canonical"
801 );
802 }
803
804 #[test]
805 fn sync_skipped_recopies_missing_target() {
806 let dir = TempDir::new().unwrap();
807 let mars_dir = dir.path().join(".mars");
808 let target = dir.path().join(".agents");
809
810 std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
811 std::fs::write(mars_dir.join("agents/coder.md"), "# Canonical").unwrap();
812
813 let checksum = hash::hash_bytes(b"# Canonical");
814 let outcomes = vec![make_skipped_with_checksum("agents/coder.md", &checksum)];
815 let mut diag = DiagnosticCollector::new();
816 let results = sync_managed_targets(
817 dir.path(),
818 &mars_dir,
819 &[".agents".to_string()],
820 &outcomes,
821 &managed_paths(&["agents/coder.md"]),
822 false,
823 &mut diag,
824 );
825
826 assert_eq!(results[0].items_synced, 1);
827 assert!(target.join("agents/coder.md").exists());
828 }
829
830 #[test]
831 fn sync_skipped_warns_on_divergent_target_and_preserves_local_content() {
832 let dir = TempDir::new().unwrap();
833 let mars_dir = dir.path().join(".mars");
834 let target = dir.path().join(".agents");
835
836 std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
837 std::fs::write(mars_dir.join("agents/coder.md"), "# Canonical").unwrap();
838
839 std::fs::create_dir_all(target.join("agents")).unwrap();
840 std::fs::write(target.join("agents/coder.md"), "# Locally edited").unwrap();
841
842 let checksum = hash::hash_bytes(b"# Canonical");
843 let outcomes = vec![make_skipped_with_checksum("agents/coder.md", &checksum)];
844 let mut diag = DiagnosticCollector::new();
845 let results = sync_managed_targets(
846 dir.path(),
847 &mars_dir,
848 &[".agents".to_string()],
849 &outcomes,
850 &managed_paths(&["agents/coder.md"]),
851 false,
852 &mut diag,
853 );
854
855 assert_eq!(results[0].items_synced, 0);
856 assert_eq!(
857 std::fs::read_to_string(target.join("agents/coder.md")).unwrap(),
858 "# Locally edited"
859 );
860
861 let diagnostics = diag.drain();
862 assert!(
863 diagnostics
864 .iter()
865 .any(|d| d.code == "target-divergent" && d.message.contains("agents/coder.md"))
866 );
867 }
868}