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        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/// 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(ChangeId),
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<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/// 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<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/// 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: &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// 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 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}