1use std::{
5 collections::BTreeSet,
6 path::{Path, PathBuf},
7};
8
9use objects::{
10 object::{
11 StateId, TimelineBranchId, TimelineCursorMoveReason, TimelineLabel, TimelineOperationId,
12 },
13 store::ObjectStore,
14};
15
16use crate::{
17 HeddleError, Repository, Result, TimelineCursorMoveRecord,
18 TimelineMaterializationRecoveryRecord, TimelineNativeToolKey, TimelineSeekTarget,
19 TimelineStepId, TimelineStore, TimelineView, WorktreeStatusDetailed,
20 repository::repository_worktree_apply::WorktreeApplyDirtyBehavior,
21};
22
23#[derive(Clone, Debug, PartialEq, Eq)]
25pub enum TimelineSeekSelector {
26 StepId(TimelineStepId),
28 NativeToolCall(TimelineNativeToolKey),
30 Undo,
32 Redo,
34 CurrentCursor,
36}
37
38#[derive(Clone, Debug, PartialEq, Eq)]
40pub enum TimelineSeekBranchConstraint {
41 Current(TimelineBranchId),
44 Target(TimelineBranchId),
46}
47
48#[derive(Clone, Copy, Debug, PartialEq, Eq)]
50pub enum TimelineMaterializeMode {
51 FailIfDirty,
53 CaptureCurrentThenSeek,
55}
56
57#[derive(Clone, Copy, Debug, PartialEq, Eq)]
59pub enum TimelineMaterializationBoundaryStatus {
60 Unknown,
63}
64
65#[derive(Clone, Debug, PartialEq, Eq)]
67pub enum TimelineMaterializationBlocker {
68 UnsupportedMode(TimelineMaterializeMode),
70 DirtyWorktree { paths: Vec<String> },
72 CheckoutStateUnknown,
74 MissingTree(StateId),
76}
77
78#[derive(Clone, Debug)]
80pub struct TimelineSeekPreview {
81 pub thread: String,
82 pub current_branch_id: Option<crate::TimelineBranchId>,
83 pub current_step_id: Option<TimelineStepId>,
84 pub current_state: Option<StateId>,
85 pub checkout_state: Option<StateId>,
86 pub target: TimelineSeekTarget,
87 pub changed_paths: Vec<String>,
88 pub worktree_status: Option<WorktreeStatusDetailed>,
89 pub boundary_status: TimelineMaterializationBoundaryStatus,
90 pub blockers: Vec<TimelineMaterializationBlocker>,
91}
92
93impl TimelineSeekPreview {
94 pub fn can_materialize(&self) -> bool {
95 self.blockers.is_empty()
96 }
97}
98
99#[derive(Clone, Debug, PartialEq, Eq)]
101pub enum TimelineMaterializeStatus {
102 Materialized,
103 AlreadyAtTarget,
104 Refused,
105 Unsupported,
106 RecoveryBlocked,
107}
108
109#[derive(Clone, Debug, PartialEq, Eq)]
110pub enum TimelineMaterializationRecoveryStatus {
111 NoPending,
112 CursorRecorded,
113 AlreadyApplied,
114 Blocked,
115}
116
117#[derive(Clone, Debug, PartialEq, Eq)]
118pub enum TimelineMaterializationRecoveryBlocker {
119 CheckoutNotAtTarget {
120 checkout_state: Option<StateId>,
121 target_state: StateId,
122 },
123}
124
125#[derive(Clone, Debug)]
126pub struct TimelineMaterializationRecoveryOutcome {
127 pub record: Option<TimelineMaterializationRecoveryRecord>,
128 pub cursor_operation_id: Option<TimelineOperationId>,
129 pub status: TimelineMaterializationRecoveryStatus,
130 pub blocker: Option<TimelineMaterializationRecoveryBlocker>,
131}
132
133impl TimelineMaterializationRecoveryOutcome {
134 fn no_pending() -> Self {
135 Self {
136 record: None,
137 cursor_operation_id: None,
138 status: TimelineMaterializationRecoveryStatus::NoPending,
139 blocker: None,
140 }
141 }
142}
143
144#[derive(Clone, Debug)]
146pub struct TimelineMaterializeOutcome {
147 pub preview: TimelineSeekPreview,
148 pub cursor_operation_id: Option<TimelineOperationId>,
149 pub status: TimelineMaterializeStatus,
150 pub recovery: TimelineMaterializationRecoveryOutcome,
151}
152
153impl Repository {
154 pub fn preview_timeline_seek(
156 &self,
157 store: &TimelineStore,
158 thread: &str,
159 selector: &TimelineSeekSelector,
160 mode: TimelineMaterializeMode,
161 ) -> Result<TimelineSeekPreview> {
162 self.preview_timeline_seek_constrained(store, thread, selector, mode, None)
163 }
164
165 pub fn preview_timeline_seek_constrained(
167 &self,
168 store: &TimelineStore,
169 thread: &str,
170 selector: &TimelineSeekSelector,
171 mode: TimelineMaterializeMode,
172 branch_constraint: Option<&TimelineSeekBranchConstraint>,
173 ) -> Result<TimelineSeekPreview> {
174 let view = TimelineView::rebuild(store)?;
175 let target = resolve_timeline_selector(&view, thread, selector)?;
176 let preview = self.preview_timeline_target(&view, thread, target, mode)?;
177 validate_branch_constraint(&preview, branch_constraint)?;
178 Ok(preview)
179 }
180
181 pub fn materialize_timeline_cursor(
189 &self,
190 store: &TimelineStore,
191 thread: &str,
192 selector: &TimelineSeekSelector,
193 mode: TimelineMaterializeMode,
194 moved_at_ms: i64,
195 ) -> Result<TimelineMaterializeOutcome> {
196 self.materialize_timeline_cursor_constrained(
197 store,
198 thread,
199 selector,
200 mode,
201 None,
202 moved_at_ms,
203 )
204 }
205
206 pub fn materialize_timeline_cursor_constrained(
209 &self,
210 store: &TimelineStore,
211 thread: &str,
212 selector: &TimelineSeekSelector,
213 mode: TimelineMaterializeMode,
214 branch_constraint: Option<&TimelineSeekBranchConstraint>,
215 moved_at_ms: i64,
216 ) -> Result<TimelineMaterializeOutcome> {
217 self.materialize_timeline_cursor_constrained_with_reason(
218 store,
219 thread,
220 selector,
221 mode,
222 branch_constraint,
223 cursor_reason(selector),
224 moved_at_ms,
225 )
226 }
227
228 #[allow(clippy::too_many_arguments)]
229 pub fn materialize_timeline_cursor_constrained_with_reason(
230 &self,
231 store: &TimelineStore,
232 thread: &str,
233 selector: &TimelineSeekSelector,
234 mode: TimelineMaterializeMode,
235 branch_constraint: Option<&TimelineSeekBranchConstraint>,
236 reason: TimelineCursorMoveReason,
237 moved_at_ms: i64,
238 ) -> Result<TimelineMaterializeOutcome> {
239 let _materialization_guard = store.lock_materialization(thread)?;
240 let recovery = self.recover_pending_timeline_materialization(store, thread)?;
241 let preview = self.preview_timeline_seek(store, thread, selector, mode)?;
242 if recovery.status == TimelineMaterializationRecoveryStatus::Blocked {
243 return Ok(TimelineMaterializeOutcome {
244 preview,
245 cursor_operation_id: None,
246 status: TimelineMaterializeStatus::RecoveryBlocked,
247 recovery,
248 });
249 }
250 validate_branch_constraint(&preview, branch_constraint)?;
251 if preview
252 .blockers
253 .iter()
254 .any(|blocker| matches!(blocker, TimelineMaterializationBlocker::UnsupportedMode(_)))
255 {
256 return Ok(TimelineMaterializeOutcome {
257 preview,
258 cursor_operation_id: None,
259 status: TimelineMaterializeStatus::Unsupported,
260 recovery,
261 });
262 }
263 if !preview.can_materialize() {
264 return Ok(TimelineMaterializeOutcome {
265 preview,
266 cursor_operation_id: None,
267 status: TimelineMaterializeStatus::Refused,
268 recovery,
269 });
270 }
271
272 let already_at_target = preview.checkout_state == Some(preview.target.state)
273 && preview
274 .worktree_status
275 .as_ref()
276 .is_none_or(WorktreeStatusDetailed::is_clean);
277
278 let moved = preview.current_step_id != preview.target.step_id
279 || preview.current_state != Some(preview.target.state)
280 || preview.current_branch_id != Some(preview.target.branch_id.clone());
281 let recovery_record = moved.then(|| {
282 TimelineMaterializationRecoveryRecord::new(
283 preview.thread.clone(),
284 preview.target.branch_id.clone(),
285 preview.current_step_id.clone(),
286 preview.target.step_id.clone(),
287 preview
288 .current_state
289 .or(preview.checkout_state)
290 .unwrap_or(preview.target.state),
291 preview.target.state,
292 reason,
293 moved_at_ms,
294 )
295 });
296 if let Some(record) = &recovery_record {
297 store.stage_materialization_recovery(record)?;
298 }
299
300 if !already_at_target {
301 self.goto(&preview.target.state)?;
302 objects::fault_inject::maybe_panic_at(
303 "timeline_materialize_after_goto_before_cursor_move",
304 );
305 }
306
307 let cursor_operation_id = if moved {
308 let record = recovery_record
309 .as_ref()
310 .expect("moved timeline materialization stages recovery");
311 let id = record_cursor_move_from_recovery(store, record)?;
312 objects::fault_inject::maybe_panic_at(
313 "timeline_materialize_after_cursor_move_before_recovery_clear",
314 );
315 store.clear_materialization_recovery(&preview.thread)?;
316 Some(id)
317 } else {
318 None
319 };
320
321 Ok(TimelineMaterializeOutcome {
322 preview,
323 cursor_operation_id,
324 status: if already_at_target {
325 TimelineMaterializeStatus::AlreadyAtTarget
326 } else {
327 TimelineMaterializeStatus::Materialized
328 },
329 recovery,
330 })
331 }
332
333 pub fn recover_pending_timeline_materialization(
334 &self,
335 store: &TimelineStore,
336 thread: &str,
337 ) -> Result<TimelineMaterializationRecoveryOutcome> {
338 let _materialization_guard = store.lock_materialization(thread)?;
339 let Some(record) = store.read_materialization_recovery(thread)? else {
340 return Ok(TimelineMaterializationRecoveryOutcome::no_pending());
341 };
342 let view = TimelineView::rebuild(store)?;
343 if timeline_cursor_matches_recovery(&view, &record) {
344 store.clear_materialization_recovery(thread)?;
345 return Ok(TimelineMaterializationRecoveryOutcome {
346 record: Some(record),
347 cursor_operation_id: None,
348 status: TimelineMaterializationRecoveryStatus::AlreadyApplied,
349 blocker: None,
350 });
351 }
352
353 let checkout_state = self.head()?;
354 if checkout_state != Some(record.to_state) {
355 return Ok(TimelineMaterializationRecoveryOutcome {
356 blocker: Some(
357 TimelineMaterializationRecoveryBlocker::CheckoutNotAtTarget {
358 checkout_state,
359 target_state: record.to_state,
360 },
361 ),
362 record: Some(record),
363 cursor_operation_id: None,
364 status: TimelineMaterializationRecoveryStatus::Blocked,
365 });
366 }
367
368 let id = record_cursor_move_from_recovery(store, &record)?;
369 store.clear_materialization_recovery(thread)?;
370 Ok(TimelineMaterializationRecoveryOutcome {
371 record: Some(record),
372 cursor_operation_id: Some(id),
373 status: TimelineMaterializationRecoveryStatus::CursorRecorded,
374 blocker: None,
375 })
376 }
377
378 fn preview_timeline_target(
379 &self,
380 view: &TimelineView,
381 thread: &str,
382 target: TimelineSeekTarget,
383 mode: TimelineMaterializeMode,
384 ) -> Result<TimelineSeekPreview> {
385 let status = view.status(thread);
386 let checkout_state = self.head()?;
387 let mut blockers = Vec::new();
388 if mode == TimelineMaterializeMode::CaptureCurrentThenSeek {
389 blockers.push(TimelineMaterializationBlocker::UnsupportedMode(mode));
390 }
391
392 let target_tree = self.tree_for_materialization_state(&target.state)?;
393 let mut changed_paths = match checkout_state {
394 Some(current) if current == target.state => Vec::new(),
395 Some(current) => {
396 let current_tree = self.tree_for_materialization_state(¤t)?;
397 changed_paths_from_worktree_apply_plan(self, Some(¤t_tree), &target_tree)?
398 }
399 None => {
400 blockers.push(TimelineMaterializationBlocker::CheckoutStateUnknown);
401 diff_tree_paths(self, None, Some(&target_tree))?
402 }
403 };
404 changed_paths.sort();
405 changed_paths.dedup();
406
407 let worktree_status = match checkout_state {
408 Some(current) => {
409 let current_tree = self.tree_for_materialization_state(¤t)?;
410 let status = self.compare_worktree_cached_detailed(¤t_tree)?;
411 if !status.is_clean() {
412 blockers.push(TimelineMaterializationBlocker::DirtyWorktree {
413 paths: dirty_status_paths(&status),
414 });
415 }
416 Some(status)
417 }
418 None => None,
419 };
420
421 Ok(TimelineSeekPreview {
422 thread: thread.to_string(),
423 current_branch_id: status.and_then(|status| status.current_branch_id.clone()),
424 current_step_id: status.and_then(|status| status.current_step_id.clone()),
425 current_state: status.and_then(|status| status.current_state),
426 checkout_state,
427 target,
428 changed_paths,
429 worktree_status,
430 boundary_status: TimelineMaterializationBoundaryStatus::Unknown,
431 blockers,
432 })
433 }
434
435 fn tree_for_materialization_state(&self, state_id: &StateId) -> Result<objects::object::Tree> {
436 let state = self
437 .store()
438 .get_state(state_id)?
439 .ok_or(HeddleError::StateNotFound(*state_id))?;
440 self.store()
441 .get_tree(&state.tree)?
442 .ok_or(TimelineMaterializationBlocker::MissingTree(*state_id))
443 .map_err(|blocker| HeddleError::Config(format!("{blocker:?}")))
444 }
445}
446
447fn validate_branch_constraint(
448 preview: &TimelineSeekPreview,
449 constraint: Option<&TimelineSeekBranchConstraint>,
450) -> Result<()> {
451 match constraint {
452 Some(TimelineSeekBranchConstraint::Target(expected))
453 if preview.target.branch_id != *expected =>
454 {
455 Err(HeddleError::Conflict(format!(
456 "timeline target belongs to branch '{}', not requested branch '{}'",
457 preview.target.branch_id, expected
458 )))
459 }
460 Some(TimelineSeekBranchConstraint::Current(expected))
461 if preview.current_branch_id.as_ref() != Some(expected) =>
462 {
463 let actual = preview
464 .current_branch_id
465 .as_ref()
466 .map(ToString::to_string)
467 .unwrap_or_else(|| "unknown".to_string());
468 Err(HeddleError::Conflict(format!(
469 "timeline cursor is on branch '{actual}', not requested branch '{expected}'"
470 )))
471 }
472 _ => Ok(()),
473 }
474}
475
476pub(crate) fn resolve_timeline_selector(
477 view: &TimelineView,
478 thread: &str,
479 selector: &TimelineSeekSelector,
480) -> Result<TimelineSeekTarget> {
481 let target = match selector {
482 TimelineSeekSelector::StepId(step_id) => view.resolve_seek_target(thread, step_id),
483 TimelineSeekSelector::NativeToolCall(native) => {
484 view.resolve_seek_to_native_call(thread, native)
485 }
486 TimelineSeekSelector::Undo => view.resolve_undo_target(thread),
487 TimelineSeekSelector::Redo => view.resolve_redo_target(thread),
488 TimelineSeekSelector::CurrentCursor => {
489 let status = view.status(thread).ok_or_else(|| {
490 HeddleError::NotFound(format!("timeline status for thread '{thread}'"))
491 })?;
492 Some(TimelineSeekTarget {
493 thread: thread.to_string(),
494 branch_id: status.current_branch_id.clone().ok_or_else(|| {
495 HeddleError::NotFound(format!("timeline current branch for thread '{thread}'"))
496 })?,
497 step_id: status.current_step_id.clone(),
498 state: status.current_state.ok_or_else(|| {
499 HeddleError::NotFound(format!("timeline current state for thread '{thread}'"))
500 })?,
501 })
502 }
503 };
504 target.ok_or_else(|| {
505 HeddleError::NotFound(format!(
506 "timeline target for thread '{}' and selector {:?}",
507 thread, selector
508 ))
509 })
510}
511
512fn cursor_reason(selector: &TimelineSeekSelector) -> TimelineCursorMoveReason {
513 match selector {
514 TimelineSeekSelector::Undo => TimelineCursorMoveReason::Undo,
515 TimelineSeekSelector::Redo => TimelineCursorMoveReason::Redo,
516 TimelineSeekSelector::StepId(_)
517 | TimelineSeekSelector::NativeToolCall(_)
518 | TimelineSeekSelector::CurrentCursor => TimelineCursorMoveReason::SeekToolCall,
519 }
520}
521
522fn record_cursor_move_from_recovery(
523 store: &TimelineStore,
524 record: &TimelineMaterializationRecoveryRecord,
525) -> Result<TimelineOperationId> {
526 store.record_cursor_move(TimelineCursorMoveRecord {
527 thread: record.thread.clone(),
528 branch_id: record.branch_id.clone(),
529 from_step_id: record.from_step_id.clone(),
530 to_step_id: record.to_step_id.clone(),
531 from_state: record.from_state,
532 to_state: record.to_state,
533 reason: record.reason.clone(),
534 moved_at_ms: record.moved_at_ms,
535 labels: vec![TimelineLabel::RepoReversible],
536 })
537}
538
539fn timeline_cursor_matches_recovery(
540 view: &TimelineView,
541 record: &TimelineMaterializationRecoveryRecord,
542) -> bool {
543 view.status(&record.thread).is_some_and(|status| {
544 status.current_branch_id.as_ref() == Some(&record.branch_id)
545 && status.current_step_id == record.to_step_id
546 && status.current_state == Some(record.to_state)
547 })
548}
549
550fn dirty_status_paths(status: &WorktreeStatusDetailed) -> Vec<String> {
551 let mut paths = BTreeSet::new();
552 paths.extend(status.modified.iter().map(display_path));
553 paths.extend(status.deleted.iter().map(display_path));
554 paths.extend(status.untracked.flatten_paths().iter().map(display_path));
555 paths.into_iter().collect()
556}
557
558#[allow(clippy::ptr_arg)]
560fn display_path(path: &PathBuf) -> String {
561 path.to_string_lossy().replace('\\', "/")
562}
563
564fn changed_paths_from_worktree_apply_plan(
565 repo: &Repository,
566 from_tree: Option<&objects::object::Tree>,
567 to_tree: &objects::object::Tree,
568) -> Result<Vec<String>> {
569 let plan = repo.plan_worktree_apply(
570 from_tree,
571 to_tree,
572 repo.root(),
573 true,
574 WorktreeApplyDirtyBehavior::RefuseOnDirty,
575 )?;
576 let removal_paths = relative_removal_paths(repo.root(), &plan.removals);
577 let mut out = BTreeSet::new();
578
579 for rel_path in &removal_paths {
580 let is_directory_removal = removal_paths
581 .iter()
582 .any(|other| other != rel_path && other.starts_with(rel_path));
583 if !is_directory_removal {
584 out.insert(display_path(rel_path));
585 }
586 }
587
588 for write in &plan.writes {
589 out.insert(display_path(&repo_relative_path(repo.root(), write.path())));
590 }
591
592 Ok(out.into_iter().collect())
593}
594
595fn relative_removal_paths(root: &Path, paths: &[PathBuf]) -> Vec<PathBuf> {
596 paths
597 .iter()
598 .map(|path| repo_relative_path(root, path))
599 .collect()
600}
601
602fn repo_relative_path(root: &Path, path: &Path) -> PathBuf {
603 path.strip_prefix(root).unwrap_or(path).to_path_buf()
604}
605
606fn diff_tree_paths(
607 repo: &Repository,
608 from_tree: Option<&objects::object::Tree>,
609 to_tree: Option<&objects::object::Tree>,
610) -> Result<Vec<String>> {
611 let mut out = BTreeSet::new();
612 diff_tree_paths_inner(repo, Path::new(""), from_tree, to_tree, &mut out)?;
613 Ok(out.into_iter().collect())
614}
615
616fn diff_tree_paths_inner(
617 repo: &Repository,
618 rel_path: &Path,
619 from_tree: Option<&objects::object::Tree>,
620 to_tree: Option<&objects::object::Tree>,
621 out: &mut BTreeSet<String>,
622) -> Result<()> {
623 let from_entries = from_tree.map(objects::object::Tree::entries).unwrap_or(&[]);
624 let to_entries = to_tree.map(objects::object::Tree::entries).unwrap_or(&[]);
625 let mut from_index = 0;
626 let mut to_index = 0;
627
628 while from_index < from_entries.len() || to_index < to_entries.len() {
629 match (from_entries.get(from_index), to_entries.get(to_index)) {
630 (Some(from_entry), Some(to_entry)) => match from_entry.name().cmp(to_entry.name()) {
631 std::cmp::Ordering::Less => {
632 collect_entry_paths(repo, &rel_path.join(from_entry.name()), from_entry, out)?;
633 from_index += 1;
634 }
635 std::cmp::Ordering::Greater => {
636 collect_entry_paths(repo, &rel_path.join(to_entry.name()), to_entry, out)?;
637 to_index += 1;
638 }
639 std::cmp::Ordering::Equal => {
640 let child_path = rel_path.join(from_entry.name());
641 if let (Some(from_hash), Some(to_hash)) =
642 (from_entry.tree_hash(), to_entry.tree_hash())
643 {
644 if from_hash != to_hash {
645 let from_subtree =
646 repo.store().get_tree(&from_hash)?.ok_or_else(|| {
647 HeddleError::NotFound(format!("tree {}", from_hash))
648 })?;
649 let to_subtree = repo.store().get_tree(&to_hash)?.ok_or_else(|| {
650 HeddleError::NotFound(format!("tree {}", to_hash))
651 })?;
652 diff_tree_paths_inner(
653 repo,
654 &child_path,
655 Some(&from_subtree),
656 Some(&to_subtree),
657 out,
658 )?;
659 }
660 } else if from_entry.target() != to_entry.target() {
661 out.insert(display_path(&child_path));
662 }
663 from_index += 1;
664 to_index += 1;
665 }
666 },
667 (Some(from_entry), None) => {
668 collect_entry_paths(repo, &rel_path.join(from_entry.name()), from_entry, out)?;
669 from_index += 1;
670 }
671 (None, Some(to_entry)) => {
672 collect_entry_paths(repo, &rel_path.join(to_entry.name()), to_entry, out)?;
673 to_index += 1;
674 }
675 (None, None) => break,
676 }
677 }
678 Ok(())
679}
680
681fn collect_entry_paths(
682 repo: &Repository,
683 rel_path: &Path,
684 entry: &objects::object::TreeEntry,
685 out: &mut BTreeSet<String>,
686) -> Result<()> {
687 if let Some(tree_hash) = entry.tree_hash() {
688 let tree = repo
689 .store()
690 .get_tree(&tree_hash)?
691 .ok_or_else(|| HeddleError::NotFound(format!("tree {}", tree_hash)))?;
692 for child in tree.entries() {
693 collect_entry_paths(repo, &rel_path.join(child.name()), child, out)?;
694 }
695 } else {
696 out.insert(display_path(&rel_path.to_path_buf()));
697 }
698 Ok(())
699}
700
701#[cfg(test)]
702mod tests {
703 use std::fs;
704
705 use objects::object::{
706 BranchCreatedV1, ContentHash, NativeToolCallRefV1, TimelineBranchId, TimelineBranchReason,
707 TimelineOperationBodyV1, TimelineOperationEnvelope, TimelineToolCallStatus,
708 TimelineToolPayloadMetadata, ToolCallFinishedV1, ToolCallStartedV1,
709 };
710 use tempfile::TempDir;
711
712 use super::*;
713
714 fn create_repo() -> (TempDir, Repository, TimelineStore) {
715 let temp = TempDir::new().unwrap();
716 let repo = Repository::init_default(temp.path()).unwrap();
717 let store = TimelineStore::open(repo.heddle_dir()).unwrap();
718 (temp, repo, store)
719 }
720
721 fn step(id: &str) -> TimelineStepId {
722 TimelineStepId::new(id)
723 }
724
725 fn branch(id: &str) -> TimelineBranchId {
726 TimelineBranchId::new(id)
727 }
728
729 fn native(call: &str) -> NativeToolCallRefV1 {
730 NativeToolCallRefV1 {
731 harness: "opencode".to_string(),
732 session_id: Some("session-1".to_string()),
733 message_id: Some("message-1".to_string()),
734 tool_call_id: call.to_string(),
735 }
736 }
737
738 fn native_key(call: &str) -> TimelineNativeToolKey {
739 TimelineNativeToolKey::from(&native(call))
740 }
741
742 fn write_state(repo: &Repository, root: &Path, path: &str, content: &str) -> StateId {
743 fs::write(root.join(path), content).unwrap();
744 repo.snapshot(Some(path.to_string()), None).unwrap().id()
745 }
746
747 fn write_timeline(store: &TimelineStore, state0: StateId, state1: StateId, state2: StateId) {
748 store
749 .write_operation(&TimelineOperationEnvelope::new(
750 TimelineOperationBodyV1::BranchCreated(BranchCreatedV1 {
751 thread: "main".to_string(),
752 branch_id: branch("tlb-main"),
753 parent_branch_id: None,
754 from_step_id: None,
755 from_state: state0,
756 reason: TimelineBranchReason::ExplicitFork,
757 created_at_ms: 1,
758 }),
759 Vec::new(),
760 ))
761 .unwrap();
762
763 store
764 .write_operation(&TimelineOperationEnvelope::new(
765 TimelineOperationBodyV1::ToolCallStarted(ToolCallStartedV1 {
766 thread: "main".to_string(),
767 step_id: step("tls-one"),
768 branch_id: branch("tlb-main"),
769 parent_step_id: None,
770 native: native("call-1"),
771 tool_name: "shell".to_string(),
772 before_state: state0,
773 payload: Some(TimelineToolPayloadMetadata {
774 summary: Some("write first version".to_string()),
775 hash: Some(ContentHash::compute_typed(
776 "timeline-tool-payload",
777 b"call-1",
778 )),
779 }),
780 started_at_ms: 2,
781 }),
782 vec![TimelineLabel::RepoReversible],
783 ))
784 .unwrap();
785
786 store
787 .write_operation(&TimelineOperationEnvelope::new(
788 TimelineOperationBodyV1::ToolCallFinished(ToolCallFinishedV1 {
789 thread: "main".to_string(),
790 step_id: step("tls-one"),
791 branch_id: branch("tlb-main"),
792 native: native("call-1"),
793 status: TimelineToolCallStatus::Succeeded,
794 before_state: state0,
795 after_state: state1,
796 capture_state: Some(state1),
797 capture_oplog_batch_id: Some(1),
798 changed: true,
799 touched_paths: vec!["tracked.txt".to_string()],
800 payload: None,
801 finished_at_ms: 3,
802 }),
803 vec![TimelineLabel::RepoReversible],
804 ))
805 .unwrap();
806
807 store
808 .write_operation(&TimelineOperationEnvelope::new(
809 TimelineOperationBodyV1::ToolCallFinished(ToolCallFinishedV1 {
810 thread: "main".to_string(),
811 step_id: step("tls-two"),
812 branch_id: branch("tlb-main"),
813 native: native("call-2"),
814 status: TimelineToolCallStatus::Succeeded,
815 before_state: state1,
816 after_state: state2,
817 capture_state: Some(state2),
818 capture_oplog_batch_id: Some(2),
819 changed: true,
820 touched_paths: vec!["tracked.txt".to_string()],
821 payload: None,
822 finished_at_ms: 4,
823 }),
824 vec![TimelineLabel::RepoReversible],
825 ))
826 .unwrap();
827 }
828
829 fn write_timeline_with_child_branch(
830 store: &TimelineStore,
831 state0: StateId,
832 state1: StateId,
833 state2: StateId,
834 ) {
835 store
836 .write_operation(&TimelineOperationEnvelope::new(
837 TimelineOperationBodyV1::BranchCreated(BranchCreatedV1 {
838 thread: "main".to_string(),
839 branch_id: branch("tlb-main"),
840 parent_branch_id: None,
841 from_step_id: None,
842 from_state: state0,
843 reason: TimelineBranchReason::ExplicitFork,
844 created_at_ms: 1,
845 }),
846 Vec::new(),
847 ))
848 .unwrap();
849
850 store
851 .write_operation(&TimelineOperationEnvelope::new(
852 TimelineOperationBodyV1::ToolCallFinished(ToolCallFinishedV1 {
853 thread: "main".to_string(),
854 step_id: step("tls-one"),
855 branch_id: branch("tlb-main"),
856 native: native("call-1"),
857 status: TimelineToolCallStatus::Succeeded,
858 before_state: state0,
859 after_state: state1,
860 capture_state: Some(state1),
861 capture_oplog_batch_id: Some(1),
862 changed: true,
863 touched_paths: vec!["tracked.txt".to_string()],
864 payload: None,
865 finished_at_ms: 2,
866 }),
867 vec![TimelineLabel::RepoReversible],
868 ))
869 .unwrap();
870
871 store
872 .write_operation(&TimelineOperationEnvelope::new(
873 TimelineOperationBodyV1::BranchCreated(BranchCreatedV1 {
874 thread: "main".to_string(),
875 branch_id: branch("tlb-child"),
876 parent_branch_id: Some(branch("tlb-main")),
877 from_step_id: Some(step("tls-one")),
878 from_state: state1,
879 reason: TimelineBranchReason::ExplicitFork,
880 created_at_ms: 3,
881 }),
882 Vec::new(),
883 ))
884 .unwrap();
885
886 store
887 .write_operation(&TimelineOperationEnvelope::new(
888 TimelineOperationBodyV1::ToolCallFinished(ToolCallFinishedV1 {
889 thread: "main".to_string(),
890 step_id: step("tls-child"),
891 branch_id: branch("tlb-child"),
892 native: native("call-2"),
893 status: TimelineToolCallStatus::Succeeded,
894 before_state: state1,
895 after_state: state2,
896 capture_state: Some(state2),
897 capture_oplog_batch_id: Some(2),
898 changed: true,
899 touched_paths: vec!["tracked.txt".to_string()],
900 payload: None,
901 finished_at_ms: 4,
902 }),
903 vec![TimelineLabel::RepoReversible],
904 ))
905 .unwrap();
906 }
907
908 #[test]
909 fn preview_resolves_step_native_undo_and_redo_selectors() {
910 let (temp, repo, store) = create_repo();
911 let state0 = repo.head().unwrap().unwrap();
912 let state1 = write_state(&repo, temp.path(), "tracked.txt", "one\n");
913 let state2 = write_state(&repo, temp.path(), "tracked.txt", "two\n");
914 write_timeline(&store, state0, state1, state2);
915
916 let by_step = repo
917 .preview_timeline_seek(
918 &store,
919 "main",
920 &TimelineSeekSelector::StepId(step("tls-one")),
921 TimelineMaterializeMode::FailIfDirty,
922 )
923 .unwrap();
924 assert_eq!(by_step.target.state, state1);
925 assert_eq!(by_step.changed_paths, vec!["tracked.txt"]);
926 assert!(by_step.can_materialize());
927
928 let by_native = repo
929 .preview_timeline_seek(
930 &store,
931 "main",
932 &TimelineSeekSelector::NativeToolCall(native_key("call-1")),
933 TimelineMaterializeMode::FailIfDirty,
934 )
935 .unwrap();
936 assert_eq!(by_native.target.state, state1);
937
938 store
939 .record_cursor_move(TimelineCursorMoveRecord {
940 thread: "main".to_string(),
941 branch_id: branch("tlb-main"),
942 from_step_id: Some(step("tls-two")),
943 to_step_id: Some(step("tls-one")),
944 from_state: state2,
945 to_state: state1,
946 reason: TimelineCursorMoveReason::Undo,
947 moved_at_ms: 5,
948 labels: Vec::new(),
949 })
950 .unwrap();
951
952 let undo = repo
953 .preview_timeline_seek(
954 &store,
955 "main",
956 &TimelineSeekSelector::Undo,
957 TimelineMaterializeMode::FailIfDirty,
958 )
959 .unwrap();
960 assert_eq!(undo.target.state, state0);
961
962 let redo = repo
963 .preview_timeline_seek(
964 &store,
965 "main",
966 &TimelineSeekSelector::Redo,
967 TimelineMaterializeMode::FailIfDirty,
968 )
969 .unwrap();
970 assert_eq!(redo.target.state, state2);
971 }
972
973 #[test]
974 fn fail_if_dirty_refuses_without_recording_cursor_move() {
975 let (temp, repo, store) = create_repo();
976 let state0 = repo.head().unwrap().unwrap();
977 let state1 = write_state(&repo, temp.path(), "tracked.txt", "one\n");
978 let state2 = write_state(&repo, temp.path(), "tracked.txt", "two\n");
979 write_timeline(&store, state0, state1, state2);
980
981 fs::write(temp.path().join("tracked.txt"), "local edit\n").unwrap();
982
983 let before_ops = TimelineView::rebuild(&store).unwrap().operation_ids().len();
984 let outcome = repo
985 .materialize_timeline_cursor(
986 &store,
987 "main",
988 &TimelineSeekSelector::StepId(step("tls-one")),
989 TimelineMaterializeMode::FailIfDirty,
990 10,
991 )
992 .unwrap();
993
994 assert_eq!(outcome.status, TimelineMaterializeStatus::Refused);
995 assert!(matches!(
996 outcome.preview.blockers.as_slice(),
997 [TimelineMaterializationBlocker::DirtyWorktree { .. }]
998 ));
999 assert_eq!(outcome.cursor_operation_id, None);
1000 assert_eq!(
1001 TimelineView::rebuild(&store).unwrap().operation_ids().len(),
1002 before_ops
1003 );
1004 assert_eq!(
1005 fs::read_to_string(temp.path().join("tracked.txt")).unwrap(),
1006 "local edit\n"
1007 );
1008 }
1009
1010 #[test]
1011 fn materialize_success_moves_checkout_then_records_cursor_move() {
1012 let (temp, repo, store) = create_repo();
1013 let state0 = repo.head().unwrap().unwrap();
1014 let state1 = write_state(&repo, temp.path(), "tracked.txt", "one\n");
1015 let state2 = write_state(&repo, temp.path(), "tracked.txt", "two\n");
1016 write_timeline(&store, state0, state1, state2);
1017
1018 let outcome = repo
1019 .materialize_timeline_cursor(
1020 &store,
1021 "main",
1022 &TimelineSeekSelector::StepId(step("tls-one")),
1023 TimelineMaterializeMode::FailIfDirty,
1024 10,
1025 )
1026 .unwrap();
1027
1028 assert_eq!(outcome.status, TimelineMaterializeStatus::Materialized);
1029 assert!(outcome.cursor_operation_id.is_some());
1030 assert_eq!(
1031 outcome.recovery.status,
1032 TimelineMaterializationRecoveryStatus::NoPending
1033 );
1034 assert!(
1035 store
1036 .read_materialization_recovery("main")
1037 .unwrap()
1038 .is_none()
1039 );
1040 assert_eq!(
1041 fs::read_to_string(temp.path().join("tracked.txt")).unwrap(),
1042 "one\n"
1043 );
1044 assert_eq!(repo.head().unwrap(), Some(state1));
1045
1046 let view = TimelineView::rebuild(&store).unwrap();
1047 let status = view.status("main").unwrap();
1048 assert_eq!(status.current_step_id, Some(step("tls-one")));
1049 assert_eq!(status.current_state, Some(state1));
1050 }
1051
1052 #[test]
1053 fn recovery_completes_cursor_move_when_checkout_reached_target() {
1054 let (temp, repo, store) = create_repo();
1055 let state0 = repo.head().unwrap().unwrap();
1056 let state1 = write_state(&repo, temp.path(), "tracked.txt", "one\n");
1057 let state2 = write_state(&repo, temp.path(), "tracked.txt", "two\n");
1058 write_timeline(&store, state0, state1, state2);
1059 let record = TimelineMaterializationRecoveryRecord::new(
1060 "main",
1061 branch("tlb-main"),
1062 Some(step("tls-two")),
1063 Some(step("tls-one")),
1064 state2,
1065 state1,
1066 TimelineCursorMoveReason::SeekToolCall,
1067 10,
1068 );
1069 store.stage_materialization_recovery(&record).unwrap();
1070 repo.goto(&state1).unwrap();
1071
1072 let outcome = repo
1073 .recover_pending_timeline_materialization(&store, "main")
1074 .unwrap();
1075
1076 assert_eq!(
1077 outcome.status,
1078 TimelineMaterializationRecoveryStatus::CursorRecorded
1079 );
1080 assert!(outcome.cursor_operation_id.is_some());
1081 assert!(
1082 store
1083 .read_materialization_recovery("main")
1084 .unwrap()
1085 .is_none()
1086 );
1087 let view = TimelineView::rebuild(&store).unwrap();
1088 let status = view.status("main").unwrap();
1089 assert_eq!(status.current_step_id, Some(step("tls-one")));
1090 assert_eq!(status.current_state, Some(state1));
1091 }
1092
1093 #[test]
1094 fn recovery_blocks_when_checkout_has_not_reached_target() {
1095 let (temp, repo, store) = create_repo();
1096 let state0 = repo.head().unwrap().unwrap();
1097 let state1 = write_state(&repo, temp.path(), "tracked.txt", "one\n");
1098 let state2 = write_state(&repo, temp.path(), "tracked.txt", "two\n");
1099 write_timeline(&store, state0, state1, state2);
1100 let record = TimelineMaterializationRecoveryRecord::new(
1101 "main",
1102 branch("tlb-main"),
1103 Some(step("tls-two")),
1104 Some(step("tls-one")),
1105 state2,
1106 state1,
1107 TimelineCursorMoveReason::SeekToolCall,
1108 10,
1109 );
1110 store.stage_materialization_recovery(&record).unwrap();
1111
1112 let outcome = repo
1113 .materialize_timeline_cursor(
1114 &store,
1115 "main",
1116 &TimelineSeekSelector::StepId(step("tls-one")),
1117 TimelineMaterializeMode::FailIfDirty,
1118 11,
1119 )
1120 .unwrap();
1121
1122 assert_eq!(outcome.status, TimelineMaterializeStatus::RecoveryBlocked);
1123 assert_eq!(
1124 outcome.recovery.status,
1125 TimelineMaterializationRecoveryStatus::Blocked
1126 );
1127 assert!(matches!(
1128 outcome.recovery.blocker,
1129 Some(
1130 TimelineMaterializationRecoveryBlocker::CheckoutNotAtTarget {
1131 checkout_state: Some(checkout),
1132 target_state
1133 }
1134 ) if checkout == state2 && target_state == state1
1135 ));
1136 assert!(
1137 store
1138 .read_materialization_recovery("main")
1139 .unwrap()
1140 .is_some()
1141 );
1142 let view = TimelineView::rebuild(&store).unwrap();
1143 assert_eq!(
1144 view.status("main").unwrap().current_step_id,
1145 Some(step("tls-two"))
1146 );
1147 }
1148
1149 #[test]
1150 fn branch_constraint_is_checked_after_materialization_recovery() {
1151 let (temp, repo, store) = create_repo();
1152 let state0 = repo.head().unwrap().unwrap();
1153 let state1 = write_state(&repo, temp.path(), "tracked.txt", "one\n");
1154 let state2 = write_state(&repo, temp.path(), "tracked.txt", "two\n");
1155 write_timeline_with_child_branch(&store, state0, state1, state2);
1156
1157 store
1158 .record_cursor_move(TimelineCursorMoveRecord {
1159 thread: "main".to_string(),
1160 branch_id: branch("tlb-main"),
1161 from_step_id: Some(step("tls-child")),
1162 to_step_id: Some(step("tls-one")),
1163 from_state: state2,
1164 to_state: state1,
1165 reason: TimelineCursorMoveReason::Undo,
1166 moved_at_ms: 5,
1167 labels: Vec::new(),
1168 })
1169 .unwrap();
1170 let view = TimelineView::rebuild(&store).unwrap();
1171 assert_eq!(
1172 view.status("main").unwrap().current_branch_id,
1173 Some(branch("tlb-main"))
1174 );
1175
1176 let record = TimelineMaterializationRecoveryRecord::new(
1177 "main",
1178 branch("tlb-child"),
1179 Some(step("tls-one")),
1180 Some(step("tls-child")),
1181 state1,
1182 state2,
1183 TimelineCursorMoveReason::Redo,
1184 10,
1185 );
1186 store.stage_materialization_recovery(&record).unwrap();
1187 repo.goto(&state2).unwrap();
1188
1189 let constraint = TimelineSeekBranchConstraint::Current(branch("tlb-child"));
1190 let outcome = repo
1191 .materialize_timeline_cursor_constrained(
1192 &store,
1193 "main",
1194 &TimelineSeekSelector::CurrentCursor,
1195 TimelineMaterializeMode::FailIfDirty,
1196 Some(&constraint),
1197 11,
1198 )
1199 .unwrap();
1200
1201 assert_eq!(outcome.status, TimelineMaterializeStatus::AlreadyAtTarget);
1202 assert_eq!(
1203 outcome.recovery.status,
1204 TimelineMaterializationRecoveryStatus::CursorRecorded
1205 );
1206 assert!(outcome.recovery.cursor_operation_id.is_some());
1207
1208 let view = TimelineView::rebuild(&store).unwrap();
1209 let status = view.status("main").unwrap();
1210 assert_eq!(status.current_branch_id, Some(branch("tlb-child")));
1211 assert_eq!(status.current_step_id, Some(step("tls-child")));
1212 assert_eq!(status.current_state, Some(state2));
1213 }
1214
1215 #[test]
1216 fn capture_current_then_seek_is_explicitly_unsupported() {
1217 let (temp, repo, store) = create_repo();
1218 let state0 = repo.head().unwrap().unwrap();
1219 let state1 = write_state(&repo, temp.path(), "tracked.txt", "one\n");
1220 let state2 = write_state(&repo, temp.path(), "tracked.txt", "two\n");
1221 write_timeline(&store, state0, state1, state2);
1222
1223 let outcome = repo
1224 .materialize_timeline_cursor(
1225 &store,
1226 "main",
1227 &TimelineSeekSelector::StepId(step("tls-one")),
1228 TimelineMaterializeMode::CaptureCurrentThenSeek,
1229 10,
1230 )
1231 .unwrap();
1232
1233 assert_eq!(outcome.status, TimelineMaterializeStatus::Unsupported);
1234 assert_eq!(outcome.cursor_operation_id, None);
1235 assert_eq!(
1236 fs::read_to_string(temp.path().join("tracked.txt")).unwrap(),
1237 "two\n"
1238 );
1239 }
1240}