1use std::{
5 collections::{BTreeMap, HashSet},
6 fs,
7 path::Path,
8 time::Instant,
9};
10
11use objects::{
12 object::{Blob, ContentHash, Tree, TreeEntry},
13 store::ObjectStore,
14 util::gitlink_placeholder_bytes,
15 worktree::WorktreeStatus,
16};
17use tracing::{debug, instrument, trace, warn};
18
19use super::{
20 HeddleError, Repository, Result,
21 repository_worktree_status::{WorktreeStatusDetailed, compare_worktree_with_index_detailed},
22};
23use crate::{
24 FsMonitorSettings, WorktreeIndex, WorktreeStatusOptions,
25 fsmonitor::ChangeMonitorSession,
26 thread_manifest::ManifestFile,
27 worktree_ignore::WorktreeIgnoreMatcher,
28 worktree_index::{WorktreeIndexLoadStats, WorktreeIndexSaveStats},
29 worktree_walk::{
30 WalkDirectory, WalkEntry, WorktreeWalkPolicy, cache_key, read_blob_with_hash,
31 validate_symlink_target, walk_worktree,
32 },
33};
34
35#[derive(Debug, Clone, Default)]
36pub struct WorktreeCompareProfile {
37 pub index_load_ms: u128,
38 pub index_snapshot_load_ms: u128,
39 pub index_journal_replay_ms: u128,
40 pub index_snapshot_bytes: u64,
41 pub index_journal_bytes: u64,
42 pub index_journal_ops: usize,
43 pub monitor_prepare_ms: u128,
44 pub compare_ms: u128,
45 pub index_save_ms: u128,
46 pub index_snapshot_write_ms: u128,
47 pub index_journal_append_ms: u128,
48 pub index_save_snapshot_bytes: u64,
49 pub index_save_journal_bytes: u64,
50 pub index_save_journal_ops: usize,
51 pub index_save_compacted: bool,
52 pub monitor_persist_ms: u128,
53 pub untracked_flatten_ms: u128,
54 pub untracked_flattened_paths: usize,
55 pub tracked_refresh_ms: u128,
56 pub untracked_scan_ms: u128,
57 pub hashing_ms: u128,
58 pub directory_cache_compare_ms: u128,
59 pub directories_scanned: u64,
60 pub directories_skipped: u64,
61 pub files_hashed: u64,
62 pub cache_hits: u64,
63 pub monitor_changed_paths: u64,
64 pub monitor_skipped_directories: u64,
65}
66
67#[derive(Debug, Clone, Default)]
68pub struct TreeBuildProfile {
69 pub tree_walk_ms: u128,
70 pub blob_prep_ms: u128,
71 pub blob_write_ms: u128,
72 pub tree_write_ms: u128,
73 pub file_count: usize,
74 pub dir_count: usize,
75}
76
77#[derive(Debug, Clone)]
78struct TreeBuildOutput {
79 tree: Tree,
80 profile: TreeBuildProfile,
81 revalidation_files: BTreeMap<String, ManifestFile>,
82}
83
84impl Repository {
85 #[instrument(skip(self), fields(dir = %dir.display()))]
87 pub fn build_tree(&self, dir: &Path) -> Result<Tree> {
88 self.build_tree_profiled(dir).map(|(tree, _)| tree)
89 }
90
91 pub fn build_tree_with_stat_cache(
106 &self,
107 dir: &Path,
108 manifest: &crate::thread_manifest::ThreadManifest,
109 ) -> Result<Tree> {
110 self.build_tree_profiled_inner(dir, None, Some(manifest))
111 .map(|(tree, _)| tree)
112 }
113
114 #[instrument(skip(self), fields(dir = %dir.display()))]
115 pub fn build_tree_profiled(&self, dir: &Path) -> Result<(Tree, TreeBuildProfile)> {
116 self.build_tree_profiled_inner(dir, None, None)
117 }
118
119 pub(crate) fn build_tree_profiled_against(
120 &self,
121 dir: &Path,
122 baseline_tree: Option<&Tree>,
123 ) -> Result<(Tree, TreeBuildProfile)> {
124 self.build_tree_profiled_inner(dir, baseline_tree, None)
125 }
126
127 #[instrument(skip(self, manifest), fields(dir = %dir.display()))]
135 pub fn build_tree_profiled_with_stat_cache(
136 &self,
137 dir: &Path,
138 manifest: &crate::thread_manifest::ThreadManifest,
139 ) -> Result<(Tree, TreeBuildProfile)> {
140 self.build_tree_profiled_inner(dir, None, Some(manifest))
141 }
142
143 pub(crate) fn build_tree_profiled_with_stat_cache_against(
144 &self,
145 dir: &Path,
146 baseline_tree: Option<&Tree>,
147 manifest: &crate::thread_manifest::ThreadManifest,
148 ) -> Result<(Tree, TreeBuildProfile)> {
149 self.build_tree_profiled_inner(dir, baseline_tree, Some(manifest))
150 }
151
152 fn build_tree_profiled_inner(
153 &self,
154 dir: &Path,
155 baseline_tree: Option<&Tree>,
156 stat_cache: Option<&crate::thread_manifest::ThreadManifest>,
157 ) -> Result<(Tree, TreeBuildProfile)> {
158 self.build_tree_profiled_output(dir, baseline_tree, stat_cache)
159 .map(|output| (output.tree, output.profile))
160 }
161
162 pub(crate) fn build_tree_profiled_for_snapshot_against(
163 &self,
164 dir: &Path,
165 baseline_tree: Option<&Tree>,
166 stat_cache: Option<&crate::thread_manifest::ThreadManifest>,
167 ) -> Result<(Tree, TreeBuildProfile, BTreeMap<String, ManifestFile>)> {
168 self.build_tree_profiled_output(dir, baseline_tree, stat_cache)
169 .map(|output| (output.tree, output.profile, output.revalidation_files))
170 }
171
172 fn build_tree_profiled_output(
173 &self,
174 dir: &Path,
175 baseline_tree: Option<&Tree>,
176 stat_cache: Option<&crate::thread_manifest::ThreadManifest>,
177 ) -> Result<TreeBuildOutput> {
178 let patterns = self.ignore_patterns()?;
179 debug!(pattern_count = patterns.len(), "Starting tree build");
180 let start = Instant::now();
181 let nested_exclusions = self.nested_thread_worktree_exclusions(dir)?;
182 let tree =
183 self.build_tree_walk(dir, &patterns, nested_exclusions, baseline_tree, stat_cache);
184 let elapsed = start.elapsed().as_millis();
185 debug!(duration_ms = elapsed, "Tree build complete");
186 tree.map(|mut output| {
187 let mut profile = output.profile;
188 profile.tree_walk_ms = elapsed;
189 output.profile = profile;
190 output
191 })
192 }
193
194 #[instrument(skip(self, patterns, nested_exclusions, baseline_tree, stat_cache), fields(dir = %dir.display()))]
195 fn build_tree_walk(
196 &self,
197 dir: &Path,
198 patterns: &[String],
199 nested_exclusions: Vec<std::path::PathBuf>,
200 baseline_tree: Option<&Tree>,
201 stat_cache: Option<&crate::thread_manifest::ThreadManifest>,
202 ) -> Result<TreeBuildOutput> {
203 let ignore_matcher =
204 WorktreeIgnoreMatcher::new(patterns).with_nested_worktree_exclusions(nested_exclusions);
205 let mut policy = TreeBuildPolicy::new(self, dir, stat_cache);
206 let mut output = walk_worktree(self, dir, &ignore_matcher, baseline_tree, &mut policy)?;
207
208 if !policy.pending_blobs.is_empty() {
214 let flush_start = Instant::now();
215 let pending = std::mem::take(&mut policy.pending_blobs);
216 self.store.put_blobs_packed(pending)?;
217 output.profile.blob_write_ms += flush_start.elapsed().as_millis();
218 }
219
220 Ok(output)
221 }
222
223 pub fn compare_worktree_cached(&self, tree: &Tree) -> Result<WorktreeStatus> {
225 self.compare_worktree_cached_with_options(tree, &self.default_worktree_status_options())
226 }
227
228 pub fn compare_worktree_cached_detailed(&self, tree: &Tree) -> Result<WorktreeStatusDetailed> {
229 self.compare_worktree_cached_detailed_with_options(
230 tree,
231 &self.default_worktree_status_options(),
232 )
233 }
234
235 pub fn compare_worktree_cached_with_options(
237 &self,
238 tree: &Tree,
239 options: &WorktreeStatusOptions,
240 ) -> Result<WorktreeStatus> {
241 self.compare_worktree_cached_profiled_with_options(tree, options)
242 .map(|(status, _)| status)
243 }
244
245 pub fn compare_worktree_cached_detailed_with_options(
246 &self,
247 tree: &Tree,
248 options: &WorktreeStatusOptions,
249 ) -> Result<WorktreeStatusDetailed> {
250 self.compare_worktree_cached_detailed_profiled_with_options(tree, options)
251 .map(|(status, _)| status)
252 }
253
254 pub fn compare_worktree_cached_profiled_with_options(
255 &self,
256 tree: &Tree,
257 options: &WorktreeStatusOptions,
258 ) -> Result<(WorktreeStatus, WorktreeCompareProfile)> {
259 let (detailed_status, mut profile) =
260 self.compare_worktree_cached_detailed_profiled_with_options(tree, options)?;
261 let flatten_start = Instant::now();
262 let flattened_paths = detailed_status.untracked.flattened_path_count();
263 let mut status = detailed_status.into_flat_status();
264 profile.untracked_flatten_ms = flatten_start.elapsed().as_millis();
265 profile.untracked_flattened_paths = flattened_paths;
266 status.modified.sort();
267 status.added.sort();
268 status.deleted.sort();
269 Ok((status, profile))
270 }
271
272 pub fn compare_worktree_cached_detailed_profiled_with_options(
273 &self,
274 tree: &Tree,
275 options: &WorktreeStatusOptions,
276 ) -> Result<(WorktreeStatusDetailed, WorktreeCompareProfile)> {
277 let index_path = self.worktree_index_path();
278 let load_start = Instant::now();
279 let (mut index, load_stats) = match WorktreeIndex::load_profiled(&index_path) {
280 Ok(result) => result,
281 Err(error) => {
282 warn!(path = %index_path.display(), %error, "Ignoring unreadable worktree index");
283 (WorktreeIndex::new(), WorktreeIndexLoadStats::default())
284 }
285 };
286 let index_load_ms = load_start.elapsed().as_millis();
287
288 let monitor_prepare_start = Instant::now();
289 let monitor = ChangeMonitorSession::prepare(self.root(), options.fsmonitor);
290 let monitor_prepare_ms = monitor_prepare_start.elapsed().as_millis();
291
292 let patterns = self.ignore_patterns()?;
293 let nested_exclusions = self.nested_thread_worktree_exclusions(self.root())?;
294 let ignore_matcher = WorktreeIgnoreMatcher::new(&patterns)
295 .with_nested_worktree_exclusions(nested_exclusions);
296 let compare_start = Instant::now();
297 let (status, stats) = compare_worktree_with_index_detailed(
298 self,
299 tree,
300 &ignore_matcher,
301 &mut index,
302 &monitor,
303 )?;
304 let compare_ms = compare_start.elapsed().as_millis();
305
306 let save_start = Instant::now();
307 let (index_save_ms, save_stats) = if index.is_dirty() {
308 match index.save_profiled(&index_path) {
309 Ok(stats) => {
310 index.mark_clean();
311 (save_start.elapsed().as_millis(), stats)
312 }
313 Err(error) => {
314 warn!(path = %index_path.display(), %error, "Failed to persist worktree index");
315 (0, WorktreeIndexSaveStats::default())
316 }
317 }
318 } else {
319 (0, WorktreeIndexSaveStats::default())
320 };
321
322 let persist_start = Instant::now();
323 if let Err(error) = monitor.persist() {
324 warn!(path = %self.root().display(), %error, "Failed to persist monitor state");
325 }
326 let monitor_persist_ms = persist_start.elapsed().as_millis();
327
328 debug!(
329 index_load_ms,
330 index_snapshot_load_ms = load_stats.snapshot_load_ms,
331 index_journal_replay_ms = load_stats.journal_replay_ms,
332 index_snapshot_bytes = load_stats.snapshot_bytes,
333 index_journal_bytes = load_stats.journal_bytes,
334 index_journal_ops = load_stats.journal_ops,
335 monitor_prepare_ms,
336 compare_ms,
337 index_save_ms,
338 index_snapshot_write_ms = save_stats.snapshot_write_ms,
339 index_journal_append_ms = save_stats.journal_append_ms,
340 index_save_snapshot_bytes = save_stats.snapshot_bytes,
341 index_save_journal_bytes = save_stats.journal_bytes,
342 index_save_journal_ops = save_stats.journal_ops,
343 index_save_compacted = save_stats.compacted,
344 index_save_compact_reason = save_stats.compact_reason.unwrap_or("none"),
345 monitor_persist_ms,
346 tracked_refresh_ms = stats.tracked_refresh_ms,
347 untracked_scan_ms = stats.untracked_scan_ms,
348 untracked_flatten_ms = 0,
349 untracked_flattened_paths = 0,
350 hashing_ms = stats.hashing_ms,
351 directory_cache_compare_ms = stats.directory_cache_compare_ms,
352 directories_scanned = stats.directories_scanned,
353 directories_skipped = stats.directories_skipped,
354 files_hashed = stats.files_hashed,
355 cache_hits = stats.cache_hits,
356 monitor_backend = monitor.backend.unwrap_or("off"),
357 monitor_status = ?monitor.status,
358 monitor_reason = monitor.reason.as_deref().unwrap_or("ready"),
359 monitor_changed_paths = stats.monitor_changed_paths,
360 monitor_skipped_directories = stats.monitor_skipped_directories,
361 "Worktree compare complete"
362 );
363
364 Ok((
365 status,
366 WorktreeCompareProfile {
367 index_load_ms,
368 index_snapshot_load_ms: load_stats.snapshot_load_ms,
369 index_journal_replay_ms: load_stats.journal_replay_ms,
370 index_snapshot_bytes: load_stats.snapshot_bytes,
371 index_journal_bytes: load_stats.journal_bytes,
372 index_journal_ops: load_stats.journal_ops,
373 monitor_prepare_ms,
374 compare_ms,
375 index_save_ms,
376 index_snapshot_write_ms: save_stats.snapshot_write_ms,
377 index_journal_append_ms: save_stats.journal_append_ms,
378 index_save_snapshot_bytes: save_stats.snapshot_bytes,
379 index_save_journal_bytes: save_stats.journal_bytes,
380 index_save_journal_ops: save_stats.journal_ops,
381 index_save_compacted: save_stats.compacted,
382 monitor_persist_ms,
383 untracked_flatten_ms: 0,
384 untracked_flattened_paths: 0,
385 tracked_refresh_ms: stats.tracked_refresh_ms,
386 untracked_scan_ms: stats.untracked_scan_ms,
387 hashing_ms: stats.hashing_ms,
388 directory_cache_compare_ms: stats.directory_cache_compare_ms,
389 directories_scanned: stats.directories_scanned,
390 directories_skipped: stats.directories_skipped,
391 files_hashed: stats.files_hashed,
392 cache_hits: stats.cache_hits,
393 monitor_changed_paths: stats.monitor_changed_paths,
394 monitor_skipped_directories: stats.monitor_skipped_directories,
395 },
396 ))
397 }
398
399 pub fn worktree_is_clean_cached(&self, tree: &Tree) -> Result<bool> {
401 self.worktree_is_clean_cached_with_options(tree, &self.default_worktree_status_options())
402 }
403
404 pub fn worktree_is_clean_cached_with_options(
406 &self,
407 tree: &Tree,
408 options: &WorktreeStatusOptions,
409 ) -> Result<bool> {
410 Ok(self
411 .compare_worktree_cached_detailed_with_options(tree, options)?
412 .is_clean())
413 }
414
415 fn worktree_index_path(&self) -> std::path::PathBuf {
416 self.root.join(".heddle/state").join("index.bin")
417 }
418
419 fn default_worktree_status_options(&self) -> WorktreeStatusOptions {
420 WorktreeStatusOptions {
421 fsmonitor: FsMonitorSettings::from(self.config.worktree.fsmonitor),
422 }
423 }
424
425 pub fn inspect_change_monitor_with_options(
426 &self,
427 options: &WorktreeStatusOptions,
428 ) -> Result<crate::ChangeMonitorReport> {
429 let session = ChangeMonitorSession::prepare(self.root(), options.fsmonitor);
430 let report = session.report();
431 session.persist()?;
432 Ok(report)
433 }
434}
435
436#[derive(Default)]
437struct TreeBuildState {
438 entries: Vec<TreeEntry>,
439 profile: TreeBuildProfile,
440 revalidation_files: BTreeMap<String, ManifestFile>,
441}
442
443struct TreeBuildPolicy<'a> {
444 repo: &'a Repository,
445 walk_root: &'a Path,
448 stat_cache: Option<&'a crate::thread_manifest::ThreadManifest>,
453 stat_cache_hits: u64,
454 pending_blobs: Vec<(ContentHash, Vec<u8>)>,
459 seen: HashSet<ContentHash>,
462}
463
464impl<'a> TreeBuildPolicy<'a> {
465 fn new(
466 repo: &'a Repository,
467 walk_root: &'a Path,
468 stat_cache: Option<&'a crate::thread_manifest::ThreadManifest>,
469 ) -> Self {
470 Self {
471 repo,
472 walk_root,
473 stat_cache,
474 stat_cache_hits: 0,
475 pending_blobs: Vec::new(),
476 seen: HashSet::new(),
477 }
478 }
479
480 fn lookup_stat_cache_hash(&self, entry: &WalkEntry<'_>) -> Option<ContentHash> {
486 let cache = self.stat_cache?;
487 let rel = entry.path.strip_prefix(self.walk_root).ok()?;
488 let mut rel_str = String::with_capacity(rel.as_os_str().len());
491 for (i, component) in rel.components().enumerate() {
492 let std::path::Component::Normal(s) = component else {
493 return None;
494 };
495 if i > 0 {
496 rel_str.push('/');
497 }
498 rel_str.push_str(s.to_str()?);
499 }
500 let cached = cache.files.get(&rel_str)?;
501 let (size, inode, mtime_ns, ctime_ns, mode) =
502 crate::stat_signature::stat_signature(entry.path, &entry.metadata);
503 let stat = crate::thread_manifest::ManifestFile {
504 hash: cached.hash,
505 size,
506 inode,
507 mtime_ns,
508 ctime_ns,
509 mode,
510 };
511 if stat.matches(cached) {
512 Some(cached.hash)
513 } else {
514 None
515 }
516 }
517
518 fn enqueue_blob(&mut self, blob: Blob, hash: ContentHash) -> Result<()> {
523 if self.seen.contains(&hash) {
524 return Ok(());
525 }
526 if self.repo.store.has_blob_locally(&hash)? {
527 self.seen.insert(hash);
528 return Ok(());
529 }
530 self.seen.insert(hash);
531 self.pending_blobs.push((hash, blob.into_content()));
532 Ok(())
533 }
534
535 fn record_revalidation_file(
536 &self,
537 entry: &WalkEntry<'_>,
538 hash: ContentHash,
539 state: &mut TreeBuildState,
540 ) -> Result<()> {
541 let rel = entry.path.strip_prefix(self.walk_root).map_err(|_| {
542 HeddleError::Config(format!(
543 "worktree entry {} escaped snapshot root {}",
544 entry.path.display(),
545 self.walk_root.display()
546 ))
547 })?;
548 let (size, inode, mtime_ns, ctime_ns, mode) =
549 crate::stat_signature::stat_signature(entry.path, &entry.metadata);
550 state.revalidation_files.insert(
551 cache_key(rel),
552 ManifestFile {
553 hash,
554 size,
555 inode,
556 mtime_ns,
557 ctime_ns,
558 mode,
559 },
560 );
561 Ok(())
562 }
563}
564
565impl WorktreeWalkPolicy for TreeBuildPolicy<'_> {
566 type DirectoryState = TreeBuildState;
567 type Output = TreeBuildOutput;
568
569 fn enter_directory(
570 &mut self,
571 _directory: &WalkDirectory<'_>,
572 _tree: Option<&Tree>,
573 ) -> Result<Self::DirectoryState> {
574 Ok(TreeBuildState::default())
575 }
576
577 fn visit_file(
578 &mut self,
579 entry: WalkEntry<'_>,
580 tree_entry: Option<&TreeEntry>,
581 state: &mut Self::DirectoryState,
582 ) -> Result<()> {
583 trace!(file = %entry.path.display(), size = entry.metadata.len(), "Processing file");
584
585 if let Some(target) = tree_entry.and_then(TreeEntry::gitlink_target) {
586 let read_start = Instant::now();
587 let (blob, hash) = read_blob_with_hash(entry.path, entry.metadata.len())?;
588 let read_elapsed = read_start.elapsed().as_millis();
589 if blob.content() == gitlink_placeholder_bytes(&target) {
590 self.record_revalidation_file(&entry, hash, state)?;
591 state.profile.file_count += 1;
592 state.profile.blob_prep_ms += read_elapsed;
593 state
594 .entries
595 .push(TreeEntry::gitlink(entry.name.to_string(), target)?);
596 return Ok(());
597 }
598
599 let enqueue_start = Instant::now();
600 self.enqueue_blob(blob, hash)?;
601 let enqueue_elapsed = enqueue_start.elapsed().as_millis();
602 state.profile.file_count += 1;
603 state.profile.blob_prep_ms += read_elapsed;
604 state.profile.blob_write_ms += enqueue_elapsed;
605 self.record_revalidation_file(&entry, hash, state)?;
606 state.entries.push(TreeEntry::file(
607 entry.name.to_string(),
608 hash,
609 entry.executable,
610 )?);
611 return Ok(());
612 }
613
614 if let Some(hash) = self.lookup_stat_cache_hash(&entry)
621 && self.repo.store.has_blob_locally(&hash)?
622 {
623 self.record_revalidation_file(&entry, hash, state)?;
624 self.stat_cache_hits += 1;
625 state.profile.file_count += 1;
626 state.entries.push(TreeEntry::file(
627 entry.name.to_string(),
628 hash,
629 entry.executable,
630 )?);
631 return Ok(());
632 }
633
634 let read_start = Instant::now();
635 let (blob, hash) = read_blob_with_hash(entry.path, entry.metadata.len())?;
636 let read_elapsed = read_start.elapsed().as_millis();
637 trace!(duration_ms = read_elapsed, "File read complete");
638
639 let enqueue_start = Instant::now();
644 self.enqueue_blob(blob, hash)?;
645 let enqueue_elapsed = enqueue_start.elapsed().as_millis();
646
647 state.profile.file_count += 1;
648 state.profile.blob_prep_ms += read_elapsed;
649 state.profile.blob_write_ms += enqueue_elapsed;
650 self.record_revalidation_file(&entry, hash, state)?;
651 state.entries.push(TreeEntry::file(
652 entry.name.to_string(),
653 hash,
654 entry.executable,
655 )?);
656 Ok(())
657 }
658
659 fn visit_symlink(
660 &mut self,
661 entry: WalkEntry<'_>,
662 _tree_entry: Option<&TreeEntry>,
663 state: &mut Self::DirectoryState,
664 ) -> Result<()> {
665 let target = fs::read_link(entry.path)?;
666 let symlink_dir = entry.path.parent().unwrap_or(self.walk_root);
678 if !validate_symlink_target(self.walk_root, symlink_dir, &target) {
679 return Err(HeddleError::InvalidSymlinkTarget {
680 path: entry
681 .path
682 .strip_prefix(self.walk_root)
683 .unwrap_or(entry.path)
684 .to_path_buf(),
685 target,
686 });
687 }
688
689 let blob = Blob::new(objects::util::symlink_target_bytes(&target));
690 let hash = blob.hash();
691 let enqueue_start = Instant::now();
692 self.enqueue_blob(blob, hash)?;
693 state.profile.blob_write_ms += enqueue_start.elapsed().as_millis();
694 self.record_revalidation_file(&entry, hash, state)?;
695 state
696 .entries
697 .push(TreeEntry::symlink(entry.name.to_string(), hash)?);
698 Ok(())
699 }
700
701 fn visit_directory_output(
702 &mut self,
703 entry: WalkEntry<'_>,
704 _tree_entry: Option<&TreeEntry>,
705 subtree: TreeBuildOutput,
706 state: &mut Self::DirectoryState,
707 ) -> Result<()> {
708 trace!(dir = %entry.path.display(), "Processing directory");
709 state.profile.blob_prep_ms += subtree.profile.blob_prep_ms;
710 state.profile.blob_write_ms += subtree.profile.blob_write_ms;
711 state.profile.tree_write_ms += subtree.profile.tree_write_ms;
712 state.profile.file_count += subtree.profile.file_count;
713 state.profile.dir_count += subtree.profile.dir_count + 1;
714 state.revalidation_files.extend(subtree.revalidation_files);
715 let store_start = Instant::now();
716 let hash = self.repo.store.put_tree(&subtree.tree)?;
717 state.profile.tree_write_ms += store_start.elapsed().as_millis();
718 state
719 .entries
720 .push(TreeEntry::directory(entry.name.to_string(), hash)?);
721 Ok(())
722 }
723
724 fn visit_missing(
725 &mut self,
726 _rel_path: &Path,
727 _tree_entry: &TreeEntry,
728 _state: &mut Self::DirectoryState,
729 ) -> Result<()> {
730 Ok(())
731 }
732
733 fn leave_directory(
734 &mut self,
735 directory: &WalkDirectory<'_>,
736 _tree: Option<&Tree>,
737 state: Self::DirectoryState,
738 ) -> Result<TreeBuildOutput> {
739 debug!(
740 dir = %self.repo.root().join(directory.rel_path).display(),
741 files = state.profile.file_count,
742 dirs = state.profile.dir_count,
743 "Directory processed"
744 );
745 Ok(TreeBuildOutput {
746 tree: Tree::from_entries(state.entries),
747 profile: state.profile,
748 revalidation_files: state.revalidation_files,
749 })
750 }
751}
752
753#[cfg(test)]
754mod tests {
755 use objects::object::ContentHash;
756 use tempfile::TempDir;
757
758 use crate::worktree_walk::{read_blob_with_hash, read_file_hash};
759
760 #[test]
761 fn read_blob_with_hash_uses_bytes_read_when_file_grows() {
762 let temp_dir = TempDir::new().unwrap();
763 let path = temp_dir.path().join("file.txt");
764
765 std::fs::write(&path, b"abc").unwrap();
766 let initial_size = std::fs::metadata(&path).unwrap().len();
767 std::fs::write(&path, b"abcdef").unwrap();
768
769 let (blob, hash) = read_blob_with_hash(&path, initial_size).unwrap();
770
771 assert_eq!(blob.content(), b"abcdef");
772 assert_eq!(hash, blob.hash());
773 }
774
775 #[test]
776 fn read_file_hash_uses_bytes_read_when_file_grows() {
777 let temp_dir = TempDir::new().unwrap();
778 let path = temp_dir.path().join("file.txt");
779
780 std::fs::write(&path, b"abc").unwrap();
781 let initial_size = std::fs::metadata(&path).unwrap().len();
782 std::fs::write(&path, b"abcdef").unwrap();
783
784 let hash = read_file_hash(&path, initial_size).unwrap();
785
786 assert_eq!(hash, ContentHash::compute_typed("blob", b"abcdef"));
787 }
788}