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