1use std::collections::BTreeMap;
35use std::path::{Path, PathBuf};
36use std::process::Command;
37
38use sha2::{Digest, Sha256};
39
40use crate::model::file_id::FileIdMap;
41use crate::model::patch::{FileId, PatchSet, PatchValue};
42use crate::model::types::{EpochId, GitOid};
43
44#[derive(Debug)]
50pub enum DiffError {
51 GitCommand {
53 command: String,
55 stderr: String,
57 exit_code: Option<i32>,
59 },
60 InvalidOid {
62 raw: String,
64 },
65 Io(std::io::Error),
67 MalformedDiffLine(String),
69 FileIdMap(String),
71}
72
73impl std::fmt::Display for DiffError {
74 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
75 match self {
76 Self::GitCommand {
77 command,
78 stderr,
79 exit_code,
80 } => {
81 write!(f, "`{command}` failed")?;
82 if let Some(code) = exit_code {
83 write!(f, " (exit {code})")?;
84 }
85 if !stderr.is_empty() {
86 write!(f, ": {stderr}")?;
87 }
88 Ok(())
89 }
90 Self::InvalidOid { raw } => write!(f, "invalid git OID: {raw:?}"),
91 Self::Io(e) => write!(f, "I/O error: {e}"),
92 Self::MalformedDiffLine(line) => write!(f, "malformed diff line: {line:?}"),
93 Self::FileIdMap(message) => write!(f, "failed to load FileId map: {message}"),
94 }
95 }
96}
97
98impl std::error::Error for DiffError {
99 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
100 if let Self::Io(e) = self {
101 Some(e)
102 } else {
103 None
104 }
105 }
106}
107
108impl From<std::io::Error> for DiffError {
109 fn from(e: std::io::Error) -> Self {
110 Self::Io(e)
111 }
112}
113
114#[derive(Debug, PartialEq, Eq)]
120enum DiffEntry {
121 Added(PathBuf),
123 Modified(PathBuf),
125 Deleted(PathBuf),
127 Renamed { from: PathBuf, to: PathBuf },
129}
130
131fn git_cmd(dir: &Path, args: &[&str]) -> Result<String, DiffError> {
137 let out = Command::new("git").args(args).current_dir(dir).output()?;
138 if out.status.success() {
139 Ok(String::from_utf8_lossy(&out.stdout).trim_end().to_owned())
140 } else {
141 Err(DiffError::GitCommand {
142 command: format!("git {}", args.join(" ")),
143 stderr: String::from_utf8_lossy(&out.stderr).trim().to_owned(),
144 exit_code: out.status.code(),
145 })
146 }
147}
148
149fn parse_diff_name_status(output: &str) -> Result<Vec<DiffEntry>, DiffError> {
157 let mut entries = Vec::new();
158 for line in output.lines() {
159 if line.is_empty() {
160 continue;
161 }
162 let parts: Vec<&str> = line.splitn(3, '\t').collect();
163 let status = parts.first().copied().unwrap_or("");
164 match status {
165 "A" if parts.len() == 2 => {
166 entries.push(DiffEntry::Added(PathBuf::from(parts[1])));
167 }
168 "M" if parts.len() == 2 => {
169 entries.push(DiffEntry::Modified(PathBuf::from(parts[1])));
170 }
171 "D" if parts.len() == 2 => {
172 entries.push(DiffEntry::Deleted(PathBuf::from(parts[1])));
173 }
174 s if s.starts_with('R') && parts.len() == 3 => {
175 entries.push(DiffEntry::Renamed {
176 from: PathBuf::from(parts[1]),
177 to: PathBuf::from(parts[2]),
178 });
179 }
180 _ => {
181 return Err(DiffError::MalformedDiffLine(line.to_owned()));
182 }
183 }
184 }
185 Ok(entries)
186}
187
188fn hash_object_write(workspace_path: &Path, abs_file: &Path) -> Result<GitOid, DiffError> {
192 let path_str = abs_file.to_string_lossy();
193 let stdout = git_cmd(workspace_path, &["hash-object", "-w", "--", &path_str])?;
194 let trimmed = stdout.trim();
195 GitOid::new(trimmed).map_err(|_| DiffError::InvalidOid {
196 raw: trimmed.to_owned(),
197 })
198}
199
200fn epoch_blob_oid(
204 workspace_path: &Path,
205 epoch: &EpochId,
206 path: &Path,
207) -> Result<GitOid, DiffError> {
208 let rev = format!("{}:{}", epoch.as_str(), path.to_string_lossy());
209 let stdout = git_cmd(workspace_path, &["rev-parse", &rev])?;
210 let trimmed = stdout.trim();
211 GitOid::new(trimmed).map_err(|_| DiffError::InvalidOid {
212 raw: trimmed.to_owned(),
213 })
214}
215
216fn file_id_from_path(path: &Path) -> FileId {
220 let mut hasher = Sha256::new();
221 hasher.update(path.to_string_lossy().as_bytes());
222 let digest = hasher.finalize();
223 let mut bytes = [0_u8; 16];
224 bytes.copy_from_slice(&digest[..16]);
225 FileId::new(u128::from_be_bytes(bytes))
226}
227
228fn file_id_from_blob(blob: &GitOid) -> FileId {
233 let hex = &blob.as_str()[..32];
235 let n = u128::from_str_radix(hex, 16).unwrap_or(0);
237 FileId::new(n)
238}
239
240fn repo_root_for_workspace(workspace_path: &Path) -> Result<PathBuf, DiffError> {
241 let root = git_cmd(workspace_path, &["rev-parse", "--show-toplevel"])?;
242 Ok(PathBuf::from(root))
243}
244
245fn load_file_id_map(workspace_path: &Path) -> Result<FileIdMap, DiffError> {
246 let repo_root = repo_root_for_workspace(workspace_path)?;
247 let fileids_path = repo_root.join(".manifold").join("fileids");
248 FileIdMap::load(&fileids_path).map_err(|e| DiffError::FileIdMap(e.to_string()))
249}
250
251pub fn compute_patchset(
283 workspace_path: &Path,
284 base_epoch: &EpochId,
285) -> Result<PatchSet, DiffError> {
286 let mut patches: BTreeMap<PathBuf, PatchValue> = BTreeMap::new();
287 let file_id_map = load_file_id_map(workspace_path)?;
288
289 let diff_out = git_cmd(
293 workspace_path,
294 &[
295 "diff",
296 "--find-renames",
297 "--name-status",
298 base_epoch.as_str(),
299 ],
300 )?;
301
302 let entries = parse_diff_name_status(&diff_out)?;
303
304 for entry in entries {
305 match entry {
306 DiffEntry::Added(path) => {
307 let abs = workspace_path.join(&path);
308 let blob = hash_object_write(workspace_path, &abs)?;
309 let file_id = file_id_map
310 .id_for_path(&path)
311 .unwrap_or_else(|| file_id_from_path(&path));
312 patches.insert(path, PatchValue::Add { blob, file_id });
313 }
314 DiffEntry::Modified(path) => {
315 let base_blob = epoch_blob_oid(workspace_path, base_epoch, &path)?;
316 let abs = workspace_path.join(&path);
317 let new_blob = hash_object_write(workspace_path, &abs)?;
318 let file_id = file_id_map
319 .id_for_path(&path)
320 .unwrap_or_else(|| file_id_from_blob(&base_blob));
321 patches.insert(
322 path,
323 PatchValue::Modify {
324 base_blob,
325 new_blob,
326 file_id,
327 },
328 );
329 }
330 DiffEntry::Deleted(path) => {
331 let previous_blob = epoch_blob_oid(workspace_path, base_epoch, &path)?;
332 let file_id = file_id_map
333 .id_for_path(&path)
334 .unwrap_or_else(|| file_id_from_blob(&previous_blob));
335 patches.insert(
336 path,
337 PatchValue::Delete {
338 previous_blob,
339 file_id,
340 },
341 );
342 }
343 DiffEntry::Renamed { from, to } => {
344 let base_blob = epoch_blob_oid(workspace_path, base_epoch, &from)?;
345 let abs_to = workspace_path.join(&to);
346 let new_blob_oid = hash_object_write(workspace_path, &abs_to)?;
347 let file_id = file_id_map
348 .id_for_path(&from)
349 .or_else(|| file_id_map.id_for_path(&to))
350 .unwrap_or_else(|| file_id_from_blob(&base_blob));
351 let new_blob = if new_blob_oid == base_blob {
353 None
354 } else {
355 Some(new_blob_oid)
356 };
357 patches.insert(
358 to,
359 PatchValue::Rename {
360 from,
361 file_id,
362 new_blob,
363 },
364 );
365 }
366 }
367 }
368
369 let untracked_out = git_cmd(
373 workspace_path,
374 &["ls-files", "--others", "--exclude-standard"],
375 )?;
376
377 for line in untracked_out.lines() {
378 if line.is_empty() {
379 continue;
380 }
381 let path = PathBuf::from(line);
382 if patches.contains_key(&path) {
384 continue;
385 }
386 let abs = workspace_path.join(&path);
387 let blob = hash_object_write(workspace_path, &abs)?;
388 let file_id = file_id_map
389 .id_for_path(&path)
390 .unwrap_or_else(|| file_id_from_path(&path));
391 patches.insert(path, PatchValue::Add { blob, file_id });
392 }
393
394 Ok(PatchSet {
395 base_epoch: base_epoch.clone(),
396 patches,
397 })
398}
399
400#[cfg(test)]
405mod tests {
406 use super::*;
407 use std::fs;
408
409 fn git_init(dir: &Path) {
418 run_git(dir, &["init", "-b", "main"]);
419 run_git(dir, &["config", "user.email", "test@test.com"]);
420 run_git(dir, &["config", "user.name", "Test"]);
421 run_git(dir, &["config", "commit.gpgsign", "false"]);
422 }
423
424 fn run_git(dir: &Path, args: &[&str]) -> String {
426 let out = Command::new("git")
427 .args(args)
428 .current_dir(dir)
429 .output()
430 .expect("git must be installed");
431 if !out.status.success() {
432 let stderr = String::from_utf8_lossy(&out.stderr);
433 panic!("git {} failed: {}", args.join(" "), stderr);
434 }
435 String::from_utf8_lossy(&out.stdout).trim().to_owned()
436 }
437
438 fn write_file(dir: &Path, path: &str, content: &str) {
440 let full = dir.join(path);
441 if let Some(parent) = full.parent() {
442 fs::create_dir_all(parent).unwrap();
443 }
444 fs::write(full, content).unwrap();
445 }
446
447 fn make_epoch(dir: &Path, files: &[(&str, &str)]) -> EpochId {
449 for (path, content) in files {
450 write_file(dir, path, content);
451 }
452 run_git(dir, &["add", "."]);
453 run_git(dir, &["commit", "-m", "epoch"]);
454 let oid = run_git(dir, &["rev-parse", "HEAD"]);
455 EpochId::new(&oid).expect("HEAD OID must be valid")
456 }
457
458 #[test]
463 fn parse_added_line() {
464 let input = "A\tsrc/new.rs";
465 let entries = parse_diff_name_status(input).unwrap();
466 assert_eq!(entries.len(), 1);
467 assert_eq!(entries[0], DiffEntry::Added(PathBuf::from("src/new.rs")));
468 }
469
470 #[test]
471 fn parse_modified_line() {
472 let input = "M\tsrc/lib.rs";
473 let entries = parse_diff_name_status(input).unwrap();
474 assert_eq!(entries.len(), 1);
475 assert_eq!(entries[0], DiffEntry::Modified(PathBuf::from("src/lib.rs")));
476 }
477
478 #[test]
479 fn parse_deleted_line() {
480 let input = "D\told.rs";
481 let entries = parse_diff_name_status(input).unwrap();
482 assert_eq!(entries.len(), 1);
483 assert_eq!(entries[0], DiffEntry::Deleted(PathBuf::from("old.rs")));
484 }
485
486 #[test]
487 fn parse_renamed_line() {
488 let input = "R90\told/path.rs\tnew/path.rs";
489 let entries = parse_diff_name_status(input).unwrap();
490 assert_eq!(entries.len(), 1);
491 assert_eq!(
492 entries[0],
493 DiffEntry::Renamed {
494 from: PathBuf::from("old/path.rs"),
495 to: PathBuf::from("new/path.rs"),
496 }
497 );
498 }
499
500 #[test]
501 fn parse_renamed_r100() {
502 let input = "R100\tfoo.rs\tbar.rs";
504 let entries = parse_diff_name_status(input).unwrap();
505 assert_eq!(
506 entries[0],
507 DiffEntry::Renamed {
508 from: PathBuf::from("foo.rs"),
509 to: PathBuf::from("bar.rs"),
510 }
511 );
512 }
513
514 #[test]
515 fn parse_empty_output() {
516 let entries = parse_diff_name_status("").unwrap();
517 assert!(entries.is_empty());
518 }
519
520 #[test]
521 fn parse_multiple_entries() {
522 let input = "A\tnew.rs\nM\told.rs\nD\tgone.rs";
523 let entries = parse_diff_name_status(input).unwrap();
524 assert_eq!(entries.len(), 3);
525 }
526
527 #[test]
528 fn parse_malformed_line_returns_error() {
529 let input = "Z\tunknown_status";
530 let result = parse_diff_name_status(input);
531 assert!(result.is_err());
532 }
533
534 #[test]
539 fn file_id_from_path_is_deterministic() {
540 let path = Path::new("src/lib.rs");
541 let id1 = file_id_from_path(path);
542 let id2 = file_id_from_path(path);
543 assert_eq!(id1, id2);
544 }
545
546 #[test]
547 fn file_id_from_path_differs_for_different_paths() {
548 let id1 = file_id_from_path(Path::new("src/a.rs"));
549 let id2 = file_id_from_path(Path::new("src/b.rs"));
550 assert_ne!(id1, id2);
551 }
552
553 #[test]
558 fn file_id_from_blob_is_deterministic() {
559 let oid = GitOid::new(&"a".repeat(40)).unwrap();
560 let id1 = file_id_from_blob(&oid);
561 let id2 = file_id_from_blob(&oid);
562 assert_eq!(id1, id2);
563 }
564
565 #[test]
566 fn file_id_from_blob_differs_for_different_blobs() {
567 let oid1 = GitOid::new(&"a".repeat(40)).unwrap();
568 let oid2 = GitOid::new(&"b".repeat(40)).unwrap();
569 assert_ne!(file_id_from_blob(&oid1), file_id_from_blob(&oid2));
570 }
571
572 #[test]
577 fn compute_patchset_empty_working_dir() {
578 let dir = tempfile::tempdir().unwrap();
579 let root = dir.path();
580
581 git_init(root);
582 write_file(root, "existing.rs", "fn main() {}");
583 run_git(root, &["add", "."]);
584 run_git(root, &["commit", "-m", "epoch"]);
585 let oid = run_git(root, &["rev-parse", "HEAD"]);
586 let epoch = EpochId::new(&oid).unwrap();
587
588 let ps = compute_patchset(root, &epoch).unwrap();
590 assert!(ps.is_empty(), "no changes → empty PatchSet");
591 assert_eq!(ps.base_epoch, epoch);
592 }
593
594 #[test]
595 fn compute_patchset_added_file() {
596 let dir = tempfile::tempdir().unwrap();
597 let root = dir.path();
598
599 git_init(root);
600 let epoch = make_epoch(root, &[("base.rs", "// base")]);
601
602 write_file(root, "new.rs", "fn new() {}");
604 run_git(root, &["add", "new.rs"]);
605
606 let ps = compute_patchset(root, &epoch).unwrap();
607 assert_eq!(ps.len(), 1);
608
609 let pv = ps
610 .patches
611 .get(&PathBuf::from("new.rs"))
612 .expect("new.rs in PatchSet");
613 assert!(
614 matches!(pv, PatchValue::Add { .. }),
615 "expected Add, got {pv:?}"
616 );
617 if let PatchValue::Add { blob, .. } = pv {
618 assert_eq!(blob.as_str().len(), 40);
620 }
621 }
622
623 #[test]
624 fn compute_patchset_untracked_file() {
625 let dir = tempfile::tempdir().unwrap();
626 let root = dir.path();
627
628 git_init(root);
629 let epoch = make_epoch(root, &[("base.rs", "// base")]);
630
631 write_file(root, "untracked.txt", "hello");
633
634 let ps = compute_patchset(root, &epoch).unwrap();
635 assert_eq!(ps.len(), 1);
636
637 let pv = ps
638 .patches
639 .get(&PathBuf::from("untracked.txt"))
640 .expect("untracked.txt in PatchSet");
641 assert!(matches!(pv, PatchValue::Add { .. }));
642 }
643
644 #[test]
645 fn compute_patchset_modified_file() {
646 let dir = tempfile::tempdir().unwrap();
647 let root = dir.path();
648
649 git_init(root);
650 let epoch = make_epoch(root, &[("lib.rs", "fn original() {}")]);
651
652 write_file(root, "lib.rs", "fn modified() {}");
654 run_git(root, &["add", "lib.rs"]);
655
656 let ps = compute_patchset(root, &epoch).unwrap();
657 assert_eq!(ps.len(), 1);
658
659 let pv = ps
660 .patches
661 .get(&PathBuf::from("lib.rs"))
662 .expect("lib.rs in PatchSet");
663 assert!(
664 matches!(pv, PatchValue::Modify { .. }),
665 "expected Modify, got {pv:?}"
666 );
667 if let PatchValue::Modify {
668 base_blob,
669 new_blob,
670 ..
671 } = pv
672 {
673 assert_ne!(base_blob, new_blob, "blobs must differ after modification");
675 assert_eq!(base_blob.as_str().len(), 40);
676 assert_eq!(new_blob.as_str().len(), 40);
677 }
678 }
679
680 #[test]
681 fn compute_patchset_deleted_file() {
682 let dir = tempfile::tempdir().unwrap();
683 let root = dir.path();
684
685 git_init(root);
686 let epoch = make_epoch(root, &[("to_delete.rs", "fn gone() {}")]);
687
688 run_git(root, &["rm", "to_delete.rs"]);
690
691 let ps = compute_patchset(root, &epoch).unwrap();
692 assert_eq!(ps.len(), 1);
693
694 let pv = ps
695 .patches
696 .get(&PathBuf::from("to_delete.rs"))
697 .expect("to_delete.rs in PatchSet");
698 assert!(
699 matches!(pv, PatchValue::Delete { .. }),
700 "expected Delete, got {pv:?}"
701 );
702 if let PatchValue::Delete { previous_blob, .. } = pv {
703 assert_eq!(previous_blob.as_str().len(), 40);
704 }
705 }
706
707 #[test]
708 fn compute_patchset_renamed_file_same_content() {
709 let dir = tempfile::tempdir().unwrap();
710 let root = dir.path();
711
712 git_init(root);
713 let content = "fn example() { println!(\"hello world\"); }\n".repeat(5);
715 let epoch = make_epoch(root, &[("old_name.rs", &content)]);
716
717 run_git(root, &["mv", "old_name.rs", "new_name.rs"]);
719
720 let ps = compute_patchset(root, &epoch).unwrap();
721 assert_eq!(ps.len(), 1, "rename → one entry at destination path");
722
723 let pv = ps
724 .patches
725 .get(&PathBuf::from("new_name.rs"))
726 .expect("new_name.rs in PatchSet");
727 assert!(
728 matches!(pv, PatchValue::Rename { .. }),
729 "expected Rename, got {pv:?}"
730 );
731 if let PatchValue::Rename { from, new_blob, .. } = pv {
732 assert_eq!(from, &PathBuf::from("old_name.rs"));
733 assert!(
734 new_blob.is_none(),
735 "content unchanged → new_blob should be None"
736 );
737 }
738 }
739
740 #[test]
741 fn compute_patchset_renamed_file_with_content_change() {
742 let dir = tempfile::tempdir().unwrap();
743 let root = dir.path();
744
745 git_init(root);
746 let content = "fn example() { println!(\"original content\"); }\n".repeat(5);
748 let epoch = make_epoch(root, &[("old.rs", &content)]);
749
750 run_git(root, &["mv", "old.rs", "new.rs"]);
752 write_file(root, "new.rs", &format!("{content}// modified\n"));
753 run_git(root, &["add", "new.rs"]);
754
755 let ps = compute_patchset(root, &epoch).unwrap();
756 assert_eq!(ps.len(), 1);
757
758 let pv = ps
759 .patches
760 .get(&PathBuf::from("new.rs"))
761 .expect("new.rs in PatchSet");
762 assert!(
763 matches!(pv, PatchValue::Rename { .. }),
764 "expected Rename, got {pv:?}"
765 );
766 if let PatchValue::Rename { from, new_blob, .. } = pv {
767 assert_eq!(from, &PathBuf::from("old.rs"));
768 assert!(
769 new_blob.is_some(),
770 "content changed → new_blob should be Some"
771 );
772 }
773 }
774
775 #[test]
776 fn compute_patchset_multiple_changes() {
777 let dir = tempfile::tempdir().unwrap();
778 let root = dir.path();
779
780 git_init(root);
781 let epoch = make_epoch(
782 root,
783 &[
784 ("keep.rs", "fn keep() {}"),
785 ("modify.rs", "fn modify() {}"),
786 ("delete.rs", "fn delete() {}"),
787 ],
788 );
789
790 write_file(root, "add.rs", "fn add() {}"); write_file(root, "modify.rs", "fn modified() {}"); run_git(root, &["rm", "delete.rs"]); run_git(root, &["add", "."]);
795
796 let ps = compute_patchset(root, &epoch).unwrap();
797
798 assert!(!ps.patches.contains_key(&PathBuf::from("keep.rs")));
800
801 assert!(matches!(
803 ps.patches.get(&PathBuf::from("add.rs")),
804 Some(PatchValue::Add { .. })
805 ));
806
807 assert!(matches!(
809 ps.patches.get(&PathBuf::from("modify.rs")),
810 Some(PatchValue::Modify { .. })
811 ));
812
813 assert!(matches!(
815 ps.patches.get(&PathBuf::from("delete.rs")),
816 Some(PatchValue::Delete { .. })
817 ));
818
819 assert_eq!(ps.len(), 3);
820 }
821
822 #[test]
823 fn compute_patchset_blob_oids_are_correct() {
824 let dir = tempfile::tempdir().unwrap();
825 let root = dir.path();
826
827 git_init(root);
828 let epoch_id = make_epoch(root, &[("file.rs", "original")]);
829
830 let expected_base_blob = run_git(
832 root,
833 &["rev-parse", &format!("{}:file.rs", epoch_id.as_str())],
834 );
835
836 write_file(root, "file.rs", "modified");
837 run_git(root, &["add", "file.rs"]);
838
839 let expected_new_blob = run_git(root, &["ls-files", "--cached", "-s", "file.rs"]);
841 let expected_new_oid: String = expected_new_blob
843 .split_whitespace()
844 .nth(1)
845 .unwrap_or("")
846 .to_owned();
847
848 let ps = compute_patchset(root, &epoch_id).unwrap();
849 if let Some(PatchValue::Modify {
850 base_blob,
851 new_blob,
852 ..
853 }) = ps.patches.get(&PathBuf::from("file.rs"))
854 {
855 assert_eq!(
856 base_blob.as_str(),
857 expected_base_blob,
858 "base_blob must match epoch blob"
859 );
860 assert_eq!(
861 new_blob.as_str(),
862 expected_new_oid,
863 "new_blob must match staged blob"
864 );
865 } else {
866 panic!("expected Modify for file.rs");
867 }
868 }
869
870 #[test]
871 fn compute_patchset_base_epoch_preserved() {
872 let dir = tempfile::tempdir().unwrap();
873 let root = dir.path();
874
875 git_init(root);
876 let epoch = make_epoch(root, &[("a.rs", "content")]);
877
878 write_file(root, "b.rs", "new");
879 run_git(root, &["add", "b.rs"]);
880
881 let ps = compute_patchset(root, &epoch).unwrap();
882 assert_eq!(
883 ps.base_epoch, epoch,
884 "base_epoch must match the epoch passed in"
885 );
886 }
887
888 #[test]
889 fn compute_patchset_uses_btreemap_ordering() {
890 let dir = tempfile::tempdir().unwrap();
891 let root = dir.path();
892
893 git_init(root);
894 let epoch = make_epoch(root, &[("baseline.rs", "x")]);
895
896 write_file(root, "z.rs", "z");
898 write_file(root, "a.rs", "a");
899 write_file(root, "m.rs", "m");
900 run_git(root, &["add", "."]);
901
902 let ps = compute_patchset(root, &epoch).unwrap();
903
904 let keys: Vec<_> = ps.patches.keys().collect();
905 let mut sorted = keys.clone();
906 sorted.sort();
907 assert_eq!(keys, sorted, "PatchSet paths must be in sorted order");
908 }
909
910 #[test]
911 fn compute_patchset_uses_fileid_map_for_modify() {
912 let dir = tempfile::tempdir().unwrap();
913 let root = dir.path();
914
915 git_init(root);
916 let epoch = make_epoch(
917 root,
918 &[(".gitignore", ".manifold/\n"), ("tracked.rs", "v1")],
919 );
920
921 let fileids_dir = root.join(".manifold");
922 fs::create_dir_all(&fileids_dir).unwrap();
923 fs::write(
924 fileids_dir.join("fileids"),
925 r#"[
926 {"path": "tracked.rs", "file_id": "0000000000000000000000000000002a"}
927]"#,
928 )
929 .unwrap();
930
931 write_file(root, "tracked.rs", "v2");
932 run_git(root, &["add", "tracked.rs"]);
933
934 let ps = compute_patchset(root, &epoch).unwrap();
935 let patch = ps.patches.get(&PathBuf::from("tracked.rs")).unwrap();
936 let PatchValue::Modify { file_id, .. } = patch else {
937 panic!("expected Modify patch");
938 };
939 assert_eq!(*file_id, FileId::new(42));
940 }
941
942 #[test]
943 fn compute_patchset_add_file_id_is_deterministic_across_calls() {
944 let dir = tempfile::tempdir().unwrap();
945 let root = dir.path();
946
947 git_init(root);
948 let epoch = make_epoch(root, &[("base.rs", "base")]);
949
950 write_file(root, "new_file.rs", "new content");
951 run_git(root, &["add", "new_file.rs"]);
952
953 let ps1 = compute_patchset(root, &epoch).unwrap();
954 let ps2 = compute_patchset(root, &epoch).unwrap();
955
956 let PatchValue::Add { file_id: id1, .. } =
957 ps1.patches.get(&PathBuf::from("new_file.rs")).unwrap()
958 else {
959 panic!("expected Add patch in first call");
960 };
961 let PatchValue::Add { file_id: id2, .. } =
962 ps2.patches.get(&PathBuf::from("new_file.rs")).unwrap()
963 else {
964 panic!("expected Add patch in second call");
965 };
966
967 assert_eq!(id1, id2);
968 }
969}