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