Skip to main content

repo/
repository_worktree_apply.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Shared worktree apply planning and execution.
3
4use std::{
5    collections::{BTreeMap, BTreeSet},
6    fs,
7    path::{Path, PathBuf},
8    time::Instant,
9};
10
11use objects::{
12    fs_atomic::{enrich_fs_error, is_directory_not_empty as fs_is_directory_not_empty},
13    object::{EntryType, Tree, TreeEntry},
14    store::ObjectStore,
15    worktree::should_ignore as should_ignore_path,
16};
17use tracing::{debug, warn};
18
19use super::{
20    HeddleError, Repository, Result,
21    repository_materialization::{MaterializedTree, WorktreeWriteOp},
22};
23use crate::{
24    FsMonitorSettings, WorktreeIndex,
25    fsmonitor::persist_current_monitor_cursor,
26    worktree_index::{DirectoryCacheEntry, WorktreeIndexLoadStats, WorktreeIndexSaveStats},
27    worktree_walk::{build_cached_entry, cache_key},
28};
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub(crate) enum WorktreeApplyStrategy {
32    Incremental,
33    FullRematerialize,
34}
35
36impl WorktreeApplyStrategy {
37    pub(crate) fn as_str(self) -> &'static str {
38        match self {
39            Self::Incremental => "incremental",
40            Self::FullRematerialize => "full_rematerialize",
41        }
42    }
43}
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub(crate) enum WorktreeApplyDirtyBehavior {
47    RefuseOnDirty,
48    DiscardLocalChanges,
49}
50
51impl WorktreeApplyDirtyBehavior {
52    pub(crate) fn as_str(self) -> &'static str {
53        match self {
54            Self::RefuseOnDirty => "refuse_on_dirty",
55            Self::DiscardLocalChanges => "discard_local_changes",
56        }
57    }
58}
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub(crate) enum WorktreeApplyFallbackReason {
62    MissingCurrentTree,
63    NonRootDirectory,
64    DirtyWorktree,
65}
66
67impl WorktreeApplyFallbackReason {
68    pub(crate) fn as_str(self) -> &'static str {
69        match self {
70            Self::MissingCurrentTree => "missing_current_tree",
71            Self::NonRootDirectory => "non_root_directory",
72            Self::DirtyWorktree => "dirty_worktree",
73        }
74    }
75}
76
77#[derive(Debug, Default)]
78pub(crate) struct WorktreeApplyStats {
79    pub(crate) unchanged_count: usize,
80    pub(crate) changed_count: usize,
81}
82
83#[derive(Debug)]
84pub(crate) struct WorktreeApplyPlan {
85    pub(crate) strategy: WorktreeApplyStrategy,
86    pub(crate) dirty_behavior: WorktreeApplyDirtyBehavior,
87    pub(crate) removals: Vec<PathBuf>,
88    pub(crate) directories: Vec<PathBuf>,
89    pub(crate) writes: Vec<WorktreeWriteOp>,
90    pub(crate) fallback_reason: Option<WorktreeApplyFallbackReason>,
91    pub(crate) stats: WorktreeApplyStats,
92}
93
94#[derive(Debug, Default)]
95pub(crate) struct WorktreeApplyReport {
96    pub(crate) delete_phase_ms: u128,
97    pub(crate) mkdir_phase_ms: u128,
98    pub(crate) write_phase_ms: u128,
99    pub(crate) index_update_ms: u128,
100    pub(crate) index_snapshot_load_ms: u128,
101    pub(crate) index_journal_replay_ms: u128,
102    pub(crate) index_snapshot_write_ms: u128,
103    pub(crate) index_journal_append_ms: u128,
104    pub(crate) index_snapshot_bytes: u64,
105    pub(crate) index_journal_bytes: u64,
106    pub(crate) index_journal_ops: usize,
107    pub(crate) index_compacted: bool,
108    pub(crate) index_compact_reason: Option<&'static str>,
109    pub(crate) fsmonitor_refresh_ms: u128,
110    pub(crate) worker_count: usize,
111}
112
113impl WorktreeApplyPlan {
114    fn incremental() -> Self {
115        Self {
116            strategy: WorktreeApplyStrategy::Incremental,
117            dirty_behavior: WorktreeApplyDirtyBehavior::RefuseOnDirty,
118            removals: Vec::new(),
119            directories: Vec::new(),
120            writes: Vec::new(),
121            fallback_reason: None,
122            stats: WorktreeApplyStats::default(),
123        }
124    }
125
126    fn fallback(
127        reason: WorktreeApplyFallbackReason,
128        dirty_behavior: WorktreeApplyDirtyBehavior,
129    ) -> Self {
130        Self {
131            strategy: WorktreeApplyStrategy::FullRematerialize,
132            dirty_behavior,
133            removals: Vec::new(),
134            directories: Vec::new(),
135            writes: Vec::new(),
136            fallback_reason: Some(reason),
137            stats: WorktreeApplyStats::default(),
138        }
139    }
140
141    pub(crate) fn is_empty(&self) -> bool {
142        self.removals.is_empty() && self.directories.is_empty() && self.writes.is_empty()
143    }
144}
145
146impl Repository {
147    pub(crate) fn plan_worktree_apply(
148        &self,
149        from_tree: Option<&Tree>,
150        to_tree: &Tree,
151        dir: &Path,
152        current_worktree_verified_clean: bool,
153        dirty_behavior: WorktreeApplyDirtyBehavior,
154    ) -> Result<WorktreeApplyPlan> {
155        let plan_start = Instant::now();
156        let plan = match from_tree {
157            None => {
158                self.refuse_full_rematerialize_on_dirty(
159                    dirty_behavior,
160                    WorktreeApplyFallbackReason::MissingCurrentTree,
161                )?;
162                WorktreeApplyPlan::fallback(
163                    WorktreeApplyFallbackReason::MissingCurrentTree,
164                    dirty_behavior,
165                )
166            }
167            Some(_) if dir != self.root() => {
168                self.refuse_full_rematerialize_on_dirty(
169                    dirty_behavior,
170                    WorktreeApplyFallbackReason::NonRootDirectory,
171                )?;
172                WorktreeApplyPlan::fallback(
173                    WorktreeApplyFallbackReason::NonRootDirectory,
174                    dirty_behavior,
175                )
176            }
177            Some(from_tree) => {
178                if !current_worktree_verified_clean {
179                    let detailed = self.compare_worktree_cached_detailed(from_tree)?;
180                    if !detailed.is_clean() {
181                        if dirty_behavior == WorktreeApplyDirtyBehavior::RefuseOnDirty {
182                            return Err(full_rematerialize_dirty_refusal_from_status(
183                                WorktreeApplyFallbackReason::DirtyWorktree,
184                                &detailed,
185                            ));
186                        }
187                        WorktreeApplyPlan::fallback(
188                            WorktreeApplyFallbackReason::DirtyWorktree,
189                            dirty_behavior,
190                        )
191                    } else {
192                        let mut plan = WorktreeApplyPlan::incremental();
193                        self.plan_tree_apply_recursive(
194                            Path::new(""),
195                            Some(from_tree),
196                            Some(to_tree),
197                            &mut plan,
198                        )?;
199                        plan
200                    }
201                } else {
202                    let mut plan = WorktreeApplyPlan::incremental();
203                    self.plan_tree_apply_recursive(
204                        Path::new(""),
205                        Some(from_tree),
206                        Some(to_tree),
207                        &mut plan,
208                    )?;
209                    plan
210                }
211            }
212        };
213
214        debug!(
215            strategy = plan.strategy.as_str(),
216            dirty_behavior = plan.dirty_behavior.as_str(),
217            changed_count = plan.stats.changed_count,
218            unchanged_count = plan.stats.unchanged_count,
219            fallback_reason = plan
220                .fallback_reason
221                .map(WorktreeApplyFallbackReason::as_str)
222                .unwrap_or("none"),
223            plan_duration_ms = plan_start.elapsed().as_millis(),
224            "Worktree apply plan ready"
225        );
226
227        Ok(plan)
228    }
229
230    pub(crate) fn execute_worktree_apply(
231        &self,
232        plan: &WorktreeApplyPlan,
233        tree: &Tree,
234        dir: &Path,
235    ) -> Result<WorktreeApplyReport> {
236        match plan.strategy {
237            WorktreeApplyStrategy::Incremental => {
238                self.execute_incremental_worktree_apply(plan, tree)
239            }
240            WorktreeApplyStrategy::FullRematerialize => {
241                if plan.dirty_behavior == WorktreeApplyDirtyBehavior::RefuseOnDirty {
242                    self.refuse_full_rematerialize_on_dirty(
243                        plan.dirty_behavior,
244                        plan.fallback_reason
245                            .unwrap_or(WorktreeApplyFallbackReason::MissingCurrentTree),
246                    )?;
247                }
248
249                let delete_start = Instant::now();
250                if self.worktree_requires_clear()? {
251                    self.clear_worktree()?;
252                }
253                let delete_phase_ms = delete_start.elapsed().as_millis();
254
255                let write_start = Instant::now();
256                let materialized = self.materialize_tree_seeded(tree, dir)?;
257                let write_phase_ms = write_start.elapsed().as_millis();
258
259                let index_update_start = Instant::now();
260                if let Err(error) = self
261                    .refresh_worktree_performance_state_after_full_rematerialize(materialized, dir)
262                {
263                    self.invalidate_worktree_performance_state()?;
264                    return Err(error);
265                }
266                let index_update_ms = index_update_start.elapsed().as_millis();
267
268                let fsmonitor_refresh_start = Instant::now();
269                self.refresh_fsmonitor_after_incremental_apply();
270                let fsmonitor_refresh_ms = fsmonitor_refresh_start.elapsed().as_millis();
271
272                Ok(WorktreeApplyReport {
273                    delete_phase_ms,
274                    mkdir_phase_ms: 0,
275                    write_phase_ms,
276                    index_update_ms,
277                    fsmonitor_refresh_ms,
278                    worker_count: 0,
279                    ..WorktreeApplyReport::default()
280                })
281            }
282        }
283    }
284
285    fn execute_incremental_worktree_apply(
286        &self,
287        plan: &WorktreeApplyPlan,
288        tree: &Tree,
289    ) -> Result<WorktreeApplyReport> {
290        if plan.is_empty() {
291            return Ok(WorktreeApplyReport::default());
292        }
293
294        let delete_start = Instant::now();
295        for path in &plan.removals {
296            remove_existing_path(path)?;
297        }
298        let delete_phase_ms = delete_start.elapsed().as_millis();
299
300        let mkdir_start = Instant::now();
301        for directory in &plan.directories {
302            fs::create_dir_all(directory)
303                .map_err(|e| HeddleError::Io(enrich_fs_error(directory, "creating", e)))?;
304        }
305        let mkdir_phase_ms = mkdir_start.elapsed().as_millis();
306
307        let write_start = Instant::now();
308        let worker_count = self.materialize_write_ops(&plan.writes)?;
309        let write_phase_ms = write_start.elapsed().as_millis();
310
311        let index_update_start = Instant::now();
312        let (index_update_ms, index_load_stats, index_save_stats) =
313            self.update_worktree_index_after_incremental_apply(plan, tree)?;
314        let index_update_ms = index_update_start
315            .elapsed()
316            .as_millis()
317            .max(index_update_ms);
318
319        let fsmonitor_refresh_start = Instant::now();
320        self.refresh_fsmonitor_after_incremental_apply();
321        let fsmonitor_refresh_ms = fsmonitor_refresh_start.elapsed().as_millis();
322
323        Ok(WorktreeApplyReport {
324            delete_phase_ms,
325            mkdir_phase_ms,
326            write_phase_ms,
327            index_update_ms,
328            index_snapshot_load_ms: index_load_stats.snapshot_load_ms,
329            index_journal_replay_ms: index_load_stats.journal_replay_ms,
330            index_snapshot_write_ms: index_save_stats.snapshot_write_ms,
331            index_journal_append_ms: index_save_stats.journal_append_ms,
332            index_snapshot_bytes: index_save_stats
333                .snapshot_bytes
334                .max(index_load_stats.snapshot_bytes),
335            index_journal_bytes: index_save_stats
336                .journal_bytes
337                .max(index_load_stats.journal_bytes),
338            index_journal_ops: index_save_stats
339                .journal_ops
340                .max(index_load_stats.journal_ops),
341            index_compacted: index_save_stats.compacted,
342            index_compact_reason: index_save_stats.compact_reason,
343            fsmonitor_refresh_ms,
344            worker_count,
345        })
346    }
347
348    fn plan_tree_apply_recursive(
349        &self,
350        rel_path: &Path,
351        from_tree: Option<&Tree>,
352        to_tree: Option<&Tree>,
353        plan: &mut WorktreeApplyPlan,
354    ) -> Result<()> {
355        let from_entries = from_tree.map(Tree::entries).unwrap_or(&[]);
356        let to_entries = to_tree.map(Tree::entries).unwrap_or(&[]);
357        let mut from_index = 0;
358        let mut to_index = 0;
359
360        while from_index < from_entries.len() || to_index < to_entries.len() {
361            match (from_entries.get(from_index), to_entries.get(to_index)) {
362                (Some(from_entry), Some(to_entry)) => match from_entry.name.cmp(&to_entry.name) {
363                    std::cmp::Ordering::Less => {
364                        self.plan_remove_entry(&rel_path.join(&from_entry.name), from_entry, plan)?;
365                        from_index += 1;
366                    }
367                    std::cmp::Ordering::Greater => {
368                        self.plan_add_entry(&rel_path.join(&to_entry.name), to_entry, plan)?;
369                        to_index += 1;
370                    }
371                    std::cmp::Ordering::Equal => {
372                        self.plan_update_entry(
373                            &rel_path.join(&from_entry.name),
374                            from_entry,
375                            to_entry,
376                            plan,
377                        )?;
378                        from_index += 1;
379                        to_index += 1;
380                    }
381                },
382                (Some(from_entry), None) => {
383                    self.plan_remove_entry(&rel_path.join(&from_entry.name), from_entry, plan)?;
384                    from_index += 1;
385                }
386                (None, Some(to_entry)) => {
387                    self.plan_add_entry(&rel_path.join(&to_entry.name), to_entry, plan)?;
388                    to_index += 1;
389                }
390                (None, None) => break,
391            }
392        }
393
394        Ok(())
395    }
396
397    fn plan_add_entry(
398        &self,
399        rel_path: &Path,
400        entry: &TreeEntry,
401        plan: &mut WorktreeApplyPlan,
402    ) -> Result<()> {
403        match entry.entry_type {
404            EntryType::Blob => {
405                plan.stats.changed_count += 1;
406                plan.writes.push(WorktreeWriteOp::Blob {
407                    path: self.root().join(rel_path),
408                    hash: entry.hash,
409                    executable: entry.is_executable(),
410                });
411            }
412            EntryType::Symlink => {
413                plan.stats.changed_count += 1;
414                plan.writes.push(WorktreeWriteOp::Symlink {
415                    path: self.root().join(rel_path),
416                    hash: entry.hash,
417                    validation_root: self.root().to_path_buf(),
418                });
419            }
420            EntryType::Tree => {
421                plan.directories.push(self.root().join(rel_path));
422                let subtree = self
423                    .store
424                    .get_tree(&entry.hash)?
425                    .ok_or_else(|| HeddleError::NotFound(format!("tree {}", entry.hash)))?;
426                self.plan_tree_apply_recursive(rel_path, None, Some(&subtree), plan)?;
427            }
428        }
429
430        Ok(())
431    }
432
433    fn plan_remove_entry(
434        &self,
435        rel_path: &Path,
436        entry: &TreeEntry,
437        plan: &mut WorktreeApplyPlan,
438    ) -> Result<()> {
439        match entry.entry_type {
440            EntryType::Blob | EntryType::Symlink => {
441                plan.stats.changed_count += 1;
442                plan.removals.push(self.root().join(rel_path));
443            }
444            EntryType::Tree => {
445                let subtree = self
446                    .store
447                    .get_tree(&entry.hash)?
448                    .ok_or_else(|| HeddleError::NotFound(format!("tree {}", entry.hash)))?;
449                self.plan_tree_apply_recursive(rel_path, Some(&subtree), None, plan)?;
450                plan.removals.push(self.root().join(rel_path));
451            }
452        }
453
454        Ok(())
455    }
456
457    fn plan_update_entry(
458        &self,
459        rel_path: &Path,
460        from_entry: &TreeEntry,
461        to_entry: &TreeEntry,
462        plan: &mut WorktreeApplyPlan,
463    ) -> Result<()> {
464        if from_entry.entry_type == EntryType::Tree && to_entry.entry_type == EntryType::Tree {
465            if from_entry.hash == to_entry.hash {
466                plan.stats.unchanged_count += 1;
467                return Ok(());
468            }
469
470            let from_subtree = self
471                .store
472                .get_tree(&from_entry.hash)?
473                .ok_or_else(|| HeddleError::NotFound(format!("tree {}", from_entry.hash)))?;
474            let to_subtree = self
475                .store
476                .get_tree(&to_entry.hash)?
477                .ok_or_else(|| HeddleError::NotFound(format!("tree {}", to_entry.hash)))?;
478            return self.plan_tree_apply_recursive(
479                rel_path,
480                Some(&from_subtree),
481                Some(&to_subtree),
482                plan,
483            );
484        }
485
486        if from_entry.entry_type == EntryType::Blob && to_entry.entry_type == EntryType::Blob {
487            if from_entry.hash == to_entry.hash && from_entry.mode == to_entry.mode {
488                plan.stats.unchanged_count += 1;
489                return Ok(());
490            }
491
492            plan.stats.changed_count += 1;
493            plan.writes.push(WorktreeWriteOp::Blob {
494                path: self.root().join(rel_path),
495                hash: to_entry.hash,
496                executable: to_entry.is_executable(),
497            });
498            return Ok(());
499        }
500
501        if from_entry.entry_type == EntryType::Symlink && to_entry.entry_type == EntryType::Symlink
502        {
503            if from_entry.hash == to_entry.hash {
504                plan.stats.unchanged_count += 1;
505                return Ok(());
506            }
507
508            plan.stats.changed_count += 1;
509            plan.removals.push(self.root().join(rel_path));
510            plan.writes.push(WorktreeWriteOp::Symlink {
511                path: self.root().join(rel_path),
512                hash: to_entry.hash,
513                validation_root: self.root().to_path_buf(),
514            });
515            return Ok(());
516        }
517
518        self.plan_remove_entry(rel_path, from_entry, plan)?;
519        self.plan_add_entry(rel_path, to_entry, plan)
520    }
521
522    pub(crate) fn clear_worktree(&self) -> Result<()> {
523        let patterns = self.ignore_patterns()?;
524        let walker = ignore::WalkBuilder::new(&self.root)
525            .hidden(false)
526            .git_ignore(false)
527            .follow_links(false)
528            .build();
529
530        let mut to_remove = Vec::new();
531
532        for entry in walker {
533            let entry = entry.map_err(|e| HeddleError::Io(std::io::Error::other(e.to_string())))?;
534            let path = entry.path();
535
536            if path == self.root {
537                continue;
538            }
539
540            let rel_path = path.strip_prefix(&self.root).unwrap_or(path);
541
542            if should_ignore_path(rel_path, &patterns) {
543                continue;
544            }
545
546            to_remove.push(path.to_path_buf());
547        }
548
549        to_remove.sort();
550        to_remove.reverse();
551
552        for path in to_remove {
553            remove_existing_path(&path)?;
554        }
555
556        Ok(())
557    }
558
559    fn worktree_requires_clear(&self) -> Result<bool> {
560        let patterns = self.ignore_patterns()?;
561        for entry in fs::read_dir(self.root())? {
562            let entry = entry?;
563            let path = entry.path();
564            let rel_path = path.strip_prefix(self.root()).unwrap_or(&path);
565            if should_ignore_path(rel_path, &patterns) {
566                continue;
567            }
568            return Ok(true);
569        }
570        Ok(false)
571    }
572
573    fn refuse_full_rematerialize_on_dirty(
574        &self,
575        dirty_behavior: WorktreeApplyDirtyBehavior,
576        reason: WorktreeApplyFallbackReason,
577    ) -> Result<()> {
578        if dirty_behavior == WorktreeApplyDirtyBehavior::DiscardLocalChanges {
579            return Ok(());
580        }
581        let at_risk_paths = self.full_rematerialize_at_risk_paths()?;
582        if at_risk_paths.is_empty() {
583            return Ok(());
584        }
585        Err(full_rematerialize_dirty_refusal(reason, at_risk_paths))
586    }
587
588    fn full_rematerialize_at_risk_paths(&self) -> Result<Vec<String>> {
589        let patterns = self.ignore_patterns()?;
590        let walker = ignore::WalkBuilder::new(&self.root)
591            .hidden(false)
592            .git_ignore(false)
593            .follow_links(false)
594            .build();
595
596        let mut paths = Vec::new();
597        for entry in walker {
598            let entry = entry.map_err(|e| HeddleError::Io(std::io::Error::other(e.to_string())))?;
599            let path = entry.path();
600            if path == self.root {
601                continue;
602            }
603            let rel_path = path.strip_prefix(&self.root).unwrap_or(path);
604            if should_ignore_path(rel_path, &patterns) {
605                continue;
606            }
607            paths.push(format!("existing: {}", rel_path.display()));
608        }
609        paths.sort();
610        Ok(paths)
611    }
612
613    fn invalidate_worktree_performance_state(&self) -> Result<()> {
614        self.remove_if_exists(&self.root.join(".heddle/state").join("index.bin"))?;
615        self.remove_if_exists(&self.root.join(".heddle/state").join("index.journal"))?;
616        self.remove_if_exists(&self.root.join(".heddle/state").join("fsmonitor.toml"))?;
617        Ok(())
618    }
619
620    fn refresh_worktree_performance_state_after_full_rematerialize(
621        &self,
622        materialized: MaterializedTree,
623        dir: &Path,
624    ) -> Result<()> {
625        if dir != self.root() {
626            return self.invalidate_worktree_performance_state();
627        }
628
629        let index_path = self.root.join(".heddle/state").join("index.bin");
630        let mut index = WorktreeIndex::new();
631        for entry in materialized.file_entries {
632            index.insert_seeded(entry.key, entry.entry);
633        }
634        for directory in materialized.directory_contexts {
635            let metadata = fs::symlink_metadata(&directory.path)?;
636            if !metadata.is_dir() {
637                return Err(HeddleError::Config(format!(
638                    "materialized path is not a directory: {}",
639                    directory.path.display()
640                )));
641            }
642            if let Some(directory_entry) = DirectoryCacheEntry::from_child_names(
643                &metadata,
644                directory.child_names.iter().map(String::as_str),
645                directory.child_names.len(),
646                Some(directory.tree_hash),
647            ) {
648                index.insert_seeded_directory(directory.key, directory_entry);
649            }
650        }
651        index.save_snapshot_profiled(&index_path).map_err(|error| {
652            HeddleError::Config(format!(
653                "save worktree index after full rematerialize: {error}"
654            ))
655        })?;
656        Ok(())
657    }
658
659    fn update_worktree_index_after_incremental_apply(
660        &self,
661        plan: &WorktreeApplyPlan,
662        tree: &Tree,
663    ) -> Result<(u128, WorktreeIndexLoadStats, WorktreeIndexSaveStats)> {
664        let index_path = self.root.join(".heddle/state").join("index.bin");
665        let load_start = Instant::now();
666        let (mut index, load_stats) = match WorktreeIndex::load_profiled(&index_path) {
667            Ok(result) => result,
668            Err(error) => {
669                warn!(path = %index_path.display(), %error, "Ignoring unreadable worktree index during incremental apply");
670                (WorktreeIndex::new(), WorktreeIndexLoadStats::default())
671            }
672        };
673
674        let mut affected_directory_keys = BTreeSet::from([String::new()]);
675
676        for path in &plan.removals {
677            let rel_path = path.strip_prefix(self.root()).unwrap_or(path);
678            index.remove_path_and_descendants(&cache_key(rel_path));
679            extend_ancestor_directory_keys(&mut affected_directory_keys, rel_path.parent());
680        }
681
682        for directory in &plan.directories {
683            let rel_path = directory.strip_prefix(self.root()).unwrap_or(directory);
684            index.remove_path_and_descendants(&cache_key(rel_path));
685            extend_ancestor_directory_keys(&mut affected_directory_keys, Some(rel_path));
686        }
687
688        for write in &plan.writes {
689            let rel_path = write
690                .path()
691                .strip_prefix(self.root())
692                .unwrap_or(write.path());
693            let key = cache_key(rel_path);
694            index.remove_path_and_descendants(&key);
695            if let Ok(metadata) = fs::symlink_metadata(write.path())
696                && let Some(cached) = build_cached_entry(
697                    write.hash(),
698                    &metadata,
699                    write.executable(),
700                    write.index_kind(),
701                )
702            {
703                index.insert(key, cached);
704            }
705            extend_ancestor_directory_keys(&mut affected_directory_keys, rel_path.parent());
706        }
707
708        let mut tree_lookup = DirectoryTreeHashLookup::new(self, tree);
709
710        for dir_key in affected_directory_keys {
711            refresh_directory_index_entry_from_tree(self, &mut index, &dir_key, &mut tree_lookup)?;
712        }
713
714        let save_stats = if index.is_dirty() {
715            let stats = index.save_profiled(&index_path).map_err(|error| {
716                HeddleError::Config(format!(
717                    "save worktree index after incremental apply: {error}"
718                ))
719            })?;
720            index.mark_clean();
721            stats
722        } else {
723            WorktreeIndexSaveStats::default()
724        };
725
726        Ok((load_start.elapsed().as_millis(), load_stats, save_stats))
727    }
728
729    fn refresh_fsmonitor_after_incremental_apply(&self) {
730        let settings = FsMonitorSettings::from(self.config.worktree.fsmonitor);
731        if let Err(error) = persist_current_monitor_cursor(self.root(), settings) {
732            warn!(root = %self.root().display(), %error, "Failed to refresh monitor cursor after incremental apply");
733        }
734    }
735
736    fn remove_if_exists(&self, path: &Path) -> Result<()> {
737        match fs::remove_file(path) {
738            Ok(()) => Ok(()),
739            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
740            Err(error) => Err(HeddleError::Io(enrich_fs_error(path, "removing", error))),
741        }
742    }
743
744    /// Remove only the heddle-tracked descendants beneath `path`, preserving
745    /// any untracked or explicitly ignored siblings.
746    ///
747    /// This exists so commands that mutate the worktree at the top-level
748    /// tree-entry granularity (`merge`, `cherry-pick`, `revert`) can drop a
749    /// tracked directory without recursively destroying the user's local
750    /// build artifacts, dependencies, or co-located Git state. The shape
751    /// matches `remove_existing_path` in this module: tracked content is
752    /// removed, then the directory itself is removed *if empty*; if ignored
753    /// content keeps it occupied, the dir is left in place. That keeps disk
754    /// in lock-step with the new tree (no stale tracked file under the dir)
755    /// without nuking work the user expects to survive.
756    ///
757    /// Ignore-pattern based variant. Uses the *current* `.heddleignore` to
758    /// decide which children to preserve. This is unsafe for the
759    /// merge/cherry-pick/revert flow when a tracked path is also matched by
760    /// a current ignore rule: the file would silently survive on disk after
761    /// HEAD advances. Prefer
762    /// [`Self::remove_tracked_descendants_with_source`] in those flows so
763    /// removal is driven by the source-tree's actual tracked set.
764    ///
765    /// `path` must be inside the repository root. If it doesn't exist, this
766    /// is a no-op. If it's a regular file or symlink, it is removed.
767    pub fn remove_tracked_descendants(&self, path: &Path) -> Result<()> {
768        let metadata = match fs::symlink_metadata(path) {
769            Ok(metadata) => metadata,
770            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
771            Err(error) => return Err(HeddleError::Io(enrich_fs_error(path, "inspecting", error))),
772        };
773
774        let file_type = metadata.file_type();
775        if file_type.is_symlink() || file_type.is_file() {
776            fs::remove_file(path)
777                .map_err(|e| HeddleError::Io(enrich_fs_error(path, "removing", e)))?;
778            return Ok(());
779        }
780        if !file_type.is_dir() {
781            return Ok(());
782        }
783
784        let patterns = self.ignore_patterns()?;
785        remove_tracked_descendants_inner_by_ignore(self.root(), path, &patterns)
786    }
787
788    /// Tree-driven variant of [`Self::remove_tracked_descendants`].
789    ///
790    /// Removal is driven by `source_subtree` — the subtree at `path` in the
791    /// state we're transitioning AWAY from. Every blob/symlink it lists is
792    /// removed; nested directory entries are recursed into using the matching
793    /// child subtree. This is intentionally independent of the *current*
794    /// ignore rules: a `.heddleignore` (or config-level) rule that newly
795    /// matches a previously-tracked path must NOT silently preserve that
796    /// path on disk. Doing so would let HEAD advance past a tree where the
797    /// path is gone while the worktree still holds the stale content,
798    /// hidden from `heddle status` by the same ignore rule. Tracked-tree
799    /// membership is the only source of truth here.
800    ///
801    /// `path` must be inside the repository root. If it doesn't exist, this
802    /// is a no-op. If it's a regular file or symlink, it is removed.
803    pub fn remove_tracked_descendants_with_source(
804        &self,
805        path: &Path,
806        source_subtree: &Tree,
807    ) -> Result<()> {
808        let metadata = match fs::symlink_metadata(path) {
809            Ok(metadata) => metadata,
810            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
811            Err(error) => return Err(HeddleError::Io(enrich_fs_error(path, "inspecting", error))),
812        };
813
814        let file_type = metadata.file_type();
815        if file_type.is_symlink() || file_type.is_file() {
816            fs::remove_file(path)
817                .map_err(|e| HeddleError::Io(enrich_fs_error(path, "removing", e)))?;
818            return Ok(());
819        }
820        if !file_type.is_dir() {
821            return Ok(());
822        }
823
824        remove_tracked_descendants_inner(self, path, source_subtree)
825    }
826
827    /// Look up the subtree at `rel_path` within `root_tree`. Returns `None`
828    /// if the path isn't reachable as a `Tree`-typed entry (missing entry,
829    /// blob entry, or unresolved hash). Used by
830    /// [`Self::remove_tracked_descendants_with_source`] callers to derive
831    /// the source subtree from a top-level tree entry.
832    pub fn resolve_subtree(&self, root_tree: &Tree, rel_path: &Path) -> Result<Option<Tree>> {
833        // Walk component-by-component, owning the current subtree at each
834        // step. Each iteration consults the most recently resolved Tree,
835        // so the previous one can be dropped — we never need to borrow
836        // across iterations.
837        let mut components = rel_path.components();
838        let first = match components.next() {
839            Some(c) => c,
840            None => return Ok(Some(root_tree.clone())),
841        };
842        let mut current = match self.descend_one(root_tree, first)? {
843            Some(t) => t,
844            None => return Ok(None),
845        };
846        for component in components {
847            current = match self.descend_one(&current, component)? {
848                Some(t) => t,
849                None => return Ok(None),
850            };
851        }
852        Ok(Some(current))
853    }
854
855    fn descend_one(
856        &self,
857        tree: &Tree,
858        component: std::path::Component<'_>,
859    ) -> Result<Option<Tree>> {
860        let name = match component.as_os_str().to_str() {
861            Some(name) => name,
862            None => return Ok(None),
863        };
864        let Some(entry) = tree.entries().iter().find(|e| e.name == name) else {
865            return Ok(None);
866        };
867        if entry.entry_type != EntryType::Tree {
868            return Ok(None);
869        }
870        self.store().get_tree(&entry.hash)
871    }
872}
873
874fn refresh_directory_index_entry_from_tree(
875    repo: &Repository,
876    index: &mut WorktreeIndex,
877    dir_key: &str,
878    tree_lookup: &mut DirectoryTreeHashLookup<'_>,
879) -> Result<()> {
880    let Some(tree) = tree_lookup.subtree_at_directory(dir_key)? else {
881        index.remove_directory(dir_key);
882        return Ok(());
883    };
884
885    let dir_path = if dir_key.is_empty() {
886        repo.root().to_path_buf()
887    } else {
888        repo.root().join(dir_key)
889    };
890    let metadata = match fs::symlink_metadata(&dir_path) {
891        Ok(metadata) if metadata.is_dir() => metadata,
892        Ok(_) | Err(_) => {
893            index.remove_directory(dir_key);
894            return Ok(());
895        }
896    };
897    if let Some(directory_entry) = DirectoryCacheEntry::from_child_names(
898        &metadata,
899        tree.entries().iter().map(|entry| entry.name.as_str()),
900        tree.entries().len(),
901        Some(tree.hash()),
902    ) {
903        index.insert_directory(dir_key.to_string(), directory_entry);
904    } else {
905        index.remove_directory(dir_key);
906    }
907    Ok(())
908}
909
910struct DirectoryTreeHashLookup<'repo> {
911    repo: &'repo Repository,
912    root_tree: &'repo Tree,
913    subtrees: BTreeMap<String, Option<Tree>>,
914}
915
916impl<'repo> DirectoryTreeHashLookup<'repo> {
917    fn new(repo: &'repo Repository, root_tree: &'repo Tree) -> Self {
918        Self {
919            repo,
920            root_tree,
921            subtrees: BTreeMap::new(),
922        }
923    }
924
925    fn subtree_at_directory(&mut self, dir_key: &str) -> Result<Option<&Tree>> {
926        if dir_key.is_empty() {
927            return Ok(Some(self.root_tree));
928        }
929
930        if !self.subtrees.contains_key(dir_key) {
931            let subtree = self.load_subtree(dir_key)?;
932            self.subtrees.insert(dir_key.to_string(), subtree);
933        }
934
935        Ok(self.subtrees.get(dir_key).and_then(Option::as_ref))
936    }
937
938    fn load_subtree(&mut self, dir_key: &str) -> Result<Option<Tree>> {
939        let Some((parent_key, name)) = split_parent_directory_key(dir_key) else {
940            return Ok(None);
941        };
942        let Some(tree_hash) = ({
943            let Some(parent_tree) = self.subtree_at_directory(&parent_key)? else {
944                return Ok(None);
945            };
946            let Some(entry) = parent_tree.get(name) else {
947                return Ok(None);
948            };
949            if entry.entry_type != EntryType::Tree {
950                return Ok(None);
951            }
952            Some(entry.hash)
953        }) else {
954            return Ok(None);
955        };
956
957        self.repo.store().get_tree(&tree_hash)
958    }
959}
960
961fn split_parent_directory_key(dir_key: &str) -> Option<(String, &str)> {
962    if dir_key.is_empty() {
963        return None;
964    }
965    let (parent_key, name) = match dir_key.rfind('/') {
966        Some(idx) => (&dir_key[..idx], &dir_key[idx + 1..]),
967        None => ("", dir_key),
968    };
969    if name.is_empty() {
970        return None;
971    }
972    Some((parent_key.to_string(), name))
973}
974
975fn extend_ancestor_directory_keys(keys: &mut BTreeSet<String>, rel_path: Option<&Path>) {
976    let Some(mut current) = rel_path else {
977        keys.insert(String::new());
978        return;
979    };
980
981    loop {
982        keys.insert(cache_key(current));
983        match current.parent() {
984            Some(parent) if !parent.as_os_str().is_empty() => current = parent,
985            _ => {
986                keys.insert(String::new());
987                break;
988            }
989        }
990    }
991}
992
993fn remove_existing_path(path: &Path) -> Result<()> {
994    match fs::symlink_metadata(path) {
995        Ok(metadata) => {
996            let file_type = metadata.file_type();
997            if file_type.is_symlink() || file_type.is_file() {
998                fs::remove_file(path)
999                    .map_err(|e| HeddleError::Io(enrich_fs_error(path, "removing", e)))?;
1000            } else if file_type.is_dir() {
1001                match fs::remove_dir(path) {
1002                    Ok(()) => {}
1003                    // The directory still holds entries the apply
1004                    // intentionally preserved — untracked or explicitly
1005                    // ignored content that the planner skipped over. Leave
1006                    // the directory in place: its tracked children are
1007                    // already gone and the local children must survive the apply.
1008                    // Without this tolerance, an undo over a real-world
1009                    // worktree with a `.git` or `target` dir aborts mid-
1010                    // run with `os error 66` after destroying the tracked
1011                    // files but before HEAD advances, leaving state
1012                    // diverged from disk.
1013                    Err(error) if is_directory_not_empty(&error) => {}
1014                    Err(error) => {
1015                        return Err(HeddleError::Io(enrich_fs_error(path, "removing", error)));
1016                    }
1017                }
1018            }
1019            Ok(())
1020        }
1021        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
1022        Err(error) => Err(HeddleError::Io(enrich_fs_error(path, "inspecting", error))),
1023    }
1024}
1025
1026fn full_rematerialize_dirty_refusal_from_status(
1027    reason: WorktreeApplyFallbackReason,
1028    detailed: &crate::WorktreeStatusDetailed,
1029) -> HeddleError {
1030    let untracked = detailed.untracked.flatten_paths();
1031    let at_risk_paths = detailed
1032        .modified
1033        .iter()
1034        .map(|path| format!("modified: {}", path.display()))
1035        .chain(
1036            detailed
1037                .deleted
1038                .iter()
1039                .map(|path| format!("deleted: {}", path.display())),
1040        )
1041        .chain(
1042            untracked
1043                .iter()
1044                .map(|path| format!("untracked: {}", path.display())),
1045        )
1046        .collect();
1047    full_rematerialize_dirty_refusal(reason, at_risk_paths)
1048}
1049
1050fn full_rematerialize_dirty_refusal(
1051    reason: WorktreeApplyFallbackReason,
1052    at_risk_paths: Vec<String>,
1053) -> HeddleError {
1054    HeddleError::Conflict(format!(
1055        "dirty worktree would be overwritten by full rematerialize ({reason}); unsnapped edits at risk: {paths}. Capture, commit, or stash them first, or rerun with --force to discard local changes.",
1056        reason = reason.as_str(),
1057        paths = format_at_risk_paths(&at_risk_paths),
1058    ))
1059}
1060
1061fn format_at_risk_paths(paths: &[String]) -> String {
1062    const LIMIT: usize = 20;
1063    let mut rendered = paths
1064        .iter()
1065        .take(LIMIT)
1066        .map(String::as_str)
1067        .collect::<Vec<_>>()
1068        .join(", ");
1069    let remaining = paths.len().saturating_sub(LIMIT);
1070    if remaining > 0 {
1071        rendered.push_str(&format!(", ... and {remaining} more"));
1072    }
1073    rendered
1074}
1075
1076/// Legacy ignore-driven walker — backs the deprecated
1077/// [`Repository::remove_tracked_descendants`] entrypoint. Walks `dir`
1078/// recursively, removing every entry whose worktree-relative path is
1079/// *not* heddle-ignored. New code should use the tree-driven variant
1080/// (`remove_tracked_descendants_inner`) so that ignore-rule changes
1081/// can't silently strand previously-tracked content on disk.
1082fn remove_tracked_descendants_inner_by_ignore(
1083    root: &Path,
1084    dir: &Path,
1085    patterns: &[String],
1086) -> Result<()> {
1087    let entries = match fs::read_dir(dir) {
1088        Ok(entries) => entries,
1089        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
1090        Err(error) => return Err(HeddleError::Io(enrich_fs_error(dir, "reading", error))),
1091    };
1092
1093    for entry in entries {
1094        let entry = entry?;
1095        let child = entry.path();
1096        let rel = child.strip_prefix(root).unwrap_or(&child);
1097
1098        if should_ignore_path(rel, patterns) {
1099            continue;
1100        }
1101
1102        let file_type = entry.file_type()?;
1103        if file_type.is_symlink() || file_type.is_file() {
1104            fs::remove_file(&child)
1105                .map_err(|e| HeddleError::Io(enrich_fs_error(&child, "removing", e)))?;
1106        } else if file_type.is_dir() {
1107            remove_tracked_descendants_inner_by_ignore(root, &child, patterns)?;
1108        }
1109    }
1110
1111    match fs::remove_dir(dir) {
1112        Ok(()) => Ok(()),
1113        Err(error) if is_directory_not_empty(&error) => Ok(()),
1114        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
1115        Err(error) => Err(HeddleError::Io(enrich_fs_error(dir, "removing", error))),
1116    }
1117}
1118
1119/// Walk the entries listed in `source_subtree` and remove the matching
1120/// children of `dir` from disk. Anything not present in the subtree (an
1121/// untracked or explicitly ignored sibling, OR previously-tracked content
1122/// that the subtree doesn't list — there is none, by construction) is left
1123/// untouched. After draining tracked
1124/// descendants, `dir` itself is removed if it ended up empty; otherwise it
1125/// is left in place for the same reason `remove_existing_path` tolerates
1126/// `ENOTEMPTY`.
1127///
1128/// Tree-driven removal is the load-bearing invariant: it is independent of
1129/// the *current* `.heddleignore` patterns, so a newly-added ignore rule
1130/// matching previously-tracked content cannot silently preserve that
1131/// content on disk after a merge/cherry-pick/revert that drops it.
1132///
1133/// `repo` is used to resolve nested subtrees by hash; `dir` must be a
1134/// directory and the caller has already verified that.
1135fn remove_tracked_descendants_inner(
1136    repo: &Repository,
1137    dir: &Path,
1138    source_subtree: &Tree,
1139) -> Result<()> {
1140    for entry in source_subtree.entries() {
1141        let child = dir.join(&entry.name);
1142        match entry.entry_type {
1143            EntryType::Blob | EntryType::Symlink => match fs::symlink_metadata(&child) {
1144                Ok(_) => {
1145                    fs::remove_file(&child)
1146                        .map_err(|e| HeddleError::Io(enrich_fs_error(&child, "removing", e)))?;
1147                }
1148                Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1149                Err(error) => {
1150                    return Err(HeddleError::Io(enrich_fs_error(
1151                        &child,
1152                        "inspecting",
1153                        error,
1154                    )));
1155                }
1156            },
1157            EntryType::Tree => {
1158                let metadata = match fs::symlink_metadata(&child) {
1159                    Ok(m) => m,
1160                    Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
1161                    Err(error) => {
1162                        return Err(HeddleError::Io(enrich_fs_error(
1163                            &child,
1164                            "inspecting",
1165                            error,
1166                        )));
1167                    }
1168                };
1169                if !metadata.file_type().is_dir() {
1170                    // Tree entry but disk holds a file/symlink: remove it
1171                    // — that file is not in the source-tree's blob set
1172                    // either, but treating it as tracked content here
1173                    // keeps disk in lock-step with the new tree.
1174                    fs::remove_file(&child)
1175                        .map_err(|e| HeddleError::Io(enrich_fs_error(&child, "removing", e)))?;
1176                    continue;
1177                }
1178                let nested = match repo.store().get_tree(&entry.hash)? {
1179                    Some(t) => t,
1180                    None => continue,
1181                };
1182                remove_tracked_descendants_inner(repo, &child, &nested)?;
1183            }
1184        }
1185    }
1186
1187    // Drop the directory itself if its tracked content is gone. Ignored
1188    // children may still be present, in which case `remove_dir` returns
1189    // `ENOTEMPTY` and we leave the dir in place — the caller's contract is
1190    // "tracked content gone", not "directory gone".
1191    match fs::remove_dir(dir) {
1192        Ok(()) => Ok(()),
1193        Err(error) if is_directory_not_empty(&error) => Ok(()),
1194        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
1195        Err(error) => Err(HeddleError::Io(enrich_fs_error(dir, "removing", error))),
1196    }
1197}
1198
1199/// Detects an `ENOTEMPTY`-equivalent error from `remove_dir`.
1200///
1201/// The apply planner only removes tracked descendants. When tracked content is
1202/// removed, the parent directory may still hold untracked or explicitly ignored
1203/// siblings; `remove_dir` then errors. Callers tolerate that error
1204/// by leaving the directory in place — the tracked children are
1205/// already gone and the ignored ones must survive the apply.
1206///
1207/// Shared between `remove_existing_path` (incremental + full apply)
1208/// and `repository_materialization::remove_materialized_leaf`
1209/// (symlink-write replacement). Both paths can otherwise abort
1210/// mid-apply after destructive writes, leaving state diverged from
1211/// disk.
1212///
1213/// Thin re-export over `objects::fs_atomic::is_directory_not_empty` so the
1214/// canonical predicate (and its raw-OS-code coverage) lives in one place
1215/// alongside the other fs error predicates the workspace shares.
1216pub(crate) fn is_directory_not_empty(error: &std::io::Error) -> bool {
1217    fs_is_directory_not_empty(error)
1218}
1219
1220#[cfg(test)]
1221mod tests {
1222    use std::{fs, thread, time::Duration};
1223
1224    use super::*;
1225    use crate::Repository;
1226
1227    fn create_repo() -> (tempfile::TempDir, Repository) {
1228        let temp_dir = tempfile::TempDir::new().unwrap();
1229        let repo = Repository::init_default(temp_dir.path()).unwrap();
1230        (temp_dir, repo)
1231    }
1232
1233    #[test]
1234    fn goto_same_tree_plan_is_incremental_and_empty() {
1235        let (temp_dir, repo) = create_repo();
1236        fs::write(temp_dir.path().join("a.txt"), "version 1").unwrap();
1237        let state = repo.snapshot(Some("base".to_string()), None).unwrap();
1238        let tree = repo.store().get_tree(&state.tree).unwrap().unwrap();
1239
1240        let plan = repo
1241            .plan_worktree_apply(
1242                Some(&tree),
1243                &tree,
1244                temp_dir.path(),
1245                true,
1246                WorktreeApplyDirtyBehavior::RefuseOnDirty,
1247            )
1248            .unwrap();
1249
1250        assert_eq!(plan.strategy, WorktreeApplyStrategy::Incremental);
1251        assert!(plan.removals.is_empty());
1252        assert!(plan.directories.is_empty());
1253        assert!(plan.writes.is_empty());
1254    }
1255
1256    #[test]
1257    fn goto_small_delta_plan_only_writes_changed_paths() {
1258        let (temp_dir, repo) = create_repo();
1259        let keep = temp_dir.path().join("keep.txt");
1260        let flip = temp_dir.path().join("flip.txt");
1261        fs::write(&keep, "keep").unwrap();
1262        fs::write(&flip, "v1").unwrap();
1263        let state_one = repo.snapshot(Some("one".to_string()), None).unwrap();
1264
1265        fs::write(&flip, "v2").unwrap();
1266        let state_two = repo.snapshot(Some("two".to_string()), None).unwrap();
1267
1268        let tree_one = repo.store().get_tree(&state_one.tree).unwrap().unwrap();
1269        let tree_two = repo.store().get_tree(&state_two.tree).unwrap().unwrap();
1270
1271        repo.goto(&state_one.change_id).unwrap();
1272        let keep_before = fs::metadata(&keep).unwrap().modified().unwrap();
1273
1274        thread::sleep(Duration::from_millis(20));
1275        let plan = repo
1276            .plan_worktree_apply(
1277                Some(&tree_one),
1278                &tree_two,
1279                temp_dir.path(),
1280                true,
1281                WorktreeApplyDirtyBehavior::RefuseOnDirty,
1282            )
1283            .unwrap();
1284        let report = repo
1285            .execute_worktree_apply(&plan, &tree_two, temp_dir.path())
1286            .unwrap();
1287
1288        assert_eq!(plan.strategy, WorktreeApplyStrategy::Incremental);
1289        assert_eq!(plan.writes.len(), 1);
1290        assert!(report.write_phase_ms < 1_000);
1291        assert_eq!(fs::read_to_string(&flip).unwrap(), "v2");
1292        assert_eq!(
1293            fs::metadata(&keep).unwrap().modified().unwrap(),
1294            keep_before
1295        );
1296    }
1297
1298    #[test]
1299    fn full_rematerialize_refuses_dirty_worktree_and_preserves_edits() {
1300        let (temp_dir, repo) = create_repo();
1301        let tracked = temp_dir.path().join("tracked.txt");
1302        let notes = temp_dir.path().join("notes.md");
1303
1304        fs::write(&tracked, "base\n").unwrap();
1305        let state_one = repo.snapshot(Some("one".to_string()), None).unwrap();
1306
1307        fs::write(&tracked, "target\n").unwrap();
1308        fs::write(temp_dir.path().join("target-only.txt"), "target file\n").unwrap();
1309        let state_two = repo.snapshot(Some("two".to_string()), None).unwrap();
1310
1311        repo.goto(&state_one.change_id).unwrap();
1312        fs::write(&tracked, "unsnapped edit\n").unwrap();
1313        fs::write(&notes, "local notes\n").unwrap();
1314
1315        let err = repo.goto(&state_two.change_id).unwrap_err();
1316        let msg = err.to_string();
1317        assert!(
1318            msg.contains("dirty worktree")
1319                && msg.contains("full rematerialize")
1320                && msg.contains("modified: tracked.txt")
1321                && msg.contains("untracked: notes.md")
1322                && msg.contains("--force"),
1323            "refusal should name the destructive apply and at-risk edits: {msg}"
1324        );
1325        assert_eq!(fs::read_to_string(&tracked).unwrap(), "unsnapped edit\n");
1326        assert_eq!(fs::read_to_string(&notes).unwrap(), "local notes\n");
1327        assert!(!temp_dir.path().join("target-only.txt").exists());
1328    }
1329
1330    #[test]
1331    fn full_rematerialize_discard_opt_in_proceeds_on_dirty_worktree() {
1332        let (temp_dir, repo) = create_repo();
1333        let tracked = temp_dir.path().join("tracked.txt");
1334        let notes = temp_dir.path().join("notes.md");
1335
1336        fs::write(&tracked, "base\n").unwrap();
1337        let state_one = repo.snapshot(Some("one".to_string()), None).unwrap();
1338
1339        fs::write(&tracked, "target\n").unwrap();
1340        fs::write(temp_dir.path().join("target-only.txt"), "target file\n").unwrap();
1341        let state_two = repo.snapshot(Some("two".to_string()), None).unwrap();
1342
1343        repo.goto(&state_one.change_id).unwrap();
1344        fs::write(&tracked, "unsnapped edit\n").unwrap();
1345        fs::write(&notes, "local notes\n").unwrap();
1346
1347        repo.goto_discard_local(&state_two.change_id).unwrap();
1348
1349        assert_eq!(fs::read_to_string(&tracked).unwrap(), "target\n");
1350        assert!(!notes.exists());
1351        assert_eq!(
1352            fs::read_to_string(temp_dir.path().join("target-only.txt")).unwrap(),
1353            "target file\n"
1354        );
1355    }
1356
1357    #[test]
1358    fn non_root_full_rematerialize_refuses_existing_worktree_content() {
1359        let (temp_dir, repo) = create_repo();
1360        fs::write(temp_dir.path().join("tracked.txt"), "tracked\n").unwrap();
1361        let state = repo.snapshot(Some("seed".to_string()), None).unwrap();
1362        let tree = repo.store().get_tree(&state.tree).unwrap().unwrap();
1363        let non_root = temp_dir.path().join("checkout");
1364
1365        let err = repo
1366            .plan_worktree_apply(
1367                Some(&tree),
1368                &tree,
1369                &non_root,
1370                false,
1371                WorktreeApplyDirtyBehavior::RefuseOnDirty,
1372            )
1373            .unwrap_err();
1374        let msg = err.to_string();
1375        assert!(
1376            msg.contains("non_root_directory") && msg.contains("existing: tracked.txt"),
1377            "non-root fallback should refuse before overwriting existing checkout content: {msg}"
1378        );
1379        assert_eq!(
1380            fs::read_to_string(temp_dir.path().join("tracked.txt")).unwrap(),
1381            "tracked\n"
1382        );
1383    }
1384
1385    #[test]
1386    fn non_root_full_rematerialize_with_discard_clears_and_writes_target_tree() {
1387        let (temp_dir, repo) = create_repo();
1388        fs::write(temp_dir.path().join("tracked.txt"), "tracked\n").unwrap();
1389        let state = repo.snapshot(Some("seed".to_string()), None).unwrap();
1390        let tree = repo.store().get_tree(&state.tree).unwrap().unwrap();
1391        fs::write(temp_dir.path().join("local.txt"), "local\n").unwrap();
1392        let non_root = temp_dir.path().join("checkout");
1393
1394        let plan = repo
1395            .plan_worktree_apply(
1396                Some(&tree),
1397                &tree,
1398                &non_root,
1399                false,
1400                WorktreeApplyDirtyBehavior::DiscardLocalChanges,
1401            )
1402            .unwrap();
1403        assert_eq!(plan.strategy, WorktreeApplyStrategy::FullRematerialize);
1404        assert_eq!(
1405            plan.fallback_reason,
1406            Some(WorktreeApplyFallbackReason::NonRootDirectory)
1407        );
1408
1409        repo.execute_worktree_apply(&plan, &tree, &non_root)
1410            .unwrap();
1411
1412        assert!(!temp_dir.path().join("local.txt").exists());
1413        assert_eq!(
1414            fs::read_to_string(non_root.join("tracked.txt")).unwrap(),
1415            "tracked\n"
1416        );
1417        assert!(
1418            !temp_dir.path().join(".heddle/state/index.bin").exists(),
1419            "non-root full rematerialize must invalidate the root worktree index"
1420        );
1421    }
1422
1423    #[test]
1424    fn execute_full_rematerialize_regates_refuse_on_dirty_plans() {
1425        let (temp_dir, repo) = create_repo();
1426        fs::write(temp_dir.path().join("tracked.txt"), "tracked\n").unwrap();
1427        let state = repo.snapshot(Some("seed".to_string()), None).unwrap();
1428        let tree = repo.store().get_tree(&state.tree).unwrap().unwrap();
1429        fs::write(temp_dir.path().join("local.txt"), "local\n").unwrap();
1430        let plan = WorktreeApplyPlan::fallback(
1431            WorktreeApplyFallbackReason::MissingCurrentTree,
1432            WorktreeApplyDirtyBehavior::RefuseOnDirty,
1433        );
1434
1435        let err = repo
1436            .execute_worktree_apply(&plan, &tree, temp_dir.path())
1437            .unwrap_err();
1438        let msg = err.to_string();
1439
1440        assert!(
1441            msg.contains("missing_current_tree") && msg.contains("existing: local.txt"),
1442            "execute-time re-gate should refuse stale full-rematerialize plans: {msg}"
1443        );
1444        assert_eq!(
1445            fs::read_to_string(temp_dir.path().join("local.txt")).unwrap(),
1446            "local\n"
1447        );
1448        assert_eq!(
1449            fs::read_to_string(temp_dir.path().join("tracked.txt")).unwrap(),
1450            "tracked\n"
1451        );
1452    }
1453
1454    #[test]
1455    fn clean_worktree_apply_still_succeeds_by_default() {
1456        let (temp_dir, repo) = create_repo();
1457        let tracked = temp_dir.path().join("tracked.txt");
1458
1459        fs::write(&tracked, "base\n").unwrap();
1460        let state_one = repo.snapshot(Some("one".to_string()), None).unwrap();
1461        fs::write(&tracked, "target\n").unwrap();
1462        let state_two = repo.snapshot(Some("two".to_string()), None).unwrap();
1463
1464        repo.goto(&state_one.change_id).unwrap();
1465        repo.goto(&state_two.change_id).unwrap();
1466
1467        assert_eq!(fs::read_to_string(&tracked).unwrap(), "target\n");
1468    }
1469
1470    #[test]
1471    fn full_rematerialize_reseeds_worktree_index() {
1472        let (temp_dir, repo) = create_repo();
1473        let nested_dir = temp_dir.path().join("src/bin");
1474        fs::create_dir_all(&nested_dir).unwrap();
1475        fs::write(temp_dir.path().join("README.md"), "hello\n").unwrap();
1476        fs::write(nested_dir.join("app.rs"), "fn main() {}\n").unwrap();
1477        let state = repo.snapshot(Some("seed".to_string()), None).unwrap();
1478        let tree = repo.store().get_tree(&state.tree).unwrap().unwrap();
1479
1480        repo.clear_worktree().unwrap();
1481
1482        let plan = WorktreeApplyPlan::fallback(
1483            WorktreeApplyFallbackReason::MissingCurrentTree,
1484            WorktreeApplyDirtyBehavior::DiscardLocalChanges,
1485        );
1486        let report = repo
1487            .execute_worktree_apply(&plan, &tree, temp_dir.path())
1488            .unwrap();
1489
1490        let index = WorktreeIndex::load(&temp_dir.path().join(".heddle/state/index.bin")).unwrap();
1491        assert!(!temp_dir.path().join(".heddle/state/index.journal").exists());
1492
1493        assert!(report.index_update_ms < 1_000);
1494        assert!(index.get("README.md").is_some());
1495        assert!(index.get("src/bin/app.rs").is_some());
1496        assert!(repo.worktree_is_clean_cached(&tree).unwrap());
1497    }
1498
1499    #[test]
1500    fn directory_tree_hash_lookup_reuses_subtree_hashes() {
1501        let (temp_dir, repo) = create_repo();
1502        let nested_dir = temp_dir.path().join("src/bin");
1503        fs::create_dir_all(&nested_dir).unwrap();
1504        fs::write(temp_dir.path().join("README.md"), "hello\n").unwrap();
1505        fs::write(nested_dir.join("app.rs"), "fn main() {}\n").unwrap();
1506        let state = repo.snapshot(Some("seed".to_string()), None).unwrap();
1507        let tree = repo.store().get_tree(&state.tree).unwrap().unwrap();
1508        let src_hash = tree.get("src").unwrap().hash;
1509        let src_tree = repo.store().get_tree(&src_hash).unwrap().unwrap();
1510        let bin_hash = src_tree.get("bin").unwrap().hash;
1511        let mut lookup = DirectoryTreeHashLookup::new(&repo, &tree);
1512
1513        assert_eq!(
1514            lookup.subtree_at_directory("").unwrap().map(Tree::hash),
1515            Some(tree.hash())
1516        );
1517        assert_eq!(
1518            lookup.subtree_at_directory("src").unwrap().map(Tree::hash),
1519            Some(src_hash)
1520        );
1521        assert_eq!(
1522            lookup
1523                .subtree_at_directory("src/bin")
1524                .unwrap()
1525                .map(Tree::hash),
1526            Some(bin_hash)
1527        );
1528        assert!(lookup.subtree_at_directory("missing").unwrap().is_none());
1529    }
1530
1531    /// Exercises the end-to-end path: a `fs::remove_dir` against a
1532    /// non-empty directory must produce a wrapped `HeddleError::Io`
1533    /// whose Display starts with "could not remove directory" and
1534    /// names the offending path. This is what the user-facing CLI
1535    /// stderr ends up showing instead of bare `os error 66`.
1536    ///
1537    /// We trip ENOTEMPTY directly through `fs::remove_dir` (the
1538    /// kernel surface that originally leaked) and confirm the
1539    /// `enrich_fs_error` wrapping naming the path.
1540    #[test]
1541    fn enriched_remove_dir_error_names_path_and_action() {
1542        use objects::fs_atomic::enrich_fs_error;
1543
1544        let dir = tempfile::TempDir::new().unwrap();
1545        let target = dir.path().join("not-empty");
1546        fs::create_dir(&target).unwrap();
1547        // Drop a file inside so `remove_dir` will surface ENOTEMPTY.
1548        fs::write(target.join("blocker"), b"x").unwrap();
1549
1550        let raw_err = fs::remove_dir(&target).unwrap_err();
1551        // Sanity: this really was the kernel's directory-not-empty
1552        // signal, otherwise the wrapping below wouldn't be exercising
1553        // the right path.
1554        assert!(
1555            super::is_directory_not_empty(&raw_err),
1556            "expected ENOTEMPTY from remove_dir on non-empty dir, got {raw_err:?}"
1557        );
1558
1559        let wrapped = enrich_fs_error(&target, "removing", raw_err);
1560        let msg = wrapped.to_string();
1561        assert!(
1562            msg.contains("could not remove directory"),
1563            "missing action verb: {msg}"
1564        );
1565        assert!(
1566            msg.contains(target.to_str().unwrap()),
1567            "missing path in message: {msg}"
1568        );
1569        assert!(
1570            msg.contains("heddle-ignored"),
1571            "missing heddle-ignored hint: {msg}"
1572        );
1573    }
1574
1575    /// Regression: when a `.heddleignore` rule (or config rule) matches a
1576    /// previously-tracked file, the legacy ignore-driven removal silently
1577    /// preserved that file on disk after a merge/cherry-pick/revert that
1578    /// dropped it. HEAD then advanced past a tree where the path is gone
1579    /// while the worktree still held the stale content, hidden from
1580    /// `heddle status` by the same ignore rule. The tree-driven walker
1581    /// must remove tracked content regardless of current ignore rules.
1582    #[test]
1583    fn tree_driven_removal_strips_tracked_files_matched_by_new_ignore_rule() {
1584        let (temp_dir, repo) = create_repo();
1585        // Snapshot a tree that tracks `legacy.txt`.
1586        fs::write(temp_dir.path().join("legacy.txt"), "tracked content").unwrap();
1587        let state = repo.snapshot(Some("seed".to_string()), None).unwrap();
1588        let source_tree = repo.store().get_tree(&state.tree).unwrap().unwrap();
1589
1590        // After the snapshot, the user (or a hook) drops a `.heddleignore`
1591        // rule that matches the previously-tracked path. The legacy code
1592        // would walk the directory, see the path is now ignored, and
1593        // skip it — leaving stale tracked content behind.
1594        fs::write(temp_dir.path().join(".heddleignore"), "legacy.txt\n").unwrap();
1595
1596        // Sanity: `legacy.txt` exists on disk.
1597        assert!(temp_dir.path().join("legacy.txt").exists());
1598
1599        // Driving removal off the source tree (which lists `legacy.txt`)
1600        // must remove it even though the current ignore rules now match.
1601        repo.remove_tracked_descendants_with_source(temp_dir.path(), &source_tree)
1602            .unwrap();
1603
1604        assert!(
1605            !temp_dir.path().join("legacy.txt").exists(),
1606            "tree-driven removal must strip tracked files even when current \
1607             ignore rules match — the source tree is the source of truth"
1608        );
1609        // `.heddleignore` is NOT in `source_tree` (it was created after
1610        // the snapshot), so the walker must leave it alone.
1611        assert!(
1612            temp_dir.path().join(".heddleignore").exists(),
1613            "untracked file outside the source-tree set must survive"
1614        );
1615    }
1616
1617    /// The tree-driven walker must preserve untracked or explicitly ignored
1618    /// siblings — the original purpose of `remove_tracked_descendants` is "drop
1619    /// tracked content without nuking local-only build/dependency output".
1620    /// Tree-driven removal achieves this by *not visiting* paths absent
1621    /// from the source tree.
1622    #[test]
1623    fn tree_driven_removal_preserves_untracked_siblings() {
1624        let (temp_dir, repo) = create_repo();
1625        fs::create_dir_all(temp_dir.path().join("pkg")).unwrap();
1626        fs::write(temp_dir.path().join("pkg/keep.txt"), "tracked").unwrap();
1627        let state = repo.snapshot(Some("seed".to_string()), None).unwrap();
1628        let source_tree = repo.store().get_tree(&state.tree).unwrap().unwrap();
1629
1630        // Now create untracked content under `pkg/` — simulating a
1631        // `node_modules/` or `target/` directory the user materializes
1632        // post-snapshot.
1633        fs::create_dir_all(temp_dir.path().join("pkg/node_modules")).unwrap();
1634        fs::write(
1635            temp_dir.path().join("pkg/node_modules/leftover"),
1636            b"untracked",
1637        )
1638        .unwrap();
1639
1640        // Resolve the `pkg` subtree and ask the walker to drop it.
1641        let pkg_subtree = repo
1642            .resolve_subtree(&source_tree, std::path::Path::new("pkg"))
1643            .unwrap()
1644            .expect("pkg subtree resolves");
1645        repo.remove_tracked_descendants_with_source(&temp_dir.path().join("pkg"), &pkg_subtree)
1646            .unwrap();
1647
1648        assert!(
1649            !temp_dir.path().join("pkg/keep.txt").exists(),
1650            "tracked content must be removed"
1651        );
1652        assert!(
1653            temp_dir.path().join("pkg/node_modules/leftover").exists(),
1654            "untracked sibling must survive the walk"
1655        );
1656        assert!(
1657            temp_dir.path().join("pkg").exists(),
1658            "the parent dir survives because untracked content keeps it occupied"
1659        );
1660    }
1661}