Skip to main content

maw/merge/
build_phase.rs

1//! BUILD phase of the epoch advancement state machine.
2//!
3//! Orchestrates the full collect → partition → resolve → build pipeline,
4//! operating on frozen inputs from PREPARE. Produces a candidate git commit
5//! and records its OID in the merge-state file.
6//!
7//! # Crash safety
8//!
9//! - The merge-state file is advanced from `Prepare` to `Build` **before**
10//!   any work begins. If a crash occurs during BUILD, recovery sees the
11//!   `Build` phase and aborts — safe because no refs were moved.
12//! - Git objects written during BUILD (blobs, trees, the candidate commit)
13//!   are harmless orphans until COMMIT moves the refs. `git gc` eventually
14//!   collects them.
15//! - The candidate commit OID is persisted to merge-state with fsync before
16//!   returning, so downstream phases can always find it.
17//!
18//! # Pipeline
19//!
20//! 1. **Collect** — snapshot each source workspace via the backend.
21//! 2. **Partition** — group changed paths into unique (single workspace) vs
22//!    shared (multiple workspaces).
23//! 3. **Resolve** — auto-merge shared paths via hash equality / diff3.
24//! 4. **Drivers** — apply deterministic merge drivers (`regenerate`, `ours`,
25//!    `theirs`) for configured path globs.
26//! 5. **Build** — apply resolved changes to the epoch tree, produce a new
27//!    git tree + commit.
28
29#![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// ---------------------------------------------------------------------------
55// BuildPhaseOutput
56// ---------------------------------------------------------------------------
57
58/// Output of a successful BUILD phase.
59#[derive(Clone, Debug)]
60pub struct BuildPhaseOutput {
61    /// The candidate commit OID produced by the merge engine.
62    pub candidate: GitOid,
63    /// Conflict records for paths that could not be auto-resolved.
64    /// Empty if the merge was fully clean.
65    pub conflicts: Vec<ConflictRecord>,
66    /// Number of changes that were resolved and applied to the tree.
67    pub resolved_count: usize,
68    /// Number of unique paths (touched by only one workspace).
69    pub unique_count: usize,
70    /// Number of shared paths (touched by multiple workspaces).
71    pub shared_count: usize,
72}
73
74// ---------------------------------------------------------------------------
75// BuildPhaseError
76// ---------------------------------------------------------------------------
77
78/// Errors that can occur during the BUILD phase.
79#[derive(Debug)]
80pub enum BuildPhaseError {
81    /// The merge-state file is not in the expected phase.
82    WrongPhase {
83        expected: MergePhase,
84        actual: MergePhase,
85    },
86    /// Merge-state I/O or serialization error.
87    State(MergeStateError),
88    /// Repository config load/parse failure.
89    Config(ConfigError),
90    /// Workspace snapshot collection failed.
91    Collect(CollectError),
92    /// Path resolution (hash equality / diff3) failed.
93    Resolve(ResolveError),
94    /// Git tree/commit construction failed.
95    Build(BuildError),
96    /// Failed to read base file content from the epoch tree.
97    ReadBase { path: PathBuf, detail: String },
98    /// Merge driver error (invalid config, unsupported shape, or failed command).
99    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
170// ---------------------------------------------------------------------------
171// run_build_phase
172// ---------------------------------------------------------------------------
173
174/// Execute the BUILD phase of the merge state machine.
175///
176/// Reads the merge-state file (which must be in `Prepare` phase), then
177/// orchestrates the full merge pipeline:
178///
179/// 1. Advance merge-state to `Build` (fsync).
180/// 2. Collect workspace snapshots via the backend.
181/// 3. Partition changed paths (unique vs shared).
182/// 4. Read base content from the epoch tree for shared paths.
183/// 5. Resolve shared paths (hash equality, then diff3).
184/// 6. Apply deterministic merge drivers (`regenerate` / `ours` / `theirs`).
185/// 7. Build the candidate git tree + commit.
186/// 8. Record the candidate OID in merge-state (fsync).
187///
188/// # Arguments
189///
190/// * `repo_root` — Path to the git repository root.
191/// * `manifold_dir` — Path to the `.manifold/` directory.
192/// * `backend` — Workspace backend for snapshot collection.
193///
194/// # Returns
195///
196/// A [`BuildPhaseOutput`] containing the candidate commit OID and any
197/// unresolved conflicts.
198///
199/// # Errors
200///
201/// Returns [`BuildPhaseError`] if the merge-state is in the wrong phase,
202/// any pipeline step fails, or the merge-state cannot be persisted.
203pub fn run_build_phase<B: WorkspaceBackend>(
204    repo_root: &Path,
205    manifold_dir: &Path,
206    backend: &B,
207) -> Result<BuildPhaseOutput, BuildPhaseError> {
208    // 1. Read and validate merge-state
209    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    // Load merge configuration (defaults if file missing).
220    let config_path = manifold_dir.join("config.toml");
221    let config = ManifoldConfig::load(&config_path)?;
222
223    // 2. Advance to BUILD (fsync — crash after this means recovery aborts)
224    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    // Run the pipeline and capture the result. On error, the merge-state
231    // stays in Build phase — recovery will abort it.
232    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    // 8. Record candidate OID in merge-state (fsync)
237    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
244/// Execute the BUILD phase with explicit inputs (for testing).
245///
246/// Does not read or write merge-state. Runs the full pipeline on the
247/// provided inputs and returns the result.
248///
249/// # Arguments
250///
251/// * `repo_root` — Path to the git repository root.
252/// * `backend` — Workspace backend for snapshot collection.
253/// * `epoch` — The epoch commit to use as the merge base.
254/// * `sources` — Workspace IDs to merge.
255pub 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    // 1. Collect snapshots (enriched with FileId + blob OID)
262    let patch_sets = collect_snapshots(repo_root, backend, sources)?;
263
264    // 2. Partition
265    let partition = partition_by_path(&patch_sets);
266    let unique_count = partition.unique_count();
267    let shared_count = partition.shared_count();
268
269    // 3. Read base contents for shared paths
270    let base_contents = read_base_contents(repo_root, epoch, &partition)?;
271
272    // Merge settings used by resolve + deterministic drivers.
273    let merge_config = MergeConfig::default();
274
275    // 4. Resolve shared paths via hash equality / diff3 / AST merge fallback
276    let resolve_result = resolve_partition_for_build(&partition, &base_contents, &merge_config)?;
277
278    // 5. Apply deterministic merge drivers
279    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    // 6. Build candidate commit
290    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
301// ---------------------------------------------------------------------------
302// Internal: pipeline execution
303// ---------------------------------------------------------------------------
304
305/// The core pipeline logic shared by both `run_build_phase` and
306/// `run_build_phase_with_inputs`.
307fn run_pipeline<B: WorkspaceBackend>(
308    repo_root: &Path,
309    backend: &B,
310    state: &MergeStateFile,
311    merge_config: &MergeConfig,
312) -> Result<BuildPhaseOutput, BuildPhaseError> {
313    // Collect snapshots from all source workspaces (enriched with FileId + blob OID)
314    let patch_sets = collect_snapshots(repo_root, backend, &state.sources)?;
315
316    // Partition changed paths into unique vs shared
317    let partition = partition_by_path(&patch_sets);
318    let unique_count = partition.unique_count();
319    let shared_count = partition.shared_count();
320
321    // Read base (epoch) content for all shared paths
322    let base_contents = read_base_contents(repo_root, &state.epoch_before, &partition)?;
323
324    // Resolve shared paths via hash equality / diff3 / AST merge fallback
325    let resolve_result = resolve_partition_for_build(&partition, &base_contents, merge_config)?;
326
327    // Apply deterministic merge drivers
328    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    // Build the candidate git tree + commit from resolved changes
339    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// ---------------------------------------------------------------------------
376// Internal: deterministic merge drivers
377// ---------------------------------------------------------------------------
378
379#[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            &regenerate_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
709// ---------------------------------------------------------------------------
710// Internal: read base contents from epoch tree
711// ---------------------------------------------------------------------------
712
713/// Read file contents from the epoch tree for all touched paths.
714///
715/// For each path that appears in either `partition.unique` or
716/// `partition.shared`, reads file content from the epoch commit via
717/// `git show <epoch>:<path>`. Paths that don't exist at the epoch are omitted
718/// from the result.
719fn 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                // Path doesn't exist at epoch — it's a new file.
739            }
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    /// File doesn't exist at the epoch commit.
755    NotFound,
756    /// Git command failed.
757    GitError(String),
758}
759
760/// Read a single file's content from the epoch commit.
761///
762/// Uses `git show <epoch>:<path>` which outputs raw file content to stdout.
763fn 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        // git show returns 128 when the path doesn't exist
779        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
794// ---------------------------------------------------------------------------
795// Helpers
796// ---------------------------------------------------------------------------
797
798fn now_secs() -> u64 {
799    SystemTime::now()
800        .duration_since(UNIX_EPOCH)
801        .unwrap_or_default()
802        .as_secs()
803}
804
805// ---------------------------------------------------------------------------
806// Tests
807// ---------------------------------------------------------------------------
808
809#[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    // -----------------------------------------------------------------------
821    // Test git helpers
822    // -----------------------------------------------------------------------
823
824    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    /// Create a test git repo with initial epoch commit.
840    /// Returns (`TempDir`, `epoch_oid`).
841    /// Epoch tree contains: README.md, lib.rs, src/main.rs.
842    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    /// Create a merge-state in PREPARE phase and write it.
870    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    // -----------------------------------------------------------------------
886    // Mock backend
887    // -----------------------------------------------------------------------
888
889    /// A mock workspace backend that returns pre-configured snapshots.
890    /// Files must actually exist on disk at the workspace path for
891    /// `collect_one` to read their content.
892    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        /// Register a workspace with a snapshot and status.
908        /// The caller must create the workspace directory and populate
909        /// files before calling `collect_snapshots`.
910        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    /// Helper: create a workspace directory with files and return the
981    /// appropriate `SnapshotResult`.
982    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        // Create parent dirs if needed
991        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)], // added
998            vec![],                         // modified
999            vec![],                         // deleted
1000        );
1001        (ws_path, snapshot)
1002    }
1003
1004    /// Helper: create a workspace directory with a modified file.
1005    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![],                         // added
1020            vec![PathBuf::from(file_name)], // modified
1021            vec![],                         // deleted
1022        );
1023        (ws_path, snapshot)
1024    }
1025
1026    /// Helper: create a workspace directory for a deletion.
1027    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![],                         // added
1036            vec![],                         // modified
1037            vec![PathBuf::from(file_name)], // deleted
1038        );
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    // -----------------------------------------------------------------------
1065    // read_file_at_epoch tests
1066    // -----------------------------------------------------------------------
1067
1068    #[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    // -----------------------------------------------------------------------
1090    // read_base_contents tests
1091    // -----------------------------------------------------------------------
1092
1093    #[test]
1094    fn read_base_contents_returns_shared_paths() {
1095        let (dir, epoch) = setup_epoch_repo();
1096
1097        // Create patch sets where both workspaces modify README.md
1098        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    // -----------------------------------------------------------------------
1169    // run_build_phase tests
1170    // -----------------------------------------------------------------------
1171
1172    #[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        // Empty workspace — no changes
1216        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        // Merge-state advanced to Build with candidate OID recorded
1229        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        // Candidate object may exist as an orphan, which is safe.
1273        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        // Verify file is in the candidate tree
1340        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        // Original files still present
1346        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        // Hash equality short-circuit: identical changes = no conflict
1404        assert!(output.conflicts.is_empty());
1405        assert_eq!(output.resolved_count, 1);
1406        assert_eq!(output.shared_count, 1);
1407
1408        // Verify content
1409        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            // Clean up merge-state between runs
1470            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        // No merge-state file at all — with_inputs doesn't need one
1546        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        // Create workspace with all three change types
1581        let ws_path = dir.path().join("ws/ws-1");
1582        fs::create_dir_all(&ws_path).unwrap();
1583
1584        // Added file
1585        fs::write(ws_path.join("new.txt"), "new content\n").unwrap();
1586        // Modified file — write new content at workspace path
1587        fs::write(ws_path.join("README.md"), "# Updated\n").unwrap();
1588        // Deleted file — lib.rs (no need to create on disk for delete)
1589
1590        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); // add + modify + delete
1603
1604        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        // Verify modified content
1613        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}