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