1use std::path::Path;
2
3use crate::error::MarsError;
4use crate::hash;
5use crate::lock::{CANONICAL_TARGET_ROOT, LockFile, LockIndex, LockedItem};
6use crate::sync::target::{TargetItem, TargetState};
7use crate::types::ContentHash;
8
9#[derive(Debug, Clone)]
11pub struct SyncDiff {
12 pub items: Vec<DiffEntry>,
13}
14
15#[derive(Debug, Clone)]
17pub enum DiffEntry {
18 Add { target: TargetItem },
20 Update {
22 target: TargetItem,
23 locked: LockedItem,
24 },
25 Unchanged {
27 target: TargetItem,
28 locked: LockedItem,
29 },
30 Conflict {
32 target: TargetItem,
33 locked: LockedItem,
34 local_hash: ContentHash,
35 },
36 Orphan { locked: LockedItem },
38 LocalModified {
40 target: TargetItem,
41 locked: LockedItem,
42 local_hash: ContentHash,
43 },
44}
45
46pub fn compute(
54 root: &Path,
55 lock: &LockFile,
56 target: &TargetState,
57 force: bool,
58) -> Result<SyncDiff, MarsError> {
59 let mut items = Vec::new();
60 let lock_index = LockIndex::new(lock);
61
62 for (_dest_key, target_item) in &target.items {
64 if let Some(locked_item) =
65 lock_index.find_output(CANONICAL_TARGET_ROOT, &target_item.dest_path)
66 {
67 let source_changed = target_item.source_hash != locked_item.source_checksum
69 || rewritten_installed_checksum(target_item)
70 .is_some_and(|checksum| checksum != locked_item.installed_checksum);
71
72 let expected_disk_checksum = if force {
76 &locked_item.source_checksum
77 } else {
78 &locked_item.installed_checksum
79 };
80
81 let disk_path = target_item.dest_path.resolve(root);
82 let hash_path = hash_path_for_kind(&disk_path, target_item.id.kind);
83 let local_changed = if hash_path.exists() {
84 let disk_hash = hash::compute_hash(&hash_path, target_item.id.kind)?;
85 let disk_hash = ContentHash::from(disk_hash);
86 if disk_hash != *expected_disk_checksum {
87 Some(disk_hash)
88 } else {
89 None
90 }
91 } else {
92 None
95 };
96
97 match (source_changed, &local_changed) {
98 (false, None) => {
99 if hash_path.exists() {
101 items.push(DiffEntry::Unchanged {
102 target: target_item.clone(),
103 locked: locked_item.clone(),
104 });
105 } else {
106 items.push(DiffEntry::Add {
108 target: target_item.clone(),
109 });
110 }
111 }
112 (true, None) => {
113 items.push(DiffEntry::Update {
115 target: target_item.clone(),
116 locked: locked_item.clone(),
117 });
118 }
119 (false, Some(local_hash)) => {
120 items.push(DiffEntry::LocalModified {
122 target: target_item.clone(),
123 locked: locked_item.clone(),
124 local_hash: local_hash.clone(),
125 });
126 }
127 (true, Some(local_hash)) => {
128 items.push(DiffEntry::Conflict {
130 target: target_item.clone(),
131 locked: locked_item.clone(),
132 local_hash: local_hash.clone(),
133 });
134 }
135 }
136 } else {
137 items.push(DiffEntry::Add {
139 target: target_item.clone(),
140 });
141 }
142 }
143
144 for (dest_path, locked_item) in lock.canonical_flat_items() {
146 if !target.items.contains_key(&dest_path) {
147 items.push(DiffEntry::Orphan {
148 locked: locked_item,
149 });
150 }
151 }
152
153 Ok(SyncDiff { items })
154}
155
156fn rewritten_installed_checksum(target_item: &TargetItem) -> Option<ContentHash> {
157 target_item
158 .rewritten_content
159 .as_ref()
160 .map(|content| ContentHash::from(hash::hash_bytes(content.as_bytes())))
161}
162
163fn hash_path_for_kind(path: &Path, kind: crate::lock::ItemKind) -> std::path::PathBuf {
164 if kind == crate::lock::ItemKind::BootstrapDoc {
165 path.parent()
166 .map(Path::to_path_buf)
167 .unwrap_or_else(|| path.to_path_buf())
168 } else {
169 path.to_path_buf()
170 }
171}
172
173#[cfg(test)]
174mod tests {
175 use super::*;
176 use crate::hash;
177 use crate::lock::{ItemId, ItemKind, LockedItemV2, OutputRecord};
178 use crate::types::{ItemName, SourceName};
179 use indexmap::IndexMap;
180 use std::fs;
181 use std::path::PathBuf;
182 use tempfile::TempDir;
183
184 fn make_target_item(
186 name: &str,
187 kind: ItemKind,
188 source_hash: &str,
189 source_path: PathBuf,
190 ) -> TargetItem {
191 let dest_path = match kind {
192 ItemKind::Agent => PathBuf::from("agents").join(format!("{name}.md")),
193 ItemKind::Skill => PathBuf::from("skills").join(name),
194 ItemKind::Hook => PathBuf::from("hooks").join(name),
195 ItemKind::McpServer => PathBuf::from("mcp").join(name),
196 ItemKind::BootstrapDoc => PathBuf::from("bootstrap").join(name).join("BOOTSTRAP.md"),
197 };
198 TargetItem {
199 id: ItemId {
200 kind,
201 name: ItemName::from(name),
202 },
203 source_name: SourceName::from("test-source"),
204 origin: crate::types::SourceOrigin::Dependency(SourceName::from("test-source")),
205 source_id: crate::types::SourceId::Path {
206 canonical: source_path.clone(),
207 subpath: None,
208 },
209 source_path,
210 dest_path: dest_path.to_string_lossy().to_string().into(),
211 source_hash: ContentHash::from(source_hash),
212 is_flat_skill: false,
213 rewritten_content: None,
214 }
215 }
216
217 fn make_v2_item(
219 name: &str,
220 kind: ItemKind,
221 source_checksum: &str,
222 installed_checksum: &str,
223 ) -> (String, LockedItemV2) {
224 let dest_path = match kind {
225 ItemKind::Agent => format!("agents/{name}.md"),
226 ItemKind::Skill => format!("skills/{name}"),
227 ItemKind::Hook => format!("hooks/{name}"),
228 ItemKind::McpServer => format!("mcp/{name}"),
229 ItemKind::BootstrapDoc => format!("bootstrap/{name}/BOOTSTRAP.md"),
230 };
231 let key = format!("{kind}/{name}");
232 let item = LockedItemV2 {
233 source: SourceName::from("test-source"),
234 kind,
235 version: None,
236 source_checksum: ContentHash::from(source_checksum),
237 outputs: vec![OutputRecord {
238 target_root: ".mars".to_string(),
239 dest_path: dest_path.into(),
240 installed_checksum: ContentHash::from(installed_checksum),
241 }],
242 };
243 (key, item)
244 }
245
246 #[test]
247 fn new_item_produces_add() {
248 let root = TempDir::new().unwrap();
249 let source_dir = TempDir::new().unwrap();
250 let source_path = source_dir.path().join("agents/coder.md");
251 fs::create_dir_all(source_dir.path().join("agents")).unwrap();
252 fs::write(&source_path, "# new agent").unwrap();
253
254 let hash = hash::hash_bytes(b"# new agent");
255
256 let target_item = make_target_item("coder", ItemKind::Agent, &hash, source_path);
257 let mut target_items = IndexMap::new();
258 target_items.insert("agents/coder.md".into(), target_item);
259 let target = TargetState {
260 items: target_items,
261 };
262
263 let lock = LockFile::empty();
264 let diff = compute(root.path(), &lock, &target, false).unwrap();
265
266 assert_eq!(diff.items.len(), 1);
267 assert!(matches!(&diff.items[0], DiffEntry::Add { .. }));
268 }
269
270 #[test]
271 fn unchanged_item_produces_unchanged() {
272 let root = TempDir::new().unwrap();
273 let content = b"# existing agent";
274 let hash = hash::hash_bytes(content);
275
276 let agents_dir = root.path().join("agents");
278 fs::create_dir_all(&agents_dir).unwrap();
279 fs::write(agents_dir.join("coder.md"), content).unwrap();
280
281 let source_path = PathBuf::from("/tmp/source/agents/coder.md");
282
283 let target_item = make_target_item("coder", ItemKind::Agent, &hash, source_path);
284 let mut target_items = IndexMap::new();
285 target_items.insert("agents/coder.md".into(), target_item);
286 let target = TargetState {
287 items: target_items,
288 };
289
290 let mut lock_items = IndexMap::new();
291 let (k, v) = make_v2_item("coder", ItemKind::Agent, &hash, &hash);
292 lock_items.insert(k, v);
293 let lock = LockFile {
294 version: 2,
295 dependencies: IndexMap::new(),
296 items: lock_items,
297 config_entries: std::collections::BTreeMap::new(),
298 };
299
300 let diff = compute(root.path(), &lock, &target, false).unwrap();
301 assert_eq!(diff.items.len(), 1);
302 assert!(matches!(&diff.items[0], DiffEntry::Unchanged { .. }));
303 }
304
305 #[test]
306 fn source_changed_local_unchanged_produces_update() {
307 let root = TempDir::new().unwrap();
308 let old_content = b"# old version";
309 let old_hash = hash::hash_bytes(old_content);
310 let new_hash = hash::hash_bytes(b"# new version");
311
312 let agents_dir = root.path().join("agents");
314 fs::create_dir_all(&agents_dir).unwrap();
315 fs::write(agents_dir.join("coder.md"), old_content).unwrap();
316
317 let source_path = PathBuf::from("/tmp/source/agents/coder.md");
318
319 let target_item = make_target_item("coder", ItemKind::Agent, &new_hash, source_path);
321 let mut target_items = IndexMap::new();
322 target_items.insert("agents/coder.md".into(), target_item);
323 let target = TargetState {
324 items: target_items,
325 };
326
327 let mut lock_items = IndexMap::new();
329 let (k, v) = make_v2_item("coder", ItemKind::Agent, &old_hash, &old_hash);
330 lock_items.insert(k, v);
331 let lock = LockFile {
332 version: 2,
333 dependencies: IndexMap::new(),
334 items: lock_items,
335 config_entries: std::collections::BTreeMap::new(),
336 };
337
338 let diff = compute(root.path(), &lock, &target, false).unwrap();
339 assert_eq!(diff.items.len(), 1);
340 assert!(matches!(&diff.items[0], DiffEntry::Update { .. }));
341 }
342
343 #[test]
344 fn local_changed_source_unchanged_produces_local_modified() {
345 let root = TempDir::new().unwrap();
346 let original_content = b"# original";
347 let original_hash = hash::hash_bytes(original_content);
348 let local_content = b"# locally modified";
349
350 let agents_dir = root.path().join("agents");
352 fs::create_dir_all(&agents_dir).unwrap();
353 fs::write(agents_dir.join("coder.md"), local_content).unwrap();
354
355 let source_path = PathBuf::from("/tmp/source/agents/coder.md");
356
357 let target_item = make_target_item("coder", ItemKind::Agent, &original_hash, source_path);
359 let mut target_items = IndexMap::new();
360 target_items.insert("agents/coder.md".into(), target_item);
361 let target = TargetState {
362 items: target_items,
363 };
364
365 let mut lock_items = IndexMap::new();
367 let (k, v) = make_v2_item("coder", ItemKind::Agent, &original_hash, &original_hash);
368 lock_items.insert(k, v);
369 let lock = LockFile {
370 version: 2,
371 dependencies: IndexMap::new(),
372 items: lock_items,
373 config_entries: std::collections::BTreeMap::new(),
374 };
375
376 let diff = compute(root.path(), &lock, &target, false).unwrap();
377 assert_eq!(diff.items.len(), 1);
378 assert!(matches!(&diff.items[0], DiffEntry::LocalModified { .. }));
379 }
380
381 #[test]
382 fn both_changed_produces_conflict() {
383 let root = TempDir::new().unwrap();
384 let original_hash = hash::hash_bytes(b"# original");
385 let new_source_hash = hash::hash_bytes(b"# new upstream");
386 let local_content = b"# locally modified";
387
388 let agents_dir = root.path().join("agents");
390 fs::create_dir_all(&agents_dir).unwrap();
391 fs::write(agents_dir.join("coder.md"), local_content).unwrap();
392
393 let source_path = PathBuf::from("/tmp/source/agents/coder.md");
394
395 let target_item = make_target_item("coder", ItemKind::Agent, &new_source_hash, source_path);
397 let mut target_items = IndexMap::new();
398 target_items.insert("agents/coder.md".into(), target_item);
399 let target = TargetState {
400 items: target_items,
401 };
402
403 let mut lock_items = IndexMap::new();
405 let (k, v) = make_v2_item("coder", ItemKind::Agent, &original_hash, &original_hash);
406 lock_items.insert(k, v);
407 let lock = LockFile {
408 version: 2,
409 dependencies: IndexMap::new(),
410 items: lock_items,
411 config_entries: std::collections::BTreeMap::new(),
412 };
413
414 let diff = compute(root.path(), &lock, &target, false).unwrap();
415 assert_eq!(diff.items.len(), 1);
416 assert!(matches!(&diff.items[0], DiffEntry::Conflict { .. }));
417 }
418
419 #[test]
420 fn orphan_detected() {
421 let root = TempDir::new().unwrap();
422
423 let target = TargetState {
425 items: IndexMap::new(),
426 };
427
428 let mut lock_items = IndexMap::new();
430 let (k, v) = make_v2_item("old-agent", ItemKind::Agent, "sha256:aaa", "sha256:aaa");
431 lock_items.insert(k, v);
432 let lock = LockFile {
433 version: 2,
434 dependencies: IndexMap::new(),
435 items: lock_items,
436 config_entries: std::collections::BTreeMap::new(),
437 };
438
439 let diff = compute(root.path(), &lock, &target, false).unwrap();
440 assert_eq!(diff.items.len(), 1);
441 assert!(matches!(&diff.items[0], DiffEntry::Orphan { .. }));
442 }
443
444 #[test]
445 fn dual_checksum_prevents_false_conflict() {
446 let root = TempDir::new().unwrap();
450
451 let source_hash = hash::hash_bytes(b"# original source");
452 let installed_content = b"# rewritten by mars";
453 let installed_hash = hash::hash_bytes(installed_content);
454
455 let agents_dir = root.path().join("agents");
457 fs::create_dir_all(&agents_dir).unwrap();
458 fs::write(agents_dir.join("coder.md"), installed_content).unwrap();
459
460 let source_path = PathBuf::from("/tmp/source/agents/coder.md");
461
462 let target_item = make_target_item("coder", ItemKind::Agent, &source_hash, source_path);
464 let mut target_items = IndexMap::new();
465 target_items.insert("agents/coder.md".into(), target_item);
466 let target = TargetState {
467 items: target_items,
468 };
469
470 let mut lock_items = IndexMap::new();
472 let (k, v) = make_v2_item("coder", ItemKind::Agent, &source_hash, &installed_hash);
473 lock_items.insert(k, v);
474 let lock = LockFile {
475 version: 2,
476 dependencies: IndexMap::new(),
477 items: lock_items,
478 config_entries: std::collections::BTreeMap::new(),
479 };
480
481 let diff = compute(root.path(), &lock, &target, false).unwrap();
482 assert_eq!(diff.items.len(), 1);
483 assert!(
486 matches!(&diff.items[0], DiffEntry::Unchanged { .. }),
487 "expected Unchanged, got {:?}",
488 diff.items[0]
489 );
490 }
491
492 #[test]
493 fn mixed_diff_entries() {
494 let root = TempDir::new().unwrap();
495 let agents_dir = root.path().join("agents");
496 fs::create_dir_all(&agents_dir).unwrap();
497
498 let hash_a = hash::hash_bytes(b"# unchanged");
499 let hash_b_old = hash::hash_bytes(b"# old version");
500 let hash_b_new = hash::hash_bytes(b"# new version");
501
502 fs::write(agents_dir.join("stable.md"), b"# unchanged").unwrap();
504
505 fs::write(agents_dir.join("updating.md"), b"# old version").unwrap();
507
508 let source_path_a = PathBuf::from("/tmp/source/agents/stable.md");
509 let source_path_b = PathBuf::from("/tmp/source/agents/updating.md");
510 let source_path_c = PathBuf::from("/tmp/source/agents/new.md");
511
512 let mut target_items = IndexMap::new();
513 target_items.insert(
514 "agents/stable.md".into(),
515 make_target_item("stable", ItemKind::Agent, &hash_a, source_path_a),
516 );
517 target_items.insert(
518 "agents/updating.md".into(),
519 make_target_item("updating", ItemKind::Agent, &hash_b_new, source_path_b),
520 );
521 target_items.insert(
522 "agents/new.md".into(),
523 make_target_item(
524 "new",
525 ItemKind::Agent,
526 &hash::hash_bytes(b"# brand new"),
527 source_path_c,
528 ),
529 );
530 let target = TargetState {
531 items: target_items,
532 };
533
534 let mut lock_items = IndexMap::new();
535 let (k, v) = make_v2_item("stable", ItemKind::Agent, &hash_a, &hash_a);
536 lock_items.insert(k, v);
537 let (k, v) = make_v2_item("updating", ItemKind::Agent, &hash_b_old, &hash_b_old);
538 lock_items.insert(k, v);
539 let (k, v) = make_v2_item("orphan", ItemKind::Agent, "sha256:xxx", "sha256:xxx");
540 lock_items.insert(k, v);
541 let lock = LockFile {
542 version: 2,
543 dependencies: IndexMap::new(),
544 items: lock_items,
545 config_entries: std::collections::BTreeMap::new(),
546 };
547
548 let diff = compute(root.path(), &lock, &target, false).unwrap();
549 assert_eq!(diff.items.len(), 4); let unchanged_count = diff
552 .items
553 .iter()
554 .filter(|d| matches!(d, DiffEntry::Unchanged { .. }))
555 .count();
556 let update_count = diff
557 .items
558 .iter()
559 .filter(|d| matches!(d, DiffEntry::Update { .. }))
560 .count();
561 let add_count = diff
562 .items
563 .iter()
564 .filter(|d| matches!(d, DiffEntry::Add { .. }))
565 .count();
566 let orphan_count = diff
567 .items
568 .iter()
569 .filter(|d| matches!(d, DiffEntry::Orphan { .. }))
570 .count();
571
572 assert_eq!(unchanged_count, 1);
573 assert_eq!(update_count, 1);
574 assert_eq!(add_count, 1);
575 assert_eq!(orphan_count, 1);
576 }
577
578 #[test]
579 fn force_uses_source_checksum_for_local_change_detection() {
580 let root = TempDir::new().unwrap();
581 let upstream_content = b"# upstream";
582 let conflicted_content = b"<<<<<<< local\n# local\n=======\n# upstream\n>>>>>>> upstream\n";
583
584 let source_hash = hash::hash_bytes(upstream_content);
585 let installed_hash = hash::hash_bytes(conflicted_content);
586
587 let agents_dir = root.path().join("agents");
589 fs::create_dir_all(&agents_dir).unwrap();
590 fs::write(agents_dir.join("coder.md"), conflicted_content).unwrap();
591
592 let mut target_items = IndexMap::new();
593 target_items.insert(
594 "agents/coder.md".into(),
595 make_target_item(
596 "coder",
597 ItemKind::Agent,
598 &source_hash,
599 PathBuf::from("/tmp/source/agents/coder.md"),
600 ),
601 );
602 let target = TargetState {
603 items: target_items,
604 };
605
606 let mut lock_items = IndexMap::new();
607 lock_items.insert(
608 "agent/coder".to_string(),
609 LockedItemV2 {
610 source: "test-source".into(),
611 kind: ItemKind::Agent,
612 version: None,
613 source_checksum: source_hash.clone().into(),
614 outputs: vec![OutputRecord {
615 target_root: ".mars".to_string(),
616 dest_path: "agents/coder.md".into(),
617 installed_checksum: installed_hash.into(),
618 }],
619 },
620 );
621 let lock = LockFile {
622 version: 2,
623 dependencies: IndexMap::new(),
624 items: lock_items,
625 config_entries: std::collections::BTreeMap::new(),
626 };
627
628 let normal = compute(root.path(), &lock, &target, false).unwrap();
629 assert!(matches!(&normal.items[0], DiffEntry::Unchanged { .. }));
630
631 let forced = compute(root.path(), &lock, &target, true).unwrap();
632 assert!(matches!(&forced.items[0], DiffEntry::LocalModified { .. }));
633 }
634
635 #[test]
636 fn canonical_diff_ignores_non_canonical_output_checksum() {
637 let root = TempDir::new().unwrap();
638 let canonical_content = b"# canonical";
639 let canonical_hash = hash::hash_bytes(canonical_content);
640 let pi_hash = hash::hash_bytes(b"# pi rewrite");
641
642 let agents_dir = root.path().join("agents");
643 fs::create_dir_all(&agents_dir).unwrap();
644 fs::write(agents_dir.join("coder.md"), canonical_content).unwrap();
645
646 let mut target_items = IndexMap::new();
647 target_items.insert(
648 "agents/coder.md".into(),
649 make_target_item(
650 "coder",
651 ItemKind::Agent,
652 &canonical_hash,
653 PathBuf::from("/tmp/source/agents/coder.md"),
654 ),
655 );
656 let target = TargetState {
657 items: target_items,
658 };
659
660 let mut lock_items = IndexMap::new();
661 lock_items.insert(
662 "agent/coder".to_string(),
663 LockedItemV2 {
664 source: SourceName::from("test-source"),
665 kind: ItemKind::Agent,
666 version: None,
667 source_checksum: canonical_hash.clone().into(),
668 outputs: vec![
669 OutputRecord {
670 target_root: ".mars".to_string(),
671 dest_path: "agents/coder.md".into(),
672 installed_checksum: canonical_hash.clone().into(),
673 },
674 OutputRecord {
675 target_root: ".pi".to_string(),
676 dest_path: "agents/coder.md".into(),
677 installed_checksum: pi_hash.into(),
678 },
679 ],
680 },
681 );
682 let lock = LockFile {
683 version: 2,
684 dependencies: IndexMap::new(),
685 items: lock_items,
686 config_entries: std::collections::BTreeMap::new(),
687 };
688
689 let diff = compute(root.path(), &lock, &target, false).unwrap();
690 assert_eq!(diff.items.len(), 1);
691 assert!(
692 matches!(&diff.items[0], DiffEntry::Unchanged { .. }),
693 "expected Unchanged, got {:?}",
694 diff.items[0]
695 );
696 }
697
698 #[test]
699 fn rewritten_content_change_produces_update() {
700 let root = TempDir::new().unwrap();
701
702 let source_content = b"---\nskills:\n- planning\n---\n# Agent\n";
703 let source_hash = hash::hash_bytes(source_content);
704 let old_installed_content = b"---\nskills:\n- planning\n---\n# Agent\n";
705 let old_installed_hash = hash::hash_bytes(old_installed_content);
706 let rewritten_content = "---\nskills:\n- strategy\n---\n# Agent\n";
707 let rewritten_hash = hash::hash_bytes(rewritten_content.as_bytes());
708
709 let agents_dir = root.path().join("agents");
710 fs::create_dir_all(&agents_dir).unwrap();
711 fs::write(agents_dir.join("coder.md"), old_installed_content).unwrap();
712
713 let mut target_items = IndexMap::new();
714 target_items.insert(
715 "agents/coder.md".into(),
716 TargetItem {
717 id: ItemId {
718 kind: ItemKind::Agent,
719 name: "coder".into(),
720 },
721 source_name: SourceName::from("test-source"),
722 origin: crate::types::SourceOrigin::Dependency(SourceName::from("test-source")),
723 source_id: crate::types::SourceId::Path {
724 canonical: PathBuf::from("/tmp/source/agents/coder.md"),
725 subpath: None,
726 },
727 source_path: PathBuf::from("/tmp/source/agents/coder.md"),
728 dest_path: "agents/coder.md".into(),
729 source_hash: source_hash.clone().into(),
730 is_flat_skill: false,
731 rewritten_content: Some(rewritten_content.to_string()),
732 },
733 );
734 let target = TargetState {
735 items: target_items,
736 };
737
738 let mut lock_items = IndexMap::new();
739 lock_items.insert(
740 "agent/coder".to_string(),
741 LockedItemV2 {
742 source: SourceName::from("test-source"),
743 kind: ItemKind::Agent,
744 version: None,
745 source_checksum: source_hash.into(),
746 outputs: vec![OutputRecord {
747 target_root: ".mars".to_string(),
748 dest_path: "agents/coder.md".into(),
749 installed_checksum: old_installed_hash.clone().into(),
750 }],
751 },
752 );
753 let lock = LockFile {
754 version: 2,
755 dependencies: IndexMap::new(),
756 items: lock_items,
757 config_entries: std::collections::BTreeMap::new(),
758 };
759
760 let diff = compute(root.path(), &lock, &target, false).unwrap();
761 assert_eq!(diff.items.len(), 1);
762 assert!(matches!(&diff.items[0], DiffEntry::Update { .. }));
763
764 assert_ne!(rewritten_hash, old_installed_hash);
765 }
766}