Skip to repository content6181 lines · 185.1 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T04:02:15.849Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
worktree_tests.rs
1mod worktree_settings_tests;
2
3use anyhow::Result;
4use encoding_rs;
5use fs::{FakeFs, Fs, PathEventKind, RealFs, RemoveOptions};
6use git::{DOT_GIT, GITIGNORE, REPO_EXCLUDE};
7use gpui::{AppContext as _, BackgroundExecutor, BorrowAppContext, Context, Task, TestAppContext};
8use parking_lot::Mutex;
9use postage::stream::Stream;
10use pretty_assertions::assert_eq;
11use rand::prelude::*;
12use rpc::{AnyProtoClient, NoopProtoClient, proto};
13use worktree::{Entry, EntryKind, Event, PathChange, Worktree, WorktreeModelHandle};
14
15use serde_json::json;
16use settings::{SettingsStore, WorktreeId};
17use std::{
18 cell::Cell,
19 env,
20 fmt::Write,
21 mem,
22 path::{Path, PathBuf},
23 rc::Rc,
24 sync::Arc,
25};
26use util::{
27 ResultExt, path,
28 paths::PathStyle,
29 rel_path::{RelPath, rel_path},
30 test::TempTree,
31};
32
33#[gpui::test]
34async fn test_traversal(cx: &mut TestAppContext) {
35 init_test(cx);
36 let fs = FakeFs::new(cx.background_executor.clone());
37 fs.insert_tree(
38 "/root",
39 json!({
40 ".gitignore": "a/b\n",
41 "a": {
42 "b": "",
43 "c": "",
44 }
45 }),
46 )
47 .await;
48
49 let tree = Worktree::local(
50 Path::new("/root"),
51 true,
52 fs,
53 Default::default(),
54 true,
55 WorktreeId::from_proto(0),
56 &mut cx.to_async(),
57 )
58 .await
59 .unwrap();
60 cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
61 .await;
62
63 tree.read_with(cx, |tree, _| {
64 assert_eq!(
65 tree.entries(false, 0)
66 .map(|entry| entry.path.as_ref())
67 .collect::<Vec<_>>(),
68 vec![
69 rel_path(""),
70 rel_path(".gitignore"),
71 rel_path("a"),
72 rel_path("a/c"),
73 ]
74 );
75 assert_eq!(
76 tree.entries(true, 0)
77 .map(|entry| entry.path.as_ref())
78 .collect::<Vec<_>>(),
79 vec![
80 rel_path(""),
81 rel_path(".gitignore"),
82 rel_path("a"),
83 rel_path("a/b"),
84 rel_path("a/c"),
85 ]
86 );
87 })
88}
89
90#[gpui::test(iterations = 10)]
91async fn test_circular_symlinks(cx: &mut TestAppContext) {
92 init_test(cx);
93 let fs = FakeFs::new(cx.background_executor.clone());
94 fs.insert_tree(
95 "/root",
96 json!({
97 "lib": {
98 "a": {
99 "a.txt": ""
100 },
101 "b": {
102 "b.txt": ""
103 }
104 }
105 }),
106 )
107 .await;
108 fs.create_symlink("/root/lib/a/lib".as_ref(), "..".into())
109 .await
110 .unwrap();
111 fs.create_symlink("/root/lib/b/lib".as_ref(), "..".into())
112 .await
113 .unwrap();
114
115 let tree = Worktree::local(
116 Path::new("/root"),
117 true,
118 fs.clone(),
119 Default::default(),
120 true,
121 WorktreeId::from_proto(0),
122 &mut cx.to_async(),
123 )
124 .await
125 .unwrap();
126
127 cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
128 .await;
129
130 tree.read_with(cx, |tree, _| {
131 assert_eq!(
132 tree.entries(false, 0)
133 .map(|entry| entry.path.as_ref())
134 .collect::<Vec<_>>(),
135 vec![
136 rel_path(""),
137 rel_path("lib"),
138 rel_path("lib/a"),
139 rel_path("lib/a/a.txt"),
140 rel_path("lib/a/lib"),
141 rel_path("lib/b"),
142 rel_path("lib/b/b.txt"),
143 rel_path("lib/b/lib"),
144 ]
145 );
146 });
147
148 fs.rename(
149 Path::new("/root/lib/a/lib"),
150 Path::new("/root/lib/a/lib-2"),
151 Default::default(),
152 )
153 .await
154 .unwrap();
155 cx.executor().run_until_parked();
156 tree.read_with(cx, |tree, _| {
157 assert_eq!(
158 tree.entries(false, 0)
159 .map(|entry| entry.path.as_ref())
160 .collect::<Vec<_>>(),
161 vec![
162 rel_path(""),
163 rel_path("lib"),
164 rel_path("lib/a"),
165 rel_path("lib/a/a.txt"),
166 rel_path("lib/a/lib-2"),
167 rel_path("lib/b"),
168 rel_path("lib/b/b.txt"),
169 rel_path("lib/b/lib"),
170 ]
171 );
172 });
173}
174
175#[gpui::test]
176async fn test_symlinks_pointing_outside(cx: &mut TestAppContext) {
177 init_test(cx);
178 let fs = FakeFs::new(cx.background_executor.clone());
179 fs.insert_tree(
180 "/root",
181 json!({
182 "dir1": {
183 "deps": {
184 // symlinks here
185 },
186 "src": {
187 "a.rs": "",
188 "b.rs": "",
189 },
190 },
191 "dir2": {
192 "src": {
193 "c.rs": "",
194 "d.rs": "",
195 }
196 },
197 "dir3": {
198 "deps": {},
199 "src": {
200 "e.rs": "",
201 "f.rs": "",
202 "nested": {
203 "deep.rs": ""
204 }
205 },
206 }
207 }),
208 )
209 .await;
210
211 // These symlinks point to directories outside of the worktree's root, dir1.
212 fs.create_symlink("/root/dir1/deps/dep-dir2".as_ref(), "../../dir2".into())
213 .await
214 .unwrap();
215 fs.create_symlink("/root/dir1/deps/dep-dir3".as_ref(), "../../dir3".into())
216 .await
217 .unwrap();
218 fs.create_symlink(
219 "/root/dir1/deps/dep-dir3-alias".as_ref(),
220 "../../dir3".into(),
221 )
222 .await
223 .unwrap();
224 fs.create_symlink(
225 "/root/dir1/deps/dep-dir3-nested".as_ref(),
226 "../../dir3/src/nested".into(),
227 )
228 .await
229 .unwrap();
230
231 let tree = Worktree::local(
232 Path::new("/root/dir1"),
233 true,
234 fs.clone(),
235 Default::default(),
236 true,
237 WorktreeId::from_proto(0),
238 &mut cx.to_async(),
239 )
240 .await
241 .unwrap();
242
243 cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
244 .await;
245
246 let tree_updates = Arc::new(Mutex::new(Vec::new()));
247 tree.update(cx, |_, cx| {
248 let tree_updates = tree_updates.clone();
249 cx.subscribe(&tree, move |_, _, event, _| {
250 if let Event::UpdatedEntries(update) = event {
251 tree_updates.lock().extend(
252 update
253 .iter()
254 .map(|(path, _, change)| (path.clone(), *change)),
255 );
256 }
257 })
258 .detach();
259 });
260
261 // The symlinked directories are not scanned by default.
262 tree.read_with(cx, |tree, _| {
263 assert_eq!(
264 tree.entries(true, 0)
265 .map(|entry| (entry.path.as_ref(), entry.is_external))
266 .collect::<Vec<_>>(),
267 vec![
268 (rel_path(""), false),
269 (rel_path("deps"), false),
270 (rel_path("deps/dep-dir2"), true),
271 (rel_path("deps/dep-dir3"), true),
272 (rel_path("deps/dep-dir3-alias"), true),
273 (rel_path("deps/dep-dir3-nested"), true),
274 (rel_path("src"), false),
275 (rel_path("src/a.rs"), false),
276 (rel_path("src/b.rs"), false),
277 ]
278 );
279
280 assert_eq!(
281 tree.entry_for_path(rel_path("deps/dep-dir2")).unwrap().kind,
282 EntryKind::UnloadedDir
283 );
284 });
285
286 // Expand one of the symlinked directories.
287 tree.read_with(cx, |tree, _| {
288 tree.as_local()
289 .unwrap()
290 .refresh_entries_for_paths(vec![rel_path("deps/dep-dir3").into()])
291 })
292 .recv()
293 .await;
294
295 // The expanded directory's contents are loaded. Subdirectories are
296 // not scanned yet.
297 tree.read_with(cx, |tree, _| {
298 assert_eq!(
299 tree.entries(true, 0)
300 .map(|entry| (entry.path.as_ref(), entry.is_external))
301 .collect::<Vec<_>>(),
302 vec![
303 (rel_path(""), false),
304 (rel_path("deps"), false),
305 (rel_path("deps/dep-dir2"), true),
306 (rel_path("deps/dep-dir3"), true),
307 (rel_path("deps/dep-dir3/deps"), true),
308 (rel_path("deps/dep-dir3/src"), true),
309 (rel_path("deps/dep-dir3-alias"), true),
310 (rel_path("deps/dep-dir3-nested"), true),
311 (rel_path("src"), false),
312 (rel_path("src/a.rs"), false),
313 (rel_path("src/b.rs"), false),
314 ]
315 );
316 });
317 assert_eq!(
318 mem::take(&mut *tree_updates.lock()),
319 &[
320 (rel_path("deps/dep-dir3").into(), PathChange::Loaded),
321 (rel_path("deps/dep-dir3/deps").into(), PathChange::Loaded),
322 (rel_path("deps/dep-dir3/src").into(), PathChange::Loaded)
323 ]
324 );
325
326 // Expand a subdirectory of one of the symlinked directories.
327 tree.read_with(cx, |tree, _| {
328 tree.as_local()
329 .unwrap()
330 .refresh_entries_for_paths(vec![rel_path("deps/dep-dir3/src").into()])
331 })
332 .recv()
333 .await;
334
335 // The expanded subdirectory's contents are loaded.
336 tree.read_with(cx, |tree, _| {
337 assert_eq!(
338 tree.entries(true, 0)
339 .map(|entry| (entry.path.as_ref(), entry.is_external))
340 .collect::<Vec<_>>(),
341 vec![
342 (rel_path(""), false),
343 (rel_path("deps"), false),
344 (rel_path("deps/dep-dir2"), true),
345 (rel_path("deps/dep-dir3"), true),
346 (rel_path("deps/dep-dir3/deps"), true),
347 (rel_path("deps/dep-dir3/src"), true),
348 (rel_path("deps/dep-dir3/src/e.rs"), true),
349 (rel_path("deps/dep-dir3/src/f.rs"), true),
350 (rel_path("deps/dep-dir3/src/nested"), true),
351 (rel_path("deps/dep-dir3-alias"), true),
352 (rel_path("deps/dep-dir3-nested"), true),
353 (rel_path("src"), false),
354 (rel_path("src/a.rs"), false),
355 (rel_path("src/b.rs"), false),
356 ]
357 );
358 });
359
360 assert_eq!(
361 mem::take(&mut *tree_updates.lock()),
362 &[
363 (rel_path("deps/dep-dir3/src").into(), PathChange::Loaded),
364 (
365 rel_path("deps/dep-dir3/src/e.rs").into(),
366 PathChange::Loaded
367 ),
368 (
369 rel_path("deps/dep-dir3/src/f.rs").into(),
370 PathChange::Loaded
371 ),
372 (
373 rel_path("deps/dep-dir3/src/nested").into(),
374 PathChange::Loaded
375 )
376 ]
377 );
378
379 // After an external symlink subtree is loaded, changes in the target should be reflected.
380 fs.insert_file(Path::new("/root/dir3/src/new.rs"), b"".to_vec())
381 .await;
382
383 wait_for_condition(cx, |cx| {
384 tree.read_with(cx, |tree, _| {
385 tree.entry_for_path(rel_path("deps/dep-dir3/src/new.rs"))
386 .is_some()
387 })
388 })
389 .await;
390
391 tree.read_with(cx, |tree, _| {
392 assert!(
393 tree.entry_for_path(rel_path("deps/dep-dir3/src/new.rs"))
394 .is_some()
395 );
396 });
397
398 tree.read_with(cx, |tree, _| {
399 tree.as_local()
400 .unwrap()
401 .refresh_entries_for_paths(vec![rel_path("deps/dep-dir3-alias").into()])
402 })
403 .recv()
404 .await;
405
406 tree.read_with(cx, |tree, _| {
407 tree.as_local()
408 .unwrap()
409 .refresh_entries_for_paths(vec![rel_path("deps/dep-dir3-alias/src").into()])
410 })
411 .recv()
412 .await;
413
414 tree.read_with(cx, |tree, _| {
415 tree.as_local()
416 .unwrap()
417 .refresh_entries_for_paths(vec![rel_path("deps/dep-dir3-nested").into()])
418 })
419 .recv()
420 .await;
421 // Create a file in the shared target subtree. Because dep-dir3 and dep-dir3-alias both
422 // point to the same target, both logical paths should observe the new file.
423 fs.insert_file(Path::new("/root/dir3/src/shared-new.rs"), b"".to_vec())
424 .await;
425
426 wait_for_condition(cx, |cx| {
427 tree.read_with(cx, |tree, _| {
428 tree.entry_for_path(rel_path("deps/dep-dir3/src/shared-new.rs"))
429 .is_some()
430 && tree
431 .entry_for_path(rel_path("deps/dep-dir3-alias/src/shared-new.rs"))
432 .is_some()
433 })
434 })
435 .await;
436
437 tree.read_with(cx, |tree, _| {
438 assert!(
439 tree.entry_for_path(rel_path("deps/dep-dir3/src/shared-new.rs"))
440 .is_some()
441 );
442 assert!(
443 tree.entry_for_path(rel_path("deps/dep-dir3-alias/src/shared-new.rs"))
444 .is_some()
445 );
446 });
447
448 // Create a file under the more specific nested target. Longest-prefix matching means this should appear under dep-dir3-nested
449 fs.insert_file(
450 Path::new("/root/dir3/src/nested/longest-prefix.rs"),
451 b"".to_vec(),
452 )
453 .await;
454
455 wait_for_condition(cx, |cx| {
456 tree.read_with(cx, |tree, _| {
457 tree.entry_for_path(rel_path("deps/dep-dir3-nested/longest-prefix.rs"))
458 .is_some()
459 })
460 })
461 .await;
462
463 tree.read_with(cx, |tree, _| {
464 assert!(
465 tree.entry_for_path(rel_path("deps/dep-dir3-nested/longest-prefix.rs"))
466 .is_some()
467 );
468 assert!(
469 tree.entry_for_path(rel_path("deps/dep-dir3/src/nested/longest-prefix.rs"))
470 .is_none()
471 );
472 assert!(
473 tree.entry_for_path(rel_path("deps/dep-dir3-alias/src/nested/longest-prefix.rs"))
474 .is_none()
475 );
476 });
477}
478
479#[gpui::test]
480async fn test_renaming_subdir_under_symlinked_root_keeps_children(cx: &mut TestAppContext) {
481 init_test(cx);
482 let fs = FakeFs::new(cx.background_executor.clone());
483 fs.insert_tree(
484 "/target",
485 json!({
486 "file1.txt": "",
487 "file2.log": "",
488 "subdir-a": {
489 "config.ini": "",
490 },
491 "subdir-b": {
492 "nested": {
493 "note.md": "",
494 },
495 },
496 }),
497 )
498 .await;
499 fs.create_symlink("/link".as_ref(), "/target".into())
500 .await
501 .unwrap();
502
503 let tree = Worktree::local(
504 Path::new("/link"),
505 true,
506 fs.clone(),
507 Default::default(),
508 true,
509 WorktreeId::from_proto(0),
510 &mut cx.to_async(),
511 )
512 .await
513 .unwrap();
514
515 cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
516 .await;
517
518 fs.rename(
519 Path::new("/link/subdir-a"),
520 Path::new("/link/subdir-aa"),
521 Default::default(),
522 )
523 .await
524 .unwrap();
525
526 wait_for_condition(cx, |cx| {
527 tree.read_with(cx, |tree, _| {
528 tree.entry_for_path(rel_path("subdir-a")).is_none()
529 && tree
530 .entry_for_path(rel_path("subdir-aa/config.ini"))
531 .is_some()
532 })
533 })
534 .await;
535
536 tree.read_with(cx, |tree, _| {
537 assert_eq!(
538 tree.entries(true, 0)
539 .map(|entry| entry.path.as_ref())
540 .collect::<Vec<_>>(),
541 vec![
542 rel_path(""),
543 rel_path("file1.txt"),
544 rel_path("file2.log"),
545 rel_path("subdir-aa"),
546 rel_path("subdir-aa/config.ini"),
547 rel_path("subdir-b"),
548 rel_path("subdir-b/nested"),
549 rel_path("subdir-b/nested/note.md"),
550 ]
551 );
552 });
553}
554
555#[gpui::test]
556async fn test_symlinked_dir_inside_project(cx: &mut TestAppContext) {
557 init_test(cx);
558 let fs = FakeFs::new(cx.background_executor.clone());
559
560 fs.insert_tree(
561 "/root",
562 json!({
563 "project": {
564 "real-dir": {
565 "existing.rs": "",
566 "nested": {
567 "deep.rs": ""
568 }
569 },
570 "links": {}
571 }
572 }),
573 )
574 .await;
575
576 fs.create_symlink(
577 "/root/project/links/internal".as_ref(),
578 "../real-dir".into(),
579 )
580 .await
581 .unwrap();
582
583 let tree = Worktree::local(
584 Path::new("/root/project"),
585 true,
586 fs.clone(),
587 Default::default(),
588 true,
589 WorktreeId::from_proto(0),
590 &mut cx.to_async(),
591 )
592 .await
593 .unwrap();
594
595 cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
596 .await;
597
598 tree.read_with(cx, |tree, _| {
599 assert_eq!(
600 tree.entries(true, 0)
601 .map(|entry| (entry.path.as_ref(), entry.is_external))
602 .collect::<Vec<_>>(),
603 vec![
604 (rel_path(""), false),
605 (rel_path("links"), false),
606 (rel_path("links/internal"), false),
607 (rel_path("links/internal/existing.rs"), false),
608 (rel_path("links/internal/nested"), false),
609 (rel_path("links/internal/nested/deep.rs"), false),
610 (rel_path("real-dir"), false),
611 (rel_path("real-dir/existing.rs"), false),
612 (rel_path("real-dir/nested"), false),
613 (rel_path("real-dir/nested/deep.rs"), false),
614 ]
615 );
616
617 assert_eq!(
618 tree.entry_for_path(rel_path("links/internal"))
619 .unwrap()
620 .kind,
621 EntryKind::Dir
622 );
623 });
624
625 fs.insert_file(Path::new("/root/project/real-dir/new.txt"), b"".to_vec())
626 .await;
627 wait_for_condition(cx, |cx| {
628 tree.read_with(cx, |tree, _| {
629 tree.entry_for_path(rel_path("links/internal/new.txt"))
630 .is_some()
631 })
632 })
633 .await;
634
635 tree.read_with(cx, |tree, _| {
636 assert!(
637 tree.entry_for_path(rel_path("links/internal/new.txt"))
638 .is_some()
639 );
640 });
641
642 fs.insert_file(
643 Path::new("/root/project/real-dir/nested/inner.txt"),
644 b"".to_vec(),
645 )
646 .await;
647 wait_for_condition(cx, |cx| {
648 tree.read_with(cx, |tree, _| {
649 tree.entry_for_path(rel_path("links/internal/nested/inner.txt"))
650 .is_some()
651 })
652 })
653 .await;
654
655 tree.read_with(cx, |tree, _| {
656 assert!(
657 tree.entry_for_path(rel_path("links/internal/nested/inner.txt"))
658 .is_some()
659 );
660 });
661}
662
663#[gpui::test]
664async fn test_scan_symlinks_always(cx: &mut TestAppContext) {
665 init_test(cx);
666
667 cx.update(|cx| {
668 cx.update_global::<SettingsStore, _>(|store, cx| {
669 store.update_user_settings(cx, |settings| {
670 settings.project.worktree.scan_symlinks =
671 Some(settings::ScanSymlinksSetting::Always);
672 });
673 });
674 });
675
676 let fs = FakeFs::new(cx.background_executor.clone());
677 fs.insert_tree(
678 "/root",
679 json!({
680 "dir1": {
681 "deps": {
682 // symlink target placed here by create_symlink below
683 },
684 "src": {
685 "a.rs": "",
686 },
687 },
688 "dir2": {
689 "src": {
690 "b.rs": "",
691 }
692 }
693 }),
694 )
695 .await;
696
697 fs.create_symlink("/root/dir1/deps/dep-dir2".as_ref(), "../../dir2".into())
698 .await
699 .unwrap();
700
701 let tree = Worktree::local(
702 Path::new("/root/dir1"),
703 true,
704 fs.clone(),
705 Default::default(),
706 true,
707 WorktreeId::from_proto(0),
708 &mut cx.to_async(),
709 )
710 .await
711 .unwrap();
712
713 cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
714 .await;
715
716 // With scan_symlinks = Always, the symlinked directory's contents should be
717 // fully visible on the first scan without any manual expansion.
718 tree.read_with(cx, |tree, _| {
719 assert_eq!(
720 tree.entries(true, 0)
721 .map(|entry| (entry.path.as_ref(), entry.is_external))
722 .collect::<Vec<_>>(),
723 vec![
724 (rel_path(""), false),
725 (rel_path("deps"), false),
726 (rel_path("deps/dep-dir2"), true),
727 (rel_path("deps/dep-dir2/src"), true),
728 (rel_path("deps/dep-dir2/src/b.rs"), true),
729 (rel_path("src"), false),
730 (rel_path("src/a.rs"), false),
731 ]
732 );
733 });
734}
735
736#[gpui::test]
737async fn test_scan_symlinks_expanded(cx: &mut TestAppContext) {
738 init_test(cx);
739
740 // scan_symlinks defaults to Expanded — no settings change needed.
741
742 let fs = FakeFs::new(cx.background_executor.clone());
743 fs.insert_tree(
744 "/root",
745 json!({
746 "dir1": {
747 "deps": {
748 // symlink target placed here by create_symlink below
749 },
750 "src": {
751 "a.rs": "",
752 },
753 },
754 "dir2": {
755 "src": {
756 "b.rs": "",
757 }
758 }
759 }),
760 )
761 .await;
762
763 fs.create_symlink("/root/dir1/deps/dep-dir2".as_ref(), "../../dir2".into())
764 .await
765 .unwrap();
766
767 let tree = Worktree::local(
768 Path::new("/root/dir1"),
769 true,
770 fs.clone(),
771 Default::default(),
772 true,
773 WorktreeId::from_proto(0),
774 &mut cx.to_async(),
775 )
776 .await
777 .unwrap();
778
779 cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
780 .await;
781
782 // With the default scan_symlinks = Expanded, the symlinked directory
783 // should appear as an UnloadedDir entry with no children visible.
784 tree.read_with(cx, |tree, _| {
785 assert_eq!(
786 tree.entries(true, 0)
787 .map(|entry| (entry.path.as_ref(), entry.is_external))
788 .collect::<Vec<_>>(),
789 vec![
790 (rel_path(""), false),
791 (rel_path("deps"), false),
792 (rel_path("deps/dep-dir2"), true),
793 (rel_path("src"), false),
794 (rel_path("src/a.rs"), false),
795 ]
796 );
797
798 assert_eq!(
799 tree.entry_for_path(rel_path("deps/dep-dir2")).unwrap().kind,
800 EntryKind::UnloadedDir
801 );
802 });
803
804 // Manually expand the symlinked directory.
805 tree.read_with(cx, |tree, _| {
806 tree.as_local()
807 .unwrap()
808 .refresh_entries_for_paths(vec![rel_path("deps/dep-dir2").into()])
809 })
810 .recv()
811 .await;
812
813 // After expansion, dep-dir2's immediate children are visible. Subdirectories
814 // within it are present but not yet scanned.
815 tree.read_with(cx, |tree, _| {
816 assert_eq!(
817 tree.entries(true, 0)
818 .map(|entry| (entry.path.as_ref(), entry.is_external))
819 .collect::<Vec<_>>(),
820 vec![
821 (rel_path(""), false),
822 (rel_path("deps"), false),
823 (rel_path("deps/dep-dir2"), true),
824 (rel_path("deps/dep-dir2/src"), true),
825 (rel_path("src"), false),
826 (rel_path("src/a.rs"), false),
827 ]
828 );
829
830 assert_eq!(
831 tree.entry_for_path(rel_path("deps/dep-dir2/src"))
832 .unwrap()
833 .kind,
834 EntryKind::UnloadedDir
835 );
836 });
837
838 // Expand the subdirectory inside the symlinked directory.
839 tree.read_with(cx, |tree, _| {
840 tree.as_local()
841 .unwrap()
842 .refresh_entries_for_paths(vec![rel_path("deps/dep-dir2/src").into()])
843 })
844 .recv()
845 .await;
846
847 // After expanding the subdirectory, its files are visible.
848 tree.read_with(cx, |tree, _| {
849 assert_eq!(
850 tree.entries(true, 0)
851 .map(|entry| (entry.path.as_ref(), entry.is_external))
852 .collect::<Vec<_>>(),
853 vec![
854 (rel_path(""), false),
855 (rel_path("deps"), false),
856 (rel_path("deps/dep-dir2"), true),
857 (rel_path("deps/dep-dir2/src"), true),
858 (rel_path("deps/dep-dir2/src/b.rs"), true),
859 (rel_path("src"), false),
860 (rel_path("src/a.rs"), false),
861 ]
862 );
863 });
864}
865
866#[gpui::test(iterations = 10)]
867async fn test_circular_symlinks_always(cx: &mut TestAppContext) {
868 init_test(cx);
869
870 cx.update(|cx| {
871 cx.update_global::<SettingsStore, _>(|store, cx| {
872 store.update_user_settings(cx, |settings| {
873 settings.project.worktree.scan_symlinks =
874 Some(settings::ScanSymlinksSetting::Always);
875 });
876 });
877 });
878
879 let fs = FakeFs::new(cx.background_executor.clone());
880 fs.insert_tree(
881 "/root",
882 json!({
883 "project": {
884 "lib": {
885 "a": {
886 "a.txt": ""
887 }
888 },
889 "deps": {}
890 },
891 "outside": {
892 "data.txt": ""
893 }
894 }),
895 )
896 .await;
897
898 fs.create_symlink("/root/project/deps/ext".as_ref(), "../../outside".into())
899 .await
900 .unwrap();
901 fs.create_symlink("/root/outside/back".as_ref(), "../../project".into())
902 .await
903 .unwrap();
904
905 let tree = Worktree::local(
906 Path::new("/root/project"),
907 true,
908 fs.clone(),
909 Default::default(),
910 true,
911 WorktreeId::from_proto(0),
912 &mut cx.to_async(),
913 )
914 .await
915 .unwrap();
916
917 cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
918 .await;
919
920 tree.read_with(cx, |tree, _| {
921 let entries: Vec<_> = tree
922 .entries(true, 0)
923 .map(|entry| (entry.path.as_ref(), entry.is_external))
924 .collect();
925
926 assert_eq!(
927 entries,
928 vec![
929 (rel_path(""), false),
930 (rel_path("deps"), false),
931 (rel_path("deps/ext"), true),
932 (rel_path("deps/ext/data.txt"), true),
933 (rel_path("lib"), false),
934 (rel_path("lib/a"), false),
935 (rel_path("lib/a/a.txt"), false),
936 ]
937 );
938 });
939}
940
941#[gpui::test]
942async fn test_scan_symlinks_always_respects_gitignore(cx: &mut TestAppContext) {
943 init_test(cx);
944
945 cx.update(|cx| {
946 cx.update_global::<SettingsStore, _>(|store, cx| {
947 store.update_user_settings(cx, |settings| {
948 settings.project.worktree.scan_symlinks =
949 Some(settings::ScanSymlinksSetting::Always);
950 });
951 });
952 });
953
954 let fs = FakeFs::new(cx.background_executor.clone());
955 fs.insert_tree(
956 "/root",
957 json!({
958 "project": {
959 ".gitignore": "ignored-dep\n",
960 "deps": {}
961 },
962 "external-included": {
963 "src": {
964 "included.rs": ""
965 }
966 },
967 "external-ignored": {
968 "src": {
969 "ignored.rs": ""
970 }
971 }
972 }),
973 )
974 .await;
975
976 fs.create_symlink(
977 "/root/project/deps/included-dep".as_ref(),
978 "../../external-included".into(),
979 )
980 .await
981 .unwrap();
982 fs.create_symlink(
983 "/root/project/deps/ignored-dep".as_ref(),
984 "../../external-ignored".into(),
985 )
986 .await
987 .unwrap();
988
989 let tree = Worktree::local(
990 Path::new("/root/project"),
991 true,
992 fs.clone(),
993 Default::default(),
994 true,
995 WorktreeId::from_proto(0),
996 &mut cx.to_async(),
997 )
998 .await
999 .unwrap();
1000
1001 cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
1002 .await;
1003
1004 tree.read_with(cx, |tree, _| {
1005 assert_eq!(
1006 tree.entries(true, 0)
1007 .map(|entry| (entry.path.as_ref(), entry.is_external, entry.is_ignored))
1008 .collect::<Vec<_>>(),
1009 vec![
1010 (rel_path(""), false, false),
1011 (rel_path(".gitignore"), false, false),
1012 (rel_path("deps"), false, false),
1013 (rel_path("deps/ignored-dep"), true, true),
1014 (rel_path("deps/included-dep"), true, false),
1015 (rel_path("deps/included-dep/src"), true, false),
1016 (rel_path("deps/included-dep/src/included.rs"), true, false),
1017 ]
1018 );
1019
1020 assert_eq!(
1021 tree.entry_for_path(rel_path("deps/ignored-dep"))
1022 .unwrap()
1023 .kind,
1024 EntryKind::UnloadedDir
1025 );
1026 });
1027}
1028
1029// Real-fs counterparts to the FakeFs scan_symlinks tests above. FakeFs does not
1030// model `fs::canonicalize` against a real filesystem, so platform-specific
1031// canonicalization or readdir behavior is not covered by the FakeFs tests.
1032// These tests use a real temp directory and a real symlink to exercise the
1033// production path on the host platform.
1034#[cfg(unix)]
1035#[gpui::test]
1036async fn test_real_fs_scan_symlinks_always(cx: &mut TestAppContext) {
1037 cx.executor().allow_parking();
1038 init_test(cx);
1039
1040 cx.update(|cx| {
1041 cx.update_global::<SettingsStore, _>(|store, cx| {
1042 store.update_user_settings(cx, |settings| {
1043 settings.project.worktree.scan_symlinks =
1044 Some(settings::ScanSymlinksSetting::Always);
1045 });
1046 });
1047 });
1048
1049 let temp_root = TempTree::new(json!({
1050 "project": {
1051 "deps": {},
1052 "src": {
1053 "a.rs": "",
1054 },
1055 },
1056 "external": {
1057 "src": {
1058 "b.rs": "",
1059 },
1060 },
1061 }));
1062
1063 // Relative symlink: from temp_root/project/deps/, `../../external` resolves
1064 // to temp_root/external — outside the worktree root at temp_root/project.
1065 std::os::unix::fs::symlink(
1066 "../../external",
1067 temp_root.path().join("project/deps/dep-external"),
1068 )
1069 .unwrap();
1070
1071 let project_root = temp_root.path().join("project");
1072 let tree = Worktree::local(
1073 project_root.as_path(),
1074 true,
1075 Arc::new(RealFs::new(None, cx.executor())),
1076 Default::default(),
1077 true,
1078 WorktreeId::from_proto(0),
1079 &mut cx.to_async(),
1080 )
1081 .await
1082 .unwrap();
1083
1084 cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
1085 .await;
1086
1087 tree.read_with(cx, |tree, _| {
1088 assert_eq!(
1089 tree.entries(true, 0)
1090 .map(|entry| (entry.path.as_ref(), entry.is_external))
1091 .collect::<Vec<_>>(),
1092 vec![
1093 (rel_path(""), false),
1094 (rel_path("deps"), false),
1095 (rel_path("deps/dep-external"), true),
1096 (rel_path("deps/dep-external/src"), true),
1097 (rel_path("deps/dep-external/src/b.rs"), true),
1098 (rel_path("src"), false),
1099 (rel_path("src/a.rs"), false),
1100 ]
1101 );
1102 });
1103}
1104
1105#[cfg(unix)]
1106#[gpui::test]
1107async fn test_real_fs_scan_symlinks_expanded(cx: &mut TestAppContext) {
1108 cx.executor().allow_parking();
1109 init_test(cx);
1110
1111 // scan_symlinks defaults to Expanded — no settings change needed.
1112
1113 let temp_root = TempTree::new(json!({
1114 "project": {
1115 "deps": {},
1116 "src": {
1117 "a.rs": "",
1118 },
1119 },
1120 "external": {
1121 "src": {
1122 "b.rs": "",
1123 },
1124 },
1125 }));
1126
1127 std::os::unix::fs::symlink(
1128 "../../external",
1129 temp_root.path().join("project/deps/dep-external"),
1130 )
1131 .unwrap();
1132
1133 let project_root = temp_root.path().join("project");
1134 let tree = Worktree::local(
1135 project_root.as_path(),
1136 true,
1137 Arc::new(RealFs::new(None, cx.executor())),
1138 Default::default(),
1139 true,
1140 WorktreeId::from_proto(0),
1141 &mut cx.to_async(),
1142 )
1143 .await
1144 .unwrap();
1145
1146 cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
1147 .await;
1148
1149 // Before expansion, the symlinked directory should appear as an UnloadedDir
1150 // with no children visible.
1151 tree.read_with(cx, |tree, _| {
1152 assert_eq!(
1153 tree.entries(true, 0)
1154 .map(|entry| (entry.path.as_ref(), entry.is_external))
1155 .collect::<Vec<_>>(),
1156 vec![
1157 (rel_path(""), false),
1158 (rel_path("deps"), false),
1159 (rel_path("deps/dep-external"), true),
1160 (rel_path("src"), false),
1161 (rel_path("src/a.rs"), false),
1162 ]
1163 );
1164
1165 assert_eq!(
1166 tree.entry_for_path(rel_path("deps/dep-external"))
1167 .unwrap()
1168 .kind,
1169 EntryKind::UnloadedDir
1170 );
1171 });
1172
1173 // Manually expand the symlinked directory. This is the case #51382 was
1174 // added to fix; if this assertion fails it's a regression of that fix on
1175 // real filesystems.
1176 tree.read_with(cx, |tree, _| {
1177 tree.as_local()
1178 .unwrap()
1179 .refresh_entries_for_paths(vec![rel_path("deps/dep-external").into()])
1180 })
1181 .recv()
1182 .await;
1183
1184 tree.read_with(cx, |tree, _| {
1185 assert_eq!(
1186 tree.entries(true, 0)
1187 .map(|entry| (entry.path.as_ref(), entry.is_external))
1188 .collect::<Vec<_>>(),
1189 vec![
1190 (rel_path(""), false),
1191 (rel_path("deps"), false),
1192 (rel_path("deps/dep-external"), true),
1193 (rel_path("deps/dep-external/src"), true),
1194 (rel_path("src"), false),
1195 (rel_path("src/a.rs"), false),
1196 ]
1197 );
1198 });
1199}
1200
1201#[gpui::test]
1202async fn test_internal_symlink_updates_preserve_entry_ids(cx: &mut TestAppContext) {
1203 init_test(cx);
1204 let fs = FakeFs::new(cx.background_executor.clone());
1205
1206 fs.insert_tree(
1207 "/root",
1208 json!({
1209 "project": {
1210 "real-dir": {
1211 "existing.rs": "old",
1212 },
1213 "links": {}
1214 }
1215 }),
1216 )
1217 .await;
1218
1219 fs.create_symlink(
1220 "/root/project/links/internal".as_ref(),
1221 "../real-dir".into(),
1222 )
1223 .await
1224 .unwrap();
1225
1226 let tree = Worktree::local(
1227 Path::new("/root/project"),
1228 true,
1229 fs.clone(),
1230 Default::default(),
1231 true,
1232 WorktreeId::from_proto(0),
1233 &mut cx.to_async(),
1234 )
1235 .await
1236 .unwrap();
1237
1238 cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
1239 .await;
1240
1241 let (real_entry_id, symlink_entry_id, old_mtime) = tree.read_with(cx, |tree, _| {
1242 let real_entry = tree
1243 .entry_for_path(rel_path("real-dir/existing.rs"))
1244 .unwrap();
1245 let symlink_entry = tree
1246 .entry_for_path(rel_path("links/internal/existing.rs"))
1247 .unwrap();
1248 assert_eq!(real_entry.inode, symlink_entry.inode);
1249 assert_eq!(real_entry.mtime, symlink_entry.mtime);
1250 assert_ne!(real_entry.id, symlink_entry.id);
1251 (real_entry.id, symlink_entry.id, real_entry.mtime)
1252 });
1253
1254 fs.write(Path::new("/root/project/real-dir/existing.rs"), b"new")
1255 .await
1256 .unwrap();
1257
1258 wait_for_condition(cx, |cx| {
1259 tree.read_with(cx, |tree, _| {
1260 let real_entry = tree
1261 .entry_for_path(rel_path("real-dir/existing.rs"))
1262 .unwrap();
1263 let symlink_entry = tree
1264 .entry_for_path(rel_path("links/internal/existing.rs"))
1265 .unwrap();
1266 real_entry.mtime != old_mtime && symlink_entry.mtime != old_mtime
1267 })
1268 })
1269 .await;
1270
1271 tree.read_with(cx, |tree, _| {
1272 let real_entry = tree
1273 .entry_for_path(rel_path("real-dir/existing.rs"))
1274 .unwrap();
1275 let symlink_entry = tree
1276 .entry_for_path(rel_path("links/internal/existing.rs"))
1277 .unwrap();
1278
1279 assert_eq!(real_entry.inode, symlink_entry.inode);
1280 assert_eq!(real_entry.id, real_entry_id);
1281 assert_eq!(
1282 tree.entry_for_id(real_entry_id).unwrap().path.as_ref(),
1283 rel_path("real-dir/existing.rs")
1284 );
1285 assert_eq!(symlink_entry.id, symlink_entry_id);
1286 assert_eq!(
1287 tree.entry_for_id(symlink_entry_id).unwrap().path.as_ref(),
1288 rel_path("links/internal/existing.rs")
1289 );
1290 });
1291}
1292
1293#[cfg(target_os = "macos")]
1294#[gpui::test]
1295async fn test_renaming_case_only(cx: &mut TestAppContext) {
1296 cx.executor().allow_parking();
1297 init_test(cx);
1298
1299 const OLD_NAME: &str = "aaa.rs";
1300 const NEW_NAME: &str = "AAA.rs";
1301
1302 let fs = Arc::new(RealFs::new(None, cx.executor()));
1303 let temp_root = TempTree::new(json!({
1304 OLD_NAME: "",
1305 }));
1306
1307 let tree = Worktree::local(
1308 temp_root.path(),
1309 true,
1310 fs.clone(),
1311 Default::default(),
1312 true,
1313 WorktreeId::from_proto(0),
1314 &mut cx.to_async(),
1315 )
1316 .await
1317 .unwrap();
1318
1319 cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
1320 .await;
1321 tree.read_with(cx, |tree, _| {
1322 assert_eq!(
1323 tree.entries(true, 0)
1324 .map(|entry| entry.path.as_ref())
1325 .collect::<Vec<_>>(),
1326 vec![rel_path(""), rel_path(OLD_NAME)]
1327 );
1328 });
1329
1330 fs.rename(
1331 &temp_root.path().join(OLD_NAME),
1332 &temp_root.path().join(NEW_NAME),
1333 fs::RenameOptions {
1334 overwrite: true,
1335 ignore_if_exists: true,
1336 create_parents: false,
1337 },
1338 )
1339 .await
1340 .unwrap();
1341
1342 tree.flush_fs_events(cx).await;
1343
1344 tree.read_with(cx, |tree, _| {
1345 assert_eq!(
1346 tree.entries(true, 0)
1347 .map(|entry| entry.path.as_ref())
1348 .collect::<Vec<_>>(),
1349 vec![rel_path(""), rel_path(NEW_NAME)]
1350 );
1351 });
1352}
1353
1354#[gpui::test]
1355async fn test_root_rescan_reconciles_stale_state(cx: &mut TestAppContext) {
1356 init_test(cx);
1357 let fs = FakeFs::new(cx.background_executor.clone());
1358 fs.insert_tree(
1359 "/root",
1360 json!({
1361 "old.txt": "",
1362 }),
1363 )
1364 .await;
1365
1366 let tree = Worktree::local(
1367 Path::new("/root"),
1368 true,
1369 fs.clone(),
1370 Default::default(),
1371 true,
1372 WorktreeId::from_proto(0),
1373 &mut cx.to_async(),
1374 )
1375 .await
1376 .unwrap();
1377
1378 cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
1379 .await;
1380
1381 tree.read_with(cx, |tree, _| {
1382 assert_eq!(
1383 tree.entries(true, 0)
1384 .map(|entry| entry.path.as_ref())
1385 .collect::<Vec<_>>(),
1386 vec![rel_path(""), rel_path("old.txt")]
1387 );
1388 });
1389
1390 fs.pause_events();
1391 fs.remove_file(Path::new("/root/old.txt"), RemoveOptions::default())
1392 .await
1393 .unwrap();
1394 fs.insert_file(Path::new("/root/new.txt"), Vec::new()).await;
1395 assert_eq!(fs.buffered_event_count(), 2);
1396 fs.clear_buffered_events();
1397
1398 tree.read_with(cx, |tree, _| {
1399 assert!(tree.entry_for_path(rel_path("old.txt")).is_some());
1400 assert!(tree.entry_for_path(rel_path("new.txt")).is_none());
1401 });
1402
1403 fs.emit_fs_event("/root", Some(fs::PathEventKind::Rescan));
1404 fs.unpause_events_and_flush();
1405 tree.flush_fs_events(cx).await;
1406
1407 tree.read_with(cx, |tree, _| {
1408 assert!(tree.entry_for_path(rel_path("old.txt")).is_none());
1409 assert!(tree.entry_for_path(rel_path("new.txt")).is_some());
1410 assert_eq!(
1411 tree.entries(true, 0)
1412 .map(|entry| entry.path.as_ref())
1413 .collect::<Vec<_>>(),
1414 vec![rel_path(""), rel_path("new.txt")]
1415 );
1416 });
1417}
1418
1419#[gpui::test]
1420async fn test_root_rescan_does_not_miss_event_before_readding_root_watcher(
1421 cx: &mut TestAppContext,
1422) {
1423 init_test(cx);
1424 let fs = FakeFs::new(cx.background_executor.clone());
1425 fs.insert_tree("/root", json!({})).await;
1426
1427 let tree = Worktree::local(
1428 Path::new("/root"),
1429 true,
1430 fs.clone(),
1431 Default::default(),
1432 true,
1433 WorktreeId::from_proto(0),
1434 &mut cx.to_async(),
1435 )
1436 .await
1437 .unwrap();
1438
1439 cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
1440 .await;
1441
1442 fs.create_file_before_next_watch_add("/root", "/root/created-before-root-readd.txt");
1443 fs.emit_fs_event("/root", Some(PathEventKind::Rescan));
1444
1445 wait_for_condition(cx, |cx| {
1446 tree.read_with(cx, |tree, _| {
1447 tree.entry_for_path(rel_path("created-before-root-readd.txt"))
1448 .is_some()
1449 })
1450 })
1451 .await;
1452}
1453
1454#[gpui::test]
1455async fn test_subtree_rescan_reports_unchanged_descendants_as_updated(cx: &mut TestAppContext) {
1456 init_test(cx);
1457 let fs = FakeFs::new(cx.background_executor.clone());
1458 fs.insert_tree(
1459 "/root",
1460 json!({
1461 "dir": {
1462 "child.txt": "",
1463 "nested": {
1464 "grandchild.txt": "",
1465 },
1466 "remove": {
1467 "removed.txt": "",
1468 }
1469 },
1470 "other.txt": "",
1471 }),
1472 )
1473 .await;
1474
1475 let tree = Worktree::local(
1476 Path::new("/root"),
1477 true,
1478 fs.clone(),
1479 Default::default(),
1480 true,
1481 WorktreeId::from_proto(0),
1482 &mut cx.to_async(),
1483 )
1484 .await
1485 .unwrap();
1486
1487 cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
1488 .await;
1489
1490 let tree_updates = Arc::new(Mutex::new(Vec::new()));
1491 tree.update(cx, |_, cx| {
1492 let tree_updates = tree_updates.clone();
1493 cx.subscribe(&tree, move |_, _, event, _| {
1494 if let Event::UpdatedEntries(update) = event {
1495 tree_updates.lock().extend(
1496 update
1497 .iter()
1498 .filter(|(path, _, _)| path.as_ref() != rel_path("fs-event-sentinel"))
1499 .map(|(path, _, change)| (path.clone(), *change)),
1500 );
1501 }
1502 })
1503 .detach();
1504 });
1505 fs.pause_events();
1506 fs.insert_file("/root/dir/new.txt", b"new content".to_vec())
1507 .await;
1508 fs.remove_dir(
1509 "/root/dir/remove".as_ref(),
1510 RemoveOptions {
1511 recursive: true,
1512 ignore_if_not_exists: false,
1513 },
1514 )
1515 .await
1516 .unwrap();
1517 fs.clear_buffered_events();
1518 fs.unpause_events_and_flush();
1519
1520 fs.emit_fs_event("/root/dir", Some(fs::PathEventKind::Rescan));
1521 tree.flush_fs_events(cx).await;
1522
1523 assert_eq!(
1524 mem::take(&mut *tree_updates.lock()),
1525 &[
1526 (rel_path("dir").into(), PathChange::Updated),
1527 (rel_path("dir/child.txt").into(), PathChange::Updated),
1528 (rel_path("dir/nested").into(), PathChange::Updated),
1529 (
1530 rel_path("dir/nested/grandchild.txt").into(),
1531 PathChange::Updated
1532 ),
1533 (rel_path("dir/new.txt").into(), PathChange::Added),
1534 (rel_path("dir/remove").into(), PathChange::Removed),
1535 (
1536 rel_path("dir/remove/removed.txt").into(),
1537 PathChange::Removed
1538 ),
1539 ]
1540 );
1541
1542 tree.read_with(cx, |tree, _| {
1543 assert!(tree.entry_for_path(rel_path("other.txt")).is_some());
1544 });
1545}
1546
1547#[gpui::test]
1548async fn test_open_gitignored_files(cx: &mut TestAppContext) {
1549 init_test(cx);
1550 let fs = FakeFs::new(cx.background_executor.clone());
1551 fs.insert_tree(
1552 "/root",
1553 json!({
1554 ".gitignore": "node_modules\n",
1555 "one": {
1556 "node_modules": {
1557 "a": {
1558 "a1.js": "a1",
1559 "a2.js": "a2",
1560 },
1561 "b": {
1562 "b1.js": "b1",
1563 "b2.js": "b2",
1564 },
1565 "c": {
1566 "c1.js": "c1",
1567 "c2.js": "c2",
1568 }
1569 },
1570 },
1571 "two": {
1572 "x.js": "",
1573 "y.js": "",
1574 },
1575 }),
1576 )
1577 .await;
1578
1579 let tree = Worktree::local(
1580 Path::new("/root"),
1581 true,
1582 fs.clone(),
1583 Default::default(),
1584 true,
1585 WorktreeId::from_proto(0),
1586 &mut cx.to_async(),
1587 )
1588 .await
1589 .unwrap();
1590
1591 cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
1592 .await;
1593
1594 tree.read_with(cx, |tree, _| {
1595 assert_eq!(
1596 tree.entries(true, 0)
1597 .map(|entry| (entry.path.as_ref(), entry.is_ignored))
1598 .collect::<Vec<_>>(),
1599 vec![
1600 (rel_path(""), false),
1601 (rel_path(".gitignore"), false),
1602 (rel_path("one"), false),
1603 (rel_path("one/node_modules"), true),
1604 (rel_path("two"), false),
1605 (rel_path("two/x.js"), false),
1606 (rel_path("two/y.js"), false),
1607 ]
1608 );
1609 });
1610
1611 // Open a file that is nested inside of a gitignored directory that
1612 // has not yet been expanded.
1613 let prev_read_dir_count = fs.read_dir_call_count();
1614 let loaded = tree
1615 .update(cx, |tree, cx| {
1616 tree.load_file(rel_path("one/node_modules/b/b1.js"), cx)
1617 })
1618 .await
1619 .unwrap();
1620
1621 tree.read_with(cx, |tree, _| {
1622 assert_eq!(
1623 tree.entries(true, 0)
1624 .map(|entry| (entry.path.as_ref(), entry.is_ignored))
1625 .collect::<Vec<_>>(),
1626 vec![
1627 (rel_path(""), false),
1628 (rel_path(".gitignore"), false),
1629 (rel_path("one"), false),
1630 (rel_path("one/node_modules"), true),
1631 (rel_path("one/node_modules/a"), true),
1632 (rel_path("one/node_modules/b"), true),
1633 (rel_path("one/node_modules/b/b1.js"), true),
1634 (rel_path("one/node_modules/b/b2.js"), true),
1635 (rel_path("one/node_modules/c"), true),
1636 (rel_path("two"), false),
1637 (rel_path("two/x.js"), false),
1638 (rel_path("two/y.js"), false),
1639 ]
1640 );
1641
1642 assert_eq!(
1643 loaded.file.path.as_ref(),
1644 rel_path("one/node_modules/b/b1.js")
1645 );
1646
1647 // Only the newly-expanded directories are scanned.
1648 assert_eq!(fs.read_dir_call_count() - prev_read_dir_count, 2);
1649 });
1650
1651 // Open another file in a different subdirectory of the same
1652 // gitignored directory.
1653 let prev_read_dir_count = fs.read_dir_call_count();
1654 let loaded = tree
1655 .update(cx, |tree, cx| {
1656 tree.load_file(rel_path("one/node_modules/a/a2.js"), cx)
1657 })
1658 .await
1659 .unwrap();
1660
1661 tree.read_with(cx, |tree, _| {
1662 assert_eq!(
1663 tree.entries(true, 0)
1664 .map(|entry| (entry.path.as_ref(), entry.is_ignored))
1665 .collect::<Vec<_>>(),
1666 vec![
1667 (rel_path(""), false),
1668 (rel_path(".gitignore"), false),
1669 (rel_path("one"), false),
1670 (rel_path("one/node_modules"), true),
1671 (rel_path("one/node_modules/a"), true),
1672 (rel_path("one/node_modules/a/a1.js"), true),
1673 (rel_path("one/node_modules/a/a2.js"), true),
1674 (rel_path("one/node_modules/b"), true),
1675 (rel_path("one/node_modules/b/b1.js"), true),
1676 (rel_path("one/node_modules/b/b2.js"), true),
1677 (rel_path("one/node_modules/c"), true),
1678 (rel_path("two"), false),
1679 (rel_path("two/x.js"), false),
1680 (rel_path("two/y.js"), false),
1681 ]
1682 );
1683
1684 assert_eq!(
1685 loaded.file.path.as_ref(),
1686 rel_path("one/node_modules/a/a2.js")
1687 );
1688
1689 // Only the newly-expanded directory is scanned.
1690 assert_eq!(fs.read_dir_call_count() - prev_read_dir_count, 1);
1691 });
1692
1693 let path = PathBuf::from("/root/one/node_modules/c/lib");
1694
1695 // No work happens when files and directories change within an unloaded directory.
1696 let prev_fs_call_count = fs.read_dir_call_count() + fs.metadata_call_count();
1697 // When we open a directory, we check each ancestor whether it's a git
1698 // repository. That means we have an fs.metadata call per ancestor that we
1699 // need to subtract here.
1700 let ancestors = path.ancestors().count();
1701
1702 fs.create_dir(path.as_ref()).await.unwrap();
1703 cx.executor().run_until_parked();
1704
1705 assert_eq!(
1706 fs.read_dir_call_count() + fs.metadata_call_count() - prev_fs_call_count - ancestors,
1707 0
1708 );
1709}
1710
1711#[gpui::test]
1712async fn test_dirs_no_longer_ignored(cx: &mut TestAppContext) {
1713 init_test(cx);
1714 let fs = FakeFs::new(cx.background_executor.clone());
1715 fs.insert_tree(
1716 "/root",
1717 json!({
1718 ".gitignore": "node_modules\n",
1719 "a": {
1720 "a.js": "",
1721 },
1722 "b": {
1723 "b.js": "",
1724 },
1725 "node_modules": {
1726 "c": {
1727 "c.js": "",
1728 },
1729 "d": {
1730 "d.js": "",
1731 "e": {
1732 "e1.js": "",
1733 "e2.js": "",
1734 },
1735 "f": {
1736 "f1.js": "",
1737 "f2.js": "",
1738 }
1739 },
1740 },
1741 }),
1742 )
1743 .await;
1744
1745 let tree = Worktree::local(
1746 Path::new("/root"),
1747 true,
1748 fs.clone(),
1749 Default::default(),
1750 true,
1751 WorktreeId::from_proto(0),
1752 &mut cx.to_async(),
1753 )
1754 .await
1755 .unwrap();
1756
1757 cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
1758 .await;
1759
1760 // Open a file within the gitignored directory, forcing some of its
1761 // subdirectories to be read, but not all.
1762 let read_dir_count_1 = fs.read_dir_call_count();
1763 tree.read_with(cx, |tree, _| {
1764 tree.as_local()
1765 .unwrap()
1766 .refresh_entries_for_paths(vec![rel_path("node_modules/d/d.js").into()])
1767 })
1768 .recv()
1769 .await;
1770
1771 // Those subdirectories are now loaded.
1772 tree.read_with(cx, |tree, _| {
1773 assert_eq!(
1774 tree.entries(true, 0)
1775 .map(|e| (e.path.as_ref(), e.is_ignored))
1776 .collect::<Vec<_>>(),
1777 &[
1778 (rel_path(""), false),
1779 (rel_path(".gitignore"), false),
1780 (rel_path("a"), false),
1781 (rel_path("a/a.js"), false),
1782 (rel_path("b"), false),
1783 (rel_path("b/b.js"), false),
1784 (rel_path("node_modules"), true),
1785 (rel_path("node_modules/c"), true),
1786 (rel_path("node_modules/d"), true),
1787 (rel_path("node_modules/d/d.js"), true),
1788 (rel_path("node_modules/d/e"), true),
1789 (rel_path("node_modules/d/f"), true),
1790 ]
1791 );
1792 });
1793 let read_dir_count_2 = fs.read_dir_call_count();
1794 assert_eq!(read_dir_count_2 - read_dir_count_1, 2);
1795
1796 // Update the gitignore so that node_modules is no longer ignored,
1797 // but a subdirectory is ignored
1798 fs.save("/root/.gitignore".as_ref(), &"e".into(), Default::default())
1799 .await
1800 .unwrap();
1801 cx.executor().run_until_parked();
1802
1803 // All of the directories that are no longer ignored are now loaded.
1804 tree.read_with(cx, |tree, _| {
1805 assert_eq!(
1806 tree.entries(true, 0)
1807 .map(|e| (e.path.as_ref(), e.is_ignored))
1808 .collect::<Vec<_>>(),
1809 &[
1810 (rel_path(""), false),
1811 (rel_path(".gitignore"), false),
1812 (rel_path("a"), false),
1813 (rel_path("a/a.js"), false),
1814 (rel_path("b"), false),
1815 (rel_path("b/b.js"), false),
1816 // This directory is no longer ignored
1817 (rel_path("node_modules"), false),
1818 (rel_path("node_modules/c"), false),
1819 (rel_path("node_modules/c/c.js"), false),
1820 (rel_path("node_modules/d"), false),
1821 (rel_path("node_modules/d/d.js"), false),
1822 // This subdirectory is now ignored
1823 (rel_path("node_modules/d/e"), true),
1824 (rel_path("node_modules/d/f"), false),
1825 (rel_path("node_modules/d/f/f1.js"), false),
1826 (rel_path("node_modules/d/f/f2.js"), false),
1827 ]
1828 );
1829 });
1830
1831 // Each of the newly-loaded directories is scanned only once.
1832 let read_dir_count_3 = fs.read_dir_call_count();
1833 assert_eq!(read_dir_count_3 - read_dir_count_2, 2);
1834}
1835
1836#[gpui::test]
1837async fn test_write_file(cx: &mut TestAppContext) {
1838 init_test(cx);
1839 cx.executor().allow_parking();
1840 let dir = TempTree::new(json!({
1841 ".git": {},
1842 ".gitignore": "ignored-dir\n",
1843 "tracked-dir": {},
1844 "ignored-dir": {}
1845 }));
1846
1847 let worktree = Worktree::local(
1848 dir.path(),
1849 true,
1850 Arc::new(RealFs::new(None, cx.executor())),
1851 Default::default(),
1852 true,
1853 WorktreeId::from_proto(0),
1854 &mut cx.to_async(),
1855 )
1856 .await
1857 .unwrap();
1858
1859 #[cfg(not(target_os = "macos"))]
1860 fs::fs_watcher::global(|_| {}).unwrap();
1861
1862 cx.read(|cx| worktree.read(cx).as_local().unwrap().scan_complete())
1863 .await;
1864 worktree.flush_fs_events(cx).await;
1865
1866 worktree
1867 .update(cx, |tree, cx| {
1868 tree.write_file(
1869 rel_path("tracked-dir/file.txt").into(),
1870 "hello".into(),
1871 Default::default(),
1872 encoding_rs::UTF_8,
1873 false,
1874 cx,
1875 )
1876 })
1877 .await
1878 .unwrap();
1879 worktree
1880 .update(cx, |tree, cx| {
1881 tree.write_file(
1882 rel_path("ignored-dir/file.txt").into(),
1883 "world".into(),
1884 Default::default(),
1885 encoding_rs::UTF_8,
1886 false,
1887 cx,
1888 )
1889 })
1890 .await
1891 .unwrap();
1892 worktree.read_with(cx, |tree, _| {
1893 let tracked = tree
1894 .entry_for_path(rel_path("tracked-dir/file.txt"))
1895 .unwrap();
1896 let ignored = tree
1897 .entry_for_path(rel_path("ignored-dir/file.txt"))
1898 .unwrap();
1899 assert!(!tracked.is_ignored);
1900 assert!(ignored.is_ignored);
1901 });
1902}
1903
1904#[gpui::test]
1905async fn test_file_scan_inclusions(cx: &mut TestAppContext) {
1906 init_test(cx);
1907 cx.executor().allow_parking();
1908 let dir = TempTree::new(json!({
1909 ".gitignore": "**/target\n/node_modules\ntop_level.txt\n",
1910 "target": {
1911 "index": "blah2"
1912 },
1913 "node_modules": {
1914 ".DS_Store": "",
1915 "prettier": {
1916 "package.json": "{}",
1917 },
1918 "package.json": "//package.json"
1919 },
1920 "src": {
1921 ".DS_Store": "",
1922 "foo": {
1923 "foo.rs": "mod another;\n",
1924 "another.rs": "// another",
1925 },
1926 "bar": {
1927 "bar.rs": "// bar",
1928 },
1929 "lib.rs": "mod foo;\nmod bar;\n",
1930 },
1931 "top_level.txt": "top level file",
1932 ".DS_Store": "",
1933 }));
1934 cx.update(|cx| {
1935 cx.update_global::<SettingsStore, _>(|store, cx| {
1936 store.update_user_settings(cx, |settings| {
1937 settings.project.worktree.file_scan_exclusions = Some(vec![]);
1938 settings.project.worktree.file_scan_inclusions = Some(vec![
1939 "node_modules/**/package.json".to_string(),
1940 "**/.DS_Store".to_string(),
1941 ]);
1942 });
1943 });
1944 });
1945
1946 let tree = Worktree::local(
1947 dir.path(),
1948 true,
1949 Arc::new(RealFs::new(None, cx.executor())),
1950 Default::default(),
1951 true,
1952 WorktreeId::from_proto(0),
1953 &mut cx.to_async(),
1954 )
1955 .await
1956 .unwrap();
1957 cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
1958 .await;
1959 tree.flush_fs_events(cx).await;
1960 tree.read_with(cx, |tree, _| {
1961 // Assert that file_scan_inclusions overrides file_scan_exclusions.
1962 check_worktree_entries(
1963 tree,
1964 WorktreeExpectations {
1965 excluded_paths: &[],
1966 ignored_paths: &["target", "node_modules"],
1967 tracked_paths: &["src/lib.rs", "src/bar/bar.rs", ".gitignore"],
1968 included_paths: &[
1969 "node_modules/prettier/package.json",
1970 ".DS_Store",
1971 "node_modules/.DS_Store",
1972 "src/.DS_Store",
1973 ],
1974 },
1975 )
1976 });
1977}
1978
1979#[gpui::test]
1980async fn test_file_scan_exclusions_overrules_inclusions(cx: &mut TestAppContext) {
1981 init_test(cx);
1982 cx.executor().allow_parking();
1983 let dir = TempTree::new(json!({
1984 ".gitignore": "**/target\n/node_modules\n",
1985 "target": {
1986 "index": "blah2"
1987 },
1988 "node_modules": {
1989 ".DS_Store": "",
1990 "prettier": {
1991 "package.json": "{}",
1992 },
1993 },
1994 "src": {
1995 ".DS_Store": "",
1996 "foo": {
1997 "foo.rs": "mod another;\n",
1998 "another.rs": "// another",
1999 },
2000 },
2001 ".DS_Store": "",
2002 }));
2003
2004 cx.update(|cx| {
2005 cx.update_global::<SettingsStore, _>(|store, cx| {
2006 store.update_user_settings(cx, |settings| {
2007 settings.project.worktree.file_scan_exclusions =
2008 Some(vec!["**/.DS_Store".to_string()]);
2009 settings.project.worktree.file_scan_inclusions =
2010 Some(vec!["**/.DS_Store".to_string()]);
2011 });
2012 });
2013 });
2014
2015 let tree = Worktree::local(
2016 dir.path(),
2017 true,
2018 Arc::new(RealFs::new(None, cx.executor())),
2019 Default::default(),
2020 true,
2021 WorktreeId::from_proto(0),
2022 &mut cx.to_async(),
2023 )
2024 .await
2025 .unwrap();
2026 cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
2027 .await;
2028 tree.flush_fs_events(cx).await;
2029 tree.read_with(cx, |tree, _| {
2030 // Assert that file_scan_inclusions overrides file_scan_exclusions.
2031 check_worktree_entries(
2032 tree,
2033 WorktreeExpectations {
2034 excluded_paths: &[".DS_Store, src/.DS_Store"],
2035 ignored_paths: &["target", "node_modules"],
2036 tracked_paths: &["src/foo/another.rs", "src/foo/foo.rs", ".gitignore"],
2037 included_paths: &[],
2038 },
2039 )
2040 });
2041}
2042
2043#[gpui::test]
2044async fn test_file_scan_inclusions_reindexes_on_setting_change(cx: &mut TestAppContext) {
2045 init_test(cx);
2046 cx.executor().allow_parking();
2047 let dir = TempTree::new(json!({
2048 ".gitignore": "**/target\n/node_modules/\n",
2049 "target": {
2050 "index": "blah2"
2051 },
2052 "node_modules": {
2053 ".DS_Store": "",
2054 "prettier": {
2055 "package.json": "{}",
2056 },
2057 },
2058 "src": {
2059 ".DS_Store": "",
2060 "foo": {
2061 "foo.rs": "mod another;\n",
2062 "another.rs": "// another",
2063 },
2064 },
2065 ".DS_Store": "",
2066 }));
2067
2068 cx.update(|cx| {
2069 cx.update_global::<SettingsStore, _>(|store, cx| {
2070 store.update_user_settings(cx, |settings| {
2071 settings.project.worktree.file_scan_exclusions = Some(vec![]);
2072 settings.project.worktree.file_scan_inclusions =
2073 Some(vec!["node_modules/**".to_string()]);
2074 });
2075 });
2076 });
2077 let tree = Worktree::local(
2078 dir.path(),
2079 true,
2080 Arc::new(RealFs::new(None, cx.executor())),
2081 Default::default(),
2082 true,
2083 WorktreeId::from_proto(0),
2084 &mut cx.to_async(),
2085 )
2086 .await
2087 .unwrap();
2088 cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
2089 .await;
2090 tree.flush_fs_events(cx).await;
2091
2092 tree.read_with(cx, |tree, _| {
2093 assert!(
2094 tree.entry_for_path(rel_path("node_modules"))
2095 .is_some_and(|f| f.is_always_included)
2096 );
2097 assert!(
2098 tree.entry_for_path(rel_path("node_modules/prettier/package.json"))
2099 .is_some_and(|f| f.is_always_included)
2100 );
2101 });
2102
2103 cx.update(|cx| {
2104 cx.update_global::<SettingsStore, _>(|store, cx| {
2105 store.update_user_settings(cx, |settings| {
2106 settings.project.worktree.file_scan_exclusions = Some(vec![]);
2107 settings.project.worktree.file_scan_inclusions = Some(vec![]);
2108 });
2109 });
2110 });
2111 cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
2112 .await;
2113 tree.flush_fs_events(cx).await;
2114
2115 tree.read_with(cx, |tree, _| {
2116 assert!(
2117 tree.entry_for_path(rel_path("node_modules"))
2118 .is_some_and(|f| !f.is_always_included)
2119 );
2120 assert!(
2121 tree.entry_for_path(rel_path("node_modules/prettier/package.json"))
2122 .is_some_and(|f| !f.is_always_included)
2123 );
2124 });
2125}
2126
2127#[gpui::test]
2128async fn test_file_scan_exclusions(cx: &mut TestAppContext) {
2129 init_test(cx);
2130 cx.executor().allow_parking();
2131 let dir = TempTree::new(json!({
2132 ".gitignore": "**/target\n/node_modules\n",
2133 "target": {
2134 "index": "blah2"
2135 },
2136 "node_modules": {
2137 ".DS_Store": "",
2138 "prettier": {
2139 "package.json": "{}",
2140 },
2141 },
2142 "src": {
2143 ".DS_Store": "",
2144 "foo": {
2145 "foo.rs": "mod another;\n",
2146 "another.rs": "// another",
2147 },
2148 "bar": {
2149 "bar.rs": "// bar",
2150 },
2151 "lib.rs": "mod foo;\nmod bar;\n",
2152 },
2153 ".DS_Store": "",
2154 }));
2155 cx.update(|cx| {
2156 cx.update_global::<SettingsStore, _>(|store, cx| {
2157 store.update_user_settings(cx, |settings| {
2158 settings.project.worktree.file_scan_exclusions =
2159 Some(vec!["**/foo/**".to_string(), "**/.DS_Store".to_string()]);
2160 });
2161 });
2162 });
2163
2164 let tree = Worktree::local(
2165 dir.path(),
2166 true,
2167 Arc::new(RealFs::new(None, cx.executor())),
2168 Default::default(),
2169 true,
2170 WorktreeId::from_proto(0),
2171 &mut cx.to_async(),
2172 )
2173 .await
2174 .unwrap();
2175 cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
2176 .await;
2177 tree.flush_fs_events(cx).await;
2178 tree.read_with(cx, |tree, _| {
2179 check_worktree_entries(
2180 tree,
2181 WorktreeExpectations {
2182 excluded_paths: &[
2183 "src/foo/foo.rs",
2184 "src/foo/another.rs",
2185 "node_modules/.DS_Store",
2186 "src/.DS_Store",
2187 ".DS_Store",
2188 ],
2189 ignored_paths: &["target", "node_modules"],
2190 tracked_paths: &["src/lib.rs", "src/bar/bar.rs", ".gitignore"],
2191 included_paths: &[],
2192 },
2193 )
2194 });
2195
2196 cx.update(|cx| {
2197 cx.update_global::<SettingsStore, _>(|store, cx| {
2198 store.update_user_settings(cx, |settings| {
2199 settings.project.worktree.file_scan_exclusions =
2200 Some(vec!["**/node_modules/**".to_string()]);
2201 });
2202 });
2203 });
2204 tree.flush_fs_events(cx).await;
2205 cx.executor().run_until_parked();
2206 tree.read_with(cx, |tree, _| {
2207 check_worktree_entries(
2208 tree,
2209 WorktreeExpectations {
2210 excluded_paths: &[
2211 "node_modules/prettier/package.json",
2212 "node_modules/.DS_Store",
2213 "node_modules",
2214 ],
2215 ignored_paths: &["target"],
2216 tracked_paths: &[
2217 ".gitignore",
2218 "src/lib.rs",
2219 "src/bar/bar.rs",
2220 "src/foo/foo.rs",
2221 "src/foo/another.rs",
2222 "src/.DS_Store",
2223 ".DS_Store",
2224 ],
2225 included_paths: &[],
2226 },
2227 )
2228 });
2229}
2230
2231#[gpui::test]
2232async fn test_hidden_files(cx: &mut TestAppContext) {
2233 init_test(cx);
2234 cx.executor().allow_parking();
2235 let dir = TempTree::new(json!({
2236 ".gitignore": "**/target\n",
2237 ".hidden_file": "content",
2238 ".hidden_dir": {
2239 "nested.rs": "code",
2240 },
2241 "src": {
2242 "visible.rs": "code",
2243 },
2244 "logs": {
2245 "app.log": "logs",
2246 "debug.log": "logs",
2247 },
2248 "visible.txt": "content",
2249 }));
2250
2251 let tree = Worktree::local(
2252 dir.path(),
2253 true,
2254 Arc::new(RealFs::new(None, cx.executor())),
2255 Default::default(),
2256 true,
2257 WorktreeId::from_proto(0),
2258 &mut cx.to_async(),
2259 )
2260 .await
2261 .unwrap();
2262 cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
2263 .await;
2264 tree.flush_fs_events(cx).await;
2265
2266 tree.read_with(cx, |tree, _| {
2267 assert_eq!(
2268 tree.entries(true, 0)
2269 .map(|entry| (entry.path.as_ref(), entry.is_hidden))
2270 .collect::<Vec<_>>(),
2271 vec![
2272 (rel_path(""), false),
2273 (rel_path(".gitignore"), true),
2274 (rel_path(".hidden_dir"), true),
2275 (rel_path(".hidden_dir/nested.rs"), true),
2276 (rel_path(".hidden_file"), true),
2277 (rel_path("logs"), false),
2278 (rel_path("logs/app.log"), false),
2279 (rel_path("logs/debug.log"), false),
2280 (rel_path("src"), false),
2281 (rel_path("src/visible.rs"), false),
2282 (rel_path("visible.txt"), false),
2283 ]
2284 );
2285 });
2286
2287 cx.update(|cx| {
2288 cx.update_global::<SettingsStore, _>(|store, cx| {
2289 store.update_user_settings(cx, |settings| {
2290 settings.project.worktree.hidden_files = Some(vec!["**/*.log".to_string()]);
2291 });
2292 });
2293 });
2294 tree.flush_fs_events(cx).await;
2295 cx.executor().run_until_parked();
2296
2297 tree.read_with(cx, |tree, _| {
2298 assert_eq!(
2299 tree.entries(true, 0)
2300 .map(|entry| (entry.path.as_ref(), entry.is_hidden))
2301 .collect::<Vec<_>>(),
2302 vec![
2303 (rel_path(""), false),
2304 (rel_path(".gitignore"), false),
2305 (rel_path(".hidden_dir"), false),
2306 (rel_path(".hidden_dir/nested.rs"), false),
2307 (rel_path(".hidden_file"), false),
2308 (rel_path("logs"), false),
2309 (rel_path("logs/app.log"), true),
2310 (rel_path("logs/debug.log"), true),
2311 (rel_path("src"), false),
2312 (rel_path("src/visible.rs"), false),
2313 (rel_path("visible.txt"), false),
2314 ]
2315 );
2316 });
2317}
2318
2319#[gpui::test]
2320async fn test_fs_events_in_exclusions(cx: &mut TestAppContext) {
2321 init_test(cx);
2322 cx.executor().allow_parking();
2323 let dir = TempTree::new(json!({
2324 ".git": {
2325 "HEAD": "ref: refs/heads/main\n",
2326 "foo": "bar",
2327 },
2328 ".gitignore": "**/target\n/node_modules\ntest_output\n",
2329 "target": {
2330 "index": "blah2"
2331 },
2332 "node_modules": {
2333 ".DS_Store": "",
2334 "prettier": {
2335 "package.json": "{}",
2336 },
2337 },
2338 "src": {
2339 ".DS_Store": "",
2340 "foo": {
2341 "foo.rs": "mod another;\n",
2342 "another.rs": "// another",
2343 },
2344 "bar": {
2345 "bar.rs": "// bar",
2346 },
2347 "lib.rs": "mod foo;\nmod bar;\n",
2348 },
2349 ".DS_Store": "",
2350 }));
2351 cx.update(|cx| {
2352 cx.update_global::<SettingsStore, _>(|store, cx| {
2353 store.update_user_settings(cx, |settings| {
2354 settings.project.worktree.file_scan_exclusions = Some(vec![
2355 "**/.git".to_string(),
2356 "node_modules/".to_string(),
2357 "build_output".to_string(),
2358 ]);
2359 });
2360 });
2361 });
2362
2363 let tree = Worktree::local(
2364 dir.path(),
2365 true,
2366 Arc::new(RealFs::new(None, cx.executor())),
2367 Default::default(),
2368 true,
2369 WorktreeId::from_proto(0),
2370 &mut cx.to_async(),
2371 )
2372 .await
2373 .unwrap();
2374 cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
2375 .await;
2376 tree.flush_fs_events(cx).await;
2377 tree.read_with(cx, |tree, _| {
2378 check_worktree_entries(
2379 tree,
2380 WorktreeExpectations {
2381 excluded_paths: &[
2382 ".git/HEAD",
2383 ".git/foo",
2384 "node_modules",
2385 "node_modules/.DS_Store",
2386 "node_modules/prettier",
2387 "node_modules/prettier/package.json",
2388 ],
2389 ignored_paths: &["target"],
2390 tracked_paths: &[
2391 ".DS_Store",
2392 "src/.DS_Store",
2393 "src/lib.rs",
2394 "src/foo/foo.rs",
2395 "src/foo/another.rs",
2396 "src/bar/bar.rs",
2397 ".gitignore",
2398 ],
2399 included_paths: &[],
2400 },
2401 )
2402 });
2403
2404 let new_excluded_dir = dir.path().join("build_output");
2405 let new_ignored_dir = dir.path().join("test_output");
2406 std::fs::create_dir_all(&new_excluded_dir)
2407 .unwrap_or_else(|e| panic!("Failed to create a {new_excluded_dir:?} directory: {e}"));
2408 std::fs::create_dir_all(&new_ignored_dir)
2409 .unwrap_or_else(|e| panic!("Failed to create a {new_ignored_dir:?} directory: {e}"));
2410 let node_modules_dir = dir.path().join("node_modules");
2411 let dot_git_dir = dir.path().join(".git");
2412 let src_dir = dir.path().join("src");
2413 for existing_dir in [&node_modules_dir, &dot_git_dir, &src_dir] {
2414 assert!(
2415 existing_dir.is_dir(),
2416 "Expect {existing_dir:?} to be present in the FS already"
2417 );
2418 }
2419
2420 for directory_for_new_file in [
2421 new_excluded_dir,
2422 new_ignored_dir,
2423 node_modules_dir,
2424 dot_git_dir,
2425 src_dir,
2426 ] {
2427 std::fs::write(directory_for_new_file.join("new_file"), "new file contents")
2428 .unwrap_or_else(|e| {
2429 panic!("Failed to create in {directory_for_new_file:?} a new file: {e}")
2430 });
2431 }
2432 tree.flush_fs_events(cx).await;
2433
2434 tree.read_with(cx, |tree, _| {
2435 check_worktree_entries(
2436 tree,
2437 WorktreeExpectations {
2438 excluded_paths: &[
2439 ".git/HEAD",
2440 ".git/foo",
2441 ".git/new_file",
2442 "node_modules",
2443 "node_modules/.DS_Store",
2444 "node_modules/prettier",
2445 "node_modules/prettier/package.json",
2446 "node_modules/new_file",
2447 "build_output",
2448 "build_output/new_file",
2449 "test_output/new_file",
2450 ],
2451 ignored_paths: &["target", "test_output"],
2452 tracked_paths: &[
2453 ".DS_Store",
2454 "src/.DS_Store",
2455 "src/lib.rs",
2456 "src/foo/foo.rs",
2457 "src/foo/another.rs",
2458 "src/bar/bar.rs",
2459 "src/new_file",
2460 ".gitignore",
2461 ],
2462 included_paths: &[],
2463 },
2464 )
2465 });
2466}
2467
2468#[gpui::test]
2469async fn test_fs_events_in_dot_git_worktree(cx: &mut TestAppContext) {
2470 init_test(cx);
2471 cx.executor().allow_parking();
2472 let dir = TempTree::new(json!({
2473 ".git": {
2474 "HEAD": "ref: refs/heads/main\n",
2475 "foo": "foo contents",
2476 },
2477 }));
2478 let dot_git_worktree_dir = dir.path().join(".git");
2479
2480 let tree = Worktree::local(
2481 dot_git_worktree_dir.clone(),
2482 true,
2483 Arc::new(RealFs::new(None, cx.executor())),
2484 Default::default(),
2485 true,
2486 WorktreeId::from_proto(0),
2487 &mut cx.to_async(),
2488 )
2489 .await
2490 .unwrap();
2491 cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
2492 .await;
2493 tree.flush_fs_events(cx).await;
2494 tree.read_with(cx, |tree, _| {
2495 check_worktree_entries(
2496 tree,
2497 WorktreeExpectations {
2498 ignored_paths: &["HEAD", "foo"],
2499 ..Default::default()
2500 },
2501 )
2502 });
2503
2504 std::fs::write(dot_git_worktree_dir.join("new_file"), "new file contents")
2505 .unwrap_or_else(|e| panic!("Failed to create in {dot_git_worktree_dir:?} a new file: {e}"));
2506 tree.flush_fs_events(cx).await;
2507 tree.read_with(cx, |tree, _| {
2508 check_worktree_entries(
2509 tree,
2510 WorktreeExpectations {
2511 ignored_paths: &["HEAD", "foo", "new_file"],
2512 ..Default::default()
2513 },
2514 )
2515 });
2516}
2517
2518#[gpui::test(iterations = 30)]
2519async fn test_create_directory_during_initial_scan(cx: &mut TestAppContext) {
2520 init_test(cx);
2521 let fs = FakeFs::new(cx.background_executor.clone());
2522 fs.insert_tree(
2523 "/root",
2524 json!({
2525 "b": {},
2526 "c": {},
2527 "d": {},
2528 }),
2529 )
2530 .await;
2531
2532 let tree = Worktree::local(
2533 "/root".as_ref(),
2534 true,
2535 fs,
2536 Default::default(),
2537 true,
2538 WorktreeId::from_proto(0),
2539 &mut cx.to_async(),
2540 )
2541 .await
2542 .unwrap();
2543
2544 let snapshot1 = tree.update(cx, |tree, cx| {
2545 let tree = tree.as_local_mut().unwrap();
2546 let snapshot = Arc::new(Mutex::new(tree.snapshot()));
2547 tree.observe_updates(0, cx, {
2548 let snapshot = snapshot.clone();
2549 let settings = tree.settings();
2550 move |update| {
2551 snapshot
2552 .lock()
2553 .apply_remote_update(update, &settings.file_scan_inclusions);
2554 async { true }
2555 }
2556 });
2557 snapshot
2558 });
2559
2560 let entry = tree
2561 .update(cx, |tree, cx| {
2562 tree.as_local_mut()
2563 .unwrap()
2564 .create_entry(rel_path("a/e").into(), true, None, cx)
2565 })
2566 .await
2567 .unwrap()
2568 .into_included()
2569 .unwrap();
2570 assert!(entry.is_dir());
2571
2572 cx.executor().run_until_parked();
2573 tree.read_with(cx, |tree, _| {
2574 assert_eq!(
2575 tree.entry_for_path(rel_path("a/e")).unwrap().kind,
2576 EntryKind::Dir
2577 );
2578 });
2579
2580 let snapshot2 = tree.update(cx, |tree, _| tree.as_local().unwrap().snapshot());
2581 assert_eq!(
2582 snapshot1.lock().entries(true, 0).collect::<Vec<_>>(),
2583 snapshot2.entries(true, 0).collect::<Vec<_>>()
2584 );
2585}
2586
2587#[gpui::test]
2588async fn test_create_dir_all_on_create_entry(cx: &mut TestAppContext) {
2589 init_test(cx);
2590 cx.executor().allow_parking();
2591
2592 let fs_fake = FakeFs::new(cx.background_executor.clone());
2593 fs_fake
2594 .insert_tree(
2595 "/root",
2596 json!({
2597 "a": {},
2598 }),
2599 )
2600 .await;
2601
2602 let tree_fake = Worktree::local(
2603 "/root".as_ref(),
2604 true,
2605 fs_fake,
2606 Default::default(),
2607 true,
2608 WorktreeId::from_proto(0),
2609 &mut cx.to_async(),
2610 )
2611 .await
2612 .unwrap();
2613
2614 let entry = tree_fake
2615 .update(cx, |tree, cx| {
2616 tree.as_local_mut().unwrap().create_entry(
2617 rel_path("a/b/c/d.txt").into(),
2618 false,
2619 None,
2620 cx,
2621 )
2622 })
2623 .await
2624 .unwrap()
2625 .into_included()
2626 .unwrap();
2627 assert!(entry.is_file());
2628
2629 cx.executor().run_until_parked();
2630 tree_fake.read_with(cx, |tree, _| {
2631 assert!(
2632 tree.entry_for_path(rel_path("a/b/c/d.txt"))
2633 .unwrap()
2634 .is_file()
2635 );
2636 assert!(tree.entry_for_path(rel_path("a/b/c")).unwrap().is_dir());
2637 assert!(tree.entry_for_path(rel_path("a/b")).unwrap().is_dir());
2638 });
2639
2640 let fs_real = Arc::new(RealFs::new(None, cx.executor()));
2641 let temp_root = TempTree::new(json!({
2642 "a": {}
2643 }));
2644
2645 let tree_real = Worktree::local(
2646 temp_root.path(),
2647 true,
2648 fs_real,
2649 Default::default(),
2650 true,
2651 WorktreeId::from_proto(0),
2652 &mut cx.to_async(),
2653 )
2654 .await
2655 .unwrap();
2656
2657 let entry = tree_real
2658 .update(cx, |tree, cx| {
2659 tree.as_local_mut().unwrap().create_entry(
2660 rel_path("a/b/c/d.txt").into(),
2661 false,
2662 None,
2663 cx,
2664 )
2665 })
2666 .await
2667 .unwrap()
2668 .into_included()
2669 .unwrap();
2670 assert!(entry.is_file());
2671
2672 cx.executor().run_until_parked();
2673 tree_real.read_with(cx, |tree, _| {
2674 assert!(
2675 tree.entry_for_path(rel_path("a/b/c/d.txt"))
2676 .unwrap()
2677 .is_file()
2678 );
2679 assert!(tree.entry_for_path(rel_path("a/b/c")).unwrap().is_dir());
2680 assert!(tree.entry_for_path(rel_path("a/b")).unwrap().is_dir());
2681 });
2682
2683 // Test smallest change
2684 let entry = tree_real
2685 .update(cx, |tree, cx| {
2686 tree.as_local_mut().unwrap().create_entry(
2687 rel_path("a/b/c/e.txt").into(),
2688 false,
2689 None,
2690 cx,
2691 )
2692 })
2693 .await
2694 .unwrap()
2695 .into_included()
2696 .unwrap();
2697 assert!(entry.is_file());
2698
2699 cx.executor().run_until_parked();
2700 tree_real.read_with(cx, |tree, _| {
2701 assert!(
2702 tree.entry_for_path(rel_path("a/b/c/e.txt"))
2703 .unwrap()
2704 .is_file()
2705 );
2706 });
2707
2708 // Test largest change
2709 let entry = tree_real
2710 .update(cx, |tree, cx| {
2711 tree.as_local_mut().unwrap().create_entry(
2712 rel_path("d/e/f/g.txt").into(),
2713 false,
2714 None,
2715 cx,
2716 )
2717 })
2718 .await
2719 .unwrap()
2720 .into_included()
2721 .unwrap();
2722 assert!(entry.is_file());
2723
2724 cx.executor().run_until_parked();
2725 tree_real.read_with(cx, |tree, _| {
2726 assert!(
2727 tree.entry_for_path(rel_path("d/e/f/g.txt"))
2728 .unwrap()
2729 .is_file()
2730 );
2731 assert!(tree.entry_for_path(rel_path("d/e/f")).unwrap().is_dir());
2732 assert!(tree.entry_for_path(rel_path("d/e")).unwrap().is_dir());
2733 assert!(tree.entry_for_path(rel_path("d")).unwrap().is_dir());
2734 });
2735}
2736
2737#[gpui::test]
2738async fn test_create_file_in_expanded_gitignored_dir(cx: &mut TestAppContext) {
2739 // Tests the behavior of our worktree refresh when a file in a gitignored directory
2740 // is created.
2741 init_test(cx);
2742 let fs = FakeFs::new(cx.background_executor.clone());
2743 fs.insert_tree(
2744 "/root",
2745 json!({
2746 ".gitignore": "ignored_dir\n",
2747 "ignored_dir": {
2748 "existing_file.txt": "existing content",
2749 "another_file.txt": "another content",
2750 },
2751 }),
2752 )
2753 .await;
2754
2755 let tree = Worktree::local(
2756 Path::new("/root"),
2757 true,
2758 fs.clone(),
2759 Default::default(),
2760 true,
2761 WorktreeId::from_proto(0),
2762 &mut cx.to_async(),
2763 )
2764 .await
2765 .unwrap();
2766
2767 cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
2768 .await;
2769
2770 tree.read_with(cx, |tree, _| {
2771 let ignored_dir = tree.entry_for_path(rel_path("ignored_dir")).unwrap();
2772 assert!(ignored_dir.is_ignored);
2773 assert_eq!(ignored_dir.kind, EntryKind::UnloadedDir);
2774 });
2775
2776 tree.update(cx, |tree, cx| {
2777 tree.load_file(rel_path("ignored_dir/existing_file.txt"), cx)
2778 })
2779 .await
2780 .unwrap();
2781
2782 tree.read_with(cx, |tree, _| {
2783 let ignored_dir = tree.entry_for_path(rel_path("ignored_dir")).unwrap();
2784 assert!(ignored_dir.is_ignored);
2785 assert_eq!(ignored_dir.kind, EntryKind::Dir);
2786
2787 assert!(
2788 tree.entry_for_path(rel_path("ignored_dir/existing_file.txt"))
2789 .is_some()
2790 );
2791 assert!(
2792 tree.entry_for_path(rel_path("ignored_dir/another_file.txt"))
2793 .is_some()
2794 );
2795 });
2796
2797 let entry = tree
2798 .update(cx, |tree, cx| {
2799 tree.create_entry(rel_path("ignored_dir/new_file.txt").into(), false, None, cx)
2800 })
2801 .await
2802 .unwrap();
2803 assert!(entry.into_included().is_some());
2804
2805 cx.executor().run_until_parked();
2806
2807 tree.read_with(cx, |tree, _| {
2808 let ignored_dir = tree.entry_for_path(rel_path("ignored_dir")).unwrap();
2809 assert!(ignored_dir.is_ignored);
2810 assert_eq!(
2811 ignored_dir.kind,
2812 EntryKind::Dir,
2813 "ignored_dir should still be loaded, not UnloadedDir"
2814 );
2815
2816 assert!(
2817 tree.entry_for_path(rel_path("ignored_dir/existing_file.txt"))
2818 .is_some(),
2819 "existing_file.txt should still be visible"
2820 );
2821 assert!(
2822 tree.entry_for_path(rel_path("ignored_dir/another_file.txt"))
2823 .is_some(),
2824 "another_file.txt should still be visible"
2825 );
2826 assert!(
2827 tree.entry_for_path(rel_path("ignored_dir/new_file.txt"))
2828 .is_some(),
2829 "new_file.txt should be visible"
2830 );
2831 });
2832}
2833
2834#[gpui::test]
2835async fn test_fs_event_for_gitignored_dir_does_not_lose_contents(cx: &mut TestAppContext) {
2836 // Tests the behavior of our worktree refresh when a directory modification for a gitignored directory
2837 // is triggered.
2838 init_test(cx);
2839 let fs = FakeFs::new(cx.background_executor.clone());
2840 fs.insert_tree(
2841 "/root",
2842 json!({
2843 ".gitignore": "ignored_dir\n",
2844 "ignored_dir": {
2845 "file1.txt": "content1",
2846 "file2.txt": "content2",
2847 },
2848 }),
2849 )
2850 .await;
2851
2852 let tree = Worktree::local(
2853 Path::new("/root"),
2854 true,
2855 fs.clone(),
2856 Default::default(),
2857 true,
2858 WorktreeId::from_proto(0),
2859 &mut cx.to_async(),
2860 )
2861 .await
2862 .unwrap();
2863
2864 cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
2865 .await;
2866
2867 // Load a file to expand the ignored directory
2868 tree.update(cx, |tree, cx| {
2869 tree.load_file(rel_path("ignored_dir/file1.txt"), cx)
2870 })
2871 .await
2872 .unwrap();
2873
2874 tree.read_with(cx, |tree, _| {
2875 let ignored_dir = tree.entry_for_path(rel_path("ignored_dir")).unwrap();
2876 assert_eq!(ignored_dir.kind, EntryKind::Dir);
2877 assert!(
2878 tree.entry_for_path(rel_path("ignored_dir/file1.txt"))
2879 .is_some()
2880 );
2881 assert!(
2882 tree.entry_for_path(rel_path("ignored_dir/file2.txt"))
2883 .is_some()
2884 );
2885 });
2886
2887 fs.emit_fs_event("/root/ignored_dir", Some(fs::PathEventKind::Changed));
2888 tree.flush_fs_events(cx).await;
2889
2890 tree.read_with(cx, |tree, _| {
2891 let ignored_dir = tree.entry_for_path(rel_path("ignored_dir")).unwrap();
2892 assert_eq!(
2893 ignored_dir.kind,
2894 EntryKind::Dir,
2895 "ignored_dir should still be loaded (Dir), not UnloadedDir"
2896 );
2897 assert!(
2898 tree.entry_for_path(rel_path("ignored_dir/file1.txt"))
2899 .is_some(),
2900 "file1.txt should still be visible after directory fs event"
2901 );
2902 assert!(
2903 tree.entry_for_path(rel_path("ignored_dir/file2.txt"))
2904 .is_some(),
2905 "file2.txt should still be visible after directory fs event"
2906 );
2907 });
2908}
2909
2910#[gpui::test(iterations = 100)]
2911async fn test_random_worktree_operations_during_initial_scan(
2912 cx: &mut TestAppContext,
2913 mut rng: StdRng,
2914) {
2915 init_test(cx);
2916 let operations = env::var("OPERATIONS")
2917 .map(|o| o.parse().unwrap())
2918 .unwrap_or(5);
2919 let initial_entries = env::var("INITIAL_ENTRIES")
2920 .map(|o| o.parse().unwrap())
2921 .unwrap_or(20);
2922
2923 let root_dir = Path::new(path!("/test"));
2924 let fs = FakeFs::new(cx.background_executor.clone()) as Arc<dyn Fs>;
2925 fs.as_fake().insert_tree(root_dir, json!({})).await;
2926 for _ in 0..initial_entries {
2927 randomly_mutate_fs(&fs, root_dir, 1.0, &mut rng).await;
2928 }
2929 log::info!("generated initial tree");
2930
2931 let worktree = Worktree::local(
2932 root_dir,
2933 true,
2934 fs.clone(),
2935 Default::default(),
2936 true,
2937 WorktreeId::from_proto(0),
2938 &mut cx.to_async(),
2939 )
2940 .await
2941 .unwrap();
2942
2943 let mut snapshots = vec![worktree.read_with(cx, |tree, _| tree.as_local().unwrap().snapshot())];
2944 let updates = Arc::new(Mutex::new(Vec::new()));
2945 worktree.update(cx, |tree, cx| {
2946 check_worktree_change_events(tree, cx);
2947
2948 tree.as_local_mut().unwrap().observe_updates(0, cx, {
2949 let updates = updates.clone();
2950 move |update| {
2951 updates.lock().push(update);
2952 async { true }
2953 }
2954 });
2955 });
2956
2957 for _ in 0..operations {
2958 worktree
2959 .update(cx, |worktree, cx| {
2960 randomly_mutate_worktree(worktree, &mut rng, cx)
2961 })
2962 .await
2963 .log_err();
2964 worktree.read_with(cx, |tree, _| {
2965 tree.as_local().unwrap().snapshot().check_invariants(true)
2966 });
2967
2968 if rng.random_bool(0.6) {
2969 snapshots.push(worktree.read_with(cx, |tree, _| tree.as_local().unwrap().snapshot()));
2970 }
2971 }
2972
2973 worktree
2974 .update(cx, |tree, _| tree.as_local_mut().unwrap().scan_complete())
2975 .await;
2976
2977 cx.executor().run_until_parked();
2978
2979 let final_snapshot = worktree.read_with(cx, |tree, _| {
2980 let tree = tree.as_local().unwrap();
2981 let snapshot = tree.snapshot();
2982 snapshot.check_invariants(true);
2983 snapshot
2984 });
2985
2986 let settings = worktree.read_with(cx, |tree, _| tree.as_local().unwrap().settings());
2987
2988 for (i, snapshot) in snapshots.into_iter().enumerate().rev() {
2989 let mut updated_snapshot = snapshot.clone();
2990 for update in updates.lock().iter() {
2991 if update.scan_id >= updated_snapshot.scan_id() as u64 {
2992 updated_snapshot
2993 .apply_remote_update(update.clone(), &settings.file_scan_inclusions);
2994 }
2995 }
2996
2997 assert_eq!(
2998 updated_snapshot.entries(true, 0).collect::<Vec<_>>(),
2999 final_snapshot.entries(true, 0).collect::<Vec<_>>(),
3000 "wrong updates after snapshot {i}: {updates:#?}",
3001 );
3002 }
3003}
3004
3005#[gpui::test(iterations = 100)]
3006async fn test_random_worktree_changes(cx: &mut TestAppContext, mut rng: StdRng) {
3007 init_test(cx);
3008 let operations = env::var("OPERATIONS")
3009 .map(|o| o.parse().unwrap())
3010 .unwrap_or(40);
3011 let initial_entries = env::var("INITIAL_ENTRIES")
3012 .map(|o| o.parse().unwrap())
3013 .unwrap_or(20);
3014
3015 let root_dir = Path::new(path!("/test"));
3016 let fs = FakeFs::new(cx.background_executor.clone()) as Arc<dyn Fs>;
3017 fs.as_fake().insert_tree(root_dir, json!({})).await;
3018 for _ in 0..initial_entries {
3019 randomly_mutate_fs(&fs, root_dir, 1.0, &mut rng).await;
3020 }
3021 log::info!("generated initial tree");
3022
3023 let worktree = Worktree::local(
3024 root_dir,
3025 true,
3026 fs.clone(),
3027 Default::default(),
3028 true,
3029 WorktreeId::from_proto(0),
3030 &mut cx.to_async(),
3031 )
3032 .await
3033 .unwrap();
3034
3035 let updates = Arc::new(Mutex::new(Vec::new()));
3036 worktree.update(cx, |tree, cx| {
3037 check_worktree_change_events(tree, cx);
3038
3039 tree.as_local_mut().unwrap().observe_updates(0, cx, {
3040 let updates = updates.clone();
3041 move |update| {
3042 updates.lock().push(update);
3043 async { true }
3044 }
3045 });
3046 });
3047
3048 worktree
3049 .update(cx, |tree, _| tree.as_local_mut().unwrap().scan_complete())
3050 .await;
3051
3052 fs.as_fake().pause_events();
3053 let mut snapshots = Vec::new();
3054 let mut mutations_len = operations;
3055 while mutations_len > 1 {
3056 if rng.random_bool(0.2) {
3057 worktree
3058 .update(cx, |worktree, cx| {
3059 randomly_mutate_worktree(worktree, &mut rng, cx)
3060 })
3061 .await
3062 .log_err();
3063 } else {
3064 randomly_mutate_fs(&fs, root_dir, 1.0, &mut rng).await;
3065 }
3066
3067 let buffered_event_count = fs.as_fake().buffered_event_count();
3068 if buffered_event_count > 0 && rng.random_bool(0.3) {
3069 if rng.random_bool(0.2) {
3070 log::info!(
3071 "simulating watcher overflow, losing {} events",
3072 buffered_event_count
3073 );
3074 fs.as_fake().simulate_watcher_overflow(root_dir);
3075 } else {
3076 let len = rng.random_range(0..=buffered_event_count);
3077 log::info!("flushing {} events", len);
3078 fs.as_fake().flush_events(len);
3079 }
3080 } else {
3081 randomly_mutate_fs(&fs, root_dir, 0.6, &mut rng).await;
3082 mutations_len -= 1;
3083 }
3084
3085 cx.executor().run_until_parked();
3086 if rng.random_bool(0.2) {
3087 log::info!("storing snapshot {}", snapshots.len());
3088 let snapshot = worktree.read_with(cx, |tree, _| tree.as_local().unwrap().snapshot());
3089 snapshots.push(snapshot);
3090 }
3091 }
3092
3093 log::info!("quiescing");
3094 fs.as_fake().flush_events(usize::MAX);
3095 cx.executor().run_until_parked();
3096
3097 let snapshot = worktree.read_with(cx, |tree, _| tree.as_local().unwrap().snapshot());
3098 snapshot.check_invariants(true);
3099 let expanded_paths = snapshot
3100 .expanded_entries()
3101 .map(|e| e.path.clone())
3102 .collect::<Vec<_>>();
3103
3104 {
3105 let new_worktree = Worktree::local(
3106 root_dir,
3107 true,
3108 fs.clone(),
3109 Default::default(),
3110 true,
3111 WorktreeId::from_proto(0),
3112 &mut cx.to_async(),
3113 )
3114 .await
3115 .unwrap();
3116 new_worktree
3117 .update(cx, |tree, _| tree.as_local_mut().unwrap().scan_complete())
3118 .await;
3119 new_worktree
3120 .update(cx, |tree, _| {
3121 tree.as_local_mut()
3122 .unwrap()
3123 .refresh_entries_for_paths(expanded_paths)
3124 })
3125 .recv()
3126 .await;
3127 let new_snapshot =
3128 new_worktree.read_with(cx, |tree, _| tree.as_local().unwrap().snapshot());
3129 assert_eq!(
3130 snapshot.entries_without_ids(true),
3131 new_snapshot.entries_without_ids(true)
3132 );
3133 }
3134
3135 let settings = worktree.read_with(cx, |tree, _| tree.as_local().unwrap().settings());
3136
3137 for (i, mut prev_snapshot) in snapshots.into_iter().enumerate().rev() {
3138 for update in updates.lock().iter() {
3139 if update.scan_id >= prev_snapshot.scan_id() as u64 {
3140 prev_snapshot.apply_remote_update(update.clone(), &settings.file_scan_inclusions);
3141 }
3142 }
3143
3144 assert_eq!(
3145 prev_snapshot
3146 .entries(true, 0)
3147 .map(ignore_pending_dir)
3148 .collect::<Vec<_>>(),
3149 snapshot
3150 .entries(true, 0)
3151 .map(ignore_pending_dir)
3152 .collect::<Vec<_>>(),
3153 "wrong updates after snapshot {i}: {updates:#?}",
3154 );
3155 }
3156
3157 fn ignore_pending_dir(entry: &Entry) -> Entry {
3158 let mut entry = entry.clone();
3159 if entry.kind.is_dir() {
3160 entry.kind = EntryKind::Dir
3161 }
3162 entry
3163 }
3164}
3165
3166#[gpui::test(iterations = 100)]
3167async fn test_random_git_updates_with_watcher_overflows(cx: &mut TestAppContext, mut rng: StdRng) {
3168 // Property: every git state change is eventually signaled via
3169 // `UpdatedGitRepositories`, no matter how the events reporting it are
3170 // batched, delayed, or lost to watcher overflows.
3171 init_test(cx);
3172 let operations = env::var("OPERATIONS")
3173 .map(|o| o.parse().unwrap())
3174 .unwrap_or(40);
3175
3176 let root_dir = Path::new(path!("/test"));
3177 let dot_git = root_dir.join(".git");
3178 // Random fs mutations are confined to this subdirectory so that they
3179 // cannot rename or delete `.git` itself.
3180 let src_dir = root_dir.join("src");
3181 let fs = FakeFs::new(cx.background_executor.clone()) as Arc<dyn Fs>;
3182 fs.as_fake()
3183 .insert_tree(
3184 root_dir,
3185 json!({
3186 ".git": {},
3187 "src": {
3188 "main.rs": "fn main() {}",
3189 },
3190 }),
3191 )
3192 .await;
3193
3194 let worktree = Worktree::local(
3195 root_dir,
3196 true,
3197 fs.clone(),
3198 Default::default(),
3199 true,
3200 WorktreeId::from_proto(0),
3201 &mut cx.to_async(),
3202 )
3203 .await
3204 .unwrap();
3205 worktree
3206 .update(cx, |tree, _| tree.as_local_mut().unwrap().scan_complete())
3207 .await;
3208 cx.executor().run_until_parked();
3209
3210 // Set when the fake repository's state is mutated, cleared when the
3211 // worktree signals a git update. A signal observed after a mutation
3212 // prompts downstream consumers (the GitStore) to re-read the repository,
3213 // at which point they see that mutation's state, so clearing on any
3214 // subsequent signal is sound.
3215 let pending_git_update: Rc<Cell<bool>> = Rc::new(Cell::new(false));
3216 worktree.update(cx, {
3217 let pending_git_update = pending_git_update.clone();
3218 |_, cx| {
3219 cx.subscribe(&cx.entity(), move |_, _, event, _| {
3220 if matches!(event, Event::UpdatedGitRepositories(_)) {
3221 pending_git_update.set(false);
3222 }
3223 })
3224 .detach();
3225 }
3226 });
3227
3228 fs.as_fake().pause_events();
3229 let mut commit_count = 0;
3230 for _ in 0..operations {
3231 match rng.random_range(0_u32..100) {
3232 // Change the repository's git state, e.g. a commit moving HEAD.
3233 0..25 => {
3234 commit_count += 1;
3235 log::info!("setting HEAD to commit {commit_count}");
3236 fs.as_fake().set_head_for_repo(
3237 &dot_git,
3238 &[("src/main.rs", format!("fn main() {{}} // {commit_count}"))],
3239 format!("sha-{commit_count}"),
3240 );
3241 pending_git_update.set(true);
3242 }
3243 // The watch queue overflows: all undelivered events are lost and
3244 // only a rescan for the root is reported.
3245 25..40 => {
3246 log::info!(
3247 "simulating watcher overflow, losing {} events",
3248 fs.as_fake().buffered_event_count()
3249 );
3250 fs.as_fake().simulate_watcher_overflow(root_dir);
3251 }
3252 // Deliver a prefix of the queued events.
3253 40..65 => {
3254 let buffered_event_count = fs.as_fake().buffered_event_count();
3255 let len = rng.random_range(0..=buffered_event_count);
3256 log::info!("flushing {len} of {buffered_event_count} events");
3257 fs.as_fake().flush_events(len);
3258 }
3259 // Unrelated churn in the working tree.
3260 _ => {
3261 randomly_mutate_fs(&fs, &src_dir, 1.0, &mut rng).await;
3262 }
3263 }
3264 cx.executor().run_until_parked();
3265 }
3266
3267 log::info!("quiescing");
3268 fs.as_fake().unpause_events_and_flush();
3269 cx.executor().run_until_parked();
3270
3271 worktree.read_with(cx, |tree, _| {
3272 tree.as_local().unwrap().snapshot().check_invariants(true)
3273 });
3274 assert!(
3275 !pending_git_update.get(),
3276 "a git state change was never signaled via UpdatedGitRepositories"
3277 );
3278}
3279
3280// The worktree's `UpdatedEntries` event can be used to follow along with
3281// all changes to the worktree's snapshot.
3282fn check_worktree_change_events(tree: &mut Worktree, cx: &mut Context<Worktree>) {
3283 let mut entries = tree.entries(true, 0).cloned().collect::<Vec<_>>();
3284 cx.subscribe(&cx.entity(), move |tree, _, event, _| {
3285 if let Event::UpdatedEntries(changes) = event {
3286 for (path, _, change_type) in changes.iter() {
3287 let entry = tree.entry_for_path(path).cloned();
3288 let ix = match entries.binary_search_by_key(&path, |e| &e.path) {
3289 Ok(ix) | Err(ix) => ix,
3290 };
3291 match change_type {
3292 PathChange::Added => entries.insert(ix, entry.unwrap()),
3293 PathChange::Removed => drop(entries.remove(ix)),
3294 PathChange::Updated => {
3295 let entry = entry.unwrap();
3296 let existing_entry = entries.get_mut(ix).unwrap();
3297 assert_eq!(existing_entry.path, entry.path);
3298 *existing_entry = entry;
3299 }
3300 PathChange::AddedOrUpdated | PathChange::Loaded => {
3301 let entry = entry.unwrap();
3302 if entries.get(ix).map(|e| &e.path) == Some(&entry.path) {
3303 *entries.get_mut(ix).unwrap() = entry;
3304 } else {
3305 entries.insert(ix, entry);
3306 }
3307 }
3308 }
3309 }
3310
3311 let new_entries = tree.entries(true, 0).cloned().collect::<Vec<_>>();
3312 assert_eq!(entries, new_entries, "incorrect changes: {:?}", changes);
3313 }
3314 })
3315 .detach();
3316}
3317
3318fn randomly_mutate_worktree(
3319 worktree: &mut Worktree,
3320 rng: &mut impl Rng,
3321 cx: &mut Context<Worktree>,
3322) -> Task<Result<()>> {
3323 log::info!("mutating worktree");
3324 let worktree = worktree.as_local_mut().unwrap();
3325 let snapshot = worktree.snapshot();
3326 let entry = snapshot.entries(false, 0).choose(rng).unwrap();
3327
3328 match rng.random_range(0_u32..100) {
3329 0..=33 if entry.path.as_ref() != RelPath::empty() => {
3330 log::info!("deleting entry {:?} ({})", entry.path, entry.id.to_usize());
3331 let task = worktree.delete_entry(entry.clone(), cx);
3332
3333 cx.background_spawn(async move {
3334 task.await?;
3335 Ok(())
3336 })
3337 }
3338 _ => {
3339 if entry.is_dir() {
3340 let child_path = entry.path.join(rel_path(&random_filename(rng)));
3341 let is_dir = rng.random_bool(0.3);
3342 log::info!(
3343 "creating {} at {:?}",
3344 if is_dir { "dir" } else { "file" },
3345 child_path,
3346 );
3347 let task = worktree.create_entry(child_path.into(), is_dir, None, cx);
3348 cx.background_spawn(async move {
3349 task.await?;
3350 Ok(())
3351 })
3352 } else {
3353 log::info!(
3354 "overwriting file {:?} ({})",
3355 &entry.path,
3356 entry.id.to_usize()
3357 );
3358 let task = worktree.write_file(
3359 entry.path.clone(),
3360 "".into(),
3361 Default::default(),
3362 encoding_rs::UTF_8,
3363 false,
3364 cx,
3365 );
3366 cx.background_spawn(async move {
3367 task.await?;
3368 Ok(())
3369 })
3370 }
3371 }
3372 }
3373}
3374
3375async fn randomly_mutate_fs(
3376 fs: &Arc<dyn Fs>,
3377 root_path: &Path,
3378 insertion_probability: f64,
3379 rng: &mut impl Rng,
3380) {
3381 log::info!("mutating fs");
3382 let mut files = Vec::new();
3383 let mut dirs = Vec::new();
3384 for path in fs.as_fake().paths(false) {
3385 if path.starts_with(root_path) {
3386 if fs.is_file(&path).await {
3387 files.push(path);
3388 } else {
3389 dirs.push(path);
3390 }
3391 }
3392 }
3393
3394 if (files.is_empty() && dirs.len() == 1) || rng.random_bool(insertion_probability) {
3395 let path = dirs.choose(rng).unwrap();
3396 let new_path = path.join(random_filename(rng));
3397
3398 if rng.random() {
3399 log::info!(
3400 "creating dir {:?}",
3401 new_path.strip_prefix(root_path).unwrap()
3402 );
3403 fs.create_dir(&new_path).await.unwrap();
3404 } else {
3405 log::info!(
3406 "creating file {:?}",
3407 new_path.strip_prefix(root_path).unwrap()
3408 );
3409 fs.create_file(&new_path, Default::default()).await.unwrap();
3410 }
3411 } else if rng.random_bool(0.05) {
3412 let ignore_dir_path = dirs.choose(rng).unwrap();
3413 let ignore_path = ignore_dir_path.join(GITIGNORE);
3414
3415 let subdirs = dirs
3416 .iter()
3417 .filter(|d| d.starts_with(ignore_dir_path))
3418 .cloned()
3419 .collect::<Vec<_>>();
3420 let subfiles = files
3421 .iter()
3422 .filter(|d| d.starts_with(ignore_dir_path))
3423 .cloned()
3424 .collect::<Vec<_>>();
3425 let files_to_ignore = {
3426 let len = rng.random_range(0..=subfiles.len());
3427 subfiles.choose_multiple(rng, len)
3428 };
3429 let dirs_to_ignore = {
3430 let len = rng.random_range(0..subdirs.len());
3431 subdirs.choose_multiple(rng, len)
3432 };
3433
3434 let mut ignore_contents = String::new();
3435 for path_to_ignore in files_to_ignore.chain(dirs_to_ignore) {
3436 writeln!(
3437 ignore_contents,
3438 "{}",
3439 path_to_ignore
3440 .strip_prefix(ignore_dir_path)
3441 .unwrap()
3442 .to_str()
3443 .unwrap()
3444 )
3445 .unwrap();
3446 }
3447 log::info!(
3448 "creating gitignore {:?} with contents:\n{}",
3449 ignore_path.strip_prefix(root_path).unwrap(),
3450 ignore_contents
3451 );
3452 fs.save(
3453 &ignore_path,
3454 &ignore_contents.as_str().into(),
3455 Default::default(),
3456 )
3457 .await
3458 .unwrap();
3459 } else {
3460 let old_path = {
3461 let file_path = files.choose(rng);
3462 let dir_path = dirs[1..].choose(rng);
3463 file_path.into_iter().chain(dir_path).choose(rng).unwrap()
3464 };
3465
3466 let is_rename = rng.random();
3467 if is_rename {
3468 let new_path_parent = dirs
3469 .iter()
3470 .filter(|d| !d.starts_with(old_path))
3471 .choose(rng)
3472 .unwrap();
3473
3474 let overwrite_existing_dir =
3475 !old_path.starts_with(new_path_parent) && rng.random_bool(0.3);
3476 let new_path = if overwrite_existing_dir {
3477 fs.remove_dir(
3478 new_path_parent,
3479 RemoveOptions {
3480 recursive: true,
3481 ignore_if_not_exists: true,
3482 },
3483 )
3484 .await
3485 .unwrap();
3486 new_path_parent.to_path_buf()
3487 } else {
3488 new_path_parent.join(random_filename(rng))
3489 };
3490
3491 log::info!(
3492 "renaming {:?} to {}{:?}",
3493 old_path.strip_prefix(root_path).unwrap(),
3494 if overwrite_existing_dir {
3495 "overwrite "
3496 } else {
3497 ""
3498 },
3499 new_path.strip_prefix(root_path).unwrap()
3500 );
3501 fs.rename(
3502 old_path,
3503 &new_path,
3504 fs::RenameOptions {
3505 overwrite: true,
3506 ignore_if_exists: true,
3507 create_parents: false,
3508 },
3509 )
3510 .await
3511 .unwrap();
3512 } else if fs.is_file(old_path).await {
3513 log::info!(
3514 "deleting file {:?}",
3515 old_path.strip_prefix(root_path).unwrap()
3516 );
3517 fs.remove_file(old_path, Default::default()).await.unwrap();
3518 } else {
3519 log::info!(
3520 "deleting dir {:?}",
3521 old_path.strip_prefix(root_path).unwrap()
3522 );
3523 fs.remove_dir(
3524 old_path,
3525 RemoveOptions {
3526 recursive: true,
3527 ignore_if_not_exists: true,
3528 },
3529 )
3530 .await
3531 .unwrap();
3532 }
3533 }
3534}
3535
3536fn random_filename(rng: &mut impl Rng) -> String {
3537 (0..6)
3538 .map(|_| rng.sample(rand::distr::Alphanumeric))
3539 .map(char::from)
3540 .collect()
3541}
3542
3543#[gpui::test]
3544async fn test_private_single_file_worktree(cx: &mut TestAppContext) {
3545 init_test(cx);
3546 let fs = FakeFs::new(cx.background_executor.clone());
3547 fs.insert_tree("/", json!({".env": "PRIVATE=secret\n"}))
3548 .await;
3549 let tree = Worktree::local(
3550 Path::new("/.env"),
3551 true,
3552 fs.clone(),
3553 Default::default(),
3554 true,
3555 WorktreeId::from_proto(0),
3556 &mut cx.to_async(),
3557 )
3558 .await
3559 .unwrap();
3560 cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
3561 .await;
3562 tree.read_with(cx, |tree, _| {
3563 let entry = tree.entry_for_path(rel_path("")).unwrap();
3564 assert!(entry.is_private);
3565 });
3566}
3567
3568#[gpui::test]
3569async fn test_repository_above_root(executor: BackgroundExecutor, cx: &mut TestAppContext) {
3570 init_test(cx);
3571
3572 let fs = FakeFs::new(executor);
3573 fs.insert_tree(
3574 path!("/root"),
3575 json!({
3576 ".git": {},
3577 "subproject": {
3578 "a.txt": "A"
3579 }
3580 }),
3581 )
3582 .await;
3583 let worktree = Worktree::local(
3584 path!("/root/subproject").as_ref(),
3585 true,
3586 fs.clone(),
3587 Arc::default(),
3588 true,
3589 WorktreeId::from_proto(0),
3590 &mut cx.to_async(),
3591 )
3592 .await
3593 .unwrap();
3594 worktree
3595 .update(cx, |worktree, _| {
3596 worktree.as_local().unwrap().scan_complete()
3597 })
3598 .await;
3599 cx.run_until_parked();
3600 let repos = worktree.update(cx, |worktree, _| {
3601 worktree.as_local().unwrap().repositories()
3602 });
3603 pretty_assertions::assert_eq!(repos, [Path::new(path!("/root")).into()]);
3604
3605 fs.touch_path(path!("/root/subproject")).await;
3606 worktree
3607 .update(cx, |worktree, _| {
3608 worktree.as_local().unwrap().scan_complete()
3609 })
3610 .await;
3611 cx.run_until_parked();
3612
3613 let repos = worktree.update(cx, |worktree, _| {
3614 worktree.as_local().unwrap().repositories()
3615 });
3616 pretty_assertions::assert_eq!(repos, [Path::new(path!("/root")).into()]);
3617}
3618
3619#[gpui::test]
3620async fn test_global_gitignore(executor: BackgroundExecutor, cx: &mut TestAppContext) {
3621 init_test(cx);
3622
3623 let home = paths::home_dir();
3624 let fs = FakeFs::new(executor);
3625 fs.insert_tree(
3626 home,
3627 json!({
3628 ".config": {
3629 "git": {
3630 "ignore": "foo\n/bar\nbaz\n"
3631 }
3632 },
3633 "project": {
3634 ".git": {},
3635 ".gitignore": "!baz",
3636 "foo": "",
3637 "bar": "",
3638 "sub": {
3639 "bar": "",
3640 },
3641 "subrepo": {
3642 ".git": {},
3643 "bar": ""
3644 },
3645 "baz": ""
3646 }
3647 }),
3648 )
3649 .await;
3650 let worktree = Worktree::local(
3651 home.join("project"),
3652 true,
3653 fs.clone(),
3654 Arc::default(),
3655 true,
3656 WorktreeId::from_proto(0),
3657 &mut cx.to_async(),
3658 )
3659 .await
3660 .unwrap();
3661 worktree
3662 .update(cx, |worktree, _| {
3663 worktree.as_local().unwrap().scan_complete()
3664 })
3665 .await;
3666 cx.run_until_parked();
3667
3668 // .gitignore overrides excludesFile, and anchored paths in excludesFile are resolved
3669 // relative to the nearest containing repository
3670 worktree.update(cx, |worktree, _cx| {
3671 check_worktree_entries(
3672 worktree,
3673 WorktreeExpectations {
3674 ignored_paths: &["foo", "bar", "subrepo/bar"],
3675 tracked_paths: &["sub/bar", "baz"],
3676 ..Default::default()
3677 },
3678 );
3679 });
3680
3681 // Ignore statuses are updated when excludesFile changes
3682 fs.write(
3683 &home.join(".config").join("git").join("ignore"),
3684 "/bar\nbaz\n".as_bytes(),
3685 )
3686 .await
3687 .unwrap();
3688 worktree
3689 .update(cx, |worktree, _| {
3690 worktree.as_local().unwrap().scan_complete()
3691 })
3692 .await;
3693 cx.run_until_parked();
3694
3695 worktree.update(cx, |worktree, _cx| {
3696 check_worktree_entries(
3697 worktree,
3698 WorktreeExpectations {
3699 ignored_paths: &["bar", "subrepo/bar"],
3700 tracked_paths: &["foo", "sub/bar", "baz"],
3701 ..Default::default()
3702 },
3703 );
3704 });
3705
3706 // Statuses are updated when .git added/removed
3707 fs.remove_dir(
3708 &home.join("project").join("subrepo").join(".git"),
3709 RemoveOptions {
3710 recursive: true,
3711 ..Default::default()
3712 },
3713 )
3714 .await
3715 .unwrap();
3716 worktree
3717 .update(cx, |worktree, _| {
3718 worktree.as_local().unwrap().scan_complete()
3719 })
3720 .await;
3721 cx.run_until_parked();
3722
3723 worktree.update(cx, |worktree, _cx| {
3724 check_worktree_entries(
3725 worktree,
3726 WorktreeExpectations {
3727 ignored_paths: &["bar"],
3728 tracked_paths: &["foo", "sub/bar", "baz", "subrepo/bar"],
3729 ..Default::default()
3730 },
3731 );
3732 });
3733}
3734
3735#[gpui::test]
3736async fn test_repo_exclude_in_worktree(executor: BackgroundExecutor, cx: &mut TestAppContext) {
3737 init_test(cx);
3738
3739 let fs = FakeFs::new(executor);
3740
3741 fs.insert_tree(
3742 path!("/repo"),
3743 json!({
3744 ".git": {
3745 "info": {
3746 "exclude": ".env.*"
3747 },
3748 "worktrees": {
3749 "my-worktree": {
3750 "commondir": "../.."
3751 }
3752 }
3753 }
3754 }),
3755 )
3756 .await;
3757
3758 fs.insert_tree(
3759 path!("/worktree"),
3760 json!({
3761 // .git is pointing to the repo
3762 ".git": "gitdir: /repo/.git/worktrees/my-worktree",
3763 ".env.local": "secret=1234",
3764 "not-ignored.txt": "",
3765 }),
3766 )
3767 .await;
3768
3769 let worktree = Worktree::local(
3770 path!("/worktree").as_ref(),
3771 true,
3772 fs.clone(),
3773 Default::default(),
3774 true,
3775 WorktreeId::from_proto(0),
3776 &mut cx.to_async(),
3777 )
3778 .await
3779 .unwrap();
3780
3781 worktree
3782 .update(cx, |worktree, _| {
3783 worktree.as_local().unwrap().scan_complete()
3784 })
3785 .await;
3786 cx.run_until_parked();
3787
3788 // .env.local should be ignored via info/exclude from the repo's exclude
3789 worktree.update(cx, |worktree, _cx| {
3790 check_worktree_entries(
3791 worktree,
3792 WorktreeExpectations {
3793 ignored_paths: &[".env.local"],
3794 tracked_paths: &["not-ignored.txt"],
3795 ..Default::default()
3796 },
3797 );
3798 });
3799}
3800
3801#[gpui::test]
3802async fn test_repo_exclude(executor: BackgroundExecutor, cx: &mut TestAppContext) {
3803 init_test(cx);
3804
3805 let fs = FakeFs::new(executor);
3806 let project_dir = Path::new(path!("/project"));
3807 fs.insert_tree(
3808 project_dir,
3809 json!({
3810 ".git": {
3811 "info": {
3812 "exclude": ".env.*"
3813 }
3814 },
3815 ".env.example": "secret=xxxx",
3816 ".env.local": "secret=1234",
3817 ".gitignore": "!.env.example",
3818 "README.md": "# Repo Exclude",
3819 "src": {
3820 "main.rs": "fn main() {}",
3821 },
3822 }),
3823 )
3824 .await;
3825
3826 let worktree = Worktree::local(
3827 project_dir,
3828 true,
3829 fs.clone(),
3830 Default::default(),
3831 true,
3832 WorktreeId::from_proto(0),
3833 &mut cx.to_async(),
3834 )
3835 .await
3836 .unwrap();
3837 worktree
3838 .update(cx, |worktree, _| {
3839 worktree.as_local().unwrap().scan_complete()
3840 })
3841 .await;
3842 cx.run_until_parked();
3843
3844 // .gitignore overrides .git/info/exclude
3845 worktree.update(cx, |worktree, _cx| {
3846 check_worktree_entries(
3847 worktree,
3848 WorktreeExpectations {
3849 ignored_paths: &[".env.local"],
3850 tracked_paths: &[".env.example", "README.md", "src/main.rs"],
3851 ..Default::default()
3852 },
3853 );
3854 });
3855
3856 // Ignore statuses are updated when .git/info/exclude file changes
3857 fs.write(
3858 &project_dir.join(DOT_GIT).join(REPO_EXCLUDE),
3859 ".env.example".as_bytes(),
3860 )
3861 .await
3862 .unwrap();
3863 worktree
3864 .update(cx, |worktree, _| {
3865 worktree.as_local().unwrap().scan_complete()
3866 })
3867 .await;
3868 cx.run_until_parked();
3869
3870 worktree.update(cx, |worktree, _cx| {
3871 check_worktree_entries(
3872 worktree,
3873 WorktreeExpectations {
3874 tracked_paths: &[".env.example", ".env.local", "README.md", "src/main.rs"],
3875 ..Default::default()
3876 },
3877 );
3878 });
3879}
3880
3881#[gpui::test]
3882async fn test_repo_exclude_anchored_pattern(executor: BackgroundExecutor, cx: &mut TestAppContext) {
3883 init_test(cx);
3884
3885 let fs = FakeFs::new(executor);
3886 let project_dir = Path::new(path!("/project"));
3887 fs.insert_tree(
3888 project_dir,
3889 json!({
3890 ".git": {
3891 "info": {
3892 "exclude": "vendor/cache"
3893 }
3894 },
3895 "vendor": {
3896 "cache": {
3897 "blob.bin": "",
3898 },
3899 "keep.txt": "",
3900 },
3901 "elsewhere": {
3902 "vendor": {
3903 "cache": {
3904 "blob.bin": "",
3905 },
3906 },
3907 },
3908 }),
3909 )
3910 .await;
3911
3912 let worktree = Worktree::local(
3913 project_dir,
3914 true,
3915 fs.clone(),
3916 Default::default(),
3917 true,
3918 WorktreeId::from_proto(0),
3919 &mut cx.to_async(),
3920 )
3921 .await
3922 .unwrap();
3923 worktree
3924 .update(cx, |worktree, _| {
3925 worktree.as_local().unwrap().scan_complete()
3926 })
3927 .await;
3928 cx.run_until_parked();
3929
3930 // An anchored pattern (containing a `/`) is matched relative to the work
3931 // tree root, so only the top-level `vendor/cache` is ignored.
3932 worktree.update(cx, |worktree, _cx| {
3933 check_worktree_entries(
3934 worktree,
3935 WorktreeExpectations {
3936 ignored_paths: &["vendor/cache"],
3937 tracked_paths: &["vendor/keep.txt", "elsewhere/vendor/cache"],
3938 ..Default::default()
3939 },
3940 );
3941 });
3942}
3943
3944#[gpui::test]
3945async fn test_repo_exclude_applies_within_nested_repos(
3946 executor: BackgroundExecutor,
3947 cx: &mut TestAppContext,
3948) {
3949 init_test(cx);
3950
3951 let fs = FakeFs::new(executor);
3952 let project_dir = Path::new(path!("/project"));
3953
3954 // Mirrors the layout used by tools that keep working copies of the
3955 // repository inside the repository itself: a bare clone in
3956 // `.scratch/clones` and a linked worktree of that clone in
3957 // `.scratch/worktrees`, both hidden via the outer repository's
3958 // `.git/info/exclude` rather than a `.gitignore`.
3959 fs.insert_tree(
3960 project_dir,
3961 json!({
3962 ".git": {
3963 "info": {
3964 "exclude": "/.scratch/worktrees/\n/.scratch/clones/\n"
3965 }
3966 },
3967 "src": {
3968 "main.rs": "fn main() {}",
3969 },
3970 ".scratch": {
3971 "clones": {
3972 "abc": {
3973 "project.git": {
3974 "HEAD": "ref: refs/heads/main",
3975 "worktrees": {
3976 "project": {
3977 "HEAD": "ref: refs/heads/feature",
3978 "commondir": "../..",
3979 }
3980 }
3981 }
3982 }
3983 },
3984 "worktrees": {
3985 "abc": {
3986 "project": {
3987 ".git": "gitdir: ../../../clones/abc/project.git/worktrees/project",
3988 "src": {
3989 "main.rs": "fn main() {}",
3990 }
3991 }
3992 }
3993 }
3994 }
3995 }),
3996 )
3997 .await;
3998
3999 let worktree = Worktree::local(
4000 project_dir,
4001 true,
4002 fs.clone(),
4003 Default::default(),
4004 true,
4005 WorktreeId::from_proto(0),
4006 &mut cx.to_async(),
4007 )
4008 .await
4009 .unwrap();
4010 worktree
4011 .update(cx, |worktree, _| {
4012 worktree.as_local().unwrap().scan_complete()
4013 })
4014 .await;
4015 cx.run_until_parked();
4016
4017 // After the initial scan, both excluded directories are ignored.
4018 worktree.update(cx, |worktree, _cx| {
4019 check_worktree_entries(
4020 worktree,
4021 WorktreeExpectations {
4022 ignored_paths: &[".scratch/clones", ".scratch/worktrees"],
4023 tracked_paths: &["src/main.rs"],
4024 ..Default::default()
4025 },
4026 );
4027 });
4028
4029 // Load a file within the excluded nested repository, as happens when a
4030 // search that includes ignored files runs or when the file is opened.
4031 worktree
4032 .update(cx, |worktree, _| {
4033 worktree.as_local().unwrap().refresh_entries_for_paths(vec![
4034 rel_path(".scratch/worktrees/abc/project/src/main.rs").into(),
4035 ])
4036 })
4037 .recv()
4038 .await;
4039 cx.run_until_parked();
4040
4041 // The nested repository's own `.git` must not cause the outer
4042 // repository's `info/exclude` rules to be dropped.
4043 worktree.update(cx, |worktree, _cx| {
4044 check_worktree_entries(
4045 worktree,
4046 WorktreeExpectations {
4047 ignored_paths: &[
4048 ".scratch/worktrees/abc",
4049 ".scratch/worktrees/abc/project",
4050 ".scratch/worktrees/abc/project/src",
4051 ".scratch/worktrees/abc/project/src/main.rs",
4052 ],
4053 tracked_paths: &["src/main.rs"],
4054 ..Default::default()
4055 },
4056 );
4057 });
4058
4059 // A file written inside the loaded nested repository (e.g. by a tool
4060 // working in the clone) must also be ignored.
4061 fs.save(
4062 path!("/project/.scratch/worktrees/abc/project/src/generated.rs").as_ref(),
4063 &"fn generated() {}".into(),
4064 Default::default(),
4065 )
4066 .await
4067 .unwrap();
4068 cx.run_until_parked();
4069
4070 worktree.update(cx, |worktree, _cx| {
4071 check_worktree_entries(
4072 worktree,
4073 WorktreeExpectations {
4074 ignored_paths: &[".scratch/worktrees/abc/project/src/generated.rs"],
4075 ..Default::default()
4076 },
4077 );
4078 });
4079
4080 // Nothing under the excluded directories is visible to a traversal that
4081 // skips ignored entries, which is what project search uses.
4082 worktree.update(cx, |worktree, _cx| {
4083 let unignored_entries = worktree
4084 .entries(false, 0)
4085 .filter(|entry| {
4086 entry.path.starts_with(rel_path(".scratch"))
4087 && entry.path.as_ref() != rel_path(".scratch")
4088 })
4089 .map(|entry| entry.path.clone())
4090 .collect::<Vec<_>>();
4091 assert_eq!(
4092 unignored_entries,
4093 Vec::<Arc<RelPath>>::new(),
4094 "entries under the excluded .scratch directories leaked into the unignored traversal",
4095 );
4096 });
4097}
4098
4099#[derive(Default)]
4100struct WorktreeExpectations {
4101 excluded_paths: &'static [&'static str],
4102 ignored_paths: &'static [&'static str],
4103 tracked_paths: &'static [&'static str],
4104 included_paths: &'static [&'static str],
4105}
4106
4107#[track_caller]
4108fn check_worktree_entries(tree: &Worktree, expectations: WorktreeExpectations) {
4109 for path in expectations.excluded_paths {
4110 let entry = tree.entry_for_path(rel_path(path));
4111 assert!(
4112 entry.is_none(),
4113 "expected path '{path}' to be excluded, but got entry: {entry:?}",
4114 );
4115 }
4116 for path in expectations.ignored_paths {
4117 let entry = tree
4118 .entry_for_path(rel_path(path))
4119 .unwrap_or_else(|| panic!("Missing entry for expected ignored path '{path}'"));
4120 assert!(
4121 entry.is_ignored,
4122 "expected path '{path}' to be ignored, but got entry: {entry:?}",
4123 );
4124 }
4125 for path in expectations.tracked_paths {
4126 let entry = tree
4127 .entry_for_path(rel_path(path))
4128 .unwrap_or_else(|| panic!("Missing entry for expected tracked path '{path}'"));
4129 assert!(
4130 !entry.is_ignored || entry.is_always_included,
4131 "expected path '{path}' to be tracked, but got entry: {entry:?}",
4132 );
4133 }
4134 for path in expectations.included_paths {
4135 let entry = tree
4136 .entry_for_path(rel_path(path))
4137 .unwrap_or_else(|| panic!("Missing entry for expected included path '{path}'"));
4138 assert!(
4139 entry.is_always_included,
4140 "expected path '{path}' to always be included, but got entry: {entry:?}",
4141 );
4142 }
4143}
4144
4145#[gpui::test]
4146async fn test_root_repo_common_dir_for_relative_gitdir(
4147 executor: BackgroundExecutor,
4148 cx: &mut TestAppContext,
4149) {
4150 init_test(cx);
4151
4152 let fs = FakeFs::new(executor);
4153 fs.insert_tree(
4154 path!("/repo"),
4155 json!({
4156 ".git": {
4157 "HEAD": "ref: refs/heads/main",
4158 "config": "[core]\n\tbare = false\n",
4159 "info": {
4160 "exclude": "ignored.txt\n",
4161 },
4162 "worktrees": {
4163 "feature-a": {
4164 "HEAD": "ref: refs/heads/feature-a",
4165 "commondir": "../..",
4166 "gitdir": "/repo/feature-a/.git",
4167 },
4168 },
4169 },
4170 "feature-a": {
4171 ".git": "gitdir: ../.git/worktrees/feature-a",
4172 "file.txt": "content",
4173 "ignored.txt": "ignored",
4174 "subdir": {
4175 "file.txt": "content",
4176 "ignored.txt": "ignored",
4177 },
4178 },
4179 }),
4180 )
4181 .await;
4182
4183 let feature_tree = Worktree::local(
4184 path!("/repo/feature-a").as_ref(),
4185 true,
4186 fs.clone(),
4187 Arc::default(),
4188 true,
4189 WorktreeId::from_proto(0),
4190 &mut cx.to_async(),
4191 )
4192 .await
4193 .unwrap();
4194 feature_tree
4195 .update(cx, |tree, _| tree.as_local().unwrap().scan_complete())
4196 .await;
4197 cx.run_until_parked();
4198
4199 feature_tree.read_with(cx, |tree, _| {
4200 assert_eq!(
4201 tree.snapshot()
4202 .root_repo_common_dir()
4203 .map(|path| path.as_ref()),
4204 Some(Path::new(path!("/repo/.git"))),
4205 );
4206 check_worktree_entries(
4207 tree,
4208 WorktreeExpectations {
4209 ignored_paths: &["ignored.txt"],
4210 tracked_paths: &["file.txt"],
4211 ..Default::default()
4212 },
4213 );
4214 });
4215
4216 let nested_tree = Worktree::local(
4217 path!("/repo/feature-a/subdir").as_ref(),
4218 true,
4219 fs.clone(),
4220 Arc::default(),
4221 true,
4222 WorktreeId::from_proto(1),
4223 &mut cx.to_async(),
4224 )
4225 .await
4226 .unwrap();
4227 nested_tree
4228 .update(cx, |tree, _| tree.as_local().unwrap().scan_complete())
4229 .await;
4230 cx.run_until_parked();
4231
4232 nested_tree.read_with(cx, |tree, _| {
4233 check_worktree_entries(
4234 tree,
4235 WorktreeExpectations {
4236 ignored_paths: &["ignored.txt"],
4237 tracked_paths: &["file.txt"],
4238 ..Default::default()
4239 },
4240 );
4241 });
4242
4243 fs.write(
4244 Path::new(path!("/repo/.git")).join(REPO_EXCLUDE).as_ref(),
4245 "file.txt\n".as_bytes(),
4246 )
4247 .await
4248 .unwrap();
4249 cx.run_until_parked();
4250
4251 feature_tree.read_with(cx, |tree, _| {
4252 check_worktree_entries(
4253 tree,
4254 WorktreeExpectations {
4255 ignored_paths: &["file.txt", "subdir/file.txt"],
4256 tracked_paths: &["ignored.txt", "subdir/ignored.txt"],
4257 ..Default::default()
4258 },
4259 );
4260 });
4261 nested_tree.read_with(cx, |tree, _| {
4262 check_worktree_entries(
4263 tree,
4264 WorktreeExpectations {
4265 ignored_paths: &["file.txt"],
4266 tracked_paths: &["ignored.txt"],
4267 ..Default::default()
4268 },
4269 );
4270 });
4271}
4272
4273#[gpui::test]
4274async fn test_root_repo_common_dir(executor: BackgroundExecutor, cx: &mut TestAppContext) {
4275 init_test(cx);
4276
4277 use git::repository::Worktree as GitWorktree;
4278
4279 let fs = FakeFs::new(executor);
4280
4281 // Set up a main repo and a linked worktree pointing back to it.
4282 fs.insert_tree(
4283 path!("/main_repo"),
4284 json!({
4285 ".git": {},
4286 "file.txt": "content",
4287 }),
4288 )
4289 .await;
4290 fs.add_linked_worktree_for_repo(
4291 Path::new(path!("/main_repo/.git")),
4292 false,
4293 GitWorktree {
4294 path: PathBuf::from(path!("/linked_worktree")),
4295 ref_name: Some("refs/heads/feature".into()),
4296 sha: "abc123".into(),
4297 is_main: false,
4298 is_bare: false,
4299 },
4300 )
4301 .await;
4302 fs.write(
4303 path!("/linked_worktree/file.txt").as_ref(),
4304 "content".as_bytes(),
4305 )
4306 .await
4307 .unwrap();
4308
4309 let tree = Worktree::local(
4310 path!("/linked_worktree").as_ref(),
4311 true,
4312 fs.clone(),
4313 Arc::default(),
4314 true,
4315 WorktreeId::from_proto(0),
4316 &mut cx.to_async(),
4317 )
4318 .await
4319 .unwrap();
4320 tree.update(cx, |tree, _| tree.as_local().unwrap().scan_complete())
4321 .await;
4322 cx.run_until_parked();
4323
4324 // For a linked worktree, root_repo_common_dir should point to the
4325 // main repo's .git, not the worktree-specific git directory.
4326 tree.read_with(cx, |tree, _| {
4327 assert_eq!(
4328 tree.snapshot().root_repo_common_dir().map(|p| p.as_ref()),
4329 Some(Path::new(path!("/main_repo/.git"))),
4330 );
4331 });
4332
4333 let event_count: Rc<Cell<usize>> = Rc::new(Cell::new(0));
4334 tree.update(cx, {
4335 let event_count = event_count.clone();
4336 |_, cx| {
4337 cx.subscribe(&cx.entity(), move |_, _, event, _| {
4338 if matches!(event, Event::UpdatedRootRepoCommonDir { .. }) {
4339 event_count.set(event_count.get() + 1);
4340 }
4341 })
4342 .detach();
4343 }
4344 });
4345
4346 // Remove .git — root_repo_common_dir should become None.
4347 fs.remove_file(
4348 &PathBuf::from(path!("/linked_worktree/.git")),
4349 Default::default(),
4350 )
4351 .await
4352 .unwrap();
4353 tree.flush_fs_events(cx).await;
4354
4355 tree.read_with(cx, |tree, _| {
4356 assert_eq!(tree.snapshot().root_repo_common_dir(), None);
4357 });
4358 assert_eq!(
4359 event_count.get(),
4360 1,
4361 "should have emitted UpdatedRootRepoCommonDir on removal"
4362 );
4363}
4364
4365#[gpui::test]
4366async fn test_invisible_worktree_does_not_track_ancestor_git_repository(
4367 executor: BackgroundExecutor,
4368 cx: &mut TestAppContext,
4369) {
4370 init_test(cx);
4371
4372 let fs = FakeFs::new(executor);
4373 fs.insert_tree(
4374 path!("/repo"),
4375 json!({
4376 ".git": {},
4377 "project": {
4378 "file.txt": "content",
4379 },
4380 }),
4381 )
4382 .await;
4383
4384 let worktree = Worktree::local(
4385 path!("/repo/project").as_ref(),
4386 false,
4387 fs.clone(),
4388 Arc::default(),
4389 true,
4390 WorktreeId::from_proto(0),
4391 &mut cx.to_async(),
4392 )
4393 .await
4394 .unwrap();
4395 worktree
4396 .update(cx, |worktree, _| {
4397 worktree.as_local().unwrap().scan_complete()
4398 })
4399 .await;
4400 cx.run_until_parked();
4401
4402 worktree.read_with(cx, |worktree, _| {
4403 let local_worktree = worktree.as_local().unwrap();
4404 assert!(local_worktree.repositories().is_empty());
4405 assert_eq!(local_worktree.root_repo_common_dir(), None);
4406 });
4407}
4408
4409#[gpui::test]
4410async fn test_linked_worktree_gitfile_event_preserves_repo(
4411 executor: BackgroundExecutor,
4412 cx: &mut TestAppContext,
4413) {
4414 // Regression test: in a linked worktree, `.git` is a file (containing
4415 // "gitdir: ..."), not a directory. When the background scanner receives
4416 // a filesystem event for a path inside the main repo's `.git` directory
4417 // (which it watches via the commondir), the ancestor-walking code in
4418 // `process_events` calls `is_git_dir` on each ancestor. If `is_git_dir`
4419 // treats `.git` files the same as `.git` directories, it incorrectly
4420 // identifies the gitfile as a git dir, adds it to `dot_git_abs_paths`,
4421 // and `update_git_repositories` panics because the path is outside the
4422 // worktree root.
4423 init_test(cx);
4424 use git::repository::Worktree as GitWorktree;
4425
4426 let fs = FakeFs::new(executor);
4427 fs.insert_tree(path!("/main_repo"), json!({ ".git": {}, "file.txt": "" }))
4428 .await;
4429 fs.add_linked_worktree_for_repo(
4430 Path::new(path!("/main_repo/.git")),
4431 false,
4432 GitWorktree {
4433 path: PathBuf::from(path!("/linked_worktree")),
4434 ref_name: Some("refs/heads/feature".into()),
4435 sha: "abc123".into(),
4436 is_main: false,
4437 is_bare: false,
4438 },
4439 )
4440 .await;
4441 fs.write(path!("/linked_worktree/file.txt").as_ref(), b"content")
4442 .await
4443 .unwrap();
4444
4445 let tree = Worktree::local(
4446 path!("/linked_worktree").as_ref(),
4447 true,
4448 fs.clone(),
4449 Arc::default(),
4450 true,
4451 WorktreeId::from_proto(0),
4452 &mut cx.to_async(),
4453 )
4454 .await
4455 .unwrap();
4456 tree.update(cx, |tree, _| tree.as_local().unwrap().scan_complete())
4457 .await;
4458 cx.run_until_parked();
4459
4460 // Overwrite the .git gitfile with garbage to trigger an event for the
4461 // gitfile path itself, which only matches `dot_git_abs_path`.
4462 fs.write(path!("/linked_worktree/.git").as_ref(), b"garbage")
4463 .await
4464 .unwrap();
4465 tree.flush_fs_events(cx).await;
4466
4467 // The worktree should still be intact.
4468 tree.read_with(cx, |tree, _| {
4469 assert_eq!(
4470 tree.snapshot().root_repo_common_dir().map(|p| p.as_ref()),
4471 Some(Path::new(path!("/main_repo/.git"))),
4472 "linked worktree repo should survive a gitfile change event"
4473 );
4474 });
4475}
4476
4477#[gpui::test]
4478async fn test_shared_common_dir_event_updates_all_repositories(
4479 executor: BackgroundExecutor,
4480 cx: &mut TestAppContext,
4481) {
4482 // A main checkout and one of its linked worktrees can both live inside the
4483 // same project worktree, sharing a common git directory. An event in that
4484 // common directory (e.g. a ref update) must refresh every repository that
4485 // reads from it, not just the first match.
4486 init_test(cx);
4487
4488 use git::repository::Worktree as GitWorktree;
4489
4490 let fs = FakeFs::new(executor.clone());
4491 fs.insert_tree(
4492 path!("/project"),
4493 json!({
4494 "main_repo": {
4495 ".git": {},
4496 "file.txt": "content",
4497 },
4498 }),
4499 )
4500 .await;
4501 fs.add_linked_worktree_for_repo(
4502 Path::new(path!("/project/main_repo/.git")),
4503 false,
4504 GitWorktree {
4505 path: PathBuf::from(path!("/project/linked")),
4506 ref_name: Some("refs/heads/feature".into()),
4507 sha: "abc123".into(),
4508 is_main: false,
4509 is_bare: false,
4510 },
4511 )
4512 .await;
4513
4514 let tree = Worktree::local(
4515 path!("/project").as_ref(),
4516 true,
4517 fs.clone(),
4518 Arc::default(),
4519 true,
4520 WorktreeId::from_proto(0),
4521 &mut cx.to_async(),
4522 )
4523 .await
4524 .unwrap();
4525 tree.update(cx, |tree, _| tree.as_local().unwrap().scan_complete())
4526 .await;
4527 cx.run_until_parked();
4528
4529 let mut events = cx.events(&tree);
4530 fs.emit_fs_event(
4531 path!("/project/main_repo/.git/refs/heads/main"),
4532 Some(PathEventKind::Changed),
4533 );
4534 executor.run_until_parked();
4535
4536 let mut updated_work_dirs = Vec::new();
4537 while let Ok(event) = events.try_recv() {
4538 if let Event::UpdatedGitRepositories(updates) = event {
4539 updated_work_dirs.extend(
4540 updates
4541 .iter()
4542 .filter_map(|update| update.new_work_directory_abs_path.clone()),
4543 );
4544 }
4545 }
4546 updated_work_dirs.sort();
4547 assert_eq!(
4548 updated_work_dirs,
4549 [
4550 Arc::from(Path::new(path!("/project/linked"))),
4551 Arc::from(Path::new(path!("/project/main_repo"))),
4552 ],
4553 "a ref update in the shared common dir should refresh both repositories"
4554 );
4555}
4556
4557#[gpui::test]
4558async fn test_noisy_dot_git_events_do_not_emit_git_repo_update(
4559 executor: BackgroundExecutor,
4560 cx: &mut TestAppContext,
4561) {
4562 // Events for object database writes, hook files, lock files, and the
4563 // reflogs of HEAD/branches/remote-tracking branches carry no git state
4564 // changes that Zed cares about beyond what the accompanying ref or index
4565 // events already convey, so they must not trigger a git metadata rescan.
4566 // The stash reflog and ref updates themselves must still trigger one.
4567 //
4568 init_test(cx);
4569
4570 use git::repository::Worktree as GitWorktree;
4571
4572 let fs = FakeFs::new(executor);
4573
4574 fs.insert_tree(
4575 path!("/main_repo"),
4576 json!({
4577 ".git": {},
4578 "file.txt": "content",
4579 }),
4580 )
4581 .await;
4582 fs.add_linked_worktree_for_repo(
4583 Path::new(path!("/main_repo/.git")),
4584 false,
4585 GitWorktree {
4586 path: PathBuf::from(path!("/linked_worktree")),
4587 ref_name: Some("refs/heads/feature".into()),
4588 sha: "abc123".into(),
4589 is_main: false,
4590 is_bare: false,
4591 },
4592 )
4593 .await;
4594 fs.write(
4595 path!("/linked_worktree/file.txt").as_ref(),
4596 "content".as_bytes(),
4597 )
4598 .await
4599 .unwrap();
4600
4601 let tree = Worktree::local(
4602 path!("/linked_worktree").as_ref(),
4603 true,
4604 fs.clone(),
4605 Arc::default(),
4606 true,
4607 WorktreeId::from_proto(0),
4608 &mut cx.to_async(),
4609 )
4610 .await
4611 .unwrap();
4612 tree.update(cx, |tree, _| tree.as_local().unwrap().scan_complete())
4613 .await;
4614 cx.run_until_parked();
4615
4616 let repo_update_count: Rc<Cell<usize>> = Rc::new(Cell::new(0));
4617 tree.update(cx, {
4618 let repo_update_count = repo_update_count.clone();
4619 |_, cx| {
4620 cx.subscribe(&cx.entity(), move |_, _, event, _| {
4621 if matches!(event, Event::UpdatedGitRepositories(_)) {
4622 repo_update_count.set(repo_update_count.get() + 1);
4623 }
4624 })
4625 .detach();
4626 }
4627 });
4628
4629 let skipped_paths = [
4630 // Standard common git dir skipped paths
4631 path!("/main_repo/.git/objects/aa/bbccddee"),
4632 path!("/main_repo/.git/objects/pack/pack-1234.pack"),
4633 path!("/main_repo/.git/hooks/pre-commit"),
4634 path!("/main_repo/.git/logs/HEAD"),
4635 path!("/main_repo/.git/logs/refs/heads/main"),
4636 path!("/main_repo/.git/logs/refs/remotes/origin/main"),
4637 path!("/main_repo/.git/logs/refs/tags/v1.0"),
4638 path!("/main_repo/.git/rebase-merge/done"),
4639 path!("/main_repo/.git/rebase-apply/onto"),
4640 path!("/main_repo/.git/sequencer/todo"),
4641 path!("/main_repo/.git/index.lock"),
4642 path!("/main_repo/.git/refs/heads/main.lock"),
4643 path!("/main_repo/.git/COMMIT_EDITMSG"),
4644 path!("/main_repo/.git/packed-refs.new"),
4645 path!("/main_repo/.git/config.new"),
4646 path!("/main_repo/.git/index.new"),
4647 path!("/main_repo/.git/index-abc123.tmp"),
4648 path!("/main_repo/.git/FETCH_HEAD"),
4649 path!("/main_repo/.git/ORIG_HEAD"),
4650 path!("/main_repo/.git/BISECT_LOG"),
4651 path!("/main_repo/.git/info/refs"),
4652 path!("/main_repo/.git/info/refs_lzOf51"),
4653 path!("/main_repo/.git/gc.pid"),
4654 // Linked-worktree specific skipped paths
4655 path!("/main_repo/.git/worktrees/feature/index.lock"),
4656 ];
4657 for path in skipped_paths {
4658 fs.emit_fs_event(path, Some(PathEventKind::Changed));
4659 cx.run_until_parked();
4660 assert_eq!(
4661 repo_update_count.get(),
4662 0,
4663 "event for {path} should not emit UpdatedGitRepositories"
4664 );
4665 }
4666
4667 let rescan_paths = [
4668 // Standard common git dir rescan paths
4669 path!("/main_repo/.git/logs/refs/stash"),
4670 path!("/main_repo/.git/refs/heads/main"),
4671 path!("/main_repo/.git/info/exclude"),
4672 path!("/main_repo/.git/refs/heads/branch.new"),
4673 path!("/main_repo/.git/refs/heads/branch.tmp"),
4674 // Linked-worktree worktree-specific rescan paths
4675 path!("/main_repo/.git/worktrees/feature/index"),
4676 path!("/main_repo/.git/worktrees/feature/HEAD"),
4677 ];
4678 for path in rescan_paths {
4679 let count_before = repo_update_count.get();
4680 fs.emit_fs_event(path, Some(PathEventKind::Changed));
4681 cx.run_until_parked();
4682 assert!(
4683 repo_update_count.get() > count_before,
4684 "event for {path} should emit UpdatedGitRepositories"
4685 );
4686 }
4687}
4688
4689#[gpui::test]
4690async fn test_watcher_overflow_rescan_reloads_git_state(cx: &mut TestAppContext) {
4691 // When the OS watch queue overflows, pending events are dropped and the
4692 // watcher reports only a `Rescan` event for the worktree root. The dropped
4693 // events may have included changes inside `.git`, so the rescan must
4694 // trigger a git state reload even though no `.git` event is ever seen.
4695 init_test(cx);
4696 let fs = FakeFs::new(cx.background_executor.clone());
4697 fs.insert_tree(
4698 path!("/root"),
4699 json!({
4700 ".git": {},
4701 "file.txt": "content",
4702 }),
4703 )
4704 .await;
4705
4706 let tree = Worktree::local(
4707 path!("/root").as_ref(),
4708 true,
4709 fs.clone(),
4710 Default::default(),
4711 true,
4712 WorktreeId::from_proto(0),
4713 &mut cx.to_async(),
4714 )
4715 .await
4716 .unwrap();
4717 tree.update(cx, |tree, _| tree.as_local().unwrap().scan_complete())
4718 .await;
4719 cx.run_until_parked();
4720
4721 let repo_update_count: Rc<Cell<usize>> = Rc::new(Cell::new(0));
4722 tree.update(cx, {
4723 let repo_update_count = repo_update_count.clone();
4724 |_, cx| {
4725 cx.subscribe(&cx.entity(), move |_, _, event, _| {
4726 if matches!(event, Event::UpdatedGitRepositories(_)) {
4727 repo_update_count.set(repo_update_count.get() + 1);
4728 }
4729 })
4730 .detach();
4731 }
4732 });
4733
4734 // A git state change occurs while events are queued but undelivered, and
4735 // is then lost to a watcher overflow: the only event the worktree ever
4736 // receives is the root rescan.
4737 fs.pause_events();
4738 fs.set_head_for_repo(
4739 path!("/root/.git").as_ref(),
4740 &[("file.txt", "content".into())],
4741 "sha-after-overflow",
4742 );
4743 fs.simulate_watcher_overflow(path!("/root"));
4744 fs.unpause_events_and_flush();
4745 cx.run_until_parked();
4746
4747 assert!(
4748 repo_update_count.get() > 0,
4749 "a watcher overflow rescan should reload git state, since the dropped \
4750 events may have included .git changes"
4751 );
4752}
4753
4754#[gpui::test]
4755async fn test_git_update_in_same_batch_as_rescan_is_not_lost(cx: &mut TestAppContext) {
4756 // When a `.git` event and a watcher rescan arrive in the same batch,
4757 // `update_git_repositories` stamps the repository's `git_dir_scan_id`, but
4758 // the rescan then re-inserts the repository entry. If the re-insertion
4759 // resets `git_dir_scan_id`, the stamp is lost before the snapshot diff can
4760 // observe it. The git update must still be signaled via
4761 // `UpdatedGitRepositories`.
4762 init_test(cx);
4763 let fs = FakeFs::new(cx.background_executor.clone());
4764 fs.insert_tree(
4765 path!("/root"),
4766 json!({
4767 ".git": {},
4768 "file.txt": "content",
4769 }),
4770 )
4771 .await;
4772
4773 let tree = Worktree::local(
4774 path!("/root").as_ref(),
4775 true,
4776 fs.clone(),
4777 Default::default(),
4778 true,
4779 WorktreeId::from_proto(0),
4780 &mut cx.to_async(),
4781 )
4782 .await
4783 .unwrap();
4784 tree.update(cx, |tree, _| tree.as_local().unwrap().scan_complete())
4785 .await;
4786 cx.run_until_parked();
4787
4788 let repo_update_count: Rc<Cell<usize>> = Rc::new(Cell::new(0));
4789 tree.update(cx, {
4790 let repo_update_count = repo_update_count.clone();
4791 |_, cx| {
4792 cx.subscribe(&cx.entity(), move |_, _, event, _| {
4793 if matches!(event, Event::UpdatedGitRepositories(_)) {
4794 repo_update_count.set(repo_update_count.get() + 1);
4795 }
4796 })
4797 .detach();
4798 }
4799 });
4800
4801 // Deliver the git change and the rescan in a single batch, as happens when
4802 // a rescan arrives while other events are still queued.
4803 fs.pause_events();
4804 fs.set_head_for_repo(
4805 path!("/root/.git").as_ref(),
4806 &[("file.txt", "content".into())],
4807 "sha-with-rescan",
4808 );
4809 fs.emit_fs_event(path!("/root"), Some(PathEventKind::Rescan));
4810 fs.unpause_events_and_flush();
4811 cx.run_until_parked();
4812
4813 assert!(
4814 repo_update_count.get() > 0,
4815 "a git update processed in the same batch as a rescan should still be \
4816 signaled via UpdatedGitRepositories"
4817 );
4818}
4819
4820#[gpui::test]
4821async fn test_linked_worktree_event_in_unregistered_common_git_dir_does_not_panic(
4822 executor: BackgroundExecutor,
4823 cx: &mut TestAppContext,
4824) {
4825 // Regression test: a rescan event on a linked worktree's commondir
4826 // must not panic when the worktree's repository has already been
4827 // unregistered from `git_repositories`.
4828 init_test(cx);
4829
4830 use git::repository::Worktree as GitWorktree;
4831
4832 let fs = FakeFs::new(executor);
4833
4834 fs.insert_tree(
4835 path!("/main_repo"),
4836 json!({
4837 ".git": {},
4838 "file.txt": "content",
4839 }),
4840 )
4841 .await;
4842 fs.add_linked_worktree_for_repo(
4843 Path::new(path!("/main_repo/.git")),
4844 false,
4845 GitWorktree {
4846 path: PathBuf::from(path!("/linked_worktree")),
4847 ref_name: Some("refs/heads/feature".into()),
4848 sha: "abc123".into(),
4849 is_main: false,
4850 is_bare: false,
4851 },
4852 )
4853 .await;
4854 fs.write(
4855 path!("/linked_worktree/file.txt").as_ref(),
4856 "content".as_bytes(),
4857 )
4858 .await
4859 .unwrap();
4860
4861 let tree = Worktree::local(
4862 path!("/linked_worktree").as_ref(),
4863 true,
4864 fs.clone(),
4865 Arc::default(),
4866 true,
4867 WorktreeId::from_proto(0),
4868 &mut cx.to_async(),
4869 )
4870 .await
4871 .unwrap();
4872 tree.update(cx, |tree, _| tree.as_local().unwrap().scan_complete())
4873 .await;
4874 cx.run_until_parked();
4875
4876 // Unregister the linked worktree's repository by removing its gitfile.
4877 fs.remove_file(
4878 Path::new(path!("/linked_worktree/.git")),
4879 Default::default(),
4880 )
4881 .await
4882 .unwrap();
4883 tree.flush_fs_events(cx).await;
4884
4885 // Deliver the kind of Rescan event `FsWatcher` emits when the kernel
4886 // signals `need_rescan` for the commondir.
4887 fs.emit_fs_event(path!("/main_repo/.git"), Some(fs::PathEventKind::Rescan));
4888 cx.run_until_parked();
4889 tree.flush_fs_events(cx).await;
4890}
4891
4892#[gpui::test]
4893async fn test_dot_git_dir_event_does_not_suppress_children(
4894 executor: BackgroundExecutor,
4895 cx: &mut TestAppContext,
4896) {
4897 // On Windows, modifying a file inside .git causes ReadDirectoryChangesW to also emit
4898 // a Modify event for the .git directory itself (because its last-write timestamp changes).
4899 // When these events arrive in the same batch, a naive ancestor-based dedup would collapse
4900 // all child events into the .git directory event, losing the information about which
4901 // specific files changed. This test verifies that the git-related event processing happens
4902 // before the dedup, so that meaningful .git child events still trigger UpdatedGitRepositories.
4903 init_test(cx);
4904
4905 let fs = FakeFs::new(executor.clone());
4906 let project_dir = Path::new(path!("/project"));
4907 fs.insert_tree(
4908 project_dir,
4909 json!({
4910 ".git": {},
4911 "src": {
4912 "main.rs": "fn main() {}",
4913 },
4914 }),
4915 )
4916 .await;
4917
4918 let worktree = Worktree::local(
4919 project_dir,
4920 true,
4921 fs.clone(),
4922 Default::default(),
4923 true,
4924 WorktreeId::from_proto(0),
4925 &mut cx.to_async(),
4926 )
4927 .await
4928 .unwrap();
4929 worktree
4930 .update(cx, |worktree, _| {
4931 worktree.as_local().unwrap().scan_complete()
4932 })
4933 .await;
4934 cx.run_until_parked();
4935
4936 let dot_git = project_dir.join(DOT_GIT);
4937
4938 // Case 1: Event for .git/index.lock only should NOT emit UpdatedGitRepositories
4939 // (index.lock is in the skipped files list)
4940 {
4941 let mut events = cx.events(&worktree);
4942 fs.pause_events();
4943 fs.emit_fs_event(dot_git.join("index.lock"), Some(PathEventKind::Created));
4944 fs.unpause_events_and_flush();
4945 executor.run_until_parked();
4946
4947 let got_git_update = drain_git_repo_updates(&mut events);
4948 assert!(
4949 !got_git_update,
4950 "should NOT emit UpdatedGitRepositories when .git batch only contains index.lock"
4951 );
4952 }
4953
4954 // Case 2: Event for just .git (bare directory event) should emit UpdatedGitRepositories
4955 {
4956 let mut events = cx.events(&worktree);
4957 fs.pause_events();
4958 fs.emit_fs_event(dot_git.clone(), Some(PathEventKind::Changed));
4959 fs.unpause_events_and_flush();
4960 executor.run_until_parked();
4961
4962 let got_git_update = drain_git_repo_updates(&mut events);
4963 assert!(
4964 got_git_update,
4965 "should emit UpdatedGitRepositories for a bare .git directory event"
4966 );
4967 }
4968
4969 // Case 3: Events for .git AND .git/index should emit UpdatedGitRepositories
4970 {
4971 let mut events = cx.events(&worktree);
4972 fs.pause_events();
4973 fs.emit_fs_event(dot_git.clone(), Some(PathEventKind::Changed));
4974 fs.emit_fs_event(dot_git.join("index"), Some(PathEventKind::Changed));
4975 fs.unpause_events_and_flush();
4976 executor.run_until_parked();
4977
4978 let got_git_update = drain_git_repo_updates(&mut events);
4979 assert!(
4980 got_git_update,
4981 "should emit UpdatedGitRepositories when .git batch contains index"
4982 );
4983 }
4984
4985 // Case 4: Event for .git/index only should emit UpdatedGitRepositories
4986 {
4987 let mut events = cx.events(&worktree);
4988 fs.pause_events();
4989 fs.emit_fs_event(dot_git.join("index"), Some(PathEventKind::Changed));
4990 fs.unpause_events_and_flush();
4991 executor.run_until_parked();
4992
4993 let got_git_update = drain_git_repo_updates(&mut events);
4994 assert!(
4995 got_git_update,
4996 "should emit UpdatedGitRepositories for a .git/index event"
4997 );
4998 }
4999
5000 {
5001 let mut events = cx.events(&worktree);
5002 fs.pause_events();
5003 fs.emit_fs_event(dot_git, Some(PathEventKind::Rescan));
5004 fs.unpause_events_and_flush();
5005 executor.run_until_parked();
5006
5007 let got_git_update = drain_git_repo_updates(&mut events);
5008 assert!(
5009 got_git_update,
5010 "should emit UpdatedGitRepositories for a .git rescan event"
5011 );
5012 }
5013
5014 {
5015 let mut events = cx.events(&worktree);
5016 fs.pause_events();
5017 fs.emit_fs_event(project_dir, Some(PathEventKind::Rescan));
5018 fs.unpause_events_and_flush();
5019 executor.run_until_parked();
5020
5021 let got_git_update = drain_git_repo_updates(&mut events);
5022 assert!(
5023 got_git_update,
5024 "should emit UpdatedGitRepositories for a .git rescan event"
5025 );
5026 }
5027}
5028
5029#[gpui::test]
5030async fn test_ref_updates_in_dot_git_subdirectories_are_detected(cx: &mut TestAppContext) {
5031 // On Linux and FreeBSD the native file watcher is non-recursive: watching `.git`
5032 // does not deliver events for files nested below it, like the loose refs that git
5033 // updates on commit, fetch, and branch operations. The worktree must watch the
5034 // `refs` tree explicitly, including directories created after the initial scan.
5035 init_test(cx);
5036 cx.executor().allow_parking();
5037
5038 let dir = TempTree::new(json!({
5039 ".git": {},
5040 "a.txt": "a-contents",
5041 }));
5042 std::fs::write(
5043 dir.path().join(".git/refs/heads/main"),
5044 "0000000000000000000000000000000000000000\n",
5045 )
5046 .unwrap();
5047
5048 let tree = Worktree::local(
5049 dir.path(),
5050 true,
5051 Arc::new(RealFs::new(None, cx.executor())),
5052 Default::default(),
5053 true,
5054 WorktreeId::from_proto(0),
5055 &mut cx.to_async(),
5056 )
5057 .await
5058 .unwrap();
5059 cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
5060 .await;
5061 tree.flush_fs_events(cx).await;
5062
5063 let mut events = cx.events(&tree);
5064 std::fs::write(
5065 dir.path().join(".git/refs/heads/main"),
5066 "1111111111111111111111111111111111111111\n",
5067 )
5068 .unwrap();
5069 expect_git_repo_update(&mut events, cx, "updating a loose ref").await;
5070
5071 std::fs::create_dir_all(dir.path().join(".git/refs/remotes/origin")).unwrap();
5072 expect_git_repo_update(&mut events, cx, "creating a directory under refs").await;
5073 tree.flush_fs_events(cx).await;
5074 drain_git_repo_updates(&mut events);
5075
5076 std::fs::write(
5077 dir.path().join(".git/refs/remotes/origin/main"),
5078 "2222222222222222222222222222222222222222\n",
5079 )
5080 .unwrap();
5081 expect_git_repo_update(
5082 &mut events,
5083 cx,
5084 "updating a ref in a directory created after the initial scan",
5085 )
5086 .await;
5087}
5088
5089async fn expect_git_repo_update(
5090 events: &mut futures::channel::mpsc::UnboundedReceiver<Event>,
5091 cx: &mut TestAppContext,
5092 description: &str,
5093) {
5094 let mut elapsed = std::time::Duration::ZERO;
5095 let timeout = std::time::Duration::from_secs(10);
5096 let poll_interval = std::time::Duration::from_millis(50);
5097 loop {
5098 match events.try_recv() {
5099 Ok(Event::UpdatedGitRepositories(_)) => return,
5100 Ok(_) => continue,
5101 Err(_) => {}
5102 }
5103 assert!(
5104 elapsed < timeout,
5105 "timed out waiting for UpdatedGitRepositories after {description}"
5106 );
5107 cx.background_executor.timer(poll_interval).await;
5108 elapsed += poll_interval;
5109 }
5110}
5111
5112fn drain_git_repo_updates(events: &mut futures::channel::mpsc::UnboundedReceiver<Event>) -> bool {
5113 let mut found = false;
5114 while let Ok(event) = events.try_recv() {
5115 if matches!(event, Event::UpdatedGitRepositories(_)) {
5116 found = true;
5117 }
5118 }
5119 found
5120}
5121
5122fn init_test(cx: &mut gpui::TestAppContext) {
5123 zlog::init_test();
5124
5125 cx.update(|cx| {
5126 let settings_store = SettingsStore::test(cx);
5127 cx.set_global(settings_store);
5128 });
5129}
5130
5131async fn wait_for_condition(
5132 cx: &mut TestAppContext,
5133 mut condition: impl FnMut(&mut TestAppContext) -> bool,
5134) {
5135 for _ in 0..50 {
5136 if condition(cx) {
5137 return;
5138 }
5139 cx.executor().run_until_parked();
5140 cx.background_executor
5141 .timer(std::time::Duration::from_millis(10))
5142 .await;
5143 }
5144 panic!("timed out waiting for test condition");
5145}
5146
5147#[gpui::test]
5148async fn test_load_file_encoding(cx: &mut TestAppContext) {
5149 init_test(cx);
5150
5151 struct TestCase {
5152 name: &'static str,
5153 bytes: Vec<u8>,
5154 expected_text: &'static str,
5155 }
5156
5157 // --- Success Cases ---
5158 let success_cases = vec![
5159 TestCase {
5160 name: "utf8.txt",
5161 bytes: "こんにちは".as_bytes().to_vec(),
5162 expected_text: "こんにちは",
5163 },
5164 TestCase {
5165 name: "sjis.txt",
5166 bytes: vec![0x82, 0xb1, 0x82, 0xf1, 0x82, 0xc9, 0x82, 0xbf, 0x82, 0xcd],
5167 expected_text: "こんにちは",
5168 },
5169 TestCase {
5170 name: "eucjp.txt",
5171 bytes: vec![0xa4, 0xb3, 0xa4, 0xf3, 0xa4, 0xcb, 0xa4, 0xc1, 0xa4, 0xcf],
5172 expected_text: "こんにちは",
5173 },
5174 TestCase {
5175 name: "iso2022jp.txt",
5176 bytes: vec![
5177 0x1b, 0x24, 0x42, 0x24, 0x33, 0x24, 0x73, 0x24, 0x4b, 0x24, 0x41, 0x24, 0x4f, 0x1b,
5178 0x28, 0x42,
5179 ],
5180 expected_text: "こんにちは",
5181 },
5182 TestCase {
5183 name: "win1252.txt",
5184 bytes: vec![0x43, 0x61, 0x66, 0xe9],
5185 expected_text: "Café",
5186 },
5187 TestCase {
5188 name: "gbk.txt",
5189 bytes: vec![
5190 0xbd, 0xf1, 0xcc, 0xec, 0xcc, 0xec, 0xc6, 0xf8, 0xb2, 0xbb, 0xb4, 0xed,
5191 ],
5192 expected_text: "今天天气不错",
5193 },
5194 // UTF-16LE with BOM
5195 TestCase {
5196 name: "utf16le_bom.txt",
5197 bytes: vec![
5198 0xFF, 0xFE, // BOM
5199 0x53, 0x30, 0x93, 0x30, 0x6B, 0x30, 0x61, 0x30, 0x6F, 0x30,
5200 ],
5201 expected_text: "こんにちは",
5202 },
5203 // UTF-16BE with BOM
5204 TestCase {
5205 name: "utf16be_bom.txt",
5206 bytes: vec![
5207 0xFE, 0xFF, // BOM
5208 0x30, 0x53, 0x30, 0x93, 0x30, 0x6B, 0x30, 0x61, 0x30, 0x6F,
5209 ],
5210 expected_text: "こんにちは",
5211 },
5212 // UTF-16LE without BOM (ASCII only)
5213 // This relies on the "null byte heuristic" we implemented.
5214 // "ABC" -> 41 00 42 00 43 00
5215 TestCase {
5216 name: "utf16le_ascii_no_bom.txt",
5217 bytes: vec![0x41, 0x00, 0x42, 0x00, 0x43, 0x00],
5218 expected_text: "ABC",
5219 },
5220 ];
5221
5222 // --- Failure Cases ---
5223 let failure_cases = vec![
5224 // Binary File (Should be detected by heuristic and return Error)
5225 // Contains random bytes and mixed nulls that don't match UTF-16 patterns
5226 TestCase {
5227 name: "binary.bin",
5228 bytes: vec![0x00, 0xFF, 0x12, 0x00, 0x99, 0x88, 0x77, 0x66, 0x00],
5229 expected_text: "", // Not used
5230 },
5231 ];
5232
5233 let root_path = if cfg!(windows) {
5234 Path::new("C:\\root")
5235 } else {
5236 Path::new("/root")
5237 };
5238
5239 let fs = FakeFs::new(cx.background_executor.clone());
5240 fs.create_dir(root_path).await.unwrap();
5241
5242 for case in success_cases.iter().chain(failure_cases.iter()) {
5243 let path = root_path.join(case.name);
5244 fs.write(&path, &case.bytes).await.unwrap();
5245 }
5246
5247 let tree = Worktree::local(
5248 root_path,
5249 true,
5250 fs,
5251 Default::default(),
5252 true,
5253 WorktreeId::from_proto(0),
5254 &mut cx.to_async(),
5255 )
5256 .await
5257 .unwrap();
5258
5259 cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
5260 .await;
5261
5262 let rel_path = |name: &str| {
5263 RelPath::new(&Path::new(name), PathStyle::local())
5264 .unwrap()
5265 .into_arc()
5266 };
5267
5268 // Run Success Tests
5269 for case in success_cases {
5270 let loaded = tree
5271 .update(cx, |tree, cx| tree.load_file(&rel_path(case.name), cx))
5272 .await;
5273 if let Err(e) = &loaded {
5274 panic!("Failed to load success case '{}': {:?}", case.name, e);
5275 }
5276 let loaded = loaded.unwrap();
5277 assert_eq!(
5278 loaded.text, case.expected_text,
5279 "Encoding mismatch for file: {}",
5280 case.name
5281 );
5282 }
5283
5284 // Run Failure Tests
5285 for case in failure_cases {
5286 let loaded = tree
5287 .update(cx, |tree, cx| tree.load_file(&rel_path(case.name), cx))
5288 .await;
5289 assert!(
5290 loaded.is_err(),
5291 "Failure case '{}' unexpectedly succeeded! It should have been detected as binary.",
5292 case.name
5293 );
5294 let err_msg = loaded.unwrap_err().to_string();
5295 println!("Got expected error for {}: {}", case.name, err_msg);
5296 }
5297}
5298
5299#[gpui::test]
5300async fn test_write_file_encoding(cx: &mut gpui::TestAppContext) {
5301 init_test(cx);
5302 let fs = FakeFs::new(cx.executor());
5303
5304 let root_path = if cfg!(windows) {
5305 Path::new("C:\\root")
5306 } else {
5307 Path::new("/root")
5308 };
5309 fs.create_dir(root_path).await.unwrap();
5310
5311 let worktree = Worktree::local(
5312 root_path,
5313 true,
5314 fs.clone(),
5315 Default::default(),
5316 true,
5317 WorktreeId::from_proto(0),
5318 &mut cx.to_async(),
5319 )
5320 .await
5321 .unwrap();
5322
5323 // Define test case structure
5324 struct TestCase {
5325 name: &'static str,
5326 text: &'static str,
5327 encoding: &'static encoding_rs::Encoding,
5328 has_bom: bool,
5329 expected_bytes: Vec<u8>,
5330 }
5331
5332 let cases = vec![
5333 // Shift_JIS with Japanese
5334 TestCase {
5335 name: "Shift_JIS with Japanese",
5336 text: "こんにちは",
5337 encoding: encoding_rs::SHIFT_JIS,
5338 has_bom: false,
5339 expected_bytes: vec![0x82, 0xb1, 0x82, 0xf1, 0x82, 0xc9, 0x82, 0xbf, 0x82, 0xcd],
5340 },
5341 // UTF-8 No BOM
5342 TestCase {
5343 name: "UTF-8 No BOM",
5344 text: "AB",
5345 encoding: encoding_rs::UTF_8,
5346 has_bom: false,
5347 expected_bytes: vec![0x41, 0x42],
5348 },
5349 // UTF-8 with BOM
5350 TestCase {
5351 name: "UTF-8 with BOM",
5352 text: "AB",
5353 encoding: encoding_rs::UTF_8,
5354 has_bom: true,
5355 expected_bytes: vec![0xEF, 0xBB, 0xBF, 0x41, 0x42],
5356 },
5357 // UTF-16LE No BOM with Japanese
5358 // NOTE: This passes thanks to the manual encoding fix implemented in `write_file`.
5359 TestCase {
5360 name: "UTF-16LE No BOM with Japanese",
5361 text: "こんにちは",
5362 encoding: encoding_rs::UTF_16LE,
5363 has_bom: false,
5364 expected_bytes: vec![0x53, 0x30, 0x93, 0x30, 0x6b, 0x30, 0x61, 0x30, 0x6f, 0x30],
5365 },
5366 // UTF-16LE with BOM
5367 TestCase {
5368 name: "UTF-16LE with BOM",
5369 text: "A",
5370 encoding: encoding_rs::UTF_16LE,
5371 has_bom: true,
5372 expected_bytes: vec![0xFF, 0xFE, 0x41, 0x00],
5373 },
5374 // UTF-16BE No BOM with Japanese
5375 // NOTE: This passes thanks to the manual encoding fix.
5376 TestCase {
5377 name: "UTF-16BE No BOM with Japanese",
5378 text: "こんにちは",
5379 encoding: encoding_rs::UTF_16BE,
5380 has_bom: false,
5381 expected_bytes: vec![0x30, 0x53, 0x30, 0x93, 0x30, 0x6b, 0x30, 0x61, 0x30, 0x6f],
5382 },
5383 // UTF-16BE with BOM
5384 TestCase {
5385 name: "UTF-16BE with BOM",
5386 text: "A",
5387 encoding: encoding_rs::UTF_16BE,
5388 has_bom: true,
5389 expected_bytes: vec![0xFE, 0xFF, 0x00, 0x41],
5390 },
5391 ];
5392
5393 for (i, case) in cases.into_iter().enumerate() {
5394 let file_name = format!("test_{}.txt", i);
5395 let path: Arc<Path> = Path::new(&file_name).into();
5396 let file_path = root_path.join(&file_name);
5397
5398 fs.insert_file(&file_path, "".into()).await;
5399
5400 let rel_path = RelPath::new(&path, PathStyle::local()).unwrap().into_arc();
5401 let text = text::Rope::from(case.text);
5402
5403 let task = worktree.update(cx, |wt, cx| {
5404 wt.write_file(
5405 rel_path,
5406 text,
5407 text::LineEnding::Unix,
5408 case.encoding,
5409 case.has_bom,
5410 cx,
5411 )
5412 });
5413
5414 if let Err(e) = task.await {
5415 panic!("Unexpected error in case '{}': {:?}", case.name, e);
5416 }
5417
5418 let bytes = fs.load_bytes(&file_path).await.unwrap();
5419
5420 assert_eq!(
5421 bytes, case.expected_bytes,
5422 "case '{}' mismatch. Expected {:?}, but got {:?}",
5423 case.name, case.expected_bytes, bytes
5424 );
5425 }
5426}
5427
5428#[gpui::test]
5429async fn test_refresh_entries_for_paths_creates_ancestors(cx: &mut TestAppContext) {
5430 init_test(cx);
5431 let fs = FakeFs::new(cx.background_executor.clone());
5432 fs.insert_tree(
5433 "/root",
5434 json!({
5435 "a": {
5436 "b": {
5437 "c": {
5438 "deep_file.txt": "content",
5439 "sibling.txt": "content"
5440 },
5441 "d": {
5442 "under_sibling_dir.txt": "content"
5443 }
5444 }
5445 }
5446 }),
5447 )
5448 .await;
5449
5450 let tree = Worktree::local(
5451 Path::new("/root"),
5452 true,
5453 fs.clone(),
5454 Default::default(),
5455 false, // Disable scanning so the initial scan doesn't discover any entries
5456 WorktreeId::from_proto(0),
5457 &mut cx.to_async(),
5458 )
5459 .await
5460 .unwrap();
5461
5462 cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
5463 .await;
5464
5465 tree.read_with(cx, |tree, _| {
5466 assert_eq!(
5467 tree.entries(true, 0)
5468 .map(|e| e.path.as_ref())
5469 .collect::<Vec<_>>(),
5470 &[rel_path("")],
5471 "Only root entry should exist when scanning is disabled"
5472 );
5473
5474 assert!(tree.entry_for_path(rel_path("a")).is_none());
5475 assert!(tree.entry_for_path(rel_path("a/b")).is_none());
5476 assert!(tree.entry_for_path(rel_path("a/b/c")).is_none());
5477 assert!(
5478 tree.entry_for_path(rel_path("a/b/c/deep_file.txt"))
5479 .is_none()
5480 );
5481 });
5482
5483 tree.read_with(cx, |tree, _| {
5484 tree.as_local()
5485 .unwrap()
5486 .refresh_entries_for_paths(vec![rel_path("a/b/c/deep_file.txt").into()])
5487 })
5488 .recv()
5489 .await;
5490
5491 tree.read_with(cx, |tree, _| {
5492 assert_eq!(
5493 tree.entries(true, 0)
5494 .map(|e| e.path.as_ref())
5495 .collect::<Vec<_>>(),
5496 &[
5497 rel_path(""),
5498 rel_path("a"),
5499 rel_path("a/b"),
5500 rel_path("a/b/c"),
5501 rel_path("a/b/c/deep_file.txt"),
5502 rel_path("a/b/c/sibling.txt"),
5503 rel_path("a/b/d"),
5504 ],
5505 "All ancestors should be created when refreshing a deeply nested path"
5506 );
5507 });
5508}
5509
5510#[gpui::test]
5511async fn test_single_file_worktree_deleted(cx: &mut TestAppContext) {
5512 init_test(cx);
5513 let fs = FakeFs::new(cx.background_executor.clone());
5514
5515 fs.insert_tree(
5516 "/root",
5517 json!({
5518 "test.txt": "content",
5519 }),
5520 )
5521 .await;
5522
5523 let tree = Worktree::local(
5524 Path::new("/root/test.txt"),
5525 true,
5526 fs.clone(),
5527 Default::default(),
5528 true,
5529 WorktreeId::from_proto(0),
5530 &mut cx.to_async(),
5531 )
5532 .await
5533 .unwrap();
5534
5535 cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
5536 .await;
5537
5538 tree.read_with(cx, |tree, _| {
5539 assert!(tree.is_single_file(), "Should be a single-file worktree");
5540 assert_eq!(tree.abs_path().as_ref(), Path::new("/root/test.txt"));
5541 });
5542
5543 // Delete the file
5544 fs.remove_file(Path::new("/root/test.txt"), Default::default())
5545 .await
5546 .unwrap();
5547
5548 // Subscribe to worktree events
5549 let deleted_event_received = Rc::new(Cell::new(false));
5550 let _subscription = cx.update({
5551 let deleted_event_received = deleted_event_received.clone();
5552 |cx| {
5553 cx.subscribe(&tree, move |_, event, _| {
5554 if matches!(event, Event::Deleted) {
5555 deleted_event_received.set(true);
5556 }
5557 })
5558 }
5559 });
5560
5561 // Trigger filesystem events - the scanner should detect the file is gone immediately
5562 // and emit a Deleted event
5563 cx.background_executor.run_until_parked();
5564 cx.background_executor
5565 .advance_clock(std::time::Duration::from_secs(1));
5566 cx.background_executor.run_until_parked();
5567
5568 assert!(
5569 deleted_event_received.get(),
5570 "Should receive Deleted event when single-file worktree root is deleted"
5571 );
5572}
5573
5574#[gpui::test]
5575async fn test_remote_worktree_without_git_emits_root_repo_event_after_first_update(
5576 cx: &mut TestAppContext,
5577) {
5578 cx.update(|cx| {
5579 let store = SettingsStore::test(cx);
5580 cx.set_global(store);
5581 });
5582
5583 let client = AnyProtoClient::new(NoopProtoClient::new());
5584
5585 let worktree = cx.update(|cx| {
5586 Worktree::remote(
5587 1,
5588 clock::ReplicaId::new(1),
5589 proto::WorktreeMetadata {
5590 id: 1,
5591 root_name: "project".to_string(),
5592 visible: true,
5593 abs_path: "/home/user/project".to_string(),
5594 root_repo_common_dir: None,
5595 root_repo_is_linked_worktree: false,
5596 },
5597 client,
5598 PathStyle::Unix,
5599 cx,
5600 )
5601 });
5602
5603 let events: Arc<std::sync::Mutex<Vec<&'static str>>> =
5604 Arc::new(std::sync::Mutex::new(Vec::new()));
5605 let events_clone = events.clone();
5606 cx.update(|cx| {
5607 cx.subscribe(&worktree, move |_, event, _cx| {
5608 if matches!(event, Event::UpdatedRootRepoCommonDir { .. }) {
5609 events_clone
5610 .lock()
5611 .unwrap()
5612 .push("UpdatedRootRepoCommonDir");
5613 }
5614 if matches!(event, Event::UpdatedEntries(_)) {
5615 events_clone.lock().unwrap().push("UpdatedEntries");
5616 }
5617 })
5618 .detach();
5619 });
5620
5621 // Send an update with entries but no repo info (plain directory).
5622 worktree.update(cx, |worktree, _cx| {
5623 worktree
5624 .as_remote()
5625 .unwrap()
5626 .update_from_remote(proto::UpdateWorktree {
5627 project_id: 1,
5628 worktree_id: 1,
5629 abs_path: "/home/user/project".to_string(),
5630 root_name: "project".to_string(),
5631 updated_entries: vec![proto::Entry {
5632 id: 1,
5633 is_dir: true,
5634 path: "".to_string(),
5635 inode: 1,
5636 mtime: Some(proto::Timestamp {
5637 seconds: 0,
5638 nanos: 0,
5639 }),
5640 is_ignored: false,
5641 is_hidden: false,
5642 is_external: false,
5643 is_fifo: false,
5644 size: None,
5645 canonical_path: None,
5646 }],
5647 removed_entries: vec![],
5648 scan_id: 1,
5649 is_last_update: true,
5650 updated_repositories: vec![],
5651 removed_repositories: vec![],
5652 root_repo_common_dir: None,
5653 root_repo_is_linked_worktree: false,
5654 });
5655 });
5656
5657 cx.run_until_parked();
5658
5659 let fired = events.lock().unwrap();
5660 assert!(
5661 fired.contains(&"UpdatedEntries"),
5662 "UpdatedEntries should fire after remote update"
5663 );
5664 assert!(
5665 fired.contains(&"UpdatedRootRepoCommonDir"),
5666 "UpdatedRootRepoCommonDir should fire after first remote update even when \
5667 root_repo_common_dir is None, to signal that repo state is now known"
5668 );
5669}
5670
5671#[gpui::test]
5672async fn test_remote_worktree_with_git_emits_root_repo_event_when_repo_info_arrives(
5673 cx: &mut TestAppContext,
5674) {
5675 cx.update(|cx| {
5676 let store = SettingsStore::test(cx);
5677 cx.set_global(store);
5678 });
5679
5680 let client = AnyProtoClient::new(NoopProtoClient::new());
5681
5682 let worktree = cx.update(|cx| {
5683 Worktree::remote(
5684 1,
5685 clock::ReplicaId::new(1),
5686 proto::WorktreeMetadata {
5687 id: 1,
5688 root_name: "project".to_string(),
5689 visible: true,
5690 abs_path: "/home/user/project".to_string(),
5691 root_repo_common_dir: None,
5692 root_repo_is_linked_worktree: false,
5693 },
5694 client,
5695 PathStyle::Unix,
5696 cx,
5697 )
5698 });
5699
5700 let events: Arc<std::sync::Mutex<Vec<&'static str>>> =
5701 Arc::new(std::sync::Mutex::new(Vec::new()));
5702 let events_clone = events.clone();
5703 cx.update(|cx| {
5704 cx.subscribe(&worktree, move |_, event, _cx| {
5705 if matches!(event, Event::UpdatedRootRepoCommonDir { .. }) {
5706 events_clone
5707 .lock()
5708 .unwrap()
5709 .push("UpdatedRootRepoCommonDir");
5710 }
5711 })
5712 .detach();
5713 });
5714
5715 // Send an update where repo info arrives (None -> Some).
5716 worktree.update(cx, |worktree, _cx| {
5717 worktree
5718 .as_remote()
5719 .unwrap()
5720 .update_from_remote(proto::UpdateWorktree {
5721 project_id: 1,
5722 worktree_id: 1,
5723 abs_path: "/home/user/project".to_string(),
5724 root_name: "project".to_string(),
5725 updated_entries: vec![proto::Entry {
5726 id: 1,
5727 is_dir: true,
5728 path: "".to_string(),
5729 inode: 1,
5730 mtime: Some(proto::Timestamp {
5731 seconds: 0,
5732 nanos: 0,
5733 }),
5734 is_ignored: false,
5735 is_hidden: false,
5736 is_external: false,
5737 is_fifo: false,
5738 size: None,
5739 canonical_path: None,
5740 }],
5741 removed_entries: vec![],
5742 scan_id: 1,
5743 is_last_update: true,
5744 updated_repositories: vec![],
5745 removed_repositories: vec![],
5746 root_repo_common_dir: Some("/home/user/project/.git".to_string()),
5747 root_repo_is_linked_worktree: false,
5748 });
5749 });
5750
5751 cx.run_until_parked();
5752
5753 let fired = events.lock().unwrap();
5754 assert!(
5755 fired.contains(&"UpdatedRootRepoCommonDir"),
5756 "UpdatedRootRepoCommonDir should fire when repo info arrives (None -> Some)"
5757 );
5758 assert_eq!(
5759 fired
5760 .iter()
5761 .filter(|e| **e == "UpdatedRootRepoCommonDir")
5762 .count(),
5763 1,
5764 "should fire exactly once, not duplicate"
5765 );
5766}
5767
5768#[gpui::test]
5769async fn test_remote_worktree_root_repo_metadata_cleared_only_by_completed_scan(
5770 cx: &mut TestAppContext,
5771) {
5772 cx.update(|cx| {
5773 let store = SettingsStore::test(cx);
5774 cx.set_global(store);
5775 });
5776
5777 let client = AnyProtoClient::new(NoopProtoClient::new());
5778
5779 // Metadata eagerly seeds the root repo info, as `AddWorktreeResponse` /
5780 // `WorktreeMetadata` do before the host's scan completes.
5781 let worktree = cx.update(|cx| {
5782 Worktree::remote(
5783 1,
5784 clock::ReplicaId::new(1),
5785 proto::WorktreeMetadata {
5786 id: 1,
5787 root_name: "feature-a".to_string(),
5788 visible: true,
5789 abs_path: "/home/user/monty/feature-a".to_string(),
5790 root_repo_common_dir: Some("/home/user/monty/.bare".to_string()),
5791 root_repo_is_linked_worktree: true,
5792 },
5793 client,
5794 PathStyle::Unix,
5795 cx,
5796 )
5797 });
5798
5799 let root_repo_metadata = |cx: &mut TestAppContext| {
5800 worktree.read_with(cx, |worktree, _| {
5801 let snapshot = worktree.snapshot();
5802 (
5803 snapshot.root_repo_common_dir().cloned(),
5804 snapshot.root_repo_is_linked_worktree(),
5805 )
5806 })
5807 };
5808
5809 let update = |scan_id: u64, is_last_update: bool| proto::UpdateWorktree {
5810 project_id: 1,
5811 worktree_id: 1,
5812 abs_path: "/home/user/monty/feature-a".to_string(),
5813 root_name: "feature-a".to_string(),
5814 updated_entries: vec![],
5815 removed_entries: vec![],
5816 scan_id,
5817 is_last_update,
5818 updated_repositories: vec![],
5819 removed_repositories: vec![],
5820 root_repo_common_dir: None,
5821 root_repo_is_linked_worktree: false,
5822 };
5823
5824 // A mid-scan update without repo info must not clobber the seeded
5825 // metadata: the sender's scanner may not have registered the repo yet.
5826 worktree.update(cx, |worktree, _cx| {
5827 worktree
5828 .as_remote()
5829 .unwrap()
5830 .update_from_remote(update(2, false));
5831 });
5832 cx.run_until_parked();
5833
5834 assert_eq!(
5835 root_repo_metadata(cx),
5836 (Some(Arc::from(Path::new("/home/user/monty/.bare"))), true,),
5837 "mid-scan update without repo info should not clear seeded metadata"
5838 );
5839
5840 // A completed scan without repo info is authoritative: the repo is gone.
5841 worktree.update(cx, |worktree, _cx| {
5842 worktree
5843 .as_remote()
5844 .unwrap()
5845 .update_from_remote(update(3, true));
5846 });
5847 cx.run_until_parked();
5848
5849 assert_eq!(
5850 root_repo_metadata(cx),
5851 (None, false),
5852 "completed scan without repo info should clear root repo metadata"
5853 );
5854}
5855
5856// Regression test: a remote worktree used to emit `UpdatedEntries` with an
5857// empty changeset (`Arc::default()`), discarding the changed paths. Consumers
5858// that key off those paths - notably the agent's `.agents/skills` refresh -
5859// therefore never fired on remote projects, so skills pasted into an already
5860// open project were never picked up. The changeset must carry the real paths.
5861//
5862// This drives the real host -> remote pipeline: a `FakeFs`-backed local
5863// worktree scans the filesystem and produces `UpdateWorktree` messages via
5864// `observe_updates`, which we relay into a remote worktree exactly as the
5865// collab server does.
5866#[gpui::test]
5867async fn test_remote_worktree_update_entries_carry_changed_paths(cx: &mut TestAppContext) {
5868 init_test(cx);
5869
5870 let fs = FakeFs::new(cx.background_executor.clone());
5871 fs.insert_tree(
5872 path!("/root"),
5873 json!({
5874 ".agents": {
5875 "skills": {}
5876 }
5877 }),
5878 )
5879 .await;
5880
5881 // The host worktree scans the fake filesystem and broadcasts updates.
5882 let host = Worktree::local(
5883 path!("/root").as_ref(),
5884 true,
5885 fs.clone(),
5886 Default::default(),
5887 true,
5888 WorktreeId::from_proto(1),
5889 &mut cx.to_async(),
5890 )
5891 .await
5892 .unwrap();
5893 cx.read(|cx| host.read(cx).as_local().unwrap().scan_complete())
5894 .await;
5895
5896 // The remote worktree receives those updates over a simulated connection.
5897 let remote = cx.update(|cx| {
5898 Worktree::remote(
5899 1,
5900 clock::ReplicaId::new(1),
5901 proto::WorktreeMetadata {
5902 id: 1,
5903 root_name: "root".to_string(),
5904 visible: true,
5905 abs_path: path!("/root").to_string(),
5906 root_repo_common_dir: None,
5907 root_repo_is_linked_worktree: false,
5908 },
5909 AnyProtoClient::new(NoopProtoClient::new()),
5910 PathStyle::local(),
5911 cx,
5912 )
5913 });
5914
5915 // Relay every `UpdateWorktree` the host emits into the remote worktree,
5916 // mirroring how the collab server forwards them. The callback only buffers
5917 // the messages; we apply them on the foreground via `relay`.
5918 let pending: Arc<Mutex<Vec<proto::UpdateWorktree>>> = Arc::new(Mutex::new(Vec::new()));
5919 host.update(cx, |host, cx| {
5920 let pending = pending.clone();
5921 host.as_local_mut()
5922 .unwrap()
5923 .observe_updates(1, cx, move |update| {
5924 pending.lock().push(update);
5925 async { true }
5926 });
5927 });
5928 let relay = {
5929 let remote = remote.clone();
5930 move |cx: &mut TestAppContext| {
5931 let updates = std::mem::take(&mut *pending.lock());
5932 remote.update(cx, |remote, _| {
5933 let remote = remote.as_remote().unwrap();
5934 for update in updates {
5935 remote.update_from_remote(update);
5936 }
5937 });
5938 }
5939 };
5940
5941 // Record the (path, change) pairs from every `UpdatedEntries` event the
5942 // remote worktree emits.
5943 let changes: Arc<Mutex<Vec<(String, PathChange)>>> = Arc::new(Mutex::new(Vec::new()));
5944 cx.update(|cx| {
5945 let changes = changes.clone();
5946 cx.subscribe(&remote, move |_, event, _cx| {
5947 if let Event::UpdatedEntries(updated) = event {
5948 changes.lock().extend(
5949 updated
5950 .iter()
5951 .map(|(path, _, change)| (path.as_unix_str().to_string(), *change)),
5952 );
5953 }
5954 })
5955 .detach();
5956 });
5957
5958 // Flush the initial sync (root + existing dirs) and ignore those paths.
5959 cx.run_until_parked();
5960 relay(cx);
5961 cx.run_until_parked();
5962 changes.lock().clear();
5963
5964 // Paste a skill folder into `.agents/skills` on the host.
5965 fs.insert_tree(
5966 path!("/root/.agents/skills/skill-1"),
5967 json!({ "SKILL.md": "skill" }),
5968 )
5969 .await;
5970 cx.run_until_parked();
5971 relay(cx);
5972 cx.run_until_parked();
5973
5974 {
5975 let changes = changes.lock();
5976 assert!(
5977 changes
5978 .iter()
5979 .any(|(path, change)| path == ".agents/skills/skill-1/SKILL.md"
5980 && *change == PathChange::AddedOrUpdated),
5981 "remote UpdatedEntries should carry the added skill path, got {:?}",
5982 changes
5983 );
5984 }
5985 changes.lock().clear();
5986
5987 // Remove the skill folder. The wire format only carries entry ids for
5988 // removals, so the remote worktree must resolve their paths against the
5989 // previous snapshot before it is replaced.
5990 fs.remove_dir(
5991 path!("/root/.agents/skills/skill-1").as_ref(),
5992 RemoveOptions {
5993 recursive: true,
5994 ignore_if_not_exists: false,
5995 },
5996 )
5997 .await
5998 .unwrap();
5999 cx.run_until_parked();
6000 relay(cx);
6001 cx.run_until_parked();
6002
6003 let changes = changes.lock();
6004 assert!(
6005 changes
6006 .iter()
6007 .any(|(path, change)| path == ".agents/skills/skill-1/SKILL.md"
6008 && *change == PathChange::Removed),
6009 "remote UpdatedEntries should carry removed paths resolved from the \
6010 previous snapshot, got {:?}",
6011 changes
6012 );
6013}
6014
6015#[gpui::test]
6016async fn test_deferred_watch_repository_above_root(
6017 executor: BackgroundExecutor,
6018 cx: &mut TestAppContext,
6019) {
6020 init_test(cx);
6021
6022 let fs = FakeFs::new(executor);
6023 fs.insert_tree(
6024 path!("/root"),
6025 json!({
6026 ".git": {},
6027 "subproject": {
6028 "a.txt": "A"
6029 }
6030 }),
6031 )
6032 .await;
6033 let worktree = Worktree::local(
6034 path!("/root/subproject").as_ref(),
6035 true,
6036 fs.clone(),
6037 Arc::default(),
6038 true,
6039 WorktreeId::from_proto(0),
6040 &mut cx.to_async(),
6041 )
6042 .await
6043 .unwrap();
6044 worktree
6045 .update(cx, |worktree, _| {
6046 worktree.as_local().unwrap().scan_complete()
6047 })
6048 .await;
6049 cx.run_until_parked();
6050
6051 worktree.update(cx, |worktree, cx| {
6052 worktree.as_local_mut().unwrap().set_defer_watch(true, cx);
6053 });
6054 worktree
6055 .update(cx, |worktree, _| {
6056 worktree.as_local().unwrap().scan_complete()
6057 })
6058 .await;
6059 cx.run_until_parked();
6060
6061 let repos = worktree.update(cx, |worktree, _| {
6062 worktree.as_local().unwrap().repositories()
6063 });
6064 pretty_assertions::assert_eq!(repos, [Path::new(path!("/root")).into()]);
6065}
6066
6067#[gpui::test]
6068async fn test_deferred_watch_symlinks_pointing_outside(cx: &mut TestAppContext) {
6069 init_test(cx);
6070 let fs = FakeFs::new(cx.background_executor.clone());
6071 fs.insert_tree(
6072 "/root",
6073 json!({
6074 "dir1": {
6075 "deps": {},
6076 "src": {
6077 "a.rs": "",
6078 },
6079 },
6080 "dir2": {
6081 "src": {
6082 "c.rs": "",
6083 }
6084 },
6085 }),
6086 )
6087 .await;
6088
6089 fs.create_symlink("/root/dir1/deps/dep-dir2".as_ref(), "../../dir2".into())
6090 .await
6091 .unwrap();
6092
6093 let tree = Worktree::local(
6094 Path::new("/root/dir1"),
6095 true,
6096 fs.clone(),
6097 Default::default(),
6098 true,
6099 WorktreeId::from_proto(0),
6100 &mut cx.to_async(),
6101 )
6102 .await
6103 .unwrap();
6104
6105 cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
6106 .await;
6107 cx.run_until_parked();
6108
6109 tree.update(cx, |tree, cx| {
6110 tree.as_local_mut().unwrap().set_defer_watch(true, cx);
6111 });
6112 cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
6113 .await;
6114 cx.run_until_parked();
6115
6116 tree.read_with(cx, |tree, _| {
6117 assert_eq!(
6118 tree.entries(true, 0)
6119 .map(|entry| (entry.path.as_ref(), entry.is_external))
6120 .collect::<Vec<_>>(),
6121 vec![
6122 (rel_path(""), false),
6123 (rel_path("deps"), false),
6124 (rel_path("deps/dep-dir2"), true),
6125 (rel_path("src"), false),
6126 (rel_path("src/a.rs"), false),
6127 ]
6128 );
6129 });
6130
6131 tree.read_with(cx, |tree, _| {
6132 tree.as_local()
6133 .unwrap()
6134 .refresh_entries_for_paths(vec![rel_path("deps/dep-dir2").into()])
6135 })
6136 .recv()
6137 .await;
6138
6139 tree.read_with(cx, |tree, _| {
6140 assert_eq!(
6141 tree.entries(true, 0)
6142 .map(|entry| (entry.path.as_ref(), entry.is_external))
6143 .collect::<Vec<_>>(),
6144 vec![
6145 (rel_path(""), false),
6146 (rel_path("deps"), false),
6147 (rel_path("deps/dep-dir2"), true),
6148 (rel_path("deps/dep-dir2/src"), true),
6149 (rel_path("src"), false),
6150 (rel_path("src/a.rs"), false),
6151 ]
6152 );
6153 });
6154
6155 tree.read_with(cx, |tree, _| {
6156 tree.as_local()
6157 .unwrap()
6158 .refresh_entries_for_paths(vec![rel_path("deps/dep-dir2/src").into()])
6159 })
6160 .recv()
6161 .await;
6162
6163 tree.read_with(cx, |tree, _| {
6164 assert!(
6165 tree.entry_for_path(rel_path("deps/dep-dir2/src/c.rs"))
6166 .is_some()
6167 );
6168 });
6169
6170 fs.insert_file(Path::new("/root/dir2/src/new.rs"), b"".to_vec())
6171 .await;
6172
6173 wait_for_condition(cx, |cx| {
6174 tree.read_with(cx, |tree, _| {
6175 tree.entry_for_path(rel_path("deps/dep-dir2/src/new.rs"))
6176 .is_some()
6177 })
6178 })
6179 .await;
6180}
6181