1use 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 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 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 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 pub fn resolve_subtree(&self, root_tree: &Tree, rel_path: &Path) -> Result<Option<Tree>> {
741 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(¤t, 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 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
928fn 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
971fn 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 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 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
1051pub(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 #[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 fs::write(target.join("blocker"), b"x").unwrap();
1215
1216 let raw_err = fs::remove_dir(&target).unwrap_err();
1217 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 #[test]
1249 fn tree_driven_removal_strips_tracked_files_matched_by_new_ignore_rule() {
1250 let (temp_dir, repo) = create_repo();
1251 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 fs::write(temp_dir.path().join(".heddleignore"), "legacy.txt\n").unwrap();
1261
1262 assert!(temp_dir.path().join("legacy.txt").exists());
1264
1265 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 assert!(
1278 temp_dir.path().join(".heddleignore").exists(),
1279 "untracked file outside the source-tree set must survive"
1280 );
1281 }
1282
1283 #[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 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 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}