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