1#![allow(clippy::missing_errors_doc)]
26
27use std::collections::{BTreeMap, HashMap};
28use std::io::Write as IoWrite;
29use std::path::{Path, PathBuf};
30use std::process::{Command, Stdio};
31use std::time::{SystemTime, UNIX_EPOCH};
32
33use crate::model::types::{EpochId, GitOid, WorkspaceId};
34
35#[derive(Clone, Debug, PartialEq, Eq)]
45pub enum ResolvedChange {
46 Upsert {
51 path: PathBuf,
53 content: Vec<u8>,
55 },
56 Delete {
58 path: PathBuf,
60 },
61}
62
63impl ResolvedChange {
64 #[must_use]
66 pub const fn path(&self) -> &PathBuf {
67 match self {
68 Self::Upsert { path, .. } | Self::Delete { path } => path,
69 }
70 }
71
72 #[must_use]
74 pub const fn is_upsert(&self) -> bool {
75 matches!(self, Self::Upsert { .. })
76 }
77
78 #[must_use]
80 pub const fn is_delete(&self) -> bool {
81 matches!(self, Self::Delete { .. })
82 }
83}
84
85#[derive(Debug)]
91pub enum BuildError {
92 GitCommand {
94 command: String,
96 stderr: String,
98 exit_code: Option<i32>,
100 },
101 Io(std::io::Error),
103 MalformedLsTree {
105 line: String,
107 },
108 InvalidOid {
110 context: String,
112 raw: String,
114 },
115}
116
117impl std::fmt::Display for BuildError {
118 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
119 match self {
120 Self::GitCommand {
121 command,
122 stderr,
123 exit_code,
124 } => {
125 write!(f, "`{command}` failed")?;
126 if let Some(code) = exit_code {
127 write!(f, " (exit {code})")?;
128 }
129 if !stderr.is_empty() {
130 write!(f, ": {stderr}")?;
131 }
132 Ok(())
133 }
134 Self::Io(e) => write!(f, "I/O error: {e}"),
135 Self::MalformedLsTree { line } => {
136 write!(f, "malformed `git ls-tree` output: {line:?}")
137 }
138 Self::InvalidOid { context, raw } => {
139 write!(f, "invalid OID from {context}: {raw:?}")
140 }
141 }
142 }
143}
144
145impl std::error::Error for BuildError {
146 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
147 if let Self::Io(e) = self {
148 Some(e)
149 } else {
150 None
151 }
152 }
153}
154
155impl From<std::io::Error> for BuildError {
156 fn from(e: std::io::Error) -> Self {
157 Self::Io(e)
158 }
159}
160
161pub fn build_merge_commit(
196 root: &Path,
197 epoch: &EpochId,
198 workspace_ids: &[WorkspaceId],
199 resolved: &[ResolvedChange],
200 message: Option<&str>,
201) -> Result<GitOid, BuildError> {
202 let mut tree = read_epoch_tree(root, epoch)?;
204
205 let mut sorted = resolved.to_vec();
207 sorted.sort_by(|a, b| a.path().cmp(b.path()));
208
209 for change in &sorted {
210 match change {
211 ResolvedChange::Upsert { path, content } => {
212 let blob_oid = write_blob(root, content)?;
213 let mode = tree
216 .get(path)
217 .map_or_else(|| "100644".to_owned(), |(m, _)| m.clone());
218 tree.insert(path.clone(), (mode, blob_oid.as_str().to_owned()));
219 }
220 ResolvedChange::Delete { path } => {
221 tree.remove(path);
222 }
223 }
224 }
225
226 let root_tree_oid = build_tree(root, &tree)?;
228
229 let commit_msg = message.map_or_else(
231 || {
232 let mut ws_names: Vec<&str> = workspace_ids.iter().map(WorkspaceId::as_str).collect();
233 ws_names.sort_unstable(); if ws_names.is_empty() {
235 "epoch: merge".to_owned()
236 } else {
237 format!("epoch: merge {}", ws_names.join(" "))
238 }
239 },
240 str::to_owned,
241 );
242
243 let commit_oid = create_commit(root, epoch, &root_tree_oid, &commit_msg)?;
245
246 Ok(commit_oid)
247}
248
249type FlatTree = BTreeMap<PathBuf, (String, String)>;
258
259fn read_epoch_tree(root: &Path, epoch: &EpochId) -> Result<FlatTree, BuildError> {
263 let output = Command::new("git")
267 .args(["ls-tree", "-r", "--full-tree", epoch.as_str()])
268 .current_dir(root)
269 .output()?;
270
271 if !output.status.success() {
272 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
273 return Err(BuildError::GitCommand {
274 command: format!("git ls-tree -r --full-tree {}", epoch.as_str()),
275 stderr,
276 exit_code: output.status.code(),
277 });
278 }
279
280 let raw = String::from_utf8_lossy(&output.stdout);
281 let mut tree = FlatTree::new();
282
283 for line in raw.lines() {
284 let line = line.trim();
285 if line.is_empty() {
286 continue;
287 }
288
289 let (meta, path_str) =
293 line.split_once('\t')
294 .ok_or_else(|| BuildError::MalformedLsTree {
295 line: line.to_owned(),
296 })?;
297
298 let parts: Vec<&str> = meta.split_whitespace().collect();
299 if parts.len() < 3 {
300 return Err(BuildError::MalformedLsTree {
301 line: line.to_owned(),
302 });
303 }
304
305 let mode = parts[0].to_owned();
306 let obj_type = parts[1];
307 let oid = parts[2].to_owned();
308
309 if obj_type != "blob" {
311 continue;
312 }
313
314 tree.insert(PathBuf::from(path_str), (mode, oid));
315 }
316
317 Ok(tree)
318}
319
320fn write_blob(root: &Path, content: &[u8]) -> Result<GitOid, BuildError> {
324 let mut child = Command::new("git")
325 .args(["hash-object", "-w", "--stdin"])
326 .current_dir(root)
327 .stdin(Stdio::piped())
328 .stdout(Stdio::piped())
329 .stderr(Stdio::piped())
330 .spawn()?;
331
332 if let Some(mut stdin) = child.stdin.take() {
334 stdin.write_all(content).map_err(BuildError::Io)?;
335 }
337
338 let output = child.wait_with_output()?;
339
340 if !output.status.success() {
341 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
342 return Err(BuildError::GitCommand {
343 command: "git hash-object -w --stdin".to_owned(),
344 stderr,
345 exit_code: output.status.code(),
346 });
347 }
348
349 let raw = String::from_utf8_lossy(&output.stdout).trim().to_owned();
350 GitOid::new(&raw).map_err(|_| BuildError::InvalidOid {
351 context: "hash-object output".to_owned(),
352 raw,
353 })
354}
355
356fn build_tree(root: &Path, flat: &FlatTree) -> Result<GitOid, BuildError> {
364 let mut all_dirs: Vec<PathBuf> = vec![PathBuf::new()]; for path in flat.keys() {
373 let mut current = path.parent().map(PathBuf::from).unwrap_or_default();
374 loop {
375 if current == PathBuf::new() || all_dirs.contains(¤t) {
376 break;
377 }
378 all_dirs.push(current.clone());
379 current = current.parent().map(PathBuf::from).unwrap_or_default();
380 }
381 }
382
383 all_dirs.sort_by(|a, b| {
385 let a_depth = a.components().count();
386 let b_depth = b.components().count();
387 b_depth.cmp(&a_depth).then(a.cmp(b))
388 });
389
390 let mut tree_oids: HashMap<PathBuf, String> = HashMap::new();
392
393 for dir in &all_dirs {
394 let mut entries: Vec<String> = Vec::new();
396
397 for (path, (mode, oid)) in flat {
399 let parent = path.parent().map(PathBuf::from).unwrap_or_default();
400 if &parent == dir {
401 let name = path
402 .file_name()
403 .and_then(|n| n.to_str())
404 .unwrap_or_default();
405 entries.push(format!("{mode} blob {oid}\t{name}"));
406 }
407 }
408
409 for (sub_path, sub_oid) in &tree_oids {
411 let parent = sub_path.parent().map(PathBuf::from).unwrap_or_default();
412 if &parent == dir {
413 let name = sub_path
414 .file_name()
415 .and_then(|n| n.to_str())
416 .unwrap_or_default();
417 entries.push(format!("040000 tree {sub_oid}\t{name}"));
418 }
419 }
420
421 entries.sort();
423
424 let mktree_input = entries.join("\n") + if entries.is_empty() { "" } else { "\n" };
426 let tree_oid = run_mktree(root, &mktree_input)?;
427 tree_oids.insert(dir.clone(), tree_oid.as_str().to_owned());
428 }
429
430 let root_oid_str = tree_oids.get(&PathBuf::new()).cloned().unwrap_or_default();
432
433 GitOid::new(&root_oid_str).map_err(|_| BuildError::InvalidOid {
434 context: "root tree OID from mktree".to_owned(),
435 raw: root_oid_str,
436 })
437}
438
439fn run_mktree(root: &Path, input: &str) -> Result<GitOid, BuildError> {
441 let mut child = Command::new("git")
442 .args(["mktree"])
443 .current_dir(root)
444 .stdin(Stdio::piped())
445 .stdout(Stdio::piped())
446 .stderr(Stdio::piped())
447 .spawn()?;
448
449 if let Some(mut stdin) = child.stdin.take() {
450 stdin.write_all(input.as_bytes()).map_err(BuildError::Io)?;
451 }
452
453 let output = child.wait_with_output()?;
454
455 if !output.status.success() {
456 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
457 return Err(BuildError::GitCommand {
458 command: "git mktree".to_owned(),
459 stderr,
460 exit_code: output.status.code(),
461 });
462 }
463
464 let raw = String::from_utf8_lossy(&output.stdout).trim().to_owned();
465 GitOid::new(&raw).map_err(|_| BuildError::InvalidOid {
466 context: "git mktree output".to_owned(),
467 raw,
468 })
469}
470
471fn create_commit(
489 root: &Path,
490 parent: &EpochId,
491 tree: &GitOid,
492 message: &str,
493) -> Result<GitOid, BuildError> {
494 let now_secs = SystemTime::now()
495 .duration_since(UNIX_EPOCH)
496 .unwrap_or_default()
497 .as_secs();
498 let timestamp = format!("{now_secs} +0000");
499
500 let output = Command::new("git")
501 .args([
502 "commit-tree",
503 tree.as_str(),
504 "-p",
505 parent.as_str(),
506 "-m",
507 message,
508 ])
509 .current_dir(root)
510 .env("GIT_AUTHOR_DATE", ×tamp)
511 .env("GIT_COMMITTER_DATE", ×tamp)
512 .output()?;
513
514 if !output.status.success() {
515 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
516 return Err(BuildError::GitCommand {
517 command: format!("git commit-tree {} -p {}", tree.as_str(), parent.as_str()),
518 stderr,
519 exit_code: output.status.code(),
520 });
521 }
522
523 let raw = String::from_utf8_lossy(&output.stdout).trim().to_owned();
524 GitOid::new(&raw).map_err(|_| BuildError::InvalidOid {
525 context: "git commit-tree output".to_owned(),
526 raw,
527 })
528}
529
530#[cfg(test)]
535mod tests {
536 use super::*;
537 use crate::model::types::{EpochId, WorkspaceId};
538 use std::fs;
539 use std::process::Command;
540 use tempfile::TempDir;
541
542 fn setup_git_repo() -> (TempDir, EpochId) {
548 let dir = TempDir::new().unwrap();
549 let root = dir.path();
550
551 run_git(root, &["init"]);
552 run_git(root, &["config", "user.name", "Test"]);
553 run_git(root, &["config", "user.email", "test@test.com"]);
554 run_git(root, &["config", "commit.gpgsign", "false"]);
555
556 fs::write(root.join("README.md"), "# Test\n").unwrap();
558 run_git(root, &["add", "README.md"]);
559 run_git(root, &["commit", "-m", "initial"]);
560
561 let oid = git_oid(root, "HEAD");
562 let epoch = EpochId::new(oid.as_str()).unwrap();
563 (dir, epoch)
564 }
565
566 fn run_git(root: &Path, args: &[&str]) {
567 let out = Command::new("git")
568 .args(args)
569 .current_dir(root)
570 .output()
571 .unwrap();
572 assert!(
573 out.status.success(),
574 "git {} failed: {}",
575 args.join(" "),
576 String::from_utf8_lossy(&out.stderr)
577 );
578 }
579
580 fn git_oid(root: &Path, rev: &str) -> GitOid {
581 let out = Command::new("git")
582 .args(["rev-parse", rev])
583 .current_dir(root)
584 .output()
585 .unwrap();
586 assert!(out.status.success(), "rev-parse {rev} failed");
587 GitOid::new(String::from_utf8_lossy(&out.stdout).trim()).unwrap()
588 }
589
590 fn git_file_content(root: &Path, commit: &str, path: &str) -> String {
591 let spec = format!("{commit}:{path}");
592 let out = Command::new("git")
593 .args(["show", &spec])
594 .current_dir(root)
595 .output()
596 .unwrap();
597 assert!(
598 out.status.success(),
599 "git show {spec} failed: {}",
600 String::from_utf8_lossy(&out.stderr)
601 );
602 String::from_utf8_lossy(&out.stdout).into_owned()
603 }
604
605 fn git_ls_tree_flat(root: &Path, commit: &str) -> Vec<String> {
606 let out = Command::new("git")
607 .args(["ls-tree", "-r", "--full-tree", "--name-only", commit])
608 .current_dir(root)
609 .output()
610 .unwrap();
611 assert!(out.status.success(), "git ls-tree failed");
612 String::from_utf8_lossy(&out.stdout)
613 .lines()
614 .map(str::to_owned)
615 .filter(|l| !l.is_empty())
616 .collect()
617 }
618
619 fn ws_ids(names: &[&str]) -> Vec<WorkspaceId> {
620 names.iter().map(|n| WorkspaceId::new(n).unwrap()).collect()
621 }
622
623 #[test]
628 fn resolved_change_path_upsert() {
629 let rc = ResolvedChange::Upsert {
630 path: PathBuf::from("foo.rs"),
631 content: vec![],
632 };
633 assert_eq!(rc.path(), &PathBuf::from("foo.rs"));
634 assert!(rc.is_upsert());
635 assert!(!rc.is_delete());
636 }
637
638 #[test]
639 fn resolved_change_path_delete() {
640 let rc = ResolvedChange::Delete {
641 path: PathBuf::from("bar.rs"),
642 };
643 assert_eq!(rc.path(), &PathBuf::from("bar.rs"));
644 assert!(!rc.is_upsert());
645 assert!(rc.is_delete());
646 }
647
648 #[test]
653 fn build_with_no_changes_matches_epoch_tree() {
654 let (dir, epoch) = setup_git_repo();
655 let root = dir.path();
656
657 let commit_oid = build_merge_commit(root, &epoch, &ws_ids(&["alpha"]), &[], None).unwrap();
658
659 let epoch_tree = git_oid(root, &format!("{}^{{tree}}", epoch.as_str()));
661 let new_tree = git_oid(root, &format!("{}^{{tree}}", commit_oid.as_str()));
662 assert_eq!(
663 epoch_tree, new_tree,
664 "empty change-set should preserve tree"
665 );
666 }
667
668 #[test]
673 fn build_adds_new_file() {
674 let (dir, epoch) = setup_git_repo();
675 let root = dir.path();
676
677 let resolved = vec![ResolvedChange::Upsert {
678 path: PathBuf::from("src/main.rs"),
679 content: b"fn main() {}".to_vec(),
680 }];
681
682 let commit_oid =
683 build_merge_commit(root, &epoch, &ws_ids(&["agent-1"]), &resolved, None).unwrap();
684
685 let content = git_file_content(root, commit_oid.as_str(), "src/main.rs");
687 assert_eq!(content, "fn main() {}");
688
689 let readme = git_file_content(root, commit_oid.as_str(), "README.md");
691 assert_eq!(readme, "# Test\n");
692
693 let files = git_ls_tree_flat(root, commit_oid.as_str());
695 assert!(files.contains(&"README.md".to_owned()));
696 assert!(files.contains(&"src/main.rs".to_owned()));
697 }
698
699 #[test]
704 fn build_modifies_existing_file() {
705 let (dir, epoch) = setup_git_repo();
706 let root = dir.path();
707
708 let resolved = vec![ResolvedChange::Upsert {
709 path: PathBuf::from("README.md"),
710 content: b"# Updated\n".to_vec(),
711 }];
712
713 let commit_oid =
714 build_merge_commit(root, &epoch, &ws_ids(&["agent-1"]), &resolved, None).unwrap();
715
716 let content = git_file_content(root, commit_oid.as_str(), "README.md");
717 assert_eq!(content, "# Updated\n");
718 }
719
720 #[test]
725 fn build_deletes_file() {
726 let (dir, epoch) = setup_git_repo();
727 let root = dir.path();
728
729 let resolved = vec![ResolvedChange::Delete {
730 path: PathBuf::from("README.md"),
731 }];
732
733 let commit_oid =
734 build_merge_commit(root, &epoch, &ws_ids(&["agent-1"]), &resolved, None).unwrap();
735
736 let files = git_ls_tree_flat(root, commit_oid.as_str());
738 assert!(
739 !files.contains(&"README.md".to_owned()),
740 "README.md should be deleted: {files:?}"
741 );
742 }
743
744 #[test]
749 fn build_mixed_changes() {
750 let (dir, epoch0) = setup_git_repo();
752 let root = dir.path();
753 fs::write(root.join("lib.rs"), "pub fn lib() {}\n").unwrap();
754 run_git(root, &["add", "lib.rs"]);
755 run_git(root, &["commit", "-m", "add lib.rs"]);
756 let epoch_oid = git_oid(root, "HEAD");
757 let epoch = EpochId::new(epoch_oid.as_str()).unwrap();
758 drop(epoch0); let resolved = vec![
761 ResolvedChange::Upsert {
762 path: PathBuf::from("README.md"),
763 content: b"# Modified\n".to_vec(),
764 },
765 ResolvedChange::Delete {
766 path: PathBuf::from("lib.rs"),
767 },
768 ResolvedChange::Upsert {
769 path: PathBuf::from("src/new.rs"),
770 content: b"pub fn new() {}\n".to_vec(),
771 },
772 ];
773
774 let commit_oid =
775 build_merge_commit(root, &epoch, &ws_ids(&["a", "b"]), &resolved, None).unwrap();
776
777 let readme = git_file_content(root, commit_oid.as_str(), "README.md");
779 assert_eq!(readme, "# Modified\n");
780
781 let files = git_ls_tree_flat(root, commit_oid.as_str());
783 assert!(
784 !files.contains(&"lib.rs".to_owned()),
785 "lib.rs should be gone"
786 );
787
788 assert!(
790 files.contains(&"src/new.rs".to_owned()),
791 "src/new.rs should be present: {files:?}"
792 );
793 }
794
795 #[test]
800 fn build_commit_message_default() {
801 let (dir, epoch) = setup_git_repo();
802 let root = dir.path();
803
804 let commit_oid =
805 build_merge_commit(root, &epoch, &ws_ids(&["beta", "alpha"]), &[], None).unwrap();
806
807 let log_out = Command::new("git")
808 .args(["log", "--format=%s", "-1", commit_oid.as_str()])
809 .current_dir(root)
810 .output()
811 .unwrap();
812 let subject = String::from_utf8_lossy(&log_out.stdout).trim().to_owned();
813
814 assert_eq!(subject, "epoch: merge alpha beta");
816 }
817
818 #[test]
819 fn build_commit_message_custom() {
820 let (dir, epoch) = setup_git_repo();
821 let root = dir.path();
822
823 let commit_oid =
824 build_merge_commit(root, &epoch, &ws_ids(&["a"]), &[], Some("custom: my merge"))
825 .unwrap();
826
827 let log_out = Command::new("git")
828 .args(["log", "--format=%s", "-1", commit_oid.as_str()])
829 .current_dir(root)
830 .output()
831 .unwrap();
832 let subject = String::from_utf8_lossy(&log_out.stdout).trim().to_owned();
833 assert_eq!(subject, "custom: my merge");
834 }
835
836 #[test]
841 fn build_commit_parent_is_epoch() {
842 let (dir, epoch) = setup_git_repo();
843 let root = dir.path();
844
845 let commit_oid = build_merge_commit(root, &epoch, &ws_ids(&["ws1"]), &[], None).unwrap();
846
847 let parent_out = Command::new("git")
849 .args(["rev-parse", &format!("{}^", commit_oid.as_str())])
850 .current_dir(root)
851 .output()
852 .unwrap();
853 let parent_oid = GitOid::new(String::from_utf8_lossy(&parent_out.stdout).trim()).unwrap();
854 assert_eq!(parent_oid, *epoch.oid());
855 }
856
857 #[test]
862 fn build_tree_is_deterministic() {
863 let (dir, epoch) = setup_git_repo();
864 let root = dir.path();
865
866 let resolved = vec![
867 ResolvedChange::Upsert {
868 path: PathBuf::from("a.rs"),
869 content: b"fn a() {}".to_vec(),
870 },
871 ResolvedChange::Upsert {
872 path: PathBuf::from("b.rs"),
873 content: b"fn b() {}".to_vec(),
874 },
875 ];
876
877 let oid1 =
878 build_merge_commit(root, &epoch, &ws_ids(&["ws-a", "ws-b"]), &resolved, None).unwrap();
879 let oid2 =
880 build_merge_commit(root, &epoch, &ws_ids(&["ws-a", "ws-b"]), &resolved, None).unwrap();
881
882 let tree1 = git_oid(root, &format!("{}^{{tree}}", oid1.as_str()));
884 let tree2 = git_oid(root, &format!("{}^{{tree}}", oid2.as_str()));
885 assert_eq!(tree1, tree2, "same inputs must produce same tree OID");
886 }
887
888 #[test]
893 fn build_commit_uses_real_timestamp() {
894 let (dir, epoch) = setup_git_repo();
895 let root = dir.path();
896
897 let before = std::time::SystemTime::now()
898 .duration_since(std::time::UNIX_EPOCH)
899 .unwrap()
900 .as_secs();
901
902 let commit_oid =
903 build_merge_commit(root, &epoch, &ws_ids(&["ws1"]), &[], None).unwrap();
904
905 let after = std::time::SystemTime::now()
906 .duration_since(std::time::UNIX_EPOCH)
907 .unwrap()
908 .as_secs();
909
910 let log_out = Command::new("git")
912 .args(["log", "--format=%at", "-1", commit_oid.as_str()])
913 .current_dir(root)
914 .output()
915 .unwrap();
916 let author_ts: u64 = String::from_utf8_lossy(&log_out.stdout)
917 .trim()
918 .parse()
919 .unwrap();
920
921 assert!(
922 author_ts >= before && author_ts <= after,
923 "commit timestamp {author_ts} should be between {before} and {after}"
924 );
925 }
926
927 #[test]
932 fn build_handles_nested_paths() {
933 let (dir, epoch) = setup_git_repo();
934 let root = dir.path();
935
936 let resolved = vec![
937 ResolvedChange::Upsert {
938 path: PathBuf::from("a/b/c/deep.rs"),
939 content: b"fn deep() {}".to_vec(),
940 },
941 ResolvedChange::Upsert {
942 path: PathBuf::from("a/b/c/other.rs"),
943 content: b"fn other() {}".to_vec(),
944 },
945 ];
946
947 let commit_oid =
948 build_merge_commit(root, &epoch, &ws_ids(&["ws"]), &resolved, None).unwrap();
949
950 let files = git_ls_tree_flat(root, commit_oid.as_str());
951 assert!(
952 files.contains(&"a/b/c/deep.rs".to_owned()),
953 "nested path should be present: {files:?}"
954 );
955 assert!(
956 files.contains(&"a/b/c/other.rs".to_owned()),
957 "nested path should be present: {files:?}"
958 );
959 }
960
961 #[test]
966 fn build_empty_workspace_list_uses_generic_message() {
967 let (dir, epoch) = setup_git_repo();
968 let root = dir.path();
969
970 let commit_oid = build_merge_commit(root, &epoch, &[], &[], None).unwrap();
971
972 let log_out = Command::new("git")
973 .args(["log", "--format=%s", "-1", commit_oid.as_str()])
974 .current_dir(root)
975 .output()
976 .unwrap();
977 let subject = String::from_utf8_lossy(&log_out.stdout).trim().to_owned();
978 assert_eq!(subject, "epoch: merge");
979 }
980
981 #[test]
986 fn build_delete_nonexistent_file_is_noop() {
987 let (dir, epoch) = setup_git_repo();
988 let root = dir.path();
989
990 let resolved = vec![ResolvedChange::Delete {
991 path: PathBuf::from("does-not-exist.rs"),
992 }];
993
994 let commit_oid =
996 build_merge_commit(root, &epoch, &ws_ids(&["ws"]), &resolved, None).unwrap();
997
998 let files = git_ls_tree_flat(root, commit_oid.as_str());
1000 assert!(
1001 files.contains(&"README.md".to_owned()),
1002 "README.md should still be present: {files:?}"
1003 );
1004 }
1005
1006 #[test]
1011 fn build_error_display_git_command() {
1012 let err = BuildError::GitCommand {
1013 command: "git mktree".to_owned(),
1014 stderr: "fatal: bad input".to_owned(),
1015 exit_code: Some(128),
1016 };
1017 let msg = format!("{err}");
1018 assert!(msg.contains("git mktree"), "missing command: {msg}");
1019 assert!(msg.contains("128"), "missing exit code: {msg}");
1020 assert!(msg.contains("fatal: bad input"), "missing stderr: {msg}");
1021 }
1022
1023 #[test]
1024 fn build_error_display_malformed_ls_tree() {
1025 let err = BuildError::MalformedLsTree {
1026 line: "garbage line".to_owned(),
1027 };
1028 let msg = format!("{err}");
1029 assert!(msg.contains("garbage line"), "missing line: {msg}");
1030 }
1031
1032 #[test]
1033 fn build_error_display_invalid_oid() {
1034 let err = BuildError::InvalidOid {
1035 context: "test context".to_owned(),
1036 raw: "not-an-oid".to_owned(),
1037 };
1038 let msg = format!("{err}");
1039 assert!(msg.contains("test context"), "missing context: {msg}");
1040 assert!(msg.contains("not-an-oid"), "missing raw: {msg}");
1041 }
1042}