1#![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#[derive(Clone, Debug, PartialEq, Eq)]
52pub struct FrozenInputs {
53 pub epoch: EpochId,
55 pub heads: BTreeMap<WorkspaceId, GitOid>,
57}
58
59#[derive(Clone, Debug, PartialEq, Eq)]
65pub enum PrepareError {
66 NoSources,
68 EpochNotFound(String),
70 WorkspaceHeadNotFound {
72 workspace: WorkspaceId,
73 detail: String,
74 },
75 MergeAlreadyInProgress,
77 InvalidOid(String),
79 State(MergeStateError),
81 GitError(String),
83 #[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
126fn 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
149fn 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
178pub 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 if sources.is_empty() {
213 return Err(PrepareError::NoSources);
214 }
215
216 let epoch = read_epoch_ref(repo_root)?;
218
219 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 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 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 let state_path = MergeStateFile::default_path(manifold_dir);
252 if !state.write_exclusive(&state_path)? {
253 match MergeStateFile::read(&state_path) {
255 Ok(existing) if !existing.phase.is_terminal() => {
256 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| ¤t == 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 state.write_atomic(&state_path)?;
282 } else {
283 return Err(PrepareError::MergeAlreadyInProgress);
284 }
285 }
286 _ => {
287 state.write_atomic(&state_path)?;
289 }
290 }
291 }
292
293 Ok(FrozenInputs { epoch, heads })
294}
295
296pub 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 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 match MergeStateFile::read(&state_path) {
331 Ok(existing) if !existing.phase.is_terminal() => {
332 return Err(PrepareError::MergeAlreadyInProgress);
333 }
334 _ => {
335 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#[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 #[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 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 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 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 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 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 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 let state_path = MergeStateFile::default_path(&manifold_dir);
492 assert!(!state_path.exists());
493
494 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 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 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 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 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 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 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 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 run_git(root, &["commit", "--allow-empty", "-m", "merge"]);
699 let candidate = git_oid(root, "HEAD");
700
701 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 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 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 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}