Skip to main content

maw/oplog/
read.rs

1//! Op log read operations: walk the causal chain from the head ref backwards.
2//!
3//! This module implements the read path of the git-native per-workspace
4//! operation log (§5.3):
5//!
6//! 1. **Read the head ref** `refs/manifold/head/<workspace>` to get the
7//!    latest operation blob OID.
8//! 2. **Read each blob** via `git cat-file -p <oid>`, deserialize JSON
9//!    to [`Operation`].
10//! 3. **Walk parents** iteratively (BFS) to reconstruct the full chain.
11//!
12//! # Walk strategy
13//!
14//! The walk uses breadth-first search to handle operations with multiple
15//! parents (e.g., merge operations). A visited set prevents processing the
16//! same operation twice when chains share common ancestors.
17//!
18//! # Stopping conditions
19//!
20//! The walk stops when:
21//! - An operation has no parents (root of the chain).
22//! - A maximum depth is reached (optional, for bounded reads).
23//! - A caller-supplied predicate returns `false` (e.g., stop at checkpoints).
24//!
25//! # Example flow
26//! ```text
27//! read_head(root, ws)              → Option<GitOid>
28//! read_operation(root, oid)        → Operation
29//! walk_chain(root, ws, None, None) → Vec<(GitOid, Operation)>
30//!   ├── read head ref → oid₃
31//!   ├── cat-file oid₃ → op₃ (parent: oid₂)
32//!   ├── cat-file oid₂ → op₂ (parent: oid₁)
33//!   └── cat-file oid₁ → op₁ (parent: [])
34//! ```
35
36use std::collections::{HashSet, VecDeque};
37use std::fmt;
38use std::path::Path;
39use std::process::Command;
40
41use crate::model::types::{GitOid, WorkspaceId};
42use crate::refs;
43
44use super::types::Operation;
45
46// ---------------------------------------------------------------------------
47// Error type
48// ---------------------------------------------------------------------------
49
50/// Errors that can occur during an op log read.
51#[derive(Debug)]
52pub enum OpLogReadError {
53    /// `git cat-file -p <oid>` failed or returned unexpected output.
54    CatFile {
55        /// The OID that could not be read.
56        oid: String,
57        /// Stderr from git.
58        stderr: String,
59        /// Process exit code, if available.
60        exit_code: Option<i32>,
61    },
62
63    /// The blob content could not be deserialized as an [`Operation`].
64    Deserialize {
65        /// The OID whose content failed deserialization.
66        oid: String,
67        /// The underlying serde error.
68        source: serde_json::Error,
69    },
70
71    /// I/O error (e.g. spawning git).
72    Io(std::io::Error),
73
74    /// A ref operation failed.
75    RefError(refs::RefError),
76
77    /// The head ref does not exist (workspace has no op log yet).
78    NoHead {
79        /// The workspace whose head ref is missing.
80        workspace_id: WorkspaceId,
81    },
82}
83
84impl fmt::Display for OpLogReadError {
85    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
86        match self {
87            Self::CatFile {
88                oid,
89                stderr,
90                exit_code,
91            } => {
92                write!(f, "`git cat-file -p {oid}` failed")?;
93                if let Some(code) = exit_code {
94                    write!(f, " (exit code {code})")?;
95                }
96                if !stderr.is_empty() {
97                    write!(f, ": {stderr}")?;
98                }
99                write!(
100                    f,
101                    "\n  To fix: verify the blob OID exists in the repository \
102                     (`git cat-file -t {oid}`)."
103                )
104            }
105            Self::Deserialize { oid, source } => {
106                write!(
107                    f,
108                    "failed to deserialize operation blob {oid}: {source}\n  \
109                     To fix: inspect the blob content with `git cat-file -p {oid}`."
110                )
111            }
112            Self::Io(e) => write!(f, "I/O error during op log read: {e}"),
113            Self::RefError(e) => write!(f, "ref read failed: {e}"),
114            Self::NoHead { workspace_id } => {
115                write!(
116                    f,
117                    "no op log head for workspace '{workspace_id}' — \
118                     the workspace has no recorded operations yet.\n  \
119                     To fix: ensure at least one operation has been appended \
120                     via `append_operation()`."
121                )
122            }
123        }
124    }
125}
126
127impl std::error::Error for OpLogReadError {
128    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
129        match self {
130            Self::Deserialize { source, .. } => Some(source),
131            Self::Io(e) => Some(e),
132            Self::RefError(e) => Some(e),
133            _ => None,
134        }
135    }
136}
137
138impl From<std::io::Error> for OpLogReadError {
139    fn from(e: std::io::Error) -> Self {
140        Self::Io(e)
141    }
142}
143
144impl From<refs::RefError> for OpLogReadError {
145    fn from(e: refs::RefError) -> Self {
146        Self::RefError(e)
147    }
148}
149
150// ---------------------------------------------------------------------------
151// Core helpers
152// ---------------------------------------------------------------------------
153
154/// Read the head ref for a workspace's op log.
155///
156/// Returns `Some(oid)` if the workspace has at least one operation recorded,
157/// or `None` if the head ref does not exist yet.
158///
159/// # Arguments
160/// * `root` — absolute path to the git repository root.
161/// * `workspace_id` — the workspace whose log head to read.
162///
163/// # Errors
164/// Returns an error if the ref read itself fails (I/O, git error).
165pub fn read_head(
166    root: &Path,
167    workspace_id: &WorkspaceId,
168) -> Result<Option<GitOid>, OpLogReadError> {
169    let ref_name = refs::workspace_head_ref(workspace_id.as_str());
170    let oid = refs::read_ref(root, &ref_name)?;
171    Ok(oid)
172}
173
174/// Read a single operation blob from the git object store.
175///
176/// Runs `git cat-file -p <oid>`, deserializes the JSON content to an
177/// [`Operation`].
178///
179/// # Arguments
180/// * `root` — absolute path to the git repository root.
181/// * `oid` — the blob OID of the operation to read.
182///
183/// # Errors
184/// Returns an error if git cannot read the blob or if the blob content
185/// is not a valid [`Operation`] JSON.
186pub fn read_operation(root: &Path, oid: &GitOid) -> Result<Operation, OpLogReadError> {
187    let output = Command::new("git")
188        .args(["cat-file", "-p", oid.as_str()])
189        .current_dir(root)
190        .output()?;
191
192    if !output.status.success() {
193        return Err(OpLogReadError::CatFile {
194            oid: oid.as_str().to_owned(),
195            stderr: String::from_utf8_lossy(&output.stderr).trim().to_owned(),
196            exit_code: output.status.code(),
197        });
198    }
199
200    Operation::from_json(&output.stdout).map_err(|e| OpLogReadError::Deserialize {
201        oid: oid.as_str().to_owned(),
202        source: e,
203    })
204}
205
206// ---------------------------------------------------------------------------
207// Chain walking
208// ---------------------------------------------------------------------------
209
210/// Walk the operation chain from the workspace head backwards.
211///
212/// Returns operations in reverse chronological order (newest first).
213/// Each entry is `(oid, operation)` — the blob OID paired with the
214/// deserialized operation.
215///
216/// # Arguments
217/// * `root` — absolute path to the git repository root.
218/// * `workspace_id` — the workspace whose log to walk.
219/// * `max_depth` — optional limit on how many operations to read.
220///   `None` reads the entire chain.
221/// * `stop_at` — optional predicate. If it returns `false` for an
222///   operation, that operation is included but its parents are not
223///   explored. Use this to stop at checkpoint operations.
224///
225/// # Walk order
226///
227/// BFS from head. Operations with multiple parents (merges) are explored
228/// breadth-first. A visited set prevents duplicates when chains converge.
229///
230/// # Errors
231/// - [`OpLogReadError::NoHead`] if the workspace has no op log.
232/// - [`OpLogReadError::CatFile`] or [`OpLogReadError::Deserialize`] if a
233///   blob cannot be read or parsed.
234pub fn walk_chain(
235    root: &Path,
236    workspace_id: &WorkspaceId,
237    max_depth: Option<usize>,
238    stop_at: Option<&dyn Fn(&Operation) -> bool>,
239) -> Result<Vec<(GitOid, Operation)>, OpLogReadError> {
240    let head = read_head(root, workspace_id)?.ok_or_else(|| OpLogReadError::NoHead {
241        workspace_id: workspace_id.clone(),
242    })?;
243
244    let mut result = Vec::new();
245    let mut visited = HashSet::new();
246    let mut queue: VecDeque<GitOid> = VecDeque::new();
247
248    queue.push_back(head);
249
250    while let Some(oid) = queue.pop_front() {
251        // Check depth limit.
252        if let Some(max) = max_depth
253            && result.len() >= max
254        {
255            break;
256        }
257
258        // Skip already-visited OIDs (prevents duplicates in DAGs).
259        if !visited.insert(oid.as_str().to_owned()) {
260            continue;
261        }
262
263        let op = read_operation(root, &oid)?;
264
265        // Check stop predicate: include this op but don't explore its parents.
266        let should_stop = stop_at.as_ref().is_some_and(|pred| pred(&op));
267
268        result.push((oid, op.clone()));
269
270        if should_stop {
271            continue;
272        }
273
274        // Enqueue parents for BFS traversal.
275        for parent_oid in &op.parent_ids {
276            if !visited.contains(parent_oid.as_str()) {
277                queue.push_back(parent_oid.clone());
278            }
279        }
280    }
281
282    Ok(result)
283}
284
285/// Walk the operation chain, reading all operations from head to root.
286///
287/// Convenience wrapper around [`walk_chain`] with no depth limit and no
288/// stop predicate.
289///
290/// # Errors
291/// Same as [`walk_chain`].
292pub fn walk_all(
293    root: &Path,
294    workspace_id: &WorkspaceId,
295) -> Result<Vec<(GitOid, Operation)>, OpLogReadError> {
296    walk_chain(root, workspace_id, None, None)
297}
298
299// ---------------------------------------------------------------------------
300// Tests
301// ---------------------------------------------------------------------------
302
303#[cfg(test)]
304#[allow(clippy::all, clippy::pedantic, clippy::nursery)]
305mod tests {
306    use super::*;
307    use crate::model::types::{EpochId, WorkspaceId};
308    use crate::oplog::types::{OpPayload, Operation};
309    use crate::oplog::write::{append_operation, write_operation_blob};
310    use std::fs;
311    use std::process::Command as StdCommand;
312    use tempfile::TempDir;
313
314    // -----------------------------------------------------------------------
315    // Test helpers
316    // -----------------------------------------------------------------------
317
318    /// Create a fresh git repo with one commit.
319    fn setup_repo() -> (TempDir, GitOid) {
320        let dir = TempDir::new().unwrap();
321        let root = dir.path();
322
323        StdCommand::new("git")
324            .args(["init"])
325            .current_dir(root)
326            .output()
327            .unwrap();
328        StdCommand::new("git")
329            .args(["config", "user.name", "Test"])
330            .current_dir(root)
331            .output()
332            .unwrap();
333        StdCommand::new("git")
334            .args(["config", "user.email", "test@test.com"])
335            .current_dir(root)
336            .output()
337            .unwrap();
338        StdCommand::new("git")
339            .args(["config", "commit.gpgsign", "false"])
340            .current_dir(root)
341            .output()
342            .unwrap();
343
344        fs::write(root.join("README.md"), "# Test\n").unwrap();
345        StdCommand::new("git")
346            .args(["add", "README.md"])
347            .current_dir(root)
348            .output()
349            .unwrap();
350        StdCommand::new("git")
351            .args(["commit", "-m", "initial"])
352            .current_dir(root)
353            .output()
354            .unwrap();
355
356        let out = StdCommand::new("git")
357            .args(["rev-parse", "HEAD"])
358            .current_dir(root)
359            .output()
360            .unwrap();
361        let oid_str = String::from_utf8_lossy(&out.stdout).trim().to_owned();
362        let oid = GitOid::new(&oid_str).unwrap();
363
364        (dir, oid)
365    }
366
367    fn epoch(c: char) -> EpochId {
368        EpochId::new(&c.to_string().repeat(40)).unwrap()
369    }
370
371    fn ws(name: &str) -> WorkspaceId {
372        WorkspaceId::new(name).unwrap()
373    }
374
375    fn make_create_op(ws_id: &WorkspaceId) -> Operation {
376        Operation {
377            parent_ids: vec![],
378            workspace_id: ws_id.clone(),
379            timestamp: "2026-02-19T12:00:00Z".to_owned(),
380            payload: OpPayload::Create { epoch: epoch('a') },
381        }
382    }
383
384    fn make_describe_op(ws_id: &WorkspaceId, parent: GitOid, message: &str) -> Operation {
385        Operation {
386            parent_ids: vec![parent],
387            workspace_id: ws_id.clone(),
388            timestamp: "2026-02-19T13:00:00Z".to_owned(),
389            payload: OpPayload::Describe {
390                message: message.to_owned(),
391            },
392        }
393    }
394
395    /// Write a chain of N operations and return (oid, op) pairs in order.
396    fn write_chain(root: &Path, ws_id: &WorkspaceId, count: usize) -> Vec<(GitOid, Operation)> {
397        let mut chain = Vec::new();
398
399        // First op: Create (no parent)
400        let op1 = make_create_op(ws_id);
401        let oid1 = append_operation(root, ws_id, &op1, None).unwrap();
402        chain.push((oid1.clone(), op1));
403
404        // Subsequent ops: Describe with incrementing messages
405        let mut prev_oid = oid1;
406        for i in 1..count {
407            let op = make_describe_op(ws_id, prev_oid.clone(), &format!("step {}", i + 1));
408            let oid = append_operation(root, ws_id, &op, Some(&prev_oid)).unwrap();
409            chain.push((oid.clone(), op));
410            prev_oid = oid;
411        }
412
413        chain
414    }
415
416    // -----------------------------------------------------------------------
417    // read_head
418    // -----------------------------------------------------------------------
419
420    #[test]
421    fn read_head_no_operations_returns_none() {
422        let (dir, _) = setup_repo();
423        let ws_id = ws("agent-1");
424        let result = read_head(dir.path(), &ws_id).unwrap();
425        assert_eq!(result, None);
426    }
427
428    #[test]
429    fn read_head_after_one_operation() {
430        let (dir, _) = setup_repo();
431        let root = dir.path();
432        let ws_id = ws("agent-1");
433
434        let op = make_create_op(&ws_id);
435        let oid = append_operation(root, &ws_id, &op, None).unwrap();
436
437        let head = read_head(root, &ws_id).unwrap();
438        assert_eq!(head, Some(oid));
439    }
440
441    #[test]
442    fn read_head_after_multiple_operations() {
443        let (dir, _) = setup_repo();
444        let root = dir.path();
445        let ws_id = ws("agent-1");
446
447        let chain = write_chain(root, &ws_id, 3);
448        let last_oid = chain.last().unwrap().0.clone();
449
450        let head = read_head(root, &ws_id).unwrap();
451        assert_eq!(head, Some(last_oid));
452    }
453
454    // -----------------------------------------------------------------------
455    // read_operation
456    // -----------------------------------------------------------------------
457
458    #[test]
459    fn read_operation_round_trip() {
460        let (dir, _) = setup_repo();
461        let root = dir.path();
462        let ws_id = ws("agent-1");
463
464        let op = make_create_op(&ws_id);
465        let oid = write_operation_blob(root, &op).unwrap();
466
467        let read_back = read_operation(root, &oid).unwrap();
468        assert_eq!(read_back, op);
469    }
470
471    #[test]
472    fn read_operation_invalid_oid_fails() {
473        let (dir, _) = setup_repo();
474        let root = dir.path();
475
476        let bad_oid = GitOid::new(&"f".repeat(40)).unwrap();
477        let result = read_operation(root, &bad_oid);
478        assert!(result.is_err());
479        assert!(matches!(result, Err(OpLogReadError::CatFile { .. })));
480    }
481
482    // -----------------------------------------------------------------------
483    // walk_chain — basic chain walking
484    // -----------------------------------------------------------------------
485
486    #[test]
487    fn walk_chain_single_op() {
488        let (dir, _) = setup_repo();
489        let root = dir.path();
490        let ws_id = ws("agent-1");
491
492        let chain = write_chain(root, &ws_id, 1);
493
494        let walked = walk_all(root, &ws_id).unwrap();
495        assert_eq!(walked.len(), 1);
496        assert_eq!(walked[0].0, chain[0].0);
497        assert_eq!(walked[0].1, chain[0].1);
498    }
499
500    #[test]
501    fn walk_chain_five_ops_reverse_order() {
502        let (dir, _) = setup_repo();
503        let root = dir.path();
504        let ws_id = ws("agent-1");
505
506        let chain = write_chain(root, &ws_id, 5);
507
508        let walked = walk_all(root, &ws_id).unwrap();
509        assert_eq!(walked.len(), 5);
510
511        // Walked should be newest-first (reverse of write order).
512        assert_eq!(walked[0].0, chain[4].0); // newest
513        assert_eq!(walked[1].0, chain[3].0);
514        assert_eq!(walked[2].0, chain[2].0);
515        assert_eq!(walked[3].0, chain[1].0);
516        assert_eq!(walked[4].0, chain[0].0); // oldest (root)
517    }
518
519    #[test]
520    fn walk_chain_preserves_all_operations() {
521        let (dir, _) = setup_repo();
522        let root = dir.path();
523        let ws_id = ws("agent-1");
524
525        let chain = write_chain(root, &ws_id, 5);
526
527        let walked = walk_all(root, &ws_id).unwrap();
528        assert_eq!(walked.len(), 5);
529
530        // All operations from the chain should be present (possibly reordered).
531        let walked_oids: HashSet<_> = walked
532            .iter()
533            .map(|(oid, _)| oid.as_str().to_owned())
534            .collect();
535        for (oid, _) in &chain {
536            assert!(
537                walked_oids.contains(oid.as_str()),
538                "OID {} from chain not found in walked result",
539                oid.as_str()
540            );
541        }
542    }
543
544    // -----------------------------------------------------------------------
545    // walk_chain — max_depth
546    // -----------------------------------------------------------------------
547
548    #[test]
549    fn walk_chain_with_max_depth() {
550        let (dir, _) = setup_repo();
551        let root = dir.path();
552        let ws_id = ws("agent-1");
553
554        write_chain(root, &ws_id, 5);
555
556        let walked = walk_chain(root, &ws_id, Some(3), None).unwrap();
557        assert_eq!(walked.len(), 3);
558    }
559
560    #[test]
561    fn walk_chain_max_depth_one() {
562        let (dir, _) = setup_repo();
563        let root = dir.path();
564        let ws_id = ws("agent-1");
565
566        let chain = write_chain(root, &ws_id, 5);
567
568        let walked = walk_chain(root, &ws_id, Some(1), None).unwrap();
569        assert_eq!(walked.len(), 1);
570        // Should be the head (newest).
571        assert_eq!(walked[0].0, chain[4].0);
572    }
573
574    #[test]
575    fn walk_chain_max_depth_exceeds_chain_length() {
576        let (dir, _) = setup_repo();
577        let root = dir.path();
578        let ws_id = ws("agent-1");
579
580        write_chain(root, &ws_id, 3);
581
582        let walked = walk_chain(root, &ws_id, Some(100), None).unwrap();
583        assert_eq!(walked.len(), 3);
584    }
585
586    // -----------------------------------------------------------------------
587    // walk_chain — stop_at predicate
588    // -----------------------------------------------------------------------
589
590    #[test]
591    fn walk_chain_stop_at_create() {
592        let (dir, _) = setup_repo();
593        let root = dir.path();
594        let ws_id = ws("agent-1");
595
596        write_chain(root, &ws_id, 5);
597
598        // Stop at Create operations (include them but don't go further).
599        let walked = walk_chain(
600            root,
601            &ws_id,
602            None,
603            Some(&|op: &Operation| matches!(op.payload, OpPayload::Create { .. })),
604        )
605        .unwrap();
606
607        // All 5 ops should be present because the Create is at the root
608        // and has no parents anyway.
609        assert_eq!(walked.len(), 5);
610    }
611
612    #[test]
613    fn walk_chain_stop_at_describe_step_3() {
614        let (dir, _) = setup_repo();
615        let root = dir.path();
616        let ws_id = ws("agent-1");
617
618        write_chain(root, &ws_id, 5);
619
620        // Stop at the operation that describes "step 3".
621        let walked = walk_chain(
622            root,
623            &ws_id,
624            None,
625            Some(&|op: &Operation| {
626                matches!(&op.payload, OpPayload::Describe { message } if message == "step 3")
627            }),
628        )
629        .unwrap();
630
631        // Should have: step 5 (head), step 4, step 3 (stop here).
632        // Step 3's parents are NOT explored.
633        assert_eq!(walked.len(), 3);
634    }
635
636    // -----------------------------------------------------------------------
637    // walk_chain — no head ref
638    // -----------------------------------------------------------------------
639
640    #[test]
641    fn walk_chain_no_head_returns_error() {
642        let (dir, _) = setup_repo();
643        let root = dir.path();
644        let ws_id = ws("nonexistent");
645
646        let result = walk_all(root, &ws_id);
647        assert!(matches!(result, Err(OpLogReadError::NoHead { .. })));
648    }
649
650    // -----------------------------------------------------------------------
651    // walk_chain — branching (multiple parents)
652    // -----------------------------------------------------------------------
653
654    #[test]
655    fn walk_chain_merge_op_with_multiple_parents() {
656        let (dir, _) = setup_repo();
657        let root = dir.path();
658        let ws_id = ws("default");
659
660        // Create two independent ops in different "workspaces" (same ws for simplicity).
661        let op1 = make_create_op(&ws_id);
662        let oid1 = write_operation_blob(root, &op1).unwrap();
663
664        let op2 = Operation {
665            parent_ids: vec![],
666            workspace_id: ws_id.clone(),
667            timestamp: "2026-02-19T12:30:00Z".to_owned(),
668            payload: OpPayload::Create { epoch: epoch('b') },
669        };
670        let oid2 = write_operation_blob(root, &op2).unwrap();
671
672        // Merge op with both as parents.
673        let merge_op = Operation {
674            parent_ids: vec![oid1.clone(), oid2.clone()],
675            workspace_id: ws_id.clone(),
676            timestamp: "2026-02-19T15:00:00Z".to_owned(),
677            payload: OpPayload::Merge {
678                sources: vec![ws("ws-a"), ws("ws-b")],
679                epoch_before: epoch('a'),
680                epoch_after: epoch('c'),
681            },
682        };
683
684        // Manually write merge blob and set head ref.
685        let merge_oid = write_operation_blob(root, &merge_op).unwrap();
686        let ref_name = refs::workspace_head_ref(ws_id.as_str());
687        refs::write_ref(root, &ref_name, &merge_oid).unwrap();
688
689        // Walk: should find all 3 operations.
690        let walked = walk_all(root, &ws_id).unwrap();
691        assert_eq!(walked.len(), 3);
692
693        // First should be the merge (head).
694        assert_eq!(walked[0].0, merge_oid);
695
696        // The other two should be oid1 and oid2 in some BFS order.
697        let walked_oids: HashSet<_> = walked
698            .iter()
699            .map(|(oid, _)| oid.as_str().to_owned())
700            .collect();
701        assert!(walked_oids.contains(oid1.as_str()));
702        assert!(walked_oids.contains(oid2.as_str()));
703    }
704
705    #[test]
706    fn walk_chain_diamond_dag_no_duplicates() {
707        let (dir, _) = setup_repo();
708        let root = dir.path();
709        let ws_id = ws("default");
710
711        // Build a diamond DAG:
712        //     root
713        //    /    \
714        //   a      b
715        //    \    /
716        //     merge
717        let root_op = make_create_op(&ws_id);
718        let root_oid = write_operation_blob(root, &root_op).unwrap();
719
720        let op_a = Operation {
721            parent_ids: vec![root_oid.clone()],
722            workspace_id: ws_id.clone(),
723            timestamp: "2026-02-19T13:00:00Z".to_owned(),
724            payload: OpPayload::Describe {
725                message: "branch a".to_owned(),
726            },
727        };
728        let oid_a = write_operation_blob(root, &op_a).unwrap();
729
730        let op_b = Operation {
731            parent_ids: vec![root_oid],
732            workspace_id: ws_id.clone(),
733            timestamp: "2026-02-19T13:30:00Z".to_owned(),
734            payload: OpPayload::Describe {
735                message: "branch b".to_owned(),
736            },
737        };
738        let oid_b = write_operation_blob(root, &op_b).unwrap();
739
740        let merge_op = Operation {
741            parent_ids: vec![oid_a, oid_b],
742            workspace_id: ws_id.clone(),
743            timestamp: "2026-02-19T14:00:00Z".to_owned(),
744            payload: OpPayload::Merge {
745                sources: vec![ws("ws-a"), ws("ws-b")],
746                epoch_before: epoch('a'),
747                epoch_after: epoch('b'),
748            },
749        };
750        let merge_oid = write_operation_blob(root, &merge_op).unwrap();
751
752        // Set head to merge.
753        let ref_name = refs::workspace_head_ref(ws_id.as_str());
754        refs::write_ref(root, &ref_name, &merge_oid).unwrap();
755
756        // Walk: should find exactly 4 operations (no duplicates for root_op).
757        let walked = walk_all(root, &ws_id).unwrap();
758        assert_eq!(
759            walked.len(),
760            4,
761            "diamond DAG should yield 4 unique operations"
762        );
763
764        // Verify no duplicate OIDs.
765        let oids: Vec<_> = walked
766            .iter()
767            .map(|(oid, _)| oid.as_str().to_owned())
768            .collect();
769        let unique: HashSet<_> = oids.iter().cloned().collect();
770        assert_eq!(oids.len(), unique.len(), "no duplicate OIDs in walk result");
771    }
772
773    // -----------------------------------------------------------------------
774    // read_operation content verification
775    // -----------------------------------------------------------------------
776
777    #[test]
778    fn read_operation_preserves_all_fields() {
779        let (dir, _) = setup_repo();
780        let root = dir.path();
781        let ws_id = ws("agent-1");
782
783        let original = Operation {
784            parent_ids: vec![
785                GitOid::new(&"a".repeat(40)).unwrap(),
786                GitOid::new(&"b".repeat(40)).unwrap(),
787            ],
788            workspace_id: ws_id,
789            timestamp: "2026-02-19T15:30:00Z".to_owned(),
790            payload: OpPayload::Compensate {
791                target_op: GitOid::new(&"c".repeat(40)).unwrap(),
792                reason: "reverting broken snapshot\nwith newlines".to_owned(),
793            },
794        };
795
796        let oid = write_operation_blob(root, &original).unwrap();
797        let read_back = read_operation(root, &oid).unwrap();
798
799        assert_eq!(read_back.parent_ids, original.parent_ids);
800        assert_eq!(read_back.workspace_id, original.workspace_id);
801        assert_eq!(read_back.timestamp, original.timestamp);
802        assert_eq!(read_back.payload, original.payload);
803    }
804
805    // -----------------------------------------------------------------------
806    // Error display
807    // -----------------------------------------------------------------------
808
809    #[test]
810    fn error_display_no_head() {
811        let err = OpLogReadError::NoHead {
812            workspace_id: ws("agent-1"),
813        };
814        let msg = format!("{err}");
815        assert!(msg.contains("agent-1"));
816        assert!(msg.contains("no op log head"));
817        assert!(msg.contains("append_operation"));
818    }
819
820    #[test]
821    fn error_display_cat_file() {
822        let err = OpLogReadError::CatFile {
823            oid: "abc123".to_owned(),
824            stderr: "fatal: not a valid object".to_owned(),
825            exit_code: Some(128),
826        };
827        let msg = format!("{err}");
828        assert!(msg.contains("cat-file"));
829        assert!(msg.contains("abc123"));
830        assert!(msg.contains("128"));
831    }
832
833    #[test]
834    fn error_display_deserialize() {
835        let err = OpLogReadError::Deserialize {
836            oid: "deadbeef".to_owned(),
837            source: serde_json::from_str::<Operation>("not json").unwrap_err(),
838        };
839        let msg = format!("{err}");
840        assert!(msg.contains("deserialize"));
841        assert!(msg.contains("deadbeef"));
842    }
843
844    #[test]
845    fn error_display_io() {
846        let err = OpLogReadError::Io(std::io::Error::new(
847            std::io::ErrorKind::NotFound,
848            "git not found",
849        ));
850        let msg = format!("{err}");
851        assert!(msg.contains("I/O error"));
852        assert!(msg.contains("git not found"));
853    }
854}