1#![allow(clippy::missing_errors_doc)]
30
31use std::collections::{BTreeMap, BTreeSet};
32use std::fmt;
33use std::fs;
34use std::path::{Path, PathBuf};
35use std::process::Command;
36use std::time::{SystemTime, UNIX_EPOCH};
37
38use glob::Pattern;
39use tempfile::Builder;
40
41use crate::backend::WorkspaceBackend;
42use crate::config::{ConfigError, ManifoldConfig, MergeConfig, MergeDriver, MergeDriverKind};
43use crate::merge::build::{BuildError, ResolvedChange, build_merge_commit};
44use crate::merge::collect::{CollectError, collect_snapshots};
45use crate::merge::partition::{PartitionResult, PathEntry, partition_by_path};
46#[cfg(not(feature = "ast-merge"))]
47use crate::merge::resolve::resolve_partition;
48#[cfg(feature = "ast-merge")]
49use crate::merge::resolve::resolve_partition_with_ast;
50use crate::merge::resolve::{ConflictRecord, ResolveError, ResolveResult};
51use crate::merge_state::{MergePhase, MergeStateError, MergeStateFile};
52use crate::model::types::{EpochId, GitOid, WorkspaceId};
53
54#[derive(Clone, Debug)]
60pub struct BuildPhaseOutput {
61 pub candidate: GitOid,
63 pub conflicts: Vec<ConflictRecord>,
66 pub resolved_count: usize,
68 pub unique_count: usize,
70 pub shared_count: usize,
72}
73
74#[derive(Debug)]
80pub enum BuildPhaseError {
81 WrongPhase {
83 expected: MergePhase,
84 actual: MergePhase,
85 },
86 State(MergeStateError),
88 Config(ConfigError),
90 Collect(CollectError),
92 Resolve(ResolveError),
94 Build(BuildError),
96 ReadBase { path: PathBuf, detail: String },
98 Driver(String),
100}
101
102impl fmt::Display for BuildPhaseError {
103 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
104 match self {
105 Self::WrongPhase { expected, actual } => {
106 write!(
107 f,
108 "BUILD: merge-state in wrong phase (expected {expected}, got {actual})"
109 )
110 }
111 Self::State(e) => write!(f, "BUILD: merge-state error: {e}"),
112 Self::Config(e) => write!(f, "BUILD: config error: {e}"),
113 Self::Collect(e) => write!(f, "BUILD: collect failed: {e}"),
114 Self::Resolve(e) => write!(f, "BUILD: resolve failed: {e}"),
115 Self::Build(e) => write!(f, "BUILD: build failed: {e}"),
116 Self::ReadBase { path, detail } => {
117 write!(
118 f,
119 "BUILD: failed to read base content for {}: {detail}",
120 path.display()
121 )
122 }
123 Self::Driver(detail) => write!(f, "BUILD: merge driver failed: {detail}"),
124 }
125 }
126}
127
128impl std::error::Error for BuildPhaseError {
129 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
130 match self {
131 Self::Config(e) => Some(e),
132 Self::Collect(e) => Some(e),
133 Self::Resolve(e) => Some(e),
134 Self::Build(e) => Some(e),
135 _ => None,
136 }
137 }
138}
139
140impl From<MergeStateError> for BuildPhaseError {
141 fn from(e: MergeStateError) -> Self {
142 Self::State(e)
143 }
144}
145
146impl From<ConfigError> for BuildPhaseError {
147 fn from(e: ConfigError) -> Self {
148 Self::Config(e)
149 }
150}
151
152impl From<CollectError> for BuildPhaseError {
153 fn from(e: CollectError) -> Self {
154 Self::Collect(e)
155 }
156}
157
158impl From<ResolveError> for BuildPhaseError {
159 fn from(e: ResolveError) -> Self {
160 Self::Resolve(e)
161 }
162}
163
164impl From<BuildError> for BuildPhaseError {
165 fn from(e: BuildError) -> Self {
166 Self::Build(e)
167 }
168}
169
170pub fn run_build_phase<B: WorkspaceBackend>(
204 repo_root: &Path,
205 manifold_dir: &Path,
206 backend: &B,
207) -> Result<BuildPhaseOutput, BuildPhaseError> {
208 let state_path = MergeStateFile::default_path(manifold_dir);
210 let mut state = MergeStateFile::read(&state_path)?;
211
212 if state.phase != MergePhase::Prepare {
213 return Err(BuildPhaseError::WrongPhase {
214 expected: MergePhase::Prepare,
215 actual: state.phase.clone(),
216 });
217 }
218
219 let config_path = manifold_dir.join("config.toml");
221 let config = ManifoldConfig::load(&config_path)?;
222
223 let now = now_secs();
225 crate::fp!("FP_BUILD_BEFORE_WORKTREE_ADD").map_err(|e| BuildPhaseError::Driver(e.to_string()))?;
226 state.advance(MergePhase::Build, now)?;
227 state.write_atomic(&state_path)?;
228 crate::fp!("FP_BUILD_AFTER_WORKTREE_ADD").map_err(|e| BuildPhaseError::Driver(e.to_string()))?;
229
230 crate::fp!("FP_BUILD_BEFORE_MERGE_COMPUTE").map_err(|e| BuildPhaseError::Driver(e.to_string()))?;
233 let output = run_pipeline(repo_root, backend, &state, &config.merge)?;
234 crate::fp!("FP_BUILD_AFTER_MERGE_COMPUTE").map_err(|e| BuildPhaseError::Driver(e.to_string()))?;
235
236 state.epoch_candidate = Some(output.candidate.clone());
238 state.updated_at = now_secs();
239 state.write_atomic(&state_path)?;
240
241 Ok(output)
242}
243
244pub fn run_build_phase_with_inputs<B: WorkspaceBackend>(
256 repo_root: &Path,
257 backend: &B,
258 epoch: &EpochId,
259 sources: &[WorkspaceId],
260) -> Result<BuildPhaseOutput, BuildPhaseError> {
261 let patch_sets = collect_snapshots(repo_root, backend, sources)?;
263
264 let partition = partition_by_path(&patch_sets);
266 let unique_count = partition.unique_count();
267 let shared_count = partition.shared_count();
268
269 let base_contents = read_base_contents(repo_root, epoch, &partition)?;
271
272 let merge_config = MergeConfig::default();
274
275 let resolve_result = resolve_partition_for_build(&partition, &base_contents, &merge_config)?;
277
278 let (resolved, conflicts) = apply_merge_drivers(
280 repo_root,
281 epoch,
282 sources,
283 &partition,
284 &base_contents,
285 resolve_result,
286 &merge_config,
287 )?;
288
289 let candidate = build_merge_commit(repo_root, epoch, sources, &resolved, None)?;
291
292 Ok(BuildPhaseOutput {
293 candidate,
294 conflicts,
295 resolved_count: resolved.len(),
296 unique_count,
297 shared_count,
298 })
299}
300
301fn run_pipeline<B: WorkspaceBackend>(
308 repo_root: &Path,
309 backend: &B,
310 state: &MergeStateFile,
311 merge_config: &MergeConfig,
312) -> Result<BuildPhaseOutput, BuildPhaseError> {
313 let patch_sets = collect_snapshots(repo_root, backend, &state.sources)?;
315
316 let partition = partition_by_path(&patch_sets);
318 let unique_count = partition.unique_count();
319 let shared_count = partition.shared_count();
320
321 let base_contents = read_base_contents(repo_root, &state.epoch_before, &partition)?;
323
324 let resolve_result = resolve_partition_for_build(&partition, &base_contents, merge_config)?;
326
327 let (resolved, conflicts) = apply_merge_drivers(
329 repo_root,
330 &state.epoch_before,
331 &state.sources,
332 &partition,
333 &base_contents,
334 resolve_result,
335 merge_config,
336 )?;
337
338 let candidate = build_merge_commit(
340 repo_root,
341 &state.epoch_before,
342 &state.sources,
343 &resolved,
344 None,
345 )?;
346
347 Ok(BuildPhaseOutput {
348 candidate,
349 conflicts,
350 resolved_count: resolved.len(),
351 unique_count,
352 shared_count,
353 })
354}
355
356fn resolve_partition_for_build(
357 partition: &PartitionResult,
358 base_contents: &BTreeMap<PathBuf, Vec<u8>>,
359 merge_config: &MergeConfig,
360) -> Result<ResolveResult, BuildPhaseError> {
361 #[cfg(feature = "ast-merge")]
362 {
363 let ast_config = crate::merge::ast_merge::AstMergeConfig::from_config(&merge_config.ast);
364 resolve_partition_with_ast(partition, base_contents, &ast_config)
365 .map_err(BuildPhaseError::from)
366 }
367
368 #[cfg(not(feature = "ast-merge"))]
369 {
370 let _ = merge_config;
371 resolve_partition(partition, base_contents).map_err(BuildPhaseError::from)
372 }
373}
374
375#[derive(Clone)]
380struct CompiledDriver {
381 index: usize,
382 pattern: Pattern,
383 driver: MergeDriver,
384}
385
386fn apply_merge_drivers(
387 repo_root: &Path,
388 epoch: &EpochId,
389 sources: &[WorkspaceId],
390 partition: &PartitionResult,
391 base_contents: &BTreeMap<PathBuf, Vec<u8>>,
392 resolve_result: ResolveResult,
393 merge_config: &MergeConfig,
394) -> Result<(Vec<ResolvedChange>, Vec<ConflictRecord>), BuildPhaseError> {
395 let effective_drivers = merge_config.effective_drivers();
396 if effective_drivers.is_empty() {
397 return Ok((resolve_result.resolved, resolve_result.conflicts));
398 }
399
400 let mut compiled = Vec::with_capacity(effective_drivers.len());
401 for (index, driver) in effective_drivers.into_iter().enumerate() {
402 let pattern = Pattern::new(&driver.match_glob).map_err(|e| {
403 BuildPhaseError::Driver(format!(
404 "invalid merge driver glob '{}': {e}",
405 driver.match_glob
406 ))
407 })?;
408 compiled.push(CompiledDriver {
409 index,
410 pattern,
411 driver,
412 });
413 }
414
415 let mut resolved_by_path: BTreeMap<PathBuf, ResolvedChange> = BTreeMap::new();
416 for change in resolve_result.resolved {
417 resolved_by_path.insert(change.path().clone(), change);
418 }
419 let mut conflicts = resolve_result.conflicts;
420 let mut regenerate_by_driver: BTreeMap<usize, BTreeSet<PathBuf>> = BTreeMap::new();
421
422 for (path, entry) in &partition.unique {
423 maybe_apply_driver(
424 path,
425 std::slice::from_ref(entry),
426 base_contents,
427 &compiled,
428 &mut resolved_by_path,
429 &mut conflicts,
430 &mut regenerate_by_driver,
431 )?;
432 }
433
434 for (path, entries) in &partition.shared {
435 maybe_apply_driver(
436 path,
437 entries,
438 base_contents,
439 &compiled,
440 &mut resolved_by_path,
441 &mut conflicts,
442 &mut regenerate_by_driver,
443 )?;
444 }
445
446 if !regenerate_by_driver.is_empty() {
447 let provisional_resolved: Vec<ResolvedChange> =
448 resolved_by_path.values().cloned().collect();
449 let provisional_candidate =
450 build_merge_commit(repo_root, epoch, sources, &provisional_resolved, None)?;
451
452 let regenerated = run_regenerate_drivers(
453 repo_root,
454 &provisional_candidate,
455 &compiled,
456 ®enerate_by_driver,
457 )?;
458
459 for change in regenerated {
460 resolved_by_path.insert(change.path().clone(), change);
461 }
462 }
463
464 conflicts.sort_by(|a, b| a.path.cmp(&b.path));
465
466 Ok((resolved_by_path.into_values().collect(), conflicts))
467}
468
469#[allow(clippy::too_many_arguments)]
470fn maybe_apply_driver(
471 path: &Path,
472 entries: &[PathEntry],
473 base_contents: &BTreeMap<PathBuf, Vec<u8>>,
474 compiled: &[CompiledDriver],
475 resolved_by_path: &mut BTreeMap<PathBuf, ResolvedChange>,
476 conflicts: &mut Vec<ConflictRecord>,
477 regenerate_by_driver: &mut BTreeMap<usize, BTreeSet<PathBuf>>,
478) -> Result<(), BuildPhaseError> {
479 let Some(driver) = select_driver(path, compiled) else {
480 return Ok(());
481 };
482
483 match driver.driver.kind {
484 MergeDriverKind::Ours => {
485 let change = ours_change(path, base_contents.get(path));
486 resolved_by_path.insert(path.to_path_buf(), change);
487 remove_conflict_path(conflicts, path);
488 }
489 MergeDriverKind::Theirs => {
490 let change = theirs_change(path, entries)?;
491 resolved_by_path.insert(path.to_path_buf(), change);
492 remove_conflict_path(conflicts, path);
493 }
494 MergeDriverKind::Regenerate => {
495 let has_command = driver
496 .driver
497 .command
498 .as_deref()
499 .map(str::trim)
500 .is_some_and(|cmd| !cmd.is_empty());
501 if !has_command {
502 return Err(BuildPhaseError::Driver(format!(
503 "regenerate driver for '{}' must set a non-empty command",
504 path.display()
505 )));
506 }
507
508 regenerate_by_driver
509 .entry(driver.index)
510 .or_default()
511 .insert(path.to_path_buf());
512 remove_conflict_path(conflicts, path);
513 }
514 }
515
516 Ok(())
517}
518
519fn select_driver<'a>(path: &Path, compiled: &'a [CompiledDriver]) -> Option<&'a CompiledDriver> {
520 compiled
521 .iter()
522 .find(|driver| driver.pattern.matches_path(path))
523}
524
525fn remove_conflict_path(conflicts: &mut Vec<ConflictRecord>, path: &Path) {
526 conflicts.retain(|conflict| conflict.path.as_path() != path);
527}
528
529fn ours_change(path: &Path, base: Option<&Vec<u8>>) -> ResolvedChange {
530 base.map_or_else(
531 || ResolvedChange::Delete {
532 path: path.to_path_buf(),
533 },
534 |content| ResolvedChange::Upsert {
535 path: path.to_path_buf(),
536 content: content.clone(),
537 },
538 )
539}
540
541fn theirs_change(path: &Path, entries: &[PathEntry]) -> Result<ResolvedChange, BuildPhaseError> {
542 if entries.len() != 1 {
543 return Err(BuildPhaseError::Driver(format!(
544 "theirs driver for '{}' requires exactly one workspace change (found {})",
545 path.display(),
546 entries.len()
547 )));
548 }
549
550 let entry = &entries[0];
551 if entry.is_deletion() {
552 return Ok(ResolvedChange::Delete {
553 path: path.to_path_buf(),
554 });
555 }
556
557 let Some(content) = &entry.content else {
558 return Err(BuildPhaseError::Driver(format!(
559 "theirs driver for '{}' is missing file content from workspace {}",
560 path.display(),
561 entry.workspace_id.as_str()
562 )));
563 };
564
565 Ok(ResolvedChange::Upsert {
566 path: path.to_path_buf(),
567 content: content.clone(),
568 })
569}
570
571fn run_regenerate_drivers(
572 repo_root: &Path,
573 candidate: &GitOid,
574 compiled: &[CompiledDriver],
575 regenerate_by_driver: &BTreeMap<usize, BTreeSet<PathBuf>>,
576) -> Result<Vec<ResolvedChange>, BuildPhaseError> {
577 let tmp_dir = Builder::new()
578 .prefix("maw-build-regenerate")
579 .tempdir()
580 .map_err(|e| BuildPhaseError::Driver(format!("failed to create temp dir: {e}")))?;
581 let worktree_path = tmp_dir.path();
582
583 create_temp_worktree(repo_root, candidate, worktree_path)?;
584
585 let result = (|| -> Result<Vec<ResolvedChange>, BuildPhaseError> {
586 for (index, paths) in regenerate_by_driver {
587 let Some(driver) = compiled.iter().find(|d| d.index == *index) else {
588 return Err(BuildPhaseError::Driver(format!(
589 "internal error: missing compiled regenerate driver #{index}"
590 )));
591 };
592
593 let Some(command) = driver
594 .driver
595 .command
596 .as_deref()
597 .map(str::trim)
598 .filter(|cmd| !cmd.is_empty())
599 else {
600 return Err(BuildPhaseError::Driver(format!(
601 "regenerate driver '{}' has no command",
602 driver.driver.match_glob
603 )));
604 };
605
606 let output = Command::new("sh")
607 .args(["-c", command])
608 .current_dir(worktree_path)
609 .output()
610 .map_err(|e| {
611 BuildPhaseError::Driver(format!(
612 "failed to spawn regenerate command `{command}`: {e}"
613 ))
614 })?;
615
616 if !output.status.success() {
617 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
618 let touched = paths
619 .iter()
620 .map(|p| p.display().to_string())
621 .collect::<Vec<_>>()
622 .join(", ");
623 return Err(BuildPhaseError::Driver(format!(
624 "regenerate command failed for [{}]: `{command}` (exit {:?}){}; treated as validation failure",
625 touched,
626 output.status.code(),
627 if stderr.is_empty() {
628 String::new()
629 } else {
630 format!(": {stderr}")
631 }
632 )));
633 }
634 }
635
636 let mut regenerated = Vec::new();
637 for paths in regenerate_by_driver.values() {
638 for path in paths {
639 let full = worktree_path.join(path);
640 if full.is_file() {
641 let content = fs::read(&full).map_err(|e| {
642 BuildPhaseError::Driver(format!(
643 "failed to read regenerated file '{}': {e}",
644 path.display()
645 ))
646 })?;
647 regenerated.push(ResolvedChange::Upsert {
648 path: path.clone(),
649 content,
650 });
651 } else {
652 regenerated.push(ResolvedChange::Delete { path: path.clone() });
653 }
654 }
655 }
656
657 regenerated.sort_by(|a, b| a.path().cmp(b.path()));
658 Ok(regenerated)
659 })();
660
661 let cleanup_result = remove_temp_worktree(repo_root, worktree_path);
662
663 match (result, cleanup_result) {
664 (Err(e), _) | (Ok(_), Err(e)) => Err(e),
665 (Ok(changes), Ok(())) => Ok(changes),
666 }
667}
668
669fn create_temp_worktree(
670 repo_root: &Path,
671 candidate: &GitOid,
672 worktree_path: &Path,
673) -> Result<(), BuildPhaseError> {
674 let path = worktree_path.to_string_lossy().to_string();
675 let output = Command::new("git")
676 .args(["worktree", "add", "--detach", &path, candidate.as_str()])
677 .current_dir(repo_root)
678 .output()
679 .map_err(|e| BuildPhaseError::Driver(format!("spawn git worktree add: {e}")))?;
680
681 if !output.status.success() {
682 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
683 return Err(BuildPhaseError::Driver(format!(
684 "git worktree add for regenerate driver failed: {stderr}"
685 )));
686 }
687
688 Ok(())
689}
690
691fn remove_temp_worktree(repo_root: &Path, worktree_path: &Path) -> Result<(), BuildPhaseError> {
692 let path = worktree_path.to_string_lossy().to_string();
693 let output = Command::new("git")
694 .args(["worktree", "remove", "--force", &path])
695 .current_dir(repo_root)
696 .output()
697 .map_err(|e| BuildPhaseError::Driver(format!("spawn git worktree remove: {e}")))?;
698
699 if !output.status.success() {
700 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
701 return Err(BuildPhaseError::Driver(format!(
702 "git worktree remove for regenerate driver failed: {stderr}"
703 )));
704 }
705
706 Ok(())
707}
708
709fn read_base_contents(
720 repo_root: &Path,
721 epoch: &EpochId,
722 partition: &PartitionResult,
723) -> Result<BTreeMap<PathBuf, Vec<u8>>, BuildPhaseError> {
724 let mut base_contents = BTreeMap::new();
725
726 let touched_paths = partition
727 .unique
728 .iter()
729 .map(|(path, _)| path)
730 .chain(partition.shared.iter().map(|(path, _)| path));
731
732 for path in touched_paths {
733 match read_file_at_epoch(repo_root, epoch, path) {
734 Ok(content) => {
735 base_contents.insert(path.clone(), content);
736 }
737 Err(ReadBaseError::NotFound) => {
738 }
740 Err(ReadBaseError::GitError(detail)) => {
741 return Err(BuildPhaseError::ReadBase {
742 path: path.clone(),
743 detail,
744 });
745 }
746 }
747 }
748
749 Ok(base_contents)
750}
751
752#[derive(Debug)]
753enum ReadBaseError {
754 NotFound,
756 GitError(String),
758}
759
760fn read_file_at_epoch(
764 repo_root: &Path,
765 epoch: &EpochId,
766 path: &Path,
767) -> Result<Vec<u8>, ReadBaseError> {
768 let spec = format!("{}:{}", epoch.as_str(), path.display());
769
770 let output = Command::new("git")
771 .args(["show", &spec])
772 .current_dir(repo_root)
773 .output()
774 .map_err(|e| ReadBaseError::GitError(format!("spawn git show: {e}")))?;
775
776 if !output.status.success() {
777 let stderr = String::from_utf8_lossy(&output.stderr);
778 if stderr.contains("does not exist")
780 || stderr.contains("path")
781 || output.status.code() == Some(128)
782 {
783 return Err(ReadBaseError::NotFound);
784 }
785 return Err(ReadBaseError::GitError(format!(
786 "git show {spec} failed: {}",
787 stderr.trim()
788 )));
789 }
790
791 Ok(output.stdout)
792}
793
794fn now_secs() -> u64 {
799 SystemTime::now()
800 .duration_since(UNIX_EPOCH)
801 .unwrap_or_default()
802 .as_secs()
803}
804
805#[cfg(test)]
810#[allow(clippy::all, clippy::pedantic, clippy::nursery)]
811mod tests {
812 use super::*;
813 use crate::backend::{SnapshotResult, WorkspaceStatus};
814 use crate::merge_state::{RecoveryOutcome, recover_from_merge_state};
815 use crate::model::types::WorkspaceInfo;
816 use std::fs;
817 use std::process::Command as StdCommand;
818 use tempfile::TempDir;
819
820 fn run_git(root: &Path, args: &[&str]) -> String {
825 let out = StdCommand::new("git")
826 .args(args)
827 .current_dir(root)
828 .output()
829 .unwrap();
830 assert!(
831 out.status.success(),
832 "git {} failed: {}",
833 args.join(" "),
834 String::from_utf8_lossy(&out.stderr)
835 );
836 String::from_utf8_lossy(&out.stdout).trim().to_owned()
837 }
838
839 fn setup_epoch_repo() -> (TempDir, EpochId) {
843 let dir = TempDir::new().unwrap();
844 let root = dir.path();
845
846 run_git(root, &["init"]);
847 run_git(root, &["config", "user.name", "Test"]);
848 run_git(root, &["config", "user.email", "test@test.com"]);
849 run_git(root, &["config", "commit.gpgsign", "false"]);
850
851 fs::write(root.join("README.md"), "# Test Project\n").unwrap();
852 fs::write(root.join("lib.rs"), "pub fn lib() {}\n").unwrap();
853 fs::create_dir_all(root.join("src")).unwrap();
854 fs::write(root.join("src/main.rs"), "fn main() {}\n").unwrap();
855
856 run_git(root, &["add", "."]);
857 run_git(root, &["commit", "-m", "epoch: initial"]);
858
859 let hex = run_git(root, &["rev-parse", "HEAD"]);
860 let epoch = EpochId::new(&hex).unwrap();
861 run_git(
862 root,
863 &["update-ref", "refs/manifold/epoch/current", epoch.as_str()],
864 );
865
866 (dir, epoch)
867 }
868
869 fn write_prepare_state(
871 manifold_dir: &Path,
872 sources: &[WorkspaceId],
873 epoch: &EpochId,
874 ) -> PathBuf {
875 fs::create_dir_all(manifold_dir).unwrap();
876 let mut state = MergeStateFile::new(sources.to_vec(), epoch.clone(), 1000);
877 for ws in sources {
878 state.frozen_heads.insert(ws.clone(), epoch.oid().clone());
879 }
880 let state_path = MergeStateFile::default_path(manifold_dir);
881 state.write_atomic(&state_path).unwrap();
882 state_path
883 }
884
885 struct MockBackend {
893 snapshots: BTreeMap<String, SnapshotResult>,
894 statuses: BTreeMap<String, WorkspaceStatus>,
895 paths: BTreeMap<String, PathBuf>,
896 }
897
898 impl MockBackend {
899 fn new() -> Self {
900 Self {
901 snapshots: BTreeMap::new(),
902 statuses: BTreeMap::new(),
903 paths: BTreeMap::new(),
904 }
905 }
906
907 fn add_workspace(
911 &mut self,
912 name: &str,
913 epoch: EpochId,
914 snapshot: SnapshotResult,
915 ws_path: PathBuf,
916 ) {
917 self.snapshots.insert(name.to_owned(), snapshot);
918 self.statuses
919 .insert(name.to_owned(), WorkspaceStatus::new(epoch, vec![], false));
920 self.paths.insert(name.to_owned(), ws_path);
921 }
922 }
923
924 #[derive(Debug)]
925 struct MockError(String);
926
927 impl fmt::Display for MockError {
928 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
929 write!(f, "mock: {}", self.0)
930 }
931 }
932
933 impl std::error::Error for MockError {}
934
935 impl WorkspaceBackend for MockBackend {
936 type Error = MockError;
937
938 fn create(
939 &self,
940 _name: &WorkspaceId,
941 _epoch: &EpochId,
942 ) -> Result<WorkspaceInfo, Self::Error> {
943 Err(MockError("not implemented".into()))
944 }
945
946 fn destroy(&self, _name: &WorkspaceId) -> Result<(), Self::Error> {
947 Err(MockError("not implemented".into()))
948 }
949
950 fn list(&self) -> Result<Vec<WorkspaceInfo>, Self::Error> {
951 Ok(vec![])
952 }
953
954 fn status(&self, name: &WorkspaceId) -> Result<WorkspaceStatus, Self::Error> {
955 self.statuses
956 .get(name.as_str())
957 .cloned()
958 .ok_or_else(|| MockError(format!("workspace {name} not found")))
959 }
960
961 fn snapshot(&self, name: &WorkspaceId) -> Result<SnapshotResult, Self::Error> {
962 self.snapshots
963 .get(name.as_str())
964 .cloned()
965 .ok_or_else(|| MockError(format!("workspace {name} not found")))
966 }
967
968 fn workspace_path(&self, name: &WorkspaceId) -> PathBuf {
969 self.paths
970 .get(name.as_str())
971 .cloned()
972 .unwrap_or_else(|| PathBuf::from(format!("/tmp/ws/{name}")))
973 }
974
975 fn exists(&self, name: &WorkspaceId) -> bool {
976 self.snapshots.contains_key(name.as_str())
977 }
978 }
979
980 fn make_workspace_with_added_file(
983 base: &Path,
984 ws_name: &str,
985 file_name: &str,
986 content: &[u8],
987 ) -> (PathBuf, SnapshotResult) {
988 let ws_path = base.join(format!("ws/{ws_name}"));
989 fs::create_dir_all(&ws_path).unwrap();
990 let full_path = ws_path.join(file_name);
992 if let Some(parent) = full_path.parent() {
993 fs::create_dir_all(parent).unwrap();
994 }
995 fs::write(&full_path, content).unwrap();
996 let snapshot = SnapshotResult::new(
997 vec![PathBuf::from(file_name)], vec![], vec![], );
1001 (ws_path, snapshot)
1002 }
1003
1004 fn make_workspace_with_modified_file(
1006 base: &Path,
1007 ws_name: &str,
1008 file_name: &str,
1009 content: &[u8],
1010 ) -> (PathBuf, SnapshotResult) {
1011 let ws_path = base.join(format!("ws/{ws_name}"));
1012 fs::create_dir_all(&ws_path).unwrap();
1013 let full_path = ws_path.join(file_name);
1014 if let Some(parent) = full_path.parent() {
1015 fs::create_dir_all(parent).unwrap();
1016 }
1017 fs::write(&full_path, content).unwrap();
1018 let snapshot = SnapshotResult::new(
1019 vec![], vec![PathBuf::from(file_name)], vec![], );
1023 (ws_path, snapshot)
1024 }
1025
1026 fn make_workspace_with_deleted_file(
1028 base: &Path,
1029 ws_name: &str,
1030 file_name: &str,
1031 ) -> (PathBuf, SnapshotResult) {
1032 let ws_path = base.join(format!("ws/{ws_name}"));
1033 fs::create_dir_all(&ws_path).unwrap();
1034 let snapshot = SnapshotResult::new(
1035 vec![], vec![], vec![PathBuf::from(file_name)], );
1039 (ws_path, snapshot)
1040 }
1041
1042 fn write_merge_config(manifold_dir: &Path, contents: &str) {
1043 fs::create_dir_all(manifold_dir).unwrap();
1044 fs::write(manifold_dir.join("config.toml"), contents).unwrap();
1045 }
1046
1047 fn commit_epoch_file(root: &Path, rel_path: &str, content: &str, message: &str) -> EpochId {
1048 let full = root.join(rel_path);
1049 if let Some(parent) = full.parent() {
1050 fs::create_dir_all(parent).unwrap();
1051 }
1052 fs::write(&full, content).unwrap();
1053 run_git(root, &["add", rel_path]);
1054 run_git(root, &["commit", "-m", message]);
1055 let hex = run_git(root, &["rev-parse", "HEAD"]);
1056 let epoch = EpochId::new(&hex).unwrap();
1057 run_git(
1058 root,
1059 &["update-ref", "refs/manifold/epoch/current", epoch.as_str()],
1060 );
1061 epoch
1062 }
1063
1064 #[test]
1069 fn read_file_at_epoch_returns_content() {
1070 let (dir, epoch) = setup_epoch_repo();
1071 let content = read_file_at_epoch(dir.path(), &epoch, Path::new("README.md")).unwrap();
1072 assert_eq!(content, b"# Test Project\n");
1073 }
1074
1075 #[test]
1076 fn read_file_at_epoch_returns_not_found_for_missing_path() {
1077 let (dir, epoch) = setup_epoch_repo();
1078 let result = read_file_at_epoch(dir.path(), &epoch, Path::new("nonexistent.txt"));
1079 assert!(matches!(result, Err(ReadBaseError::NotFound)));
1080 }
1081
1082 #[test]
1083 fn read_file_at_epoch_nested_path() {
1084 let (dir, epoch) = setup_epoch_repo();
1085 let content = read_file_at_epoch(dir.path(), &epoch, Path::new("src/main.rs")).unwrap();
1086 assert_eq!(content, b"fn main() {}\n");
1087 }
1088
1089 #[test]
1094 fn read_base_contents_returns_shared_paths() {
1095 let (dir, epoch) = setup_epoch_repo();
1096
1097 let ws_a = WorkspaceId::new("ws-a").unwrap();
1099 let ws_b = WorkspaceId::new("ws-b").unwrap();
1100
1101 use crate::merge::types::{ChangeKind, FileChange, PatchSet};
1102
1103 let patch_sets = vec![
1104 PatchSet::new(
1105 ws_a,
1106 epoch.clone(),
1107 vec![FileChange::new(
1108 PathBuf::from("README.md"),
1109 ChangeKind::Modified,
1110 Some(b"# Modified by A\n".to_vec()),
1111 )],
1112 ),
1113 PatchSet::new(
1114 ws_b,
1115 epoch.clone(),
1116 vec![FileChange::new(
1117 PathBuf::from("README.md"),
1118 ChangeKind::Modified,
1119 Some(b"# Modified by B\n".to_vec()),
1120 )],
1121 ),
1122 ];
1123
1124 let partition = partition_by_path(&patch_sets);
1125 assert_eq!(partition.shared_count(), 1);
1126
1127 let base = read_base_contents(dir.path(), &epoch, &partition).unwrap();
1128 assert_eq!(base.len(), 1);
1129 assert_eq!(base[&PathBuf::from("README.md")], b"# Test Project\n");
1130 }
1131
1132 #[test]
1133 fn read_base_contents_omits_new_files() {
1134 let (dir, epoch) = setup_epoch_repo();
1135
1136 use crate::merge::types::{ChangeKind, FileChange, PatchSet};
1137 let ws_a = WorkspaceId::new("ws-a").unwrap();
1138 let ws_b = WorkspaceId::new("ws-b").unwrap();
1139
1140 let patch_sets = vec![
1141 PatchSet::new(
1142 ws_a,
1143 epoch.clone(),
1144 vec![FileChange::new(
1145 PathBuf::from("new_file.txt"),
1146 ChangeKind::Added,
1147 Some(b"from A\n".to_vec()),
1148 )],
1149 ),
1150 PatchSet::new(
1151 ws_b,
1152 epoch.clone(),
1153 vec![FileChange::new(
1154 PathBuf::from("new_file.txt"),
1155 ChangeKind::Added,
1156 Some(b"from B\n".to_vec()),
1157 )],
1158 ),
1159 ];
1160
1161 let partition = partition_by_path(&patch_sets);
1162 assert_eq!(partition.shared_count(), 1);
1163
1164 let base = read_base_contents(dir.path(), &epoch, &partition).unwrap();
1165 assert!(base.is_empty(), "new files not in epoch should be omitted");
1166 }
1167
1168 #[test]
1173 fn build_phase_wrong_state_rejected() {
1174 let dir = TempDir::new().unwrap();
1175 let manifold_dir = dir.path().join(".manifold");
1176 fs::create_dir_all(&manifold_dir).unwrap();
1177
1178 let ws = WorkspaceId::new("ws-1").unwrap();
1179 let epoch = EpochId::new(&"a".repeat(40)).unwrap();
1180
1181 let mut state = MergeStateFile::new(vec![ws], epoch, 1000);
1182 state.advance(MergePhase::Build, 1001).unwrap();
1183 let state_path = MergeStateFile::default_path(&manifold_dir);
1184 state.write_atomic(&state_path).unwrap();
1185
1186 let backend = MockBackend::new();
1187 let result = run_build_phase(dir.path(), &manifold_dir, &backend);
1188 assert!(matches!(
1189 result,
1190 Err(BuildPhaseError::WrongPhase {
1191 expected: MergePhase::Prepare,
1192 actual: MergePhase::Build,
1193 })
1194 ));
1195 }
1196
1197 #[test]
1198 fn build_phase_merge_state_not_found() {
1199 let dir = TempDir::new().unwrap();
1200 let manifold_dir = dir.path().join(".manifold");
1201 fs::create_dir_all(&manifold_dir).unwrap();
1202
1203 let backend = MockBackend::new();
1204 let result = run_build_phase(dir.path(), &manifold_dir, &backend);
1205 assert!(matches!(result, Err(BuildPhaseError::State(_))));
1206 }
1207
1208 #[test]
1209 fn build_phase_advances_state_and_records_candidate() {
1210 let (dir, epoch) = setup_epoch_repo();
1211 let manifold_dir = dir.path().join(".manifold");
1212 let ws = WorkspaceId::new("ws-1").unwrap();
1213 let state_path = write_prepare_state(&manifold_dir, &[ws], &epoch);
1214
1215 let ws_path = dir.path().join("ws/ws-1");
1217 fs::create_dir_all(&ws_path).unwrap();
1218 let mut backend = MockBackend::new();
1219 backend.add_workspace(
1220 "ws-1",
1221 epoch,
1222 SnapshotResult::new(vec![], vec![], vec![]),
1223 ws_path,
1224 );
1225
1226 let output = run_build_phase(dir.path(), &manifold_dir, &backend).unwrap();
1227
1228 let final_state = MergeStateFile::read(&state_path).unwrap();
1230 assert_eq!(final_state.phase, MergePhase::Build);
1231 assert_eq!(final_state.epoch_candidate, Some(output.candidate));
1232 }
1233
1234 #[test]
1235 fn build_phase_crash_recovery_aborts_without_moving_refs() {
1236 let (dir, epoch) = setup_epoch_repo();
1237 let manifold_dir = dir.path().join(".manifold");
1238 let ws = WorkspaceId::new("ws-1").unwrap();
1239 let state_path = write_prepare_state(&manifold_dir, &[ws], &epoch);
1240
1241 let (ws_path, snapshot) = make_workspace_with_added_file(
1242 dir.path(),
1243 "ws-1",
1244 "feature.rs",
1245 b"pub fn feature() {}\n",
1246 );
1247 let mut backend = MockBackend::new();
1248 backend.add_workspace("ws-1", epoch, snapshot, ws_path.clone());
1249
1250 let head_before = run_git(dir.path(), &["rev-parse", "HEAD"]);
1251 let epoch_before = run_git(dir.path(), &["rev-parse", "refs/manifold/epoch/current"]);
1252
1253 let output = run_build_phase(dir.path(), &manifold_dir, &backend).unwrap();
1254
1255 let outcome = recover_from_merge_state(&state_path).unwrap();
1256 assert_eq!(
1257 outcome,
1258 RecoveryOutcome::AbortedPreCommit {
1259 from: MergePhase::Build
1260 }
1261 );
1262 assert!(!state_path.exists());
1263
1264 let head_after = run_git(dir.path(), &["rev-parse", "HEAD"]);
1265 let epoch_after = run_git(dir.path(), &["rev-parse", "refs/manifold/epoch/current"]);
1266 assert_eq!(head_after, head_before, "BUILD recovery must not move HEAD");
1267 assert_eq!(
1268 epoch_after, epoch_before,
1269 "BUILD recovery must not move epoch ref"
1270 );
1271
1272 run_git(
1274 dir.path(),
1275 &[
1276 "cat-file",
1277 "-e",
1278 &format!("{}^{{commit}}", output.candidate.as_str()),
1279 ],
1280 );
1281
1282 assert_eq!(
1283 fs::read_to_string(ws_path.join("feature.rs")).unwrap(),
1284 "pub fn feature() {}\n"
1285 );
1286
1287 run_git(dir.path(), &["fsck", "--no-progress"]);
1288 }
1289
1290 #[test]
1291 fn build_phase_no_changes_produces_valid_commit() {
1292 let (dir, epoch) = setup_epoch_repo();
1293 let manifold_dir = dir.path().join(".manifold");
1294 let ws = WorkspaceId::new("ws-1").unwrap();
1295 write_prepare_state(&manifold_dir, &[ws], &epoch);
1296
1297 let ws_path = dir.path().join("ws/ws-1");
1298 fs::create_dir_all(&ws_path).unwrap();
1299 let mut backend = MockBackend::new();
1300 backend.add_workspace(
1301 "ws-1",
1302 epoch,
1303 SnapshotResult::new(vec![], vec![], vec![]),
1304 ws_path,
1305 );
1306
1307 let output = run_build_phase(dir.path(), &manifold_dir, &backend).unwrap();
1308
1309 assert!(!output.candidate.as_str().is_empty());
1310 assert!(output.conflicts.is_empty());
1311 assert_eq!(output.resolved_count, 0);
1312 assert_eq!(output.unique_count, 0);
1313 assert_eq!(output.shared_count, 0);
1314 }
1315
1316 #[test]
1317 fn build_phase_adds_new_file() {
1318 let (dir, epoch) = setup_epoch_repo();
1319 let manifold_dir = dir.path().join(".manifold");
1320 let ws = WorkspaceId::new("ws-1").unwrap();
1321 write_prepare_state(&manifold_dir, &[ws], &epoch);
1322
1323 let (ws_path, snapshot) = make_workspace_with_added_file(
1324 dir.path(),
1325 "ws-1",
1326 "feature.rs",
1327 b"pub fn feature() {}\n",
1328 );
1329 let mut backend = MockBackend::new();
1330 backend.add_workspace("ws-1", epoch, snapshot, ws_path);
1331
1332 let output = run_build_phase(dir.path(), &manifold_dir, &backend).unwrap();
1333
1334 assert!(output.conflicts.is_empty());
1335 assert_eq!(output.resolved_count, 1);
1336 assert_eq!(output.unique_count, 1);
1337 assert_eq!(output.shared_count, 0);
1338
1339 let tree = run_git(
1341 dir.path(),
1342 &["ls-tree", "-r", "--name-only", output.candidate.as_str()],
1343 );
1344 assert!(tree.contains("feature.rs"));
1345 assert!(tree.contains("README.md"));
1347 assert!(tree.contains("lib.rs"));
1348 }
1349
1350 #[test]
1351 fn build_phase_disjoint_two_workspaces() {
1352 let (dir, epoch) = setup_epoch_repo();
1353 let manifold_dir = dir.path().join(".manifold");
1354 let ws_a = WorkspaceId::new("ws-a").unwrap();
1355 let ws_b = WorkspaceId::new("ws-b").unwrap();
1356 write_prepare_state(&manifold_dir, &[ws_a, ws_b], &epoch);
1357
1358 let (path_a, snap_a) =
1359 make_workspace_with_added_file(dir.path(), "ws-a", "feature_a.rs", b"pub fn a() {}\n");
1360 let (path_b, snap_b) =
1361 make_workspace_with_added_file(dir.path(), "ws-b", "feature_b.rs", b"pub fn b() {}\n");
1362
1363 let mut backend = MockBackend::new();
1364 backend.add_workspace("ws-a", epoch.clone(), snap_a, path_a);
1365 backend.add_workspace("ws-b", epoch, snap_b, path_b);
1366
1367 let output = run_build_phase(dir.path(), &manifold_dir, &backend).unwrap();
1368
1369 assert!(output.conflicts.is_empty());
1370 assert_eq!(output.resolved_count, 2);
1371 assert_eq!(output.unique_count, 2);
1372 assert_eq!(output.shared_count, 0);
1373
1374 let tree = run_git(
1375 dir.path(),
1376 &["ls-tree", "-r", "--name-only", output.candidate.as_str()],
1377 );
1378 assert!(tree.contains("feature_a.rs"));
1379 assert!(tree.contains("feature_b.rs"));
1380 assert!(tree.contains("README.md"));
1381 }
1382
1383 #[test]
1384 fn build_phase_identical_modifications_resolve_cleanly() {
1385 let (dir, epoch) = setup_epoch_repo();
1386 let manifold_dir = dir.path().join(".manifold");
1387 let ws_a = WorkspaceId::new("ws-a").unwrap();
1388 let ws_b = WorkspaceId::new("ws-b").unwrap();
1389 write_prepare_state(&manifold_dir, &[ws_a, ws_b], &epoch);
1390
1391 let new_content = b"# Updated README\n";
1392 let (path_a, snap_a) =
1393 make_workspace_with_modified_file(dir.path(), "ws-a", "README.md", new_content);
1394 let (path_b, snap_b) =
1395 make_workspace_with_modified_file(dir.path(), "ws-b", "README.md", new_content);
1396
1397 let mut backend = MockBackend::new();
1398 backend.add_workspace("ws-a", epoch.clone(), snap_a, path_a);
1399 backend.add_workspace("ws-b", epoch, snap_b, path_b);
1400
1401 let output = run_build_phase(dir.path(), &manifold_dir, &backend).unwrap();
1402
1403 assert!(output.conflicts.is_empty());
1405 assert_eq!(output.resolved_count, 1);
1406 assert_eq!(output.shared_count, 1);
1407
1408 let content = run_git(
1410 dir.path(),
1411 &["show", &format!("{}:README.md", output.candidate.as_str())],
1412 );
1413 assert_eq!(content, "# Updated README");
1414 }
1415
1416 #[test]
1417 fn build_phase_delete_removes_file_from_tree() {
1418 let (dir, epoch) = setup_epoch_repo();
1419 let manifold_dir = dir.path().join(".manifold");
1420 let ws = WorkspaceId::new("ws-1").unwrap();
1421 write_prepare_state(&manifold_dir, &[ws], &epoch);
1422
1423 let (ws_path, snapshot) = make_workspace_with_deleted_file(dir.path(), "ws-1", "lib.rs");
1424
1425 let mut backend = MockBackend::new();
1426 backend.add_workspace("ws-1", epoch, snapshot, ws_path);
1427
1428 let output = run_build_phase(dir.path(), &manifold_dir, &backend).unwrap();
1429
1430 assert!(output.conflicts.is_empty());
1431
1432 let tree = run_git(
1433 dir.path(),
1434 &["ls-tree", "-r", "--name-only", output.candidate.as_str()],
1435 );
1436 assert!(!tree.contains("lib.rs"), "deleted file must be removed");
1437 assert!(tree.contains("README.md"), "other files preserved");
1438 }
1439
1440 #[test]
1441 fn build_phase_candidate_parent_is_epoch() {
1442 let (dir, epoch) = setup_epoch_repo();
1443 let manifold_dir = dir.path().join(".manifold");
1444 let ws = WorkspaceId::new("ws-1").unwrap();
1445 write_prepare_state(&manifold_dir, &[ws], &epoch);
1446
1447 let (ws_path, snapshot) =
1448 make_workspace_with_added_file(dir.path(), "ws-1", "test.txt", b"test content\n");
1449 let mut backend = MockBackend::new();
1450 backend.add_workspace("ws-1", epoch.clone(), snapshot, ws_path);
1451
1452 let output = run_build_phase(dir.path(), &manifold_dir, &backend).unwrap();
1453
1454 let parent = run_git(
1455 dir.path(),
1456 &["rev-parse", &format!("{}^", output.candidate.as_str())],
1457 );
1458 assert_eq!(parent, epoch.as_str());
1459 }
1460
1461 #[test]
1462 fn build_phase_is_deterministic() {
1463 let (dir, epoch) = setup_epoch_repo();
1464 let ws = WorkspaceId::new("ws-1").unwrap();
1465
1466 let mut tree_oids = vec![];
1467 for _ in 0..2 {
1468 let manifold_dir = dir.path().join(".manifold");
1469 let state_path = MergeStateFile::default_path(&manifold_dir);
1471 if state_path.exists() {
1472 fs::remove_file(&state_path).unwrap();
1473 }
1474 write_prepare_state(&manifold_dir, &[ws.clone()], &epoch);
1475
1476 let (ws_path, snapshot) = make_workspace_with_added_file(
1477 dir.path(),
1478 "ws-1",
1479 "new.txt",
1480 b"deterministic content\n",
1481 );
1482 let mut backend = MockBackend::new();
1483 backend.add_workspace("ws-1", epoch.clone(), snapshot, ws_path);
1484
1485 let output = run_build_phase(dir.path(), &manifold_dir, &backend).unwrap();
1486 let tree_oid = run_git(
1487 dir.path(),
1488 &[
1489 "rev-parse",
1490 &format!("{}^{{tree}}", output.candidate.as_str()),
1491 ],
1492 );
1493 tree_oids.push(tree_oid);
1494 }
1495
1496 assert_eq!(
1497 tree_oids[0], tree_oids[1],
1498 "same inputs must produce same tree OID"
1499 );
1500 }
1501
1502 #[test]
1503 fn build_phase_three_way_disjoint() {
1504 let (dir, epoch) = setup_epoch_repo();
1505 let manifold_dir = dir.path().join(".manifold");
1506 let ws_a = WorkspaceId::new("ws-a").unwrap();
1507 let ws_b = WorkspaceId::new("ws-b").unwrap();
1508 let ws_c = WorkspaceId::new("ws-c").unwrap();
1509 write_prepare_state(&manifold_dir, &[ws_a, ws_b, ws_c], &epoch);
1510
1511 let (pa, sa) = make_workspace_with_added_file(dir.path(), "ws-a", "a.txt", b"aaa\n");
1512 let (pb, sb) = make_workspace_with_added_file(dir.path(), "ws-b", "b.txt", b"bbb\n");
1513 let (pc, sc) = make_workspace_with_added_file(dir.path(), "ws-c", "c.txt", b"ccc\n");
1514
1515 let mut backend = MockBackend::new();
1516 backend.add_workspace("ws-a", epoch.clone(), sa, pa);
1517 backend.add_workspace("ws-b", epoch.clone(), sb, pb);
1518 backend.add_workspace("ws-c", epoch, sc, pc);
1519
1520 let output = run_build_phase(dir.path(), &manifold_dir, &backend).unwrap();
1521
1522 assert!(output.conflicts.is_empty());
1523 assert_eq!(output.resolved_count, 3);
1524 assert_eq!(output.unique_count, 3);
1525
1526 let tree = run_git(
1527 dir.path(),
1528 &["ls-tree", "-r", "--name-only", output.candidate.as_str()],
1529 );
1530 assert!(tree.contains("a.txt"));
1531 assert!(tree.contains("b.txt"));
1532 assert!(tree.contains("c.txt"));
1533 }
1534
1535 #[test]
1536 fn build_phase_with_inputs_bypasses_state_file() {
1537 let (dir, epoch) = setup_epoch_repo();
1538 let ws = WorkspaceId::new("ws-1").unwrap();
1539
1540 let (ws_path, snapshot) =
1541 make_workspace_with_added_file(dir.path(), "ws-1", "hello.txt", b"hello world\n");
1542 let mut backend = MockBackend::new();
1543 backend.add_workspace("ws-1", epoch.clone(), snapshot, ws_path);
1544
1545 let output = run_build_phase_with_inputs(dir.path(), &backend, &epoch, &[ws]).unwrap();
1547
1548 assert!(!output.candidate.as_str().is_empty());
1549 assert!(output.conflicts.is_empty());
1550 assert_eq!(output.resolved_count, 1);
1551 }
1552
1553 #[test]
1554 fn build_phase_error_display() {
1555 let err = BuildPhaseError::WrongPhase {
1556 expected: MergePhase::Prepare,
1557 actual: MergePhase::Validate,
1558 };
1559 let msg = format!("{err}");
1560 assert!(msg.contains("wrong phase"));
1561 assert!(msg.contains("prepare"));
1562 assert!(msg.contains("validate"));
1563
1564 let err = BuildPhaseError::ReadBase {
1565 path: PathBuf::from("src/main.rs"),
1566 detail: "not found".to_owned(),
1567 };
1568 let msg = format!("{err}");
1569 assert!(msg.contains("src/main.rs"));
1570 assert!(msg.contains("not found"));
1571 }
1572
1573 #[test]
1574 fn build_phase_mixed_add_modify_delete() {
1575 let (dir, epoch) = setup_epoch_repo();
1576 let manifold_dir = dir.path().join(".manifold");
1577 let ws = WorkspaceId::new("ws-1").unwrap();
1578 write_prepare_state(&manifold_dir, &[ws], &epoch);
1579
1580 let ws_path = dir.path().join("ws/ws-1");
1582 fs::create_dir_all(&ws_path).unwrap();
1583
1584 fs::write(ws_path.join("new.txt"), "new content\n").unwrap();
1586 fs::write(ws_path.join("README.md"), "# Updated\n").unwrap();
1588 let snapshot = SnapshotResult::new(
1591 vec![PathBuf::from("new.txt")],
1592 vec![PathBuf::from("README.md")],
1593 vec![PathBuf::from("lib.rs")],
1594 );
1595
1596 let mut backend = MockBackend::new();
1597 backend.add_workspace("ws-1", epoch, snapshot, ws_path);
1598
1599 let output = run_build_phase(dir.path(), &manifold_dir, &backend).unwrap();
1600
1601 assert!(output.conflicts.is_empty());
1602 assert_eq!(output.resolved_count, 3); let tree = run_git(
1605 dir.path(),
1606 &["ls-tree", "-r", "--name-only", output.candidate.as_str()],
1607 );
1608 assert!(tree.contains("new.txt"), "added file present");
1609 assert!(tree.contains("README.md"), "modified file present");
1610 assert!(!tree.contains("lib.rs"), "deleted file removed");
1611
1612 let readme = run_git(
1614 dir.path(),
1615 &["show", &format!("{}:README.md", output.candidate.as_str())],
1616 );
1617 assert_eq!(readme, "# Updated");
1618 }
1619
1620 #[test]
1621 fn build_phase_regenerate_driver_resolves_cargo_lock_conflict() {
1622 let (dir, _epoch0) = setup_epoch_repo();
1623 let _ = commit_epoch_file(
1624 dir.path(),
1625 "Cargo.toml",
1626 "[package]\nname = \"demo\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
1627 "epoch: add Cargo.toml",
1628 );
1629 let epoch = commit_epoch_file(
1630 dir.path(),
1631 "Cargo.lock",
1632 "# base lock\n",
1633 "epoch: add Cargo.lock",
1634 );
1635
1636 let manifold_dir = dir.path().join(".manifold");
1637 let ws_a = WorkspaceId::new("ws-a").unwrap();
1638 let ws_b = WorkspaceId::new("ws-b").unwrap();
1639 write_prepare_state(&manifold_dir, &[ws_a, ws_b], &epoch);
1640
1641 write_merge_config(
1642 &manifold_dir,
1643 r#"[[merge.drivers]]
1644match = "Cargo.lock"
1645kind = "regenerate"
1646command = "printf 're-generated lockfile\n' > Cargo.lock"
1647"#,
1648 );
1649
1650 let (path_a, snap_a) =
1651 make_workspace_with_modified_file(dir.path(), "ws-a", "Cargo.lock", b"from ws-a\n");
1652 let (path_b, snap_b) =
1653 make_workspace_with_modified_file(dir.path(), "ws-b", "Cargo.lock", b"from ws-b\n");
1654
1655 let mut backend = MockBackend::new();
1656 backend.add_workspace("ws-a", epoch.clone(), snap_a, path_a);
1657 backend.add_workspace("ws-b", epoch, snap_b, path_b);
1658
1659 let output = run_build_phase(dir.path(), &manifold_dir, &backend).unwrap();
1660 assert!(output.conflicts.is_empty());
1661
1662 let lock = run_git(
1663 dir.path(),
1664 &["show", &format!("{}:Cargo.lock", output.candidate.as_str())],
1665 );
1666 assert_eq!(lock, "re-generated lockfile");
1667 }
1668
1669 #[test]
1670 fn build_phase_regenerate_driver_resolves_generated_artifact_glob() {
1671 let (dir, _epoch0) = setup_epoch_repo();
1672 let epoch = commit_epoch_file(
1673 dir.path(),
1674 "src/gen/schema.json",
1675 "{\"version\":0}\n",
1676 "epoch: add generated artifact",
1677 );
1678
1679 let manifold_dir = dir.path().join(".manifold");
1680 let ws_a = WorkspaceId::new("ws-a").unwrap();
1681 let ws_b = WorkspaceId::new("ws-b").unwrap();
1682 write_prepare_state(&manifold_dir, &[ws_a, ws_b], &epoch);
1683
1684 write_merge_config(
1685 &manifold_dir,
1686 r#"[[merge.drivers]]
1687match = "src/gen/**"
1688kind = "regenerate"
1689command = "mkdir -p src/gen && printf '{\"version\":42}\n' > src/gen/schema.json"
1690"#,
1691 );
1692
1693 let (path_a, snap_a) = make_workspace_with_modified_file(
1694 dir.path(),
1695 "ws-a",
1696 "src/gen/schema.json",
1697 b"{\"version\":1}\n",
1698 );
1699 let (path_b, snap_b) = make_workspace_with_modified_file(
1700 dir.path(),
1701 "ws-b",
1702 "src/gen/schema.json",
1703 b"{\"version\":2}\n",
1704 );
1705
1706 let mut backend = MockBackend::new();
1707 backend.add_workspace("ws-a", epoch.clone(), snap_a, path_a);
1708 backend.add_workspace("ws-b", epoch, snap_b, path_b);
1709
1710 let output = run_build_phase(dir.path(), &manifold_dir, &backend).unwrap();
1711 assert!(output.conflicts.is_empty());
1712
1713 let generated = run_git(
1714 dir.path(),
1715 &[
1716 "show",
1717 &format!("{}:src/gen/schema.json", output.candidate.as_str()),
1718 ],
1719 );
1720 assert_eq!(generated, "{\"version\":42}");
1721 }
1722
1723 #[test]
1724 fn build_phase_ours_driver_keeps_epoch_version() {
1725 let (dir, epoch) = setup_epoch_repo();
1726 let manifold_dir = dir.path().join(".manifold");
1727 let ws_a = WorkspaceId::new("ws-a").unwrap();
1728 let ws_b = WorkspaceId::new("ws-b").unwrap();
1729 write_prepare_state(&manifold_dir, &[ws_a, ws_b], &epoch);
1730
1731 write_merge_config(
1732 &manifold_dir,
1733 r#"[[merge.drivers]]
1734match = "README.md"
1735kind = "ours"
1736"#,
1737 );
1738
1739 let (path_a, snap_a) =
1740 make_workspace_with_modified_file(dir.path(), "ws-a", "README.md", b"# ws-a\n");
1741 let (path_b, snap_b) =
1742 make_workspace_with_modified_file(dir.path(), "ws-b", "README.md", b"# ws-b\n");
1743
1744 let mut backend = MockBackend::new();
1745 backend.add_workspace("ws-a", epoch.clone(), snap_a, path_a);
1746 backend.add_workspace("ws-b", epoch, snap_b, path_b);
1747
1748 let output = run_build_phase(dir.path(), &manifold_dir, &backend).unwrap();
1749 assert!(output.conflicts.is_empty());
1750
1751 let readme = run_git(
1752 dir.path(),
1753 &["show", &format!("{}:README.md", output.candidate.as_str())],
1754 );
1755 assert_eq!(readme, "# Test Project");
1756 }
1757
1758 #[test]
1759 fn build_phase_theirs_driver_requires_single_workspace() {
1760 let (dir, epoch) = setup_epoch_repo();
1761 let manifold_dir = dir.path().join(".manifold");
1762 let ws_a = WorkspaceId::new("ws-a").unwrap();
1763 let ws_b = WorkspaceId::new("ws-b").unwrap();
1764 write_prepare_state(&manifold_dir, &[ws_a, ws_b], &epoch);
1765
1766 write_merge_config(
1767 &manifold_dir,
1768 r#"[[merge.drivers]]
1769match = "README.md"
1770kind = "theirs"
1771"#,
1772 );
1773
1774 let (path_a, snap_a) =
1775 make_workspace_with_modified_file(dir.path(), "ws-a", "README.md", b"# ws-a\n");
1776 let (path_b, snap_b) =
1777 make_workspace_with_modified_file(dir.path(), "ws-b", "README.md", b"# ws-b\n");
1778
1779 let mut backend = MockBackend::new();
1780 backend.add_workspace("ws-a", epoch.clone(), snap_a, path_a);
1781 backend.add_workspace("ws-b", epoch, snap_b, path_b);
1782
1783 let err = run_build_phase(dir.path(), &manifold_dir, &backend).unwrap_err();
1784 let msg = format!("{err}");
1785 assert!(msg.contains("requires exactly one workspace"));
1786 }
1787
1788 #[test]
1789 fn build_phase_regenerate_failure_reported_as_validation_failure() {
1790 let (dir, _epoch0) = setup_epoch_repo();
1791 let epoch = commit_epoch_file(
1792 dir.path(),
1793 "Cargo.lock",
1794 "# base lock\n",
1795 "epoch: add Cargo.lock",
1796 );
1797
1798 let manifold_dir = dir.path().join(".manifold");
1799 let ws = WorkspaceId::new("ws-1").unwrap();
1800 write_prepare_state(&manifold_dir, std::slice::from_ref(&ws), &epoch);
1801
1802 write_merge_config(
1803 &manifold_dir,
1804 r#"[[merge.drivers]]
1805match = "Cargo.lock"
1806kind = "regenerate"
1807command = "exit 19"
1808"#,
1809 );
1810
1811 let (ws_path, snapshot) =
1812 make_workspace_with_modified_file(dir.path(), "ws-1", "Cargo.lock", b"changed\n");
1813
1814 let mut backend = MockBackend::new();
1815 backend.add_workspace("ws-1", epoch, snapshot, ws_path);
1816
1817 let err = run_build_phase(dir.path(), &manifold_dir, &backend).unwrap_err();
1818 let msg = format!("{err}");
1819 assert!(msg.contains("treated as validation failure"));
1820 }
1821}