1use 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#[derive(Debug)]
52pub enum OpLogReadError {
53 CatFile {
55 oid: String,
57 stderr: String,
59 exit_code: Option<i32>,
61 },
62
63 Deserialize {
65 oid: String,
67 source: serde_json::Error,
69 },
70
71 Io(std::io::Error),
73
74 RefError(refs::RefError),
76
77 NoHead {
79 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
150pub 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
174pub 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
206pub 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 if let Some(max) = max_depth
253 && result.len() >= max
254 {
255 break;
256 }
257
258 if !visited.insert(oid.as_str().to_owned()) {
260 continue;
261 }
262
263 let op = read_operation(root, &oid)?;
264
265 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 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
285pub 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#[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 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 fn write_chain(root: &Path, ws_id: &WorkspaceId, count: usize) -> Vec<(GitOid, Operation)> {
397 let mut chain = Vec::new();
398
399 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 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 #[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 #[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 #[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 assert_eq!(walked[0].0, chain[4].0); 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); }
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 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 #[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 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 #[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 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 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 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 assert_eq!(walked.len(), 3);
634 }
635
636 #[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 #[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 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 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 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 let walked = walk_all(root, &ws_id).unwrap();
691 assert_eq!(walked.len(), 3);
692
693 assert_eq!(walked[0].0, merge_oid);
695
696 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 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 let ref_name = refs::workspace_head_ref(ws_id.as_str());
754 refs::write_ref(root, &ref_name, &merge_oid).unwrap();
755
756 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 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 #[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 #[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}