1use std::{
5 collections::BTreeSet,
6 path::{Path, PathBuf},
7};
8
9use objects::{
10 object::{
11 ChangeId, 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(ChangeId),
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<ChangeId>,
85 pub checkout_state: Option<ChangeId>,
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<ChangeId>,
121 target_state: ChangeId,
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: &ChangeId) -> 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) -> ChangeId {
743 fs::write(root.join(path), content).unwrap();
744 repo.snapshot(Some(path.to_string()), None)
745 .unwrap()
746 .change_id
747 }
748
749 fn write_timeline(store: &TimelineStore, state0: ChangeId, state1: ChangeId, state2: ChangeId) {
750 store
751 .write_operation(&TimelineOperationEnvelope::new(
752 TimelineOperationBodyV1::BranchCreated(BranchCreatedV1 {
753 thread: "main".to_string(),
754 branch_id: branch("tlb-main"),
755 parent_branch_id: None,
756 from_step_id: None,
757 from_state: state0,
758 reason: TimelineBranchReason::ExplicitFork,
759 created_at_ms: 1,
760 }),
761 Vec::new(),
762 ))
763 .unwrap();
764
765 store
766 .write_operation(&TimelineOperationEnvelope::new(
767 TimelineOperationBodyV1::ToolCallStarted(ToolCallStartedV1 {
768 thread: "main".to_string(),
769 step_id: step("tls-one"),
770 branch_id: branch("tlb-main"),
771 parent_step_id: None,
772 native: native("call-1"),
773 tool_name: "shell".to_string(),
774 before_state: state0,
775 payload: Some(TimelineToolPayloadMetadata {
776 summary: Some("write first version".to_string()),
777 hash: Some(ContentHash::compute_typed(
778 "timeline-tool-payload",
779 b"call-1",
780 )),
781 }),
782 started_at_ms: 2,
783 }),
784 vec![TimelineLabel::RepoReversible],
785 ))
786 .unwrap();
787
788 store
789 .write_operation(&TimelineOperationEnvelope::new(
790 TimelineOperationBodyV1::ToolCallFinished(ToolCallFinishedV1 {
791 thread: "main".to_string(),
792 step_id: step("tls-one"),
793 branch_id: branch("tlb-main"),
794 native: native("call-1"),
795 status: TimelineToolCallStatus::Succeeded,
796 before_state: state0,
797 after_state: state1,
798 capture_state: Some(state1),
799 capture_oplog_batch_id: Some(1),
800 changed: true,
801 touched_paths: vec!["tracked.txt".to_string()],
802 payload: None,
803 finished_at_ms: 3,
804 }),
805 vec![TimelineLabel::RepoReversible],
806 ))
807 .unwrap();
808
809 store
810 .write_operation(&TimelineOperationEnvelope::new(
811 TimelineOperationBodyV1::ToolCallFinished(ToolCallFinishedV1 {
812 thread: "main".to_string(),
813 step_id: step("tls-two"),
814 branch_id: branch("tlb-main"),
815 native: native("call-2"),
816 status: TimelineToolCallStatus::Succeeded,
817 before_state: state1,
818 after_state: state2,
819 capture_state: Some(state2),
820 capture_oplog_batch_id: Some(2),
821 changed: true,
822 touched_paths: vec!["tracked.txt".to_string()],
823 payload: None,
824 finished_at_ms: 4,
825 }),
826 vec![TimelineLabel::RepoReversible],
827 ))
828 .unwrap();
829 }
830
831 fn write_timeline_with_child_branch(
832 store: &TimelineStore,
833 state0: ChangeId,
834 state1: ChangeId,
835 state2: ChangeId,
836 ) {
837 store
838 .write_operation(&TimelineOperationEnvelope::new(
839 TimelineOperationBodyV1::BranchCreated(BranchCreatedV1 {
840 thread: "main".to_string(),
841 branch_id: branch("tlb-main"),
842 parent_branch_id: None,
843 from_step_id: None,
844 from_state: state0,
845 reason: TimelineBranchReason::ExplicitFork,
846 created_at_ms: 1,
847 }),
848 Vec::new(),
849 ))
850 .unwrap();
851
852 store
853 .write_operation(&TimelineOperationEnvelope::new(
854 TimelineOperationBodyV1::ToolCallFinished(ToolCallFinishedV1 {
855 thread: "main".to_string(),
856 step_id: step("tls-one"),
857 branch_id: branch("tlb-main"),
858 native: native("call-1"),
859 status: TimelineToolCallStatus::Succeeded,
860 before_state: state0,
861 after_state: state1,
862 capture_state: Some(state1),
863 capture_oplog_batch_id: Some(1),
864 changed: true,
865 touched_paths: vec!["tracked.txt".to_string()],
866 payload: None,
867 finished_at_ms: 2,
868 }),
869 vec![TimelineLabel::RepoReversible],
870 ))
871 .unwrap();
872
873 store
874 .write_operation(&TimelineOperationEnvelope::new(
875 TimelineOperationBodyV1::BranchCreated(BranchCreatedV1 {
876 thread: "main".to_string(),
877 branch_id: branch("tlb-child"),
878 parent_branch_id: Some(branch("tlb-main")),
879 from_step_id: Some(step("tls-one")),
880 from_state: state1,
881 reason: TimelineBranchReason::ExplicitFork,
882 created_at_ms: 3,
883 }),
884 Vec::new(),
885 ))
886 .unwrap();
887
888 store
889 .write_operation(&TimelineOperationEnvelope::new(
890 TimelineOperationBodyV1::ToolCallFinished(ToolCallFinishedV1 {
891 thread: "main".to_string(),
892 step_id: step("tls-child"),
893 branch_id: branch("tlb-child"),
894 native: native("call-2"),
895 status: TimelineToolCallStatus::Succeeded,
896 before_state: state1,
897 after_state: state2,
898 capture_state: Some(state2),
899 capture_oplog_batch_id: Some(2),
900 changed: true,
901 touched_paths: vec!["tracked.txt".to_string()],
902 payload: None,
903 finished_at_ms: 4,
904 }),
905 vec![TimelineLabel::RepoReversible],
906 ))
907 .unwrap();
908 }
909
910 #[test]
911 fn preview_resolves_step_native_undo_and_redo_selectors() {
912 let (temp, repo, store) = create_repo();
913 let state0 = repo.head().unwrap().unwrap();
914 let state1 = write_state(&repo, temp.path(), "tracked.txt", "one\n");
915 let state2 = write_state(&repo, temp.path(), "tracked.txt", "two\n");
916 write_timeline(&store, state0, state1, state2);
917
918 let by_step = repo
919 .preview_timeline_seek(
920 &store,
921 "main",
922 &TimelineSeekSelector::StepId(step("tls-one")),
923 TimelineMaterializeMode::FailIfDirty,
924 )
925 .unwrap();
926 assert_eq!(by_step.target.state, state1);
927 assert_eq!(by_step.changed_paths, vec!["tracked.txt"]);
928 assert!(by_step.can_materialize());
929
930 let by_native = repo
931 .preview_timeline_seek(
932 &store,
933 "main",
934 &TimelineSeekSelector::NativeToolCall(native_key("call-1")),
935 TimelineMaterializeMode::FailIfDirty,
936 )
937 .unwrap();
938 assert_eq!(by_native.target.state, state1);
939
940 store
941 .record_cursor_move(TimelineCursorMoveRecord {
942 thread: "main".to_string(),
943 branch_id: branch("tlb-main"),
944 from_step_id: Some(step("tls-two")),
945 to_step_id: Some(step("tls-one")),
946 from_state: state2,
947 to_state: state1,
948 reason: TimelineCursorMoveReason::Undo,
949 moved_at_ms: 5,
950 labels: Vec::new(),
951 })
952 .unwrap();
953
954 let undo = repo
955 .preview_timeline_seek(
956 &store,
957 "main",
958 &TimelineSeekSelector::Undo,
959 TimelineMaterializeMode::FailIfDirty,
960 )
961 .unwrap();
962 assert_eq!(undo.target.state, state0);
963
964 let redo = repo
965 .preview_timeline_seek(
966 &store,
967 "main",
968 &TimelineSeekSelector::Redo,
969 TimelineMaterializeMode::FailIfDirty,
970 )
971 .unwrap();
972 assert_eq!(redo.target.state, state2);
973 }
974
975 #[test]
976 fn fail_if_dirty_refuses_without_recording_cursor_move() {
977 let (temp, repo, store) = create_repo();
978 let state0 = repo.head().unwrap().unwrap();
979 let state1 = write_state(&repo, temp.path(), "tracked.txt", "one\n");
980 let state2 = write_state(&repo, temp.path(), "tracked.txt", "two\n");
981 write_timeline(&store, state0, state1, state2);
982
983 fs::write(temp.path().join("tracked.txt"), "local edit\n").unwrap();
984
985 let before_ops = TimelineView::rebuild(&store).unwrap().operation_ids().len();
986 let outcome = repo
987 .materialize_timeline_cursor(
988 &store,
989 "main",
990 &TimelineSeekSelector::StepId(step("tls-one")),
991 TimelineMaterializeMode::FailIfDirty,
992 10,
993 )
994 .unwrap();
995
996 assert_eq!(outcome.status, TimelineMaterializeStatus::Refused);
997 assert!(matches!(
998 outcome.preview.blockers.as_slice(),
999 [TimelineMaterializationBlocker::DirtyWorktree { .. }]
1000 ));
1001 assert_eq!(outcome.cursor_operation_id, None);
1002 assert_eq!(
1003 TimelineView::rebuild(&store).unwrap().operation_ids().len(),
1004 before_ops
1005 );
1006 assert_eq!(
1007 fs::read_to_string(temp.path().join("tracked.txt")).unwrap(),
1008 "local edit\n"
1009 );
1010 }
1011
1012 #[test]
1013 fn materialize_success_moves_checkout_then_records_cursor_move() {
1014 let (temp, repo, store) = create_repo();
1015 let state0 = repo.head().unwrap().unwrap();
1016 let state1 = write_state(&repo, temp.path(), "tracked.txt", "one\n");
1017 let state2 = write_state(&repo, temp.path(), "tracked.txt", "two\n");
1018 write_timeline(&store, state0, state1, state2);
1019
1020 let outcome = repo
1021 .materialize_timeline_cursor(
1022 &store,
1023 "main",
1024 &TimelineSeekSelector::StepId(step("tls-one")),
1025 TimelineMaterializeMode::FailIfDirty,
1026 10,
1027 )
1028 .unwrap();
1029
1030 assert_eq!(outcome.status, TimelineMaterializeStatus::Materialized);
1031 assert!(outcome.cursor_operation_id.is_some());
1032 assert_eq!(
1033 outcome.recovery.status,
1034 TimelineMaterializationRecoveryStatus::NoPending
1035 );
1036 assert!(
1037 store
1038 .read_materialization_recovery("main")
1039 .unwrap()
1040 .is_none()
1041 );
1042 assert_eq!(
1043 fs::read_to_string(temp.path().join("tracked.txt")).unwrap(),
1044 "one\n"
1045 );
1046 assert_eq!(repo.head().unwrap(), Some(state1));
1047
1048 let view = TimelineView::rebuild(&store).unwrap();
1049 let status = view.status("main").unwrap();
1050 assert_eq!(status.current_step_id, Some(step("tls-one")));
1051 assert_eq!(status.current_state, Some(state1));
1052 }
1053
1054 #[test]
1055 fn recovery_completes_cursor_move_when_checkout_reached_target() {
1056 let (temp, repo, store) = create_repo();
1057 let state0 = repo.head().unwrap().unwrap();
1058 let state1 = write_state(&repo, temp.path(), "tracked.txt", "one\n");
1059 let state2 = write_state(&repo, temp.path(), "tracked.txt", "two\n");
1060 write_timeline(&store, state0, state1, state2);
1061 let record = TimelineMaterializationRecoveryRecord::new(
1062 "main",
1063 branch("tlb-main"),
1064 Some(step("tls-two")),
1065 Some(step("tls-one")),
1066 state2,
1067 state1,
1068 TimelineCursorMoveReason::SeekToolCall,
1069 10,
1070 );
1071 store.stage_materialization_recovery(&record).unwrap();
1072 repo.goto(&state1).unwrap();
1073
1074 let outcome = repo
1075 .recover_pending_timeline_materialization(&store, "main")
1076 .unwrap();
1077
1078 assert_eq!(
1079 outcome.status,
1080 TimelineMaterializationRecoveryStatus::CursorRecorded
1081 );
1082 assert!(outcome.cursor_operation_id.is_some());
1083 assert!(
1084 store
1085 .read_materialization_recovery("main")
1086 .unwrap()
1087 .is_none()
1088 );
1089 let view = TimelineView::rebuild(&store).unwrap();
1090 let status = view.status("main").unwrap();
1091 assert_eq!(status.current_step_id, Some(step("tls-one")));
1092 assert_eq!(status.current_state, Some(state1));
1093 }
1094
1095 #[test]
1096 fn recovery_blocks_when_checkout_has_not_reached_target() {
1097 let (temp, repo, store) = create_repo();
1098 let state0 = repo.head().unwrap().unwrap();
1099 let state1 = write_state(&repo, temp.path(), "tracked.txt", "one\n");
1100 let state2 = write_state(&repo, temp.path(), "tracked.txt", "two\n");
1101 write_timeline(&store, state0, state1, state2);
1102 let record = TimelineMaterializationRecoveryRecord::new(
1103 "main",
1104 branch("tlb-main"),
1105 Some(step("tls-two")),
1106 Some(step("tls-one")),
1107 state2,
1108 state1,
1109 TimelineCursorMoveReason::SeekToolCall,
1110 10,
1111 );
1112 store.stage_materialization_recovery(&record).unwrap();
1113
1114 let outcome = repo
1115 .materialize_timeline_cursor(
1116 &store,
1117 "main",
1118 &TimelineSeekSelector::StepId(step("tls-one")),
1119 TimelineMaterializeMode::FailIfDirty,
1120 11,
1121 )
1122 .unwrap();
1123
1124 assert_eq!(outcome.status, TimelineMaterializeStatus::RecoveryBlocked);
1125 assert_eq!(
1126 outcome.recovery.status,
1127 TimelineMaterializationRecoveryStatus::Blocked
1128 );
1129 assert!(matches!(
1130 outcome.recovery.blocker,
1131 Some(
1132 TimelineMaterializationRecoveryBlocker::CheckoutNotAtTarget {
1133 checkout_state: Some(checkout),
1134 target_state
1135 }
1136 ) if checkout == state2 && target_state == state1
1137 ));
1138 assert!(
1139 store
1140 .read_materialization_recovery("main")
1141 .unwrap()
1142 .is_some()
1143 );
1144 let view = TimelineView::rebuild(&store).unwrap();
1145 assert_eq!(
1146 view.status("main").unwrap().current_step_id,
1147 Some(step("tls-two"))
1148 );
1149 }
1150
1151 #[test]
1152 fn branch_constraint_is_checked_after_materialization_recovery() {
1153 let (temp, repo, store) = create_repo();
1154 let state0 = repo.head().unwrap().unwrap();
1155 let state1 = write_state(&repo, temp.path(), "tracked.txt", "one\n");
1156 let state2 = write_state(&repo, temp.path(), "tracked.txt", "two\n");
1157 write_timeline_with_child_branch(&store, state0, state1, state2);
1158
1159 store
1160 .record_cursor_move(TimelineCursorMoveRecord {
1161 thread: "main".to_string(),
1162 branch_id: branch("tlb-main"),
1163 from_step_id: Some(step("tls-child")),
1164 to_step_id: Some(step("tls-one")),
1165 from_state: state2,
1166 to_state: state1,
1167 reason: TimelineCursorMoveReason::Undo,
1168 moved_at_ms: 5,
1169 labels: Vec::new(),
1170 })
1171 .unwrap();
1172 let view = TimelineView::rebuild(&store).unwrap();
1173 assert_eq!(
1174 view.status("main").unwrap().current_branch_id,
1175 Some(branch("tlb-main"))
1176 );
1177
1178 let record = TimelineMaterializationRecoveryRecord::new(
1179 "main",
1180 branch("tlb-child"),
1181 Some(step("tls-one")),
1182 Some(step("tls-child")),
1183 state1,
1184 state2,
1185 TimelineCursorMoveReason::Redo,
1186 10,
1187 );
1188 store.stage_materialization_recovery(&record).unwrap();
1189 repo.goto(&state2).unwrap();
1190
1191 let constraint = TimelineSeekBranchConstraint::Current(branch("tlb-child"));
1192 let outcome = repo
1193 .materialize_timeline_cursor_constrained(
1194 &store,
1195 "main",
1196 &TimelineSeekSelector::CurrentCursor,
1197 TimelineMaterializeMode::FailIfDirty,
1198 Some(&constraint),
1199 11,
1200 )
1201 .unwrap();
1202
1203 assert_eq!(outcome.status, TimelineMaterializeStatus::AlreadyAtTarget);
1204 assert_eq!(
1205 outcome.recovery.status,
1206 TimelineMaterializationRecoveryStatus::CursorRecorded
1207 );
1208 assert!(outcome.recovery.cursor_operation_id.is_some());
1209
1210 let view = TimelineView::rebuild(&store).unwrap();
1211 let status = view.status("main").unwrap();
1212 assert_eq!(status.current_branch_id, Some(branch("tlb-child")));
1213 assert_eq!(status.current_step_id, Some(step("tls-child")));
1214 assert_eq!(status.current_state, Some(state2));
1215 }
1216
1217 #[test]
1218 fn capture_current_then_seek_is_explicitly_unsupported() {
1219 let (temp, repo, store) = create_repo();
1220 let state0 = repo.head().unwrap().unwrap();
1221 let state1 = write_state(&repo, temp.path(), "tracked.txt", "one\n");
1222 let state2 = write_state(&repo, temp.path(), "tracked.txt", "two\n");
1223 write_timeline(&store, state0, state1, state2);
1224
1225 let outcome = repo
1226 .materialize_timeline_cursor(
1227 &store,
1228 "main",
1229 &TimelineSeekSelector::StepId(step("tls-one")),
1230 TimelineMaterializeMode::CaptureCurrentThenSeek,
1231 10,
1232 )
1233 .unwrap();
1234
1235 assert_eq!(outcome.status, TimelineMaterializeStatus::Unsupported);
1236 assert_eq!(outcome.cursor_operation_id, None);
1237 assert_eq!(
1238 fs::read_to_string(temp.path().join("tracked.txt")).unwrap(),
1239 "two\n"
1240 );
1241 }
1242}