Skip to main content

maw/merge/
prepare.rs

1//! PREPARE phase of the epoch advancement state machine.
2//!
3//! Freezes all merge inputs (epoch commit + workspace HEAD commits) so that
4//! the merge is deterministic regardless of concurrent workspace activity.
5//!
6//! # Concurrency & crash safety
7//!
8//! The merge-state file is created exclusively (`O_CREAT | O_EXCL` via
9//! `OpenOptions::create_new`). The first writer wins atomically; a second
10//! concurrent PREPARE receives `EEXIST` and maps it to
11//! `MergeAlreadyInProgress`. When overwriting a terminal/stale state the
12//! regular atomic write (write-to-temp + fsync + rename) is used.
13//!
14//! If a crash occurs during PREPARE:
15//!
16//! - **Before write:** No merge-state file exists → nothing to recover.
17//! - **After write:** A valid merge-state in `Prepare` phase exists →
18//!   recovery aborts it (safe, no state was changed).
19//!
20//! # Inputs
21//!
22//! - The current epoch commit (`refs/manifold/epoch/current`)
23//! - The HEAD commit of each source workspace
24//!
25//! # Outputs
26//!
27//! - A persisted `merge-state.json` in `Prepare` phase with all OIDs frozen.
28//! - A [`FrozenInputs`] struct for downstream phases.
29
30#![allow(clippy::missing_errors_doc)]
31
32use std::collections::BTreeMap;
33use std::fmt;
34use std::path::Path;
35use std::process::Command;
36use std::time::{SystemTime, UNIX_EPOCH};
37
38use crate::merge_state::{MergePhase, MergeStateError, MergeStateFile};
39use crate::model::types::{EpochId, GitOid, WorkspaceId};
40use crate::refs;
41
42// ---------------------------------------------------------------------------
43// FrozenInputs
44// ---------------------------------------------------------------------------
45
46/// The frozen set of inputs for a merge operation.
47///
48/// After PREPARE completes, these OIDs are immutable references. The merge
49/// engine operates on exactly these commits, regardless of any concurrent
50/// workspace activity.
51#[derive(Clone, Debug, PartialEq, Eq)]
52pub struct FrozenInputs {
53    /// The epoch commit that serves as the merge base.
54    pub epoch: EpochId,
55    /// Map of workspace ID → HEAD commit OID at freeze time.
56    pub heads: BTreeMap<WorkspaceId, GitOid>,
57}
58
59// ---------------------------------------------------------------------------
60// PrepareError
61// ---------------------------------------------------------------------------
62
63/// Errors that can occur during the PREPARE phase.
64#[derive(Clone, Debug, PartialEq, Eq)]
65pub enum PrepareError {
66    /// No source workspaces provided.
67    NoSources,
68    /// Failed to read the current epoch ref.
69    EpochNotFound(String),
70    /// Failed to read a workspace HEAD.
71    WorkspaceHeadNotFound {
72        workspace: WorkspaceId,
73        detail: String,
74    },
75    /// A merge is already in progress (merge-state file exists).
76    MergeAlreadyInProgress,
77    /// Invalid OID from git.
78    InvalidOid(String),
79    /// Merge-state I/O or serialization error.
80    State(MergeStateError),
81    /// Git command failed.
82    GitError(String),
83    /// Failpoint injection (assurance testing only).
84    #[cfg(feature = "failpoints")]
85    Failpoint(String),
86}
87
88impl fmt::Display for PrepareError {
89    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
90        match self {
91            Self::NoSources => write!(f, "PREPARE: no source workspaces provided"),
92            Self::EpochNotFound(detail) => {
93                write!(f, "PREPARE: epoch ref not found: {detail}")
94            }
95            Self::WorkspaceHeadNotFound { workspace, detail } => {
96                write!(
97                    f,
98                    "PREPARE: HEAD not found for workspace {workspace}: {detail}"
99                )
100            }
101            Self::MergeAlreadyInProgress => {
102                write!(
103                    f,
104                    "PREPARE: merge already in progress (merge-state file exists)"
105                )
106            }
107            Self::InvalidOid(detail) => {
108                write!(f, "PREPARE: invalid OID from git: {detail}")
109            }
110            Self::State(e) => write!(f, "PREPARE: {e}"),
111            Self::GitError(detail) => write!(f, "PREPARE: git error: {detail}"),
112            #[cfg(feature = "failpoints")]
113            Self::Failpoint(name) => write!(f, "PREPARE: failpoint fired: {name}"),
114        }
115    }
116}
117
118impl std::error::Error for PrepareError {}
119
120impl From<MergeStateError> for PrepareError {
121    fn from(e: MergeStateError) -> Self {
122        Self::State(e)
123    }
124}
125
126// ---------------------------------------------------------------------------
127// Git helpers
128// ---------------------------------------------------------------------------
129
130/// Read the current epoch OID from `refs/manifold/epoch/current`.
131///
132/// Uses `git rev-parse` in the given repo root directory.
133fn read_epoch_ref(repo_root: &Path) -> Result<EpochId, PrepareError> {
134    let output = Command::new("git")
135        .args(["rev-parse", "--verify", "refs/manifold/epoch/current"])
136        .current_dir(repo_root)
137        .output()
138        .map_err(|e| PrepareError::GitError(format!("spawn git: {e}")))?;
139
140    if !output.status.success() {
141        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
142        return Err(PrepareError::EpochNotFound(stderr));
143    }
144
145    let hex = String::from_utf8_lossy(&output.stdout).trim().to_owned();
146    EpochId::new(&hex).map_err(|e| PrepareError::InvalidOid(e.to_string()))
147}
148
149/// Read the HEAD commit OID of a workspace directory.
150///
151/// Uses `git rev-parse HEAD` in the workspace directory.
152fn read_workspace_head(
153    _repo_root: &Path,
154    workspace: &WorkspaceId,
155    workspace_dir: &Path,
156) -> Result<GitOid, PrepareError> {
157    let output = Command::new("git")
158        .args(["rev-parse", "--verify", "HEAD"])
159        .current_dir(workspace_dir)
160        .output()
161        .map_err(|e| PrepareError::WorkspaceHeadNotFound {
162            workspace: workspace.clone(),
163            detail: format!("spawn git: {e}"),
164        })?;
165
166    if !output.status.success() {
167        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
168        return Err(PrepareError::WorkspaceHeadNotFound {
169            workspace: workspace.clone(),
170            detail: stderr,
171        });
172    }
173
174    let hex = String::from_utf8_lossy(&output.stdout).trim().to_owned();
175    GitOid::new(&hex).map_err(|e| PrepareError::InvalidOid(e.to_string()))
176}
177
178// ---------------------------------------------------------------------------
179// run_prepare_phase
180// ---------------------------------------------------------------------------
181
182/// Execute the PREPARE phase of the merge state machine.
183///
184/// 1. Verify no merge is already in progress.
185/// 2. Read the current epoch from `refs/manifold/epoch/current`.
186/// 3. For each source workspace, read its HEAD commit.
187/// 4. Record all frozen OIDs in a new merge-state file.
188/// 5. Write merge-state atomically with fsync.
189///
190/// # Arguments
191///
192/// * `repo_root` - Path to the git repository root.
193/// * `manifold_dir` - Path to the `.manifold/` directory.
194/// * `sources` - The workspace IDs to merge.
195/// * `workspace_dirs` - Map of workspace ID → absolute workspace directory path.
196///
197/// # Returns
198///
199/// The [`FrozenInputs`] containing the epoch and all workspace HEAD OIDs.
200///
201/// # Errors
202///
203/// Returns [`PrepareError`] if any input cannot be read, a merge is already
204/// in progress, or the merge-state file cannot be written.
205pub fn run_prepare_phase(
206    repo_root: &Path,
207    manifold_dir: &Path,
208    sources: &[WorkspaceId],
209    workspace_dirs: &BTreeMap<WorkspaceId, std::path::PathBuf>,
210) -> Result<FrozenInputs, PrepareError> {
211    // 1. Validate inputs
212    if sources.is_empty() {
213        return Err(PrepareError::NoSources);
214    }
215
216    // 2. Read current epoch
217    let epoch = read_epoch_ref(repo_root)?;
218
219    // 3. Read workspace HEADs
220    let mut heads = BTreeMap::new();
221    for ws_id in sources {
222        let ws_dir =
223            workspace_dirs
224                .get(ws_id)
225                .ok_or_else(|| PrepareError::WorkspaceHeadNotFound {
226                    workspace: ws_id.clone(),
227                    detail: "workspace directory not provided".to_owned(),
228                })?;
229        let head = read_workspace_head(repo_root, ws_id, ws_dir)?;
230        heads.insert(ws_id.clone(), head);
231    }
232
233    // 4. Create merge-state
234    let now = SystemTime::now()
235        .duration_since(UNIX_EPOCH)
236        .unwrap_or_default()
237        .as_secs();
238
239    let mut state = MergeStateFile::new(sources.to_vec(), epoch.clone(), now);
240    state.frozen_heads = heads.clone();
241
242    // 5. Ensure .manifold directory exists
243    std::fs::create_dir_all(manifold_dir).map_err(|e| {
244        PrepareError::State(MergeStateError::Io(format!(
245            "create {}: {e}",
246            manifold_dir.display()
247        )))
248    })?;
249
250    // 6. Try exclusive creation (O_CREAT | O_EXCL) — first writer wins
251    let state_path = MergeStateFile::default_path(manifold_dir);
252    if !state.write_exclusive(&state_path)? {
253        // File already exists — check if it's safe to overwrite
254        match MergeStateFile::read(&state_path) {
255            Ok(existing) if !existing.phase.is_terminal() => {
256                // For Commit/Cleanup phases the COMMIT has already run (refs
257                // updated) but the process was killed before the cleanup
258                // deleted merge-state.json.  Detect this by checking whether
259                // the epoch ref has already advanced to epoch_candidate.  If
260                // it has, the previous merge finished — the stale file is safe
261                // to overwrite.
262                let is_post_commit = matches!(
263                    existing.phase,
264                    MergePhase::Commit | MergePhase::Cleanup
265                );
266                let stale_completed = is_post_commit
267                    && existing.epoch_candidate.as_ref().is_some_and(|candidate| {
268                        refs::read_epoch_current(repo_root)
269                            .ok()
270                            .flatten()
271                            .is_some_and(|current| &current == candidate)
272                    });
273
274                if stale_completed {
275                    eprintln!(
276                        "WARNING: stale merge-state found at phase '{}' but epoch ref already \
277                         advanced — previous merge completed without cleanup. Clearing stale state.",
278                        existing.phase
279                    );
280                    // Overwrite stale file
281                    state.write_atomic(&state_path)?;
282                } else {
283                    return Err(PrepareError::MergeAlreadyInProgress);
284                }
285            }
286            _ => {
287                // Terminal or corrupt — safe to overwrite
288                state.write_atomic(&state_path)?;
289            }
290        }
291    }
292
293    Ok(FrozenInputs { epoch, heads })
294}
295
296/// Execute the PREPARE phase with an explicit epoch (for testing or
297/// when the epoch is already known).
298///
299/// Same as [`run_prepare_phase`] but skips reading the epoch ref.
300pub fn run_prepare_phase_with_epoch(
301    manifold_dir: &Path,
302    epoch: EpochId,
303    sources: &[WorkspaceId],
304    heads: BTreeMap<WorkspaceId, GitOid>,
305) -> Result<FrozenInputs, PrepareError> {
306    if sources.is_empty() {
307        return Err(PrepareError::NoSources);
308    }
309
310    let now = SystemTime::now()
311        .duration_since(UNIX_EPOCH)
312        .unwrap_or_default()
313        .as_secs();
314
315    let mut state = MergeStateFile::new(sources.to_vec(), epoch.clone(), now);
316    state.frozen_heads = heads.clone();
317
318    std::fs::create_dir_all(manifold_dir).map_err(|e| {
319        PrepareError::State(MergeStateError::Io(format!(
320            "create {}: {e}",
321            manifold_dir.display()
322        )))
323    })?;
324
325    // Try exclusive creation (O_CREAT | O_EXCL) — first writer wins
326    let state_path = MergeStateFile::default_path(manifold_dir);
327    crate::fp!("FP_PREPARE_BEFORE_STATE_WRITE").map_err(|e| PrepareError::GitError(e.to_string()))?;
328    if !state.write_exclusive(&state_path)? {
329        // File already exists — check if it's safe to overwrite
330        match MergeStateFile::read(&state_path) {
331            Ok(existing) if !existing.phase.is_terminal() => {
332                return Err(PrepareError::MergeAlreadyInProgress);
333            }
334            _ => {
335                // Terminal or corrupt — safe to overwrite
336                state.write_atomic(&state_path)?;
337            }
338        }
339    }
340    crate::fp!("FP_PREPARE_AFTER_STATE_WRITE").map_err(|e| PrepareError::GitError(e.to_string()))?;
341
342    Ok(FrozenInputs { epoch, heads })
343}
344
345// ---------------------------------------------------------------------------
346// Tests
347// ---------------------------------------------------------------------------
348
349#[cfg(test)]
350#[allow(clippy::all, clippy::pedantic, clippy::nursery)]
351mod tests {
352    use super::*;
353    use crate::merge_state::{MergePhase, RecoveryOutcome, recover_from_merge_state};
354
355    fn test_epoch() -> EpochId {
356        EpochId::new(&"a".repeat(40)).unwrap()
357    }
358
359    fn test_oid(c: char) -> GitOid {
360        GitOid::new(&c.to_string().repeat(40)).unwrap()
361    }
362
363    fn test_ws(name: &str) -> WorkspaceId {
364        WorkspaceId::new(name).unwrap()
365    }
366
367    // -- run_prepare_phase_with_epoch --
368
369    #[test]
370    fn prepare_freezes_inputs() {
371        let dir = tempfile::tempdir().unwrap();
372        let manifold_dir = dir.path().join(".manifold");
373
374        let epoch = test_epoch();
375        let ws_a = test_ws("agent-a");
376        let ws_b = test_ws("agent-b");
377
378        let mut heads = BTreeMap::new();
379        heads.insert(ws_a.clone(), test_oid('b'));
380        heads.insert(ws_b.clone(), test_oid('c'));
381
382        let sources = vec![ws_a.clone(), ws_b.clone()];
383        let frozen =
384            run_prepare_phase_with_epoch(&manifold_dir, epoch.clone(), &sources, heads.clone())
385                .unwrap();
386
387        assert_eq!(frozen.epoch, epoch);
388        assert_eq!(frozen.heads.len(), 2);
389        assert_eq!(frozen.heads[&ws_a], test_oid('b'));
390        assert_eq!(frozen.heads[&ws_b], test_oid('c'));
391    }
392
393    #[test]
394    fn prepare_writes_merge_state_file() {
395        let dir = tempfile::tempdir().unwrap();
396        let manifold_dir = dir.path().join(".manifold");
397
398        let epoch = test_epoch();
399        let ws = test_ws("worker-1");
400        let mut heads = BTreeMap::new();
401        heads.insert(ws.clone(), test_oid('d'));
402
403        let sources = vec![ws.clone()];
404        run_prepare_phase_with_epoch(&manifold_dir, epoch.clone(), &sources, heads).unwrap();
405
406        // Verify file exists and contents
407        let state_path = MergeStateFile::default_path(&manifold_dir);
408        assert!(state_path.exists());
409
410        let state = MergeStateFile::read(&state_path).unwrap();
411        assert_eq!(state.phase, MergePhase::Prepare);
412        assert_eq!(state.sources, vec![ws.clone()]);
413        assert_eq!(state.epoch_before, epoch);
414        assert_eq!(state.frozen_heads.len(), 1);
415        assert_eq!(state.frozen_heads[&ws], test_oid('d'));
416        assert!(state.epoch_candidate.is_none());
417        assert!(state.validation_result.is_none());
418    }
419
420    #[test]
421    fn prepare_rejects_empty_sources() {
422        let dir = tempfile::tempdir().unwrap();
423        let manifold_dir = dir.path().join(".manifold");
424
425        let result =
426            run_prepare_phase_with_epoch(&manifold_dir, test_epoch(), &[], BTreeMap::new());
427        assert!(matches!(result, Err(PrepareError::NoSources)));
428    }
429
430    #[test]
431    fn prepare_rejects_in_progress_merge() {
432        let dir = tempfile::tempdir().unwrap();
433        let manifold_dir = dir.path().join(".manifold");
434        std::fs::create_dir_all(&manifold_dir).unwrap();
435
436        // Write an in-progress merge-state
437        let existing = MergeStateFile::new(vec![test_ws("old")], test_epoch(), 1000);
438        let state_path = MergeStateFile::default_path(&manifold_dir);
439        existing.write_atomic(&state_path).unwrap();
440
441        // Try to prepare — should fail
442        let mut heads = BTreeMap::new();
443        heads.insert(test_ws("new"), test_oid('e'));
444        let result =
445            run_prepare_phase_with_epoch(&manifold_dir, test_epoch(), &[test_ws("new")], heads);
446        assert!(matches!(result, Err(PrepareError::MergeAlreadyInProgress)));
447    }
448
449    #[test]
450    fn prepare_overwrites_terminal_state() {
451        let dir = tempfile::tempdir().unwrap();
452        let manifold_dir = dir.path().join(".manifold");
453        std::fs::create_dir_all(&manifold_dir).unwrap();
454
455        // Write a completed merge-state
456        let mut existing = MergeStateFile::new(vec![test_ws("old")], test_epoch(), 1000);
457        existing.advance(MergePhase::Build, 1001).unwrap();
458        existing.advance(MergePhase::Validate, 1002).unwrap();
459        existing.advance(MergePhase::Commit, 1003).unwrap();
460        existing.advance(MergePhase::Cleanup, 1004).unwrap();
461        existing.advance(MergePhase::Complete, 1005).unwrap();
462        let state_path = MergeStateFile::default_path(&manifold_dir);
463        existing.write_atomic(&state_path).unwrap();
464
465        // Prepare should succeed (overwrite terminal state)
466        let ws = test_ws("new-ws");
467        let mut heads = BTreeMap::new();
468        heads.insert(ws.clone(), test_oid('f'));
469        let frozen =
470            run_prepare_phase_with_epoch(&manifold_dir, test_epoch(), &[ws.clone()], heads)
471                .unwrap();
472
473        assert_eq!(frozen.heads.len(), 1);
474
475        // Verify state file was overwritten
476        let state = MergeStateFile::read(&state_path).unwrap();
477        assert_eq!(state.phase, MergePhase::Prepare);
478        assert_eq!(state.sources, vec![ws]);
479    }
480
481    #[test]
482    fn prepare_crash_safety_file_is_valid_or_absent() {
483        let dir = tempfile::tempdir().unwrap();
484        let manifold_dir = dir.path().join(".manifold");
485
486        let ws = test_ws("crash-test");
487        let mut heads = BTreeMap::new();
488        heads.insert(ws.clone(), test_oid('a'));
489
490        // Before prepare: no file
491        let state_path = MergeStateFile::default_path(&manifold_dir);
492        assert!(!state_path.exists());
493
494        // After prepare: valid file
495        run_prepare_phase_with_epoch(&manifold_dir, test_epoch(), &[ws], heads).unwrap();
496
497        assert!(state_path.exists());
498        let state = MergeStateFile::read(&state_path).unwrap();
499        assert_eq!(state.phase, MergePhase::Prepare);
500    }
501
502    #[test]
503    fn prepare_recovery_aborts_and_preserves_workspace_files() {
504        let dir = tempfile::tempdir().unwrap();
505        let manifold_dir = dir.path().join(".manifold");
506
507        let ws = test_ws("worker-1");
508        let ws_file = dir.path().join("ws").join("worker-1").join("result.txt");
509        std::fs::create_dir_all(ws_file.parent().unwrap()).unwrap();
510        std::fs::write(&ws_file, "worker output\n").unwrap();
511
512        let mut heads = BTreeMap::new();
513        heads.insert(ws.clone(), test_oid('c'));
514        run_prepare_phase_with_epoch(&manifold_dir, test_epoch(), &[ws], heads).unwrap();
515
516        let state_path = MergeStateFile::default_path(&manifold_dir);
517        let outcome = recover_from_merge_state(&state_path).unwrap();
518        assert_eq!(
519            outcome,
520            RecoveryOutcome::AbortedPreCommit {
521                from: MergePhase::Prepare
522            }
523        );
524        assert!(!state_path.exists());
525        assert_eq!(std::fs::read_to_string(ws_file).unwrap(), "worker output\n");
526    }
527
528    #[test]
529    fn prepare_creates_manifold_dir() {
530        let dir = tempfile::tempdir().unwrap();
531        let manifold_dir = dir.path().join("deep").join("nested").join(".manifold");
532
533        let ws = test_ws("ws-1");
534        let mut heads = BTreeMap::new();
535        heads.insert(ws.clone(), test_oid('b'));
536
537        run_prepare_phase_with_epoch(&manifold_dir, test_epoch(), &[ws], heads).unwrap();
538
539        assert!(manifold_dir.exists());
540    }
541
542    #[test]
543    fn prepare_records_correct_oids_for_multiple_workspaces() {
544        let dir = tempfile::tempdir().unwrap();
545        let manifold_dir = dir.path().join(".manifold");
546
547        let ws1 = test_ws("ws-1");
548        let ws2 = test_ws("ws-2");
549        let ws3 = test_ws("ws-3");
550
551        let mut heads = BTreeMap::new();
552        heads.insert(ws1.clone(), test_oid('1'));
553        heads.insert(ws2.clone(), test_oid('2'));
554        heads.insert(ws3.clone(), test_oid('3'));
555
556        let sources = vec![ws1.clone(), ws2.clone(), ws3.clone()];
557        let frozen =
558            run_prepare_phase_with_epoch(&manifold_dir, test_epoch(), &sources, heads).unwrap();
559
560        // Verify each OID is correctly recorded
561        assert_eq!(frozen.heads[&ws1].as_str(), &"1".repeat(40));
562        assert_eq!(frozen.heads[&ws2].as_str(), &"2".repeat(40));
563        assert_eq!(frozen.heads[&ws3].as_str(), &"3".repeat(40));
564
565        // Verify persisted state matches
566        let state_path = MergeStateFile::default_path(&manifold_dir);
567        let state = MergeStateFile::read(&state_path).unwrap();
568        assert_eq!(state.frozen_heads.len(), 3);
569        assert_eq!(state.frozen_heads[&ws1].as_str(), &"1".repeat(40));
570    }
571
572    #[test]
573    fn prepare_frozen_inputs_are_deterministic() {
574        // Run PREPARE twice with same inputs → same frozen outputs
575        for _ in 0..2 {
576            let dir = tempfile::tempdir().unwrap();
577            let manifold_dir = dir.path().join(".manifold");
578
579            let epoch = test_epoch();
580            let ws = test_ws("det-test");
581            let mut heads = BTreeMap::new();
582            heads.insert(ws.clone(), test_oid('d'));
583
584            let frozen =
585                run_prepare_phase_with_epoch(&manifold_dir, epoch.clone(), &[ws.clone()], heads)
586                    .unwrap();
587
588            assert_eq!(frozen.epoch, epoch);
589            assert_eq!(frozen.heads[&ws], test_oid('d'));
590        }
591    }
592
593    #[test]
594    fn prepare_state_serialization_includes_frozen_heads() {
595        let dir = tempfile::tempdir().unwrap();
596        let manifold_dir = dir.path().join(".manifold");
597
598        let ws = test_ws("serial-test");
599        let mut heads = BTreeMap::new();
600        heads.insert(ws.clone(), test_oid('e'));
601
602        run_prepare_phase_with_epoch(&manifold_dir, test_epoch(), &[ws], heads).unwrap();
603
604        // Read raw JSON and verify frozen_heads is present
605        let state_path = MergeStateFile::default_path(&manifold_dir);
606        let raw_json = std::fs::read_to_string(&state_path).unwrap();
607        assert!(raw_json.contains("frozen_heads"));
608        assert!(raw_json.contains(&"e".repeat(40)));
609    }
610
611    #[test]
612    fn prepare_error_display() {
613        let err = PrepareError::NoSources;
614        assert!(format!("{err}").contains("no source workspaces"));
615
616        let err = PrepareError::MergeAlreadyInProgress;
617        assert!(format!("{err}").contains("already in progress"));
618
619        let err = PrepareError::EpochNotFound("not found".to_owned());
620        assert!(format!("{err}").contains("epoch ref not found"));
621
622        let ws = test_ws("bad-ws");
623        let err = PrepareError::WorkspaceHeadNotFound {
624            workspace: ws,
625            detail: "missing".to_owned(),
626        };
627        assert!(format!("{err}").contains("bad-ws"));
628    }
629
630    // ---------------------------------------------------------------------------
631    // Stale post-commit recovery tests (require a real git repo)
632    // ---------------------------------------------------------------------------
633
634    fn run_git(root: &Path, args: &[&str]) -> String {
635        let out = std::process::Command::new("git")
636            .args(args)
637            .current_dir(root)
638            .output()
639            .unwrap();
640        assert!(
641            out.status.success(),
642            "git {} failed:\n{}",
643            args.join(" "),
644            String::from_utf8_lossy(&out.stderr)
645        );
646        String::from_utf8_lossy(&out.stdout).trim().to_owned()
647    }
648
649    fn git_oid(root: &Path, rev: &str) -> GitOid {
650        let hex = run_git(root, &["rev-parse", rev]);
651        GitOid::new(&hex).unwrap()
652    }
653
654    /// Create a minimal git repo with one commit and the epoch ref pointing to it.
655    /// Returns (TempDir, initial_commit_oid).
656    fn setup_git_repo_with_epoch() -> (tempfile::TempDir, GitOid) {
657        let dir = tempfile::TempDir::new().unwrap();
658        let root = dir.path();
659        run_git(root, &["init"]);
660        run_git(root, &["config", "user.name", "Test"]);
661        run_git(root, &["config", "user.email", "test@test.com"]);
662        run_git(root, &["config", "commit.gpgsign", "false"]);
663        run_git(root, &["commit", "--allow-empty", "-m", "initial"]);
664        let initial = git_oid(root, "HEAD");
665        run_git(root, &["update-ref", refs::EPOCH_CURRENT, initial.as_str()]);
666        (dir, initial)
667    }
668
669    /// Build a stale merge-state.json at the given phase with epoch_candidate set.
670    fn write_stale_commit_state(
671        manifold_dir: &Path,
672        epoch_before: &GitOid,
673        epoch_candidate: &GitOid,
674        phase: MergePhase,
675        ws_name: &str,
676    ) {
677        std::fs::create_dir_all(manifold_dir).unwrap();
678        let eb = EpochId::new(epoch_before.as_str()).unwrap();
679        let mut state = MergeStateFile::new(vec![WorkspaceId::new(ws_name).unwrap()], eb, 1000);
680        state.advance(MergePhase::Build, 1001).unwrap();
681        state.advance(MergePhase::Validate, 1002).unwrap();
682        state.advance(MergePhase::Commit, 1003).unwrap();
683        state.epoch_candidate = Some(epoch_candidate.clone());
684        if phase == MergePhase::Cleanup {
685            state.advance(MergePhase::Cleanup, 1004).unwrap();
686        }
687        state
688            .write_atomic(&MergeStateFile::default_path(manifold_dir))
689            .unwrap();
690    }
691
692    #[test]
693    fn prepare_clears_stale_commit_phase_when_epoch_already_advanced() {
694        let (dir, epoch_before) = setup_git_repo_with_epoch();
695        let root = dir.path();
696
697        // Simulate a merge commit: new commit = epoch_candidate
698        run_git(root, &["commit", "--allow-empty", "-m", "merge"]);
699        let candidate = git_oid(root, "HEAD");
700
701        // Add workspace as a git worktree
702        let ws_name = "stale-ws";
703        let ws_path = root.join(ws_name);
704        run_git(root, &["worktree", "add", ws_path.to_str().unwrap(), "HEAD"]);
705
706        let manifold_dir = root.join(".manifold");
707        write_stale_commit_state(&manifold_dir, &epoch_before, &candidate, MergePhase::Commit, ws_name);
708
709        // Advance epoch ref (previous merge completed its COMMIT)
710        run_git(root, &["update-ref", refs::EPOCH_CURRENT, candidate.as_str()]);
711
712        let ws_id = WorkspaceId::new(ws_name).unwrap();
713        let mut workspace_dirs = BTreeMap::new();
714        workspace_dirs.insert(ws_id.clone(), ws_path);
715
716        // Should succeed: stale state is auto-cleared
717        let result = run_prepare_phase(root, &manifold_dir, &[ws_id], &workspace_dirs);
718        assert!(result.is_ok(), "expected success clearing stale state, got: {result:?}");
719
720        let new_state = MergeStateFile::read(&MergeStateFile::default_path(&manifold_dir)).unwrap();
721        assert_eq!(new_state.phase, MergePhase::Prepare);
722    }
723
724    #[test]
725    fn prepare_clears_stale_cleanup_phase_when_epoch_already_advanced() {
726        let (dir, epoch_before) = setup_git_repo_with_epoch();
727        let root = dir.path();
728
729        run_git(root, &["commit", "--allow-empty", "-m", "merge"]);
730        let candidate = git_oid(root, "HEAD");
731
732        let ws_name = "stale-ws2";
733        let ws_path = root.join(ws_name);
734        run_git(root, &["worktree", "add", ws_path.to_str().unwrap(), "HEAD"]);
735
736        let manifold_dir = root.join(".manifold");
737        write_stale_commit_state(&manifold_dir, &epoch_before, &candidate, MergePhase::Cleanup, ws_name);
738
739        run_git(root, &["update-ref", refs::EPOCH_CURRENT, candidate.as_str()]);
740
741        let ws_id = WorkspaceId::new(ws_name).unwrap();
742        let mut workspace_dirs = BTreeMap::new();
743        workspace_dirs.insert(ws_id.clone(), ws_path);
744
745        let result = run_prepare_phase(root, &manifold_dir, &[ws_id], &workspace_dirs);
746        assert!(result.is_ok(), "expected success clearing stale cleanup state, got: {result:?}");
747    }
748
749    #[test]
750    fn prepare_blocks_genuine_in_progress_commit_phase() {
751        let (dir, epoch_before) = setup_git_repo_with_epoch();
752        let root = dir.path();
753
754        run_git(root, &["commit", "--allow-empty", "-m", "in-flight merge"]);
755        let candidate = git_oid(root, "HEAD");
756
757        let ws_name = "active-ws";
758        let ws_path = root.join(ws_name);
759        run_git(root, &["worktree", "add", ws_path.to_str().unwrap(), "HEAD"]);
760
761        let manifold_dir = root.join(".manifold");
762        write_stale_commit_state(&manifold_dir, &epoch_before, &candidate, MergePhase::Commit, ws_name);
763
764        // Epoch ref is still at epoch_before (commit hasn't completed yet)
765        // epoch_before is still in refs/manifold/epoch/current from setup
766
767        let ws_id = WorkspaceId::new(ws_name).unwrap();
768        let mut workspace_dirs = BTreeMap::new();
769        workspace_dirs.insert(ws_id.clone(), ws_path);
770
771        let result = run_prepare_phase(root, &manifold_dir, &[ws_id], &workspace_dirs);
772        assert!(
773            matches!(result, Err(PrepareError::MergeAlreadyInProgress)),
774            "expected MergeAlreadyInProgress, got: {result:?}"
775        );
776    }
777}