Skip to main content

repo/
timeline_materialize.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Timeline cursor materialization helpers.
3
4use 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/// Selects the logical timeline target a checkout should materialize.
24#[derive(Clone, Debug, PartialEq, Eq)]
25pub enum TimelineSeekSelector {
26    /// Seek directly to a thread-local step id.
27    StepId(TimelineStepId),
28    /// Seek to the step associated with a native harness tool-call id.
29    NativeToolCall(TimelineNativeToolKey),
30    /// Seek to the previous step from the current logical cursor.
31    Undo,
32    /// Seek to the next step from the current logical cursor.
33    Redo,
34    /// Materialize the current logical cursor without changing it.
35    CurrentCursor,
36}
37
38/// Optional branch invariant supplied by an embedding harness.
39#[derive(Clone, Debug, PartialEq, Eq)]
40pub enum TimelineSeekBranchConstraint {
41    /// The logical cursor must currently be on this branch after any pending
42    /// recovery has completed.
43    Current(TimelineBranchId),
44    /// The resolved target must belong to this branch.
45    Target(TimelineBranchId),
46}
47
48/// Materialization safety mode.
49#[derive(Clone, Copy, Debug, PartialEq, Eq)]
50pub enum TimelineMaterializeMode {
51    /// Refuse when the physical checkout has uncommitted tracked/untracked work.
52    FailIfDirty,
53    /// Capture the current checkout before seeking. Reserved for a later slice.
54    CaptureCurrentThenSeek,
55}
56
57/// Conservative boundary assessment for a materialization preview.
58#[derive(Clone, Copy, Debug, PartialEq, Eq)]
59pub enum TimelineMaterializationBoundaryStatus {
60    /// The repo apply path only touches tracked repo paths; no ignored/outside
61    /// boundary probe has been performed for this preview.
62    Unknown,
63}
64
65/// Why a materialization preview cannot proceed.
66#[derive(Clone, Debug, PartialEq, Eq)]
67pub enum TimelineMaterializationBlocker {
68    /// The requested mode is defined but not implemented by this slice.
69    UnsupportedMode(TimelineMaterializeMode),
70    /// The physical checkout has local changes relative to its current state.
71    DirtyWorktree { paths: Vec<String> },
72    /// The physical checkout has no known current state to compare against.
73    CheckoutStateUnknown,
74    /// The state object exists, but its tree is unavailable.
75    MissingTree(StateId),
76}
77
78/// Preview of a timeline seek before optional physical materialization.
79#[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/// Final status for a materialization attempt.
100#[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/// Result of a materialization attempt.
145#[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    /// Preview a timeline seek target without mutating the checkout or timeline.
155    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    /// Preview a timeline seek target with an optional branch invariant.
166    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    /// Materialize a logical timeline cursor target into the physical checkout.
182    ///
183    /// This writes the timeline `cursor_moved` operation only after the checkout
184    /// is known to be at the target state. A per-thread recovery sidecar is
185    /// staged before the checkout move and cleared after the cursor move, so a
186    /// later call can finish the logical cursor update if a process crashes
187    /// between those writes.
188    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    /// Materialize a logical timeline cursor target with an optional branch
207    /// invariant checked after pending recovery has been applied.
208    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(&current)?;
397                changed_paths_from_worktree_apply_plan(self, Some(&current_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(&current)?;
410                let status = self.compare_worktree_cached_detailed(&current_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// Used as a fn-value over `&PathBuf` items, so the signature can't take `&Path`.
559#[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}