Skip to main content

mars_agents/target_sync/
mod.rs

1//! Target sync — copy content from .mars/ canonical store to managed targets.
2//!
3//! After `apply_plan()` writes resolved content to `.mars/agents/` and `.mars/skills/`,
4//! this module copies that content to all configured native target directories (`.claude/`, etc.).
5//!
6//! All targets are managed outputs — they get copies (not symlinks) of .mars/ content.
7
8use 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/// A directory that mars manages — materialized from .mars/.
18#[derive(Debug, Clone)]
19pub struct ManagedTarget {
20    /// Target directory path relative to project root (e.g. ".claude").
21    pub path: String,
22}
23
24/// Result of syncing content to a single target directory.
25#[derive(Debug, Clone)]
26pub struct TargetSyncOutcome {
27    /// Target directory name (e.g. ".claude").
28    pub target: String,
29    /// Number of items successfully synced.
30    pub items_synced: usize,
31    /// Number of items removed (orphan cleanup).
32    pub items_removed: usize,
33    /// Non-fatal errors encountered during sync.
34    pub errors: Vec<String>,
35}
36
37/// Sync all managed targets from .mars/ canonical store.
38///
39/// For each configured target, copies content from `.mars/agents/` and `.mars/skills/`
40/// into the target directory.
41/// Cleans up orphaned items that are no longer in the apply outcomes.
42///
43/// Target sync is non-fatal by default (D9) — errors per-target are recorded but don't
44/// stop other targets from being synced.
45pub 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
96/// Sync a single target directory from .mars/ canonical store.
97fn 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    // Ensure target directory exists
111    std::fs::create_dir_all(target_root)?;
112
113    // Track expected paths for orphan cleanup
114    let mut expected_paths: HashSet<String> = HashSet::new();
115    let native_skill_variant_key = crate::target::TargetRegistry::new()
116        .get(target_name)
117        .and_then(|adapter| adapter.skill_variant_key())
118        .map(str::to_owned);
119
120    for outcome in outcomes {
121        if outcome.item_id.kind == crate::lock::ItemKind::BootstrapDoc {
122            // Package-level bootstrap docs are Meridian-only canonical content.
123            // Skill-level bootstrap docs still reach native targets as ordinary
124            // files inside skill directories.
125            continue;
126        }
127        let dest_rel = outcome.dest_path.as_str();
128
129        match &outcome.action {
130            ActionTaken::Removed => {
131                // Remove from target too
132                let target_path = target_root.join(dest_rel);
133                if target_path.exists() || target_path.symlink_metadata().is_ok() {
134                    if let Err(e) = fs_ops::safe_remove(&target_path) {
135                        errors.push(format!("failed to remove {dest_rel}: {e}"));
136                    } else {
137                        items_removed += 1;
138                    }
139                }
140            }
141            ActionTaken::Skipped => {
142                // Item is unchanged in .mars/ — still expected in target
143                expected_paths.insert(dest_rel.to_string());
144                let source = mars_dir.join(dest_rel);
145                let dest = target_root.join(dest_rel);
146                if source.exists() || source.symlink_metadata().is_ok() {
147                    let should_refresh_native_skill = outcome.item_id.kind
148                        == crate::lock::ItemKind::Skill
149                        && native_skill_variant_key.is_some();
150                    if force || !dest.exists() || should_refresh_native_skill {
151                        let previous_target_hash = if should_refresh_native_skill && dest.exists() {
152                            crate::hash::compute_hash(&dest, outcome.item_id.kind).ok()
153                        } else {
154                            None
155                        };
156                        match copy_item_to_target(
157                            &source,
158                            &dest,
159                            outcome.item_id.kind,
160                            outcome.item_id.name.as_str(),
161                            native_skill_variant_key.as_deref(),
162                            diag,
163                        ) {
164                            Ok(()) => {
165                                items_synced += 1;
166                                if let Some(previous_target_hash) = previous_target_hash
167                                    && let Ok(current_target_hash) =
168                                        crate::hash::compute_hash(&dest, outcome.item_id.kind)
169                                    && previous_target_hash != current_target_hash
170                                {
171                                    diag.warn(
172                                        "target-native-projection-repaired",
173                                        format!(
174                                            "repaired diverged native projection: {target_name}/{dest_rel}/SKILL.md"
175                                        ),
176                                    );
177                                }
178                            }
179                            Err(e) => errors.push(format!("failed to copy {dest_rel}: {e}")),
180                        }
181                    } else if native_skill_variant_key.is_none()
182                        && let Some(expected_checksum) = &outcome.installed_checksum
183                    {
184                        match crate::hash::compute_hash(&dest, outcome.item_id.kind) {
185                            Ok(actual) => {
186                                let actual = ContentHash::from(actual);
187                                if &actual != expected_checksum {
188                                    diag.warn(
189                                        "target-divergent",
190                                        format!(
191                                            "target `{target_name}` item `{}` diverged from `.mars` (preserved local content; run `mars sync --force` or `mars repair` to reset)",
192                                            dest_rel
193                                        ),
194                                    );
195                                }
196                            }
197                            Err(e) => {
198                                errors.push(format!("failed to verify {dest_rel} checksum: {e}"))
199                            }
200                        }
201                    }
202                }
203            }
204            _ => {
205                // Installed, Updated, Merged, Conflicted, Kept
206                // All of these mean content exists in .mars/ and should be copied to target
207                expected_paths.insert(dest_rel.to_string());
208                let source = mars_dir.join(dest_rel);
209                let dest = target_root.join(dest_rel);
210                if source.exists() || source.symlink_metadata().is_ok() {
211                    match copy_item_to_target(
212                        &source,
213                        &dest,
214                        outcome.item_id.kind,
215                        outcome.item_id.name.as_str(),
216                        native_skill_variant_key.as_deref(),
217                        diag,
218                    ) {
219                        Ok(()) => items_synced += 1,
220                        Err(e) => errors.push(format!("failed to copy {dest_rel}: {e}")),
221                    }
222                }
223            }
224        }
225    }
226
227    // Orphan cleanup: scan target for items not in expected set
228    let orphan_removed = cleanup_orphans(
229        target_root,
230        &expected_paths,
231        previous_managed_paths,
232        &mut errors,
233    );
234    items_removed += orphan_removed;
235
236    Ok(TargetSyncOutcome {
237        target: target_name.to_string(),
238        items_synced,
239        items_removed,
240        errors,
241    })
242}
243
244/// Copy an item (file or directory) from .mars/ to a target directory.
245///
246/// Follows symlinks on the source side (D26 — targets get file copies, not symlinks).
247/// Uses atomic operations via the reconcile layer.
248fn copy_item_to_target(
249    source: &Path,
250    dest: &Path,
251    kind: crate::lock::ItemKind,
252    item_name: &str,
253    native_skill_variant_key: Option<&str>,
254    diag: &mut DiagnosticCollector,
255) -> Result<(), MarsError> {
256    if kind == crate::lock::ItemKind::Skill && native_skill_variant_key.is_some() {
257        crate::compiler::variants::validate_skill_variants(source, item_name, diag);
258        return crate::compiler::variants::project_skill_for_target(
259            source,
260            dest,
261            native_skill_variant_key,
262            diag,
263            item_name,
264        );
265    }
266
267    // Ensure parent directories exist
268    if let Some(parent) = dest.parent() {
269        std::fs::create_dir_all(parent)?;
270    }
271
272    // Follow symlinks to determine if source is a file or directory
273    let metadata = std::fs::metadata(source)?;
274
275    if metadata.is_dir() {
276        fs_ops::atomic_copy_dir(source, dest)?;
277    } else if metadata.is_file() {
278        fs_ops::atomic_copy_file(source, dest)?;
279    }
280
281    Ok(())
282}
283
284/// Clean up orphaned items in a target directory.
285///
286/// Uses lock v2 output records (via `previous_managed_paths`) to determine
287/// what was managed in the prior sync, rather than scanning hardcoded
288/// subdirectories. Removes entries that were previously managed but are no
289/// longer expected in the current sync.
290///
291/// Returns the number of items removed.
292fn cleanup_orphans(
293    target_root: &Path,
294    expected: &HashSet<String>,
295    previous_managed_paths: &HashSet<String>,
296    errors: &mut Vec<String>,
297) -> usize {
298    let mut removed = 0;
299
300    // Lock-driven: iterate paths from the old lock, not hardcoded subdirectories.
301    // Only remove entries that were previously managed and are no longer expected.
302    for managed_path in previous_managed_paths {
303        if expected.contains(managed_path) {
304            continue;
305        }
306
307        let full_path = target_root.join(managed_path);
308
309        // Skip if the path doesn't exist (already removed or never synced to this target).
310        if !full_path.exists() && full_path.symlink_metadata().is_err() {
311            continue;
312        }
313
314        // Skip symlinked paths (legacy link setup — don't touch).
315        if full_path
316            .symlink_metadata()
317            .map(|m| m.file_type().is_symlink())
318            .unwrap_or(false)
319        {
320            continue;
321        }
322
323        if let Err(e) = fs_ops::safe_remove(&full_path) {
324            errors.push(format!("failed to remove orphan {managed_path}: {e}"));
325        } else {
326            removed += 1;
327        }
328    }
329
330    removed
331}
332
333#[cfg(test)]
334mod tests {
335    use super::*;
336    use crate::diagnostic::DiagnosticCollector;
337    use crate::hash;
338    use crate::sync::apply::{ActionOutcome, ActionTaken};
339    use crate::types::{DestPath, ItemName};
340    use tempfile::TempDir;
341
342    fn make_outcome(dest: &str, action: ActionTaken) -> ActionOutcome {
343        ActionOutcome {
344            item_id: crate::lock::ItemId {
345                kind: crate::lock::ItemKind::Agent,
346                name: ItemName::from("test"),
347            },
348            action,
349            dest_path: DestPath::from(dest),
350            source_name: "test-source".into(),
351            source_checksum: None,
352            installed_checksum: None,
353        }
354    }
355
356    fn managed_paths(paths: &[&str]) -> HashSet<String> {
357        paths
358            .iter()
359            .map(|p| (*p).to_string())
360            .collect::<HashSet<String>>()
361    }
362
363    fn make_skipped_with_checksum(dest: &str, checksum: &str) -> ActionOutcome {
364        let mut outcome = make_outcome(dest, ActionTaken::Skipped);
365        outcome.installed_checksum = Some(checksum.into());
366        outcome
367    }
368
369    #[test]
370    fn sync_copies_installed_items_to_target() {
371        let dir = TempDir::new().unwrap();
372        let mars_dir = dir.path().join(".mars");
373        let target = dir.path().join(".agents");
374
375        // Set up .mars/ content
376        std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
377        std::fs::write(mars_dir.join("agents/coder.md"), "# Coder").unwrap();
378
379        let outcomes = vec![make_outcome("agents/coder.md", ActionTaken::Installed)];
380        let mut diag = DiagnosticCollector::new();
381
382        let results = sync_managed_targets(
383            dir.path(),
384            &mars_dir,
385            &[".agents".to_string()],
386            &outcomes,
387            &managed_paths(&[]),
388            false,
389            &mut diag,
390        );
391
392        assert_eq!(results.len(), 1);
393        assert_eq!(results[0].items_synced, 1);
394        assert!(results[0].errors.is_empty());
395        assert!(target.join("agents/coder.md").exists());
396        assert_eq!(
397            std::fs::read_to_string(target.join("agents/coder.md")).unwrap(),
398            "# Coder"
399        );
400    }
401
402    #[test]
403    fn sync_removes_items_from_target() {
404        let dir = TempDir::new().unwrap();
405        let mars_dir = dir.path().join(".mars");
406        let target = dir.path().join(".agents");
407
408        std::fs::create_dir_all(&mars_dir).unwrap();
409        std::fs::create_dir_all(target.join("agents")).unwrap();
410        std::fs::write(target.join("agents/old.md"), "# Old").unwrap();
411
412        let outcomes = vec![make_outcome("agents/old.md", ActionTaken::Removed)];
413        let mut diag = DiagnosticCollector::new();
414
415        let results = sync_managed_targets(
416            dir.path(),
417            &mars_dir,
418            &[".agents".to_string()],
419            &outcomes,
420            &managed_paths(&["agents/old.md"]),
421            false,
422            &mut diag,
423        );
424
425        assert_eq!(results[0].items_removed, 1);
426        assert!(!target.join("agents/old.md").exists());
427    }
428
429    #[test]
430    fn sync_cleans_up_previous_managed_orphans() {
431        let dir = TempDir::new().unwrap();
432        let mars_dir = dir.path().join(".mars");
433        let target = dir.path().join(".agents");
434
435        // Set up .mars/ with one agent
436        std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
437        std::fs::write(mars_dir.join("agents/coder.md"), "# Coder").unwrap();
438
439        // Set up target with an extra agent (orphan)
440        std::fs::create_dir_all(target.join("agents")).unwrap();
441        std::fs::write(target.join("agents/orphan.md"), "# Orphan").unwrap();
442
443        let outcomes = vec![make_outcome("agents/coder.md", ActionTaken::Installed)];
444        let mut diag = DiagnosticCollector::new();
445
446        let results = sync_managed_targets(
447            dir.path(),
448            &mars_dir,
449            &[".agents".to_string()],
450            &outcomes,
451            &managed_paths(&["agents/orphan.md"]),
452            false,
453            &mut diag,
454        );
455
456        assert!(target.join("agents/coder.md").exists());
457        assert!(!target.join("agents/orphan.md").exists());
458        assert_eq!(results[0].items_removed, 1);
459    }
460
461    #[test]
462    fn sync_preserves_unmanaged_files_in_target() {
463        let dir = TempDir::new().unwrap();
464        let mars_dir = dir.path().join(".mars");
465        let target = dir.path().join(".agents");
466
467        std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
468        std::fs::write(mars_dir.join("agents/coder.md"), "# Coder").unwrap();
469
470        std::fs::create_dir_all(target.join("agents")).unwrap();
471        std::fs::write(target.join("agents/custom.md"), "# User custom").unwrap();
472
473        let outcomes = vec![make_outcome("agents/coder.md", ActionTaken::Installed)];
474        let mut diag = DiagnosticCollector::new();
475
476        let results = sync_managed_targets(
477            dir.path(),
478            &mars_dir,
479            &[".agents".to_string()],
480            &outcomes,
481            &managed_paths(&[]),
482            false,
483            &mut diag,
484        );
485
486        assert!(target.join("agents/coder.md").exists());
487        assert!(target.join("agents/custom.md").exists());
488        assert_eq!(results[0].items_removed, 0);
489    }
490
491    #[test]
492    fn sync_multiple_targets() {
493        let dir = TempDir::new().unwrap();
494        let mars_dir = dir.path().join(".mars");
495
496        std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
497        std::fs::write(mars_dir.join("agents/coder.md"), "# Coder").unwrap();
498
499        let outcomes = vec![make_outcome("agents/coder.md", ActionTaken::Installed)];
500        let mut diag = DiagnosticCollector::new();
501
502        let results = sync_managed_targets(
503            dir.path(),
504            &mars_dir,
505            &[".agents".to_string(), ".claude".to_string()],
506            &outcomes,
507            &managed_paths(&[]),
508            false,
509            &mut diag,
510        );
511
512        assert_eq!(results.len(), 2);
513        assert!(dir.path().join(".agents/agents/coder.md").exists());
514        assert!(dir.path().join(".claude/agents/coder.md").exists());
515    }
516
517    #[test]
518    fn sync_skill_directory() {
519        let dir = TempDir::new().unwrap();
520        let mars_dir = dir.path().join(".mars");
521        let target = dir.path().join(".agents");
522
523        std::fs::create_dir_all(mars_dir.join("skills/planning")).unwrap();
524        std::fs::write(mars_dir.join("skills/planning/SKILL.md"), "# Planning").unwrap();
525
526        let mut outcome = make_outcome("skills/planning", ActionTaken::Installed);
527        outcome.item_id.kind = crate::lock::ItemKind::Skill;
528        let outcomes = vec![outcome];
529        let mut diag = DiagnosticCollector::new();
530
531        let results = sync_managed_targets(
532            dir.path(),
533            &mars_dir,
534            &[".agents".to_string()],
535            &outcomes,
536            &managed_paths(&[]),
537            false,
538            &mut diag,
539        );
540
541        assert_eq!(results[0].items_synced, 1);
542        assert!(target.join("skills/planning/SKILL.md").exists());
543    }
544
545    #[test]
546    fn sync_projects_skills_for_native_harness_targets() {
547        let dir = TempDir::new().unwrap();
548        let mars_dir = dir.path().join(".mars");
549        let target = dir.path().join(".claude");
550
551        std::fs::create_dir_all(mars_dir.join("skills/planning/resources")).unwrap();
552        std::fs::create_dir_all(mars_dir.join("skills/planning/variants/claude")).unwrap();
553        std::fs::create_dir_all(target.join("skills")).unwrap();
554        std::fs::write(target.join("skills/orphan"), "# Orphan").unwrap();
555        std::fs::write(mars_dir.join("skills/planning/SKILL.md"), "# Base").unwrap();
556        std::fs::write(
557            mars_dir.join("skills/planning/resources/BOOTSTRAP.md"),
558            "# Bootstrap",
559        )
560        .unwrap();
561        std::fs::write(
562            mars_dir.join("skills/planning/variants/claude/SKILL.md"),
563            "# Claude",
564        )
565        .unwrap();
566
567        let mut outcome = make_outcome("skills/planning", ActionTaken::Installed);
568        outcome.item_id.kind = crate::lock::ItemKind::Skill;
569        let outcomes = vec![outcome];
570        let mut diag = DiagnosticCollector::new();
571
572        let results = sync_managed_targets(
573            dir.path(),
574            &mars_dir,
575            &[".claude".to_string()],
576            &outcomes,
577            &managed_paths(&["skills/planning", "skills/orphan"]),
578            false,
579            &mut diag,
580        );
581
582        assert_eq!(results[0].items_synced, 1);
583        assert_eq!(
584            std::fs::read_to_string(target.join("skills/planning/SKILL.md")).unwrap(),
585            "# Claude"
586        );
587        assert_eq!(
588            std::fs::read_to_string(target.join("skills/planning/resources/BOOTSTRAP.md")).unwrap(),
589            "# Bootstrap"
590        );
591        assert!(!target.join("skills/planning/variants").exists());
592        assert!(!target.join("skills/orphan").exists());
593    }
594
595    #[test]
596    fn cleanup_orphans_uses_forward_slash_keys_for_expected_paths() {
597        let dir = TempDir::new().unwrap();
598        let target_root = dir.path().join(".agents");
599        std::fs::create_dir_all(target_root.join("agents")).unwrap();
600        std::fs::write(target_root.join("agents/coder.md"), "# Managed").unwrap();
601        std::fs::write(target_root.join("agents/orphan.md"), "# Orphan").unwrap();
602
603        let mut expected = HashSet::new();
604        expected.insert(
605            DestPath::new(r"agents\coder.md")
606                .unwrap()
607                .as_str()
608                .to_string(),
609        );
610
611        let removed = cleanup_orphans(
612            &target_root,
613            &expected,
614            &managed_paths(&["agents/coder.md", "agents/orphan.md"]),
615            &mut Vec::new(),
616        );
617
618        assert_eq!(removed, 1);
619        assert!(target_root.join("agents/coder.md").exists());
620        assert!(!target_root.join("agents/orphan.md").exists());
621    }
622
623    #[test]
624    fn sync_convergence_on_rerun() {
625        let dir = TempDir::new().unwrap();
626        let mars_dir = dir.path().join(".mars");
627        let target = dir.path().join(".agents");
628
629        std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
630        std::fs::write(mars_dir.join("agents/coder.md"), "# Coder").unwrap();
631
632        let outcomes = vec![make_outcome("agents/coder.md", ActionTaken::Installed)];
633        let mut diag = DiagnosticCollector::new();
634
635        // First run
636        sync_managed_targets(
637            dir.path(),
638            &mars_dir,
639            &[".agents".to_string()],
640            &outcomes,
641            &managed_paths(&[]),
642            false,
643            &mut diag,
644        );
645
646        // Second run with Skipped action — should converge (file already exists)
647        let outcomes2 = vec![make_outcome("agents/coder.md", ActionTaken::Skipped)];
648        let results = sync_managed_targets(
649            dir.path(),
650            &mars_dir,
651            &[".agents".to_string()],
652            &outcomes2,
653            &managed_paths(&["agents/coder.md"]),
654            false,
655            &mut diag,
656        );
657
658        assert!(target.join("agents/coder.md").exists());
659        // items_synced should be 0 since file already exists
660        assert_eq!(results[0].items_synced, 0);
661    }
662
663    #[test]
664    fn sync_force_refreshes_skipped_target_content() {
665        let dir = TempDir::new().unwrap();
666        let mars_dir = dir.path().join(".mars");
667        let target = dir.path().join(".agents");
668
669        std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
670        std::fs::write(mars_dir.join("agents/coder.md"), "# Canonical").unwrap();
671
672        std::fs::create_dir_all(target.join("agents")).unwrap();
673        std::fs::write(target.join("agents/coder.md"), "# Tampered").unwrap();
674
675        let outcomes = vec![make_outcome("agents/coder.md", ActionTaken::Skipped)];
676        let mut diag = DiagnosticCollector::new();
677        let results = sync_managed_targets(
678            dir.path(),
679            &mars_dir,
680            &[".agents".to_string()],
681            &outcomes,
682            &managed_paths(&["agents/coder.md"]),
683            true,
684            &mut diag,
685        );
686
687        assert_eq!(results[0].items_synced, 1);
688        assert_eq!(
689            std::fs::read_to_string(target.join("agents/coder.md")).unwrap(),
690            "# Canonical"
691        );
692    }
693
694    #[test]
695    fn sync_skipped_recopies_missing_target() {
696        let dir = TempDir::new().unwrap();
697        let mars_dir = dir.path().join(".mars");
698        let target = dir.path().join(".agents");
699
700        std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
701        std::fs::write(mars_dir.join("agents/coder.md"), "# Canonical").unwrap();
702
703        let checksum = hash::hash_bytes(b"# Canonical");
704        let outcomes = vec![make_skipped_with_checksum("agents/coder.md", &checksum)];
705        let mut diag = DiagnosticCollector::new();
706        let results = sync_managed_targets(
707            dir.path(),
708            &mars_dir,
709            &[".agents".to_string()],
710            &outcomes,
711            &managed_paths(&["agents/coder.md"]),
712            false,
713            &mut diag,
714        );
715
716        assert_eq!(results[0].items_synced, 1);
717        assert!(target.join("agents/coder.md").exists());
718    }
719
720    #[test]
721    fn sync_skipped_warns_on_divergent_target_and_preserves_local_content() {
722        let dir = TempDir::new().unwrap();
723        let mars_dir = dir.path().join(".mars");
724        let target = dir.path().join(".agents");
725
726        std::fs::create_dir_all(mars_dir.join("agents")).unwrap();
727        std::fs::write(mars_dir.join("agents/coder.md"), "# Canonical").unwrap();
728
729        std::fs::create_dir_all(target.join("agents")).unwrap();
730        std::fs::write(target.join("agents/coder.md"), "# Locally edited").unwrap();
731
732        let checksum = hash::hash_bytes(b"# Canonical");
733        let outcomes = vec![make_skipped_with_checksum("agents/coder.md", &checksum)];
734        let mut diag = DiagnosticCollector::new();
735        let results = sync_managed_targets(
736            dir.path(),
737            &mars_dir,
738            &[".agents".to_string()],
739            &outcomes,
740            &managed_paths(&["agents/coder.md"]),
741            false,
742            &mut diag,
743        );
744
745        assert_eq!(results[0].items_synced, 0);
746        assert_eq!(
747            std::fs::read_to_string(target.join("agents/coder.md")).unwrap(),
748            "# Locally edited"
749        );
750
751        let diagnostics = diag.drain();
752        assert!(
753            diagnostics
754                .iter()
755                .any(|d| d.code == "target-divergent" && d.message.contains("agents/coder.md"))
756        );
757    }
758}