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