1use 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 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 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 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 pub fn resolve_subtree(&self, root_tree: &Tree, rel_path: &Path) -> Result<Option<Tree>> {
839 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(¤t, 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 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
1092fn 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 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 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 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
1180pub(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(¬es, "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(¬es).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(¬es, "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 #[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 fs::write(target.join("blocker"), b"x").unwrap();
1540
1541 let raw_err = fs::remove_dir(&target).unwrap_err();
1542 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 #[test]
1574 fn tree_driven_removal_strips_tracked_files_matched_by_new_ignore_rule() {
1575 let (temp_dir, repo) = create_repo();
1576 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 fs::write(temp_dir.path().join(".heddleignore"), "legacy.txt\n").unwrap();
1586
1587 assert!(temp_dir.path().join("legacy.txt").exists());
1589
1590 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 assert!(
1603 temp_dir.path().join(".heddleignore").exists(),
1604 "untracked file outside the source-tree set must survive"
1605 );
1606 }
1607
1608 #[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 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 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}