Skip to main content

maw/
refs.rs

1//! Git ref management for Manifold's `refs/manifold/*` namespace.
2//!
3//! Provides low-level helpers to read, write, atomically update, and delete
4//! git refs used by Manifold. All operations run `git update-ref` (or
5//! `git rev-parse`) in the repository root directory.
6//!
7//! # Manifold Ref Hierarchy
8//!
9//! ```text
10//! refs/manifold/
11//! ├── epoch/
12//! │   └── current       ← OID of the current epoch commit
13//! ├── head/
14//! │   └── <workspace>   ← latest operation OID for each workspace (Phase 2+)
15//! └── ws/
16//!     └── <workspace>   ← materialized workspace state commit (Level 1 compat)
17//! ```
18//!
19//! # Concurrency
20//!
21//! [`write_ref_cas`] implements optimistic concurrency control. Git's
22//! internal ref locking makes the CAS atomic: if the ref's current value
23//! does not match the expected old OID, git rejects the update and the
24//! function returns [`RefError::CasMismatch`]. Callers should retry on
25//! mismatch.
26
27#![allow(clippy::missing_errors_doc)]
28
29use std::fmt;
30use std::io::Write;
31use std::path::Path;
32use std::process::{Command, Stdio};
33
34use crate::model::types::GitOid;
35
36// ---------------------------------------------------------------------------
37// Well-known ref names
38// ---------------------------------------------------------------------------
39
40/// The git ref that tracks the current epoch commit.
41///
42/// Set during `maw init` to epoch₀ (the initial commit), and advanced
43/// atomically during epoch promotion.
44pub const EPOCH_CURRENT: &str = "refs/manifold/epoch/current";
45
46/// Prefix for per-workspace head refs (used in Phase 2+).
47pub const HEAD_PREFIX: &str = "refs/manifold/head/";
48
49/// Prefix for per-workspace materialized state refs (Level 1 compatibility).
50pub const WORKSPACE_STATE_PREFIX: &str = "refs/manifold/ws/";
51
52/// Prefix for per-workspace creation epoch refs.
53///
54/// Stores the epoch a workspace was created at, so that `status()` can
55/// distinguish "HEAD advanced because the agent committed" from "HEAD is the
56/// epoch" even after the workspace has local commits.
57pub const WORKSPACE_EPOCH_PREFIX: &str = "refs/manifold/epoch/ws/";
58
59/// Build the per-workspace head ref name.
60///
61/// # Example
62/// ```
63/// assert_eq!(maw::refs::workspace_head_ref("default"),
64///            "refs/manifold/head/default");
65/// ```
66#[must_use]
67pub fn workspace_head_ref(workspace_name: &str) -> String {
68    format!("{HEAD_PREFIX}{workspace_name}")
69}
70
71/// Build the per-workspace Level 1 state ref name.
72///
73/// # Example
74/// ```
75/// assert_eq!(maw::refs::workspace_state_ref("default"),
76///            "refs/manifold/ws/default");
77/// ```
78#[must_use]
79pub fn workspace_state_ref(workspace_name: &str) -> String {
80    format!("{WORKSPACE_STATE_PREFIX}{workspace_name}")
81}
82
83/// Build the per-workspace creation epoch ref name.
84///
85/// This ref records the epoch a workspace was based on at creation time.
86/// Unlike HEAD (which advances when agents commit), this ref stays fixed
87/// for the lifetime of the workspace.
88///
89/// # Example
90/// ```
91/// assert_eq!(maw::refs::workspace_epoch_ref("agent-1"),
92///            "refs/manifold/epoch/ws/agent-1");
93/// ```
94#[must_use]
95pub fn workspace_epoch_ref(workspace_name: &str) -> String {
96    format!("{WORKSPACE_EPOCH_PREFIX}{workspace_name}")
97}
98
99// ---------------------------------------------------------------------------
100// Error type
101// ---------------------------------------------------------------------------
102
103/// Errors that can occur during ref operations.
104#[derive(Debug)]
105pub enum RefError {
106    /// A git command failed (non-zero exit code).
107    GitCommand {
108        /// The command that was run (e.g., `"git update-ref ..."`).
109        command: String,
110        /// Stderr output from git, trimmed.
111        stderr: String,
112        /// Process exit code, if available.
113        exit_code: Option<i32>,
114    },
115    /// An I/O error spawning git.
116    Io(std::io::Error),
117    /// Git returned an OID that failed validation.
118    InvalidOid {
119        /// The ref name that was read.
120        ref_name: String,
121        /// The raw value returned by git.
122        raw_value: String,
123    },
124    /// CAS failed because the ref's current value differs from `old_oid`.
125    ///
126    /// The caller should re-read the ref and retry, or bail out.
127    CasMismatch {
128        /// The ref that could not be updated.
129        ref_name: String,
130    },
131}
132
133impl fmt::Display for RefError {
134    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
135        match self {
136            Self::GitCommand {
137                command,
138                stderr,
139                exit_code,
140            } => {
141                write!(f, "`{command}` failed")?;
142                if let Some(code) = exit_code {
143                    write!(f, " (exit code {code})")?;
144                }
145                if !stderr.is_empty() {
146                    write!(f, ": {stderr}")?;
147                }
148                Ok(())
149            }
150            Self::Io(e) => write!(f, "I/O error spawning git: {e}"),
151            Self::InvalidOid {
152                ref_name,
153                raw_value,
154            } => {
155                write!(
156                    f,
157                    "invalid OID from `{ref_name}`: {raw_value:?} \
158                     (expected 40 lowercase hex characters)"
159                )
160            }
161            Self::CasMismatch { ref_name } => {
162                write!(
163                    f,
164                    "CAS failed for `{ref_name}`: ref was modified concurrently — \
165                     read the current value and retry"
166                )
167            }
168        }
169    }
170}
171
172impl std::error::Error for RefError {
173    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
174        if let Self::Io(e) = self {
175            Some(e)
176        } else {
177            None
178        }
179    }
180}
181
182impl From<std::io::Error> for RefError {
183    fn from(e: std::io::Error) -> Self {
184        Self::Io(e)
185    }
186}
187
188// ---------------------------------------------------------------------------
189// Public API
190// ---------------------------------------------------------------------------
191
192/// Read a git ref and return its OID, or `None` if it does not exist.
193///
194/// Runs `git rev-parse <name>` in `root`. Returns `None` if the ref is
195/// missing (git exits non-zero with "unknown revision or path").
196///
197/// # Errors
198/// Returns an error if git cannot be spawned, if git fails for a reason
199/// other than a missing ref, or if the returned OID is malformed.
200pub fn read_ref(root: &Path, name: &str) -> Result<Option<GitOid>, RefError> {
201    let output = Command::new("git")
202        .args(["rev-parse", name])
203        .current_dir(root)
204        .output()?;
205
206    if output.status.success() {
207        let raw = String::from_utf8_lossy(&output.stdout);
208        let oid_str = raw.trim();
209        let oid = GitOid::new(oid_str).map_err(|_| RefError::InvalidOid {
210            ref_name: name.to_owned(),
211            raw_value: oid_str.to_owned(),
212        })?;
213        return Ok(Some(oid));
214    }
215
216    // Distinguish "ref not found" from other errors.
217    let stderr = String::from_utf8_lossy(&output.stderr);
218    let stderr_trimmed = stderr.trim();
219
220    // git rev-parse exits 128 with "unknown revision or path" when the ref
221    // does not exist. Treat this as "not found" rather than a hard error.
222    if stderr_trimmed.contains("unknown revision")
223        || stderr_trimmed.contains("ambiguous argument")
224        || stderr_trimmed.contains("not a valid object")
225    {
226        return Ok(None);
227    }
228
229    Err(RefError::GitCommand {
230        command: format!("git rev-parse {name}"),
231        stderr: stderr_trimmed.to_owned(),
232        exit_code: output.status.code(),
233    })
234}
235
236/// Write (create or overwrite) a git ref unconditionally.
237///
238/// Runs `git update-ref <name> <oid>`. This is equivalent to
239/// `git update-ref <name> <new_oid>` without an old-value guard, so it
240/// will succeed regardless of the ref's current value.
241///
242/// For safe concurrent updates, use [`write_ref_cas`] instead.
243///
244/// # Errors
245/// Returns an error if git cannot be spawned or exits non-zero.
246pub fn write_ref(root: &Path, name: &str, oid: &GitOid) -> Result<(), RefError> {
247    let output = Command::new("git")
248        .args(["update-ref", name, oid.as_str()])
249        .current_dir(root)
250        .output()?;
251
252    if output.status.success() {
253        return Ok(());
254    }
255
256    Err(RefError::GitCommand {
257        command: format!("git update-ref {name} {}", oid.as_str()),
258        stderr: String::from_utf8_lossy(&output.stderr).trim().to_owned(),
259        exit_code: output.status.code(),
260    })
261}
262
263/// Atomically update a git ref using compare-and-swap (CAS).
264///
265/// Runs `git update-ref <name> <new_oid> <old_oid>`. Git internally holds
266/// a lock on the ref file during the update. The update succeeds only if
267/// the ref's current value matches `old_oid`. If it does not match, git
268/// exits non-zero and this function returns [`RefError::CasMismatch`].
269///
270/// # Concurrency
271/// This is the correct primitive for epoch advancement in multi-agent
272/// scenarios. Each agent reads the current epoch, does its work, then
273/// tries to advance with CAS. If another agent advanced first, the CAS
274/// fails and the agent must re-read and retry.
275///
276/// # Creating a ref that must not exist
277/// Pass the zero OID (`0000000000000000000000000000000000000000`) as
278/// `old_oid` to succeed only if the ref does not currently exist.
279///
280/// # Errors
281/// - [`RefError::CasMismatch`] — ref was modified concurrently.
282/// - [`RefError::GitCommand`] — other git failure.
283/// - [`RefError::Io`] — git could not be spawned.
284pub fn write_ref_cas(
285    root: &Path,
286    name: &str,
287    old_oid: &GitOid,
288    new_oid: &GitOid,
289) -> Result<(), RefError> {
290    let output = Command::new("git")
291        .args(["update-ref", name, new_oid.as_str(), old_oid.as_str()])
292        .current_dir(root)
293        .output()?;
294
295    if output.status.success() {
296        return Ok(());
297    }
298
299    let stderr = String::from_utf8_lossy(&output.stderr);
300    let stderr_trimmed = stderr.trim();
301
302    // git update-ref prints "cannot lock ref" or "is at ... not ..." when
303    // the old-value check fails (CAS mismatch).
304    if stderr_trimmed.contains("cannot lock ref")
305        || stderr_trimmed.contains("is at")
306        || stderr_trimmed.contains("but expected")
307    {
308        return Err(RefError::CasMismatch {
309            ref_name: name.to_owned(),
310        });
311    }
312
313    Err(RefError::GitCommand {
314        command: format!(
315            "git update-ref {name} {} {}",
316            new_oid.as_str(),
317            old_oid.as_str()
318        ),
319        stderr: stderr_trimmed.to_owned(),
320        exit_code: output.status.code(),
321    })
322}
323
324/// Atomically update multiple refs using `git update-ref --stdin`.
325///
326/// Each entry is a `(ref_name, old_oid, new_oid)` tuple. All updates are
327/// applied in a single transaction: either every ref moves or none does.
328///
329/// Uses the `start` / `prepare` / `commit` protocol so that the prepare
330/// step validates all updates before the commit step makes them visible.
331///
332/// # CAS semantics
333/// Each update includes the expected old OID. If any ref's current value
334/// does not match its expected old OID, the entire transaction is aborted
335/// and [`RefError::CasMismatch`] is returned (with the first ref name
336/// that git complained about, or the first ref in the batch if the
337/// specific ref cannot be determined).
338///
339/// # Errors
340/// - [`RefError::CasMismatch`] — a ref was modified concurrently.
341/// - [`RefError::GitCommand`] — other git failure.
342/// - [`RefError::Io`] — git could not be spawned or stdin write failed.
343pub fn update_refs_atomic(
344    root: &Path,
345    updates: &[(&str, &GitOid, &GitOid)],
346) -> Result<(), RefError> {
347    let mut child = Command::new("git")
348        .args(["update-ref", "--stdin"])
349        .current_dir(root)
350        .stdin(Stdio::piped())
351        .stdout(Stdio::piped())
352        .stderr(Stdio::piped())
353        .spawn()?;
354
355    {
356        let stdin = child.stdin.as_mut().ok_or_else(|| RefError::Io(
357            std::io::Error::new(std::io::ErrorKind::BrokenPipe, "failed to open stdin"),
358        ))?;
359
360        writeln!(stdin, "start")?;
361        for (ref_name, old_oid, new_oid) in updates {
362            writeln!(
363                stdin,
364                "update {} {} {}",
365                ref_name,
366                new_oid.as_str(),
367                old_oid.as_str()
368            )?;
369        }
370        writeln!(stdin, "prepare")?;
371        writeln!(stdin, "commit")?;
372    }
373
374    let output = child.wait_with_output()?;
375
376    if output.status.success() {
377        return Ok(());
378    }
379
380    let stderr = String::from_utf8_lossy(&output.stderr);
381    let stderr_trimmed = stderr.trim();
382
383    if stderr_trimmed.contains("cannot lock ref")
384        || stderr_trimmed.contains("is at")
385        || stderr_trimmed.contains("but expected")
386    {
387        // Try to identify which ref failed from stderr.
388        let ref_name = updates
389            .iter()
390            .find(|(name, _, _)| stderr_trimmed.contains(name))
391            .map_or_else(
392                || updates[0].0.to_owned(),
393                |(name, _, _)| (*name).to_owned(),
394            );
395        return Err(RefError::CasMismatch { ref_name });
396    }
397
398    Err(RefError::GitCommand {
399        command: "git update-ref --stdin".to_owned(),
400        stderr: stderr_trimmed.to_owned(),
401        exit_code: output.status.code(),
402    })
403}
404
405/// Delete a git ref.
406///
407/// Runs `git update-ref -d <name>`. Idempotent: if the ref does not exist,
408/// git exits successfully (no-op). If you need to guard against concurrent
409/// deletion, use [`write_ref_cas`] with the expected OID followed by
410/// `delete_ref`.
411///
412/// # Errors
413/// Returns an error if git cannot be spawned or exits non-zero for a
414/// reason other than the ref already being absent.
415pub fn delete_ref(root: &Path, name: &str) -> Result<(), RefError> {
416    let output = Command::new("git")
417        .args(["update-ref", "-d", name])
418        .current_dir(root)
419        .output()?;
420
421    if output.status.success() {
422        return Ok(());
423    }
424
425    let stderr = String::from_utf8_lossy(&output.stderr);
426    let stderr_trimmed = stderr.trim();
427
428    // git update-ref -d exits 0 if the ref doesn't exist (no-op).
429    // If it exits non-zero, something else went wrong.
430    Err(RefError::GitCommand {
431        command: format!("git update-ref -d {name}"),
432        stderr: stderr_trimmed.to_owned(),
433        exit_code: output.status.code(),
434    })
435}
436
437// ---------------------------------------------------------------------------
438// Convenience wrappers for Manifold-specific refs
439// ---------------------------------------------------------------------------
440
441/// Read `refs/manifold/epoch/current`.
442///
443/// Returns `None` if the ref has not been set (e.g., before `maw init`).
444pub fn read_epoch_current(root: &Path) -> Result<Option<GitOid>, RefError> {
445    read_ref(root, EPOCH_CURRENT)
446}
447
448/// Write `refs/manifold/epoch/current` unconditionally.
449///
450/// Used during `maw init` to set the initial epoch₀.
451pub fn write_epoch_current(root: &Path, oid: &GitOid) -> Result<(), RefError> {
452    write_ref(root, EPOCH_CURRENT, oid)
453}
454
455/// Advance `refs/manifold/epoch/current` from `old_epoch` to `new_epoch` via CAS.
456///
457/// Returns [`RefError::CasMismatch`] if another agent advanced the epoch first.
458pub fn advance_epoch(root: &Path, old_epoch: &GitOid, new_epoch: &GitOid) -> Result<(), RefError> {
459    write_ref_cas(root, EPOCH_CURRENT, old_epoch, new_epoch)
460}
461
462// ---------------------------------------------------------------------------
463// Tests
464// ---------------------------------------------------------------------------
465
466#[cfg(test)]
467mod tests {
468    use super::*;
469    use std::fs;
470    use std::process::Command;
471    use tempfile::TempDir;
472
473    // -----------------------------------------------------------------------
474    // Test helpers
475    // -----------------------------------------------------------------------
476
477    /// Create a fresh git repo with one commit and return the HEAD OID.
478    fn setup_repo() -> (TempDir, GitOid) {
479        let dir = TempDir::new().unwrap();
480        let root = dir.path();
481
482        Command::new("git")
483            .args(["init"])
484            .current_dir(root)
485            .output()
486            .unwrap();
487        Command::new("git")
488            .args(["config", "user.name", "Test"])
489            .current_dir(root)
490            .output()
491            .unwrap();
492        Command::new("git")
493            .args(["config", "user.email", "test@test.com"])
494            .current_dir(root)
495            .output()
496            .unwrap();
497        Command::new("git")
498            .args(["config", "commit.gpgsign", "false"])
499            .current_dir(root)
500            .output()
501            .unwrap();
502
503        fs::write(root.join("README.md"), "# Test\n").unwrap();
504        Command::new("git")
505            .args(["add", "README.md"])
506            .current_dir(root)
507            .output()
508            .unwrap();
509        Command::new("git")
510            .args(["commit", "-m", "initial"])
511            .current_dir(root)
512            .output()
513            .unwrap();
514
515        let out = Command::new("git")
516            .args(["rev-parse", "HEAD"])
517            .current_dir(root)
518            .output()
519            .unwrap();
520        let oid_str = String::from_utf8_lossy(&out.stdout).trim().to_owned();
521        let oid = GitOid::new(&oid_str).unwrap();
522
523        (dir, oid)
524    }
525
526    /// Create a second commit in the repo and return its OID.
527    fn add_commit(root: &std::path::Path) -> GitOid {
528        fs::write(root.join("extra.txt"), "extra\n").unwrap();
529        Command::new("git")
530            .args(["add", "extra.txt"])
531            .current_dir(root)
532            .output()
533            .unwrap();
534        Command::new("git")
535            .args(["commit", "-m", "second"])
536            .current_dir(root)
537            .output()
538            .unwrap();
539
540        let out = Command::new("git")
541            .args(["rev-parse", "HEAD"])
542            .current_dir(root)
543            .output()
544            .unwrap();
545        let oid_str = String::from_utf8_lossy(&out.stdout).trim().to_owned();
546        GitOid::new(&oid_str).unwrap()
547    }
548
549    // -----------------------------------------------------------------------
550    // workspace_head_ref
551    // -----------------------------------------------------------------------
552
553    #[test]
554    fn workspace_head_ref_format() {
555        assert_eq!(workspace_head_ref("default"), "refs/manifold/head/default");
556        assert_eq!(workspace_head_ref("agent-1"), "refs/manifold/head/agent-1");
557    }
558
559    #[test]
560    fn workspace_state_ref_format() {
561        assert_eq!(workspace_state_ref("default"), "refs/manifold/ws/default");
562        assert_eq!(workspace_state_ref("agent-1"), "refs/manifold/ws/agent-1");
563    }
564
565    #[test]
566    fn workspace_epoch_ref_format() {
567        assert_eq!(
568            workspace_epoch_ref("agent-1"),
569            "refs/manifold/epoch/ws/agent-1"
570        );
571        assert_eq!(
572            workspace_epoch_ref("default"),
573            "refs/manifold/epoch/ws/default"
574        );
575    }
576
577    // -----------------------------------------------------------------------
578    // read_ref
579    // -----------------------------------------------------------------------
580
581    #[test]
582    fn read_ref_existing() {
583        let (dir, oid) = setup_repo();
584        let root = dir.path();
585
586        // Write a known ref
587        Command::new("git")
588            .args(["update-ref", "refs/manifold/epoch/current", oid.as_str()])
589            .current_dir(root)
590            .output()
591            .unwrap();
592
593        let result = read_ref(root, "refs/manifold/epoch/current").unwrap();
594        assert_eq!(result, Some(oid));
595    }
596
597    #[test]
598    fn read_ref_missing_returns_none() {
599        let (dir, _oid) = setup_repo();
600        let root = dir.path();
601
602        let result = read_ref(root, "refs/manifold/does-not-exist").unwrap();
603        assert_eq!(result, None);
604    }
605
606    #[test]
607    fn read_ref_head() {
608        let (dir, oid) = setup_repo();
609        let root = dir.path();
610
611        // HEAD always exists
612        let result = read_ref(root, "HEAD").unwrap();
613        assert_eq!(result, Some(oid));
614    }
615
616    // -----------------------------------------------------------------------
617    // write_ref
618    // -----------------------------------------------------------------------
619
620    #[test]
621    fn write_ref_creates_new() {
622        let (dir, oid) = setup_repo();
623        let root = dir.path();
624
625        write_ref(root, EPOCH_CURRENT, &oid).unwrap();
626
627        let result = read_ref(root, EPOCH_CURRENT).unwrap();
628        assert_eq!(result, Some(oid));
629    }
630
631    #[test]
632    fn write_ref_overwrites_existing() {
633        let (dir, first_oid) = setup_repo();
634        let root = dir.path();
635        let second_oid = add_commit(root);
636
637        write_ref(root, EPOCH_CURRENT, &first_oid).unwrap();
638        write_ref(root, EPOCH_CURRENT, &second_oid).unwrap();
639
640        let result = read_ref(root, EPOCH_CURRENT).unwrap();
641        assert_eq!(result, Some(second_oid));
642    }
643
644    // -----------------------------------------------------------------------
645    // write_ref_cas
646    // -----------------------------------------------------------------------
647
648    #[test]
649    fn write_ref_cas_succeeds_with_correct_old_value() {
650        let (dir, first_oid) = setup_repo();
651        let root = dir.path();
652        let second_oid = add_commit(root);
653
654        // Set initial value
655        write_ref(root, EPOCH_CURRENT, &first_oid).unwrap();
656
657        // CAS from first → second
658        write_ref_cas(root, EPOCH_CURRENT, &first_oid, &second_oid).unwrap();
659
660        let result = read_ref(root, EPOCH_CURRENT).unwrap();
661        assert_eq!(result, Some(second_oid));
662    }
663
664    #[test]
665    fn write_ref_cas_fails_with_wrong_old_value() {
666        let (dir, first_oid) = setup_repo();
667        let root = dir.path();
668        let second_oid = add_commit(root);
669        let third_oid = add_commit(root);
670
671        // Set to second_oid
672        write_ref(root, EPOCH_CURRENT, &second_oid).unwrap();
673
674        // Try CAS with first_oid as expected old (wrong!)
675        let err = write_ref_cas(root, EPOCH_CURRENT, &first_oid, &third_oid).unwrap_err();
676        assert!(
677            matches!(err, RefError::CasMismatch { .. }),
678            "expected CasMismatch, got: {err}"
679        );
680
681        // Ref should still be second_oid
682        let result = read_ref(root, EPOCH_CURRENT).unwrap();
683        assert_eq!(result, Some(second_oid));
684    }
685
686    #[test]
687    fn write_ref_cas_prevents_concurrent_advance() {
688        // Simulate two agents racing to advance epoch.
689        // Agent A reads epoch=v1, advances to v2.
690        // Agent B reads epoch=v1 (stale), tries to advance to v3.
691        // Agent B's CAS should fail because epoch is now v2.
692        let (dir, v1) = setup_repo();
693        let root = dir.path();
694        let v2 = add_commit(root);
695        let v3 = add_commit(root);
696
697        write_ref(root, EPOCH_CURRENT, &v1).unwrap();
698
699        // Agent A advances v1 → v2 (succeeds)
700        write_ref_cas(root, EPOCH_CURRENT, &v1, &v2).unwrap();
701
702        // Agent B tries v1 → v3 (fails, current is v2)
703        let err = write_ref_cas(root, EPOCH_CURRENT, &v1, &v3).unwrap_err();
704        assert!(
705            matches!(err, RefError::CasMismatch { .. }),
706            "agent B should lose the race: {err}"
707        );
708
709        // Epoch stayed at v2
710        let result = read_ref(root, EPOCH_CURRENT).unwrap();
711        assert_eq!(result, Some(v2));
712    }
713
714    // -----------------------------------------------------------------------
715    // delete_ref
716    // -----------------------------------------------------------------------
717
718    #[test]
719    fn delete_ref_removes_existing() {
720        let (dir, oid) = setup_repo();
721        let root = dir.path();
722
723        write_ref(root, EPOCH_CURRENT, &oid).unwrap();
724        assert!(read_ref(root, EPOCH_CURRENT).unwrap().is_some());
725
726        delete_ref(root, EPOCH_CURRENT).unwrap();
727        assert!(read_ref(root, EPOCH_CURRENT).unwrap().is_none());
728    }
729
730    #[test]
731    fn delete_ref_idempotent() {
732        let (dir, oid) = setup_repo();
733        let root = dir.path();
734
735        write_ref(root, EPOCH_CURRENT, &oid).unwrap();
736        delete_ref(root, EPOCH_CURRENT).unwrap();
737        // Deleting again should not error
738        delete_ref(root, EPOCH_CURRENT).unwrap();
739    }
740
741    #[test]
742    fn delete_ref_missing_is_noop() {
743        let (dir, _) = setup_repo();
744        let root = dir.path();
745
746        // Should not error even if the ref never existed
747        delete_ref(root, "refs/manifold/nonexistent").unwrap();
748    }
749
750    // -----------------------------------------------------------------------
751    // Convenience wrappers
752    // -----------------------------------------------------------------------
753
754    #[test]
755    fn read_epoch_current_missing() {
756        let (dir, _) = setup_repo();
757        assert!(read_epoch_current(dir.path()).unwrap().is_none());
758    }
759
760    #[test]
761    fn write_and_read_epoch_current() {
762        let (dir, oid) = setup_repo();
763        let root = dir.path();
764
765        write_epoch_current(root, &oid).unwrap();
766        let result = read_epoch_current(root).unwrap();
767        assert_eq!(result, Some(oid));
768    }
769
770    #[test]
771    fn advance_epoch_happy_path() {
772        let (dir, v1) = setup_repo();
773        let root = dir.path();
774        let v2 = add_commit(root);
775
776        write_epoch_current(root, &v1).unwrap();
777        advance_epoch(root, &v1, &v2).unwrap();
778
779        assert_eq!(read_epoch_current(root).unwrap(), Some(v2));
780    }
781
782    #[test]
783    fn advance_epoch_stale_fails() {
784        let (dir, v1) = setup_repo();
785        let root = dir.path();
786        let v2 = add_commit(root);
787        let v3 = add_commit(root);
788
789        write_epoch_current(root, &v2).unwrap();
790
791        // Try to advance from v1 (stale) to v3 — should fail
792        let err = advance_epoch(root, &v1, &v3).unwrap_err();
793        assert!(
794            matches!(err, RefError::CasMismatch { .. }),
795            "expected CasMismatch: {err}"
796        );
797    }
798
799    // -----------------------------------------------------------------------
800    // update_refs_atomic
801    // -----------------------------------------------------------------------
802
803    #[test]
804    fn update_refs_atomic_moves_both_refs() {
805        let (dir, v1) = setup_repo();
806        let root = dir.path();
807        let v2 = add_commit(root);
808
809        write_ref(root, EPOCH_CURRENT, &v1).unwrap();
810        write_ref(root, "refs/heads/test-branch", &v1).unwrap();
811
812        update_refs_atomic(
813            root,
814            &[
815                (EPOCH_CURRENT, &v1, &v2),
816                ("refs/heads/test-branch", &v1, &v2),
817            ],
818        )
819        .unwrap();
820
821        assert_eq!(read_ref(root, EPOCH_CURRENT).unwrap(), Some(v2.clone()));
822        assert_eq!(
823            read_ref(root, "refs/heads/test-branch").unwrap(),
824            Some(v2)
825        );
826    }
827
828    #[test]
829    fn update_refs_atomic_fails_if_any_ref_stale() {
830        let (dir, v1) = setup_repo();
831        let root = dir.path();
832        let v2 = add_commit(root);
833        let v3 = add_commit(root);
834
835        // Set epoch to v2, branch to v1
836        write_ref(root, EPOCH_CURRENT, &v2).unwrap();
837        write_ref(root, "refs/heads/test-branch", &v1).unwrap();
838
839        // Try atomic update expecting epoch=v1 (wrong!) and branch=v1
840        let err = update_refs_atomic(
841            root,
842            &[
843                (EPOCH_CURRENT, &v1, &v3),
844                ("refs/heads/test-branch", &v1, &v3),
845            ],
846        )
847        .unwrap_err();
848
849        assert!(
850            matches!(err, RefError::CasMismatch { .. }),
851            "expected CasMismatch, got: {err}"
852        );
853
854        // Neither ref should have moved
855        assert_eq!(read_ref(root, EPOCH_CURRENT).unwrap(), Some(v2));
856        assert_eq!(
857            read_ref(root, "refs/heads/test-branch").unwrap(),
858            Some(v1)
859        );
860    }
861
862    #[test]
863    fn update_refs_atomic_single_ref() {
864        let (dir, v1) = setup_repo();
865        let root = dir.path();
866        let v2 = add_commit(root);
867
868        write_ref(root, EPOCH_CURRENT, &v1).unwrap();
869
870        update_refs_atomic(root, &[(EPOCH_CURRENT, &v1, &v2)]).unwrap();
871
872        assert_eq!(read_ref(root, EPOCH_CURRENT).unwrap(), Some(v2));
873    }
874
875    // -----------------------------------------------------------------------
876    // Error display
877    // -----------------------------------------------------------------------
878
879    #[test]
880    fn error_display_git_command() {
881        let err = RefError::GitCommand {
882            command: "git update-ref refs/manifold/epoch/current abc123".to_owned(),
883            stderr: "fatal: bad object".to_owned(),
884            exit_code: Some(128),
885        };
886        let msg = format!("{err}");
887        assert!(msg.contains("git update-ref"));
888        assert!(msg.contains("128"));
889        assert!(msg.contains("fatal: bad object"));
890    }
891
892    #[test]
893    fn error_display_cas_mismatch() {
894        let err = RefError::CasMismatch {
895            ref_name: "refs/manifold/epoch/current".to_owned(),
896        };
897        let msg = format!("{err}");
898        assert!(msg.contains("CAS failed"));
899        assert!(msg.contains("refs/manifold/epoch/current"));
900        assert!(msg.contains("concurrently"));
901    }
902
903    #[test]
904    fn error_display_invalid_oid() {
905        let err = RefError::InvalidOid {
906            ref_name: "refs/manifold/epoch/current".to_owned(),
907            raw_value: "garbage".to_owned(),
908        };
909        let msg = format!("{err}");
910        assert!(msg.contains("invalid OID"));
911        assert!(msg.contains("garbage"));
912    }
913}