1use crate::{
2 worktree_settings::WorktreeSettings, Entry, EntryKind, Event, PathChange, Snapshot, Worktree,
3 WorktreeModelHandle,
4};
5use anyhow::Result;
6use fs::{FakeFs, Fs, RealFs, RemoveOptions};
7use git::{repository::GitFileStatus, GITIGNORE};
8use gpui::{BorrowAppContext, ModelContext, Task, TestAppContext};
9use parking_lot::Mutex;
10use postage::stream::Stream;
11use pretty_assertions::assert_eq;
12use rand::prelude::*;
13use serde_json::json;
14use settings::{Settings, SettingsStore};
15use std::{
16 env,
17 fmt::Write,
18 mem,
19 path::{Path, PathBuf},
20 sync::Arc,
21};
22use util::{test::temp_tree, ResultExt};
23
24#[gpui::test]
25async fn test_traversal(cx: &mut TestAppContext) {
26 init_test(cx);
27 let fs = FakeFs::new(cx.background_executor.clone());
28 fs.insert_tree(
29 "/root",
30 json!({
31 ".gitignore": "a/b\n",
32 "a": {
33 "b": "",
34 "c": "",
35 }
36 }),
37 )
38 .await;
39
40 let tree = Worktree::local(
41 Path::new("/root"),
42 true,
43 fs,
44 Default::default(),
45 &mut cx.to_async(),
46 )
47 .await
48 .unwrap();
49 cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
50 .await;
51
52 tree.read_with(cx, |tree, _| {
53 assert_eq!(
54 tree.entries(false, 0)
55 .map(|entry| entry.path.as_ref())
56 .collect::<Vec<_>>(),
57 vec![
58 Path::new(""),
59 Path::new(".gitignore"),
60 Path::new("a"),
61 Path::new("a/c"),
62 ]
63 );
64 assert_eq!(
65 tree.entries(true, 0)
66 .map(|entry| entry.path.as_ref())
67 .collect::<Vec<_>>(),
68 vec![
69 Path::new(""),
70 Path::new(".gitignore"),
71 Path::new("a"),
72 Path::new("a/b"),
73 Path::new("a/c"),
74 ]
75 );
76 })
77}
78
79#[gpui::test(iterations = 10)]
80async fn test_circular_symlinks(cx: &mut TestAppContext) {
81 init_test(cx);
82 let fs = FakeFs::new(cx.background_executor.clone());
83 fs.insert_tree(
84 "/root",
85 json!({
86 "lib": {
87 "a": {
88 "a.txt": ""
89 },
90 "b": {
91 "b.txt": ""
92 }
93 }
94 }),
95 )
96 .await;
97 fs.create_symlink("/root/lib/a/lib".as_ref(), "..".into())
98 .await
99 .unwrap();
100 fs.create_symlink("/root/lib/b/lib".as_ref(), "..".into())
101 .await
102 .unwrap();
103
104 let tree = Worktree::local(
105 Path::new("/root"),
106 true,
107 fs.clone(),
108 Default::default(),
109 &mut cx.to_async(),
110 )
111 .await
112 .unwrap();
113
114 cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
115 .await;
116
117 tree.read_with(cx, |tree, _| {
118 assert_eq!(
119 tree.entries(false, 0)
120 .map(|entry| entry.path.as_ref())
121 .collect::<Vec<_>>(),
122 vec![
123 Path::new(""),
124 Path::new("lib"),
125 Path::new("lib/a"),
126 Path::new("lib/a/a.txt"),
127 Path::new("lib/a/lib"),
128 Path::new("lib/b"),
129 Path::new("lib/b/b.txt"),
130 Path::new("lib/b/lib"),
131 ]
132 );
133 });
134
135 fs.rename(
136 Path::new("/root/lib/a/lib"),
137 Path::new("/root/lib/a/lib-2"),
138 Default::default(),
139 )
140 .await
141 .unwrap();
142 cx.executor().run_until_parked();
143 tree.read_with(cx, |tree, _| {
144 assert_eq!(
145 tree.entries(false, 0)
146 .map(|entry| entry.path.as_ref())
147 .collect::<Vec<_>>(),
148 vec![
149 Path::new(""),
150 Path::new("lib"),
151 Path::new("lib/a"),
152 Path::new("lib/a/a.txt"),
153 Path::new("lib/a/lib-2"),
154 Path::new("lib/b"),
155 Path::new("lib/b/b.txt"),
156 Path::new("lib/b/lib"),
157 ]
158 );
159 });
160}
161
162#[gpui::test]
163async fn test_symlinks_pointing_outside(cx: &mut TestAppContext) {
164 init_test(cx);
165 let fs = FakeFs::new(cx.background_executor.clone());
166 fs.insert_tree(
167 "/root",
168 json!({
169 "dir1": {
170 "deps": {
171 // symlinks here
172 },
173 "src": {
174 "a.rs": "",
175 "b.rs": "",
176 },
177 },
178 "dir2": {
179 "src": {
180 "c.rs": "",
181 "d.rs": "",
182 }
183 },
184 "dir3": {
185 "deps": {},
186 "src": {
187 "e.rs": "",
188 "f.rs": "",
189 },
190 }
191 }),
192 )
193 .await;
194
195 // These symlinks point to directories outside of the worktree's root, dir1.
196 fs.create_symlink("/root/dir1/deps/dep-dir2".as_ref(), "../../dir2".into())
197 .await
198 .unwrap();
199 fs.create_symlink("/root/dir1/deps/dep-dir3".as_ref(), "../../dir3".into())
200 .await
201 .unwrap();
202
203 let tree = Worktree::local(
204 Path::new("/root/dir1"),
205 true,
206 fs.clone(),
207 Default::default(),
208 &mut cx.to_async(),
209 )
210 .await
211 .unwrap();
212
213 cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
214 .await;
215
216 let tree_updates = Arc::new(Mutex::new(Vec::new()));
217 tree.update(cx, |_, cx| {
218 let tree_updates = tree_updates.clone();
219 cx.subscribe(&tree, move |_, _, event, _| {
220 if let Event::UpdatedEntries(update) = event {
221 tree_updates.lock().extend(
222 update
223 .iter()
224 .map(|(path, _, change)| (path.clone(), *change)),
225 );
226 }
227 })
228 .detach();
229 });
230
231 // The symlinked directories are not scanned by default.
232 tree.read_with(cx, |tree, _| {
233 assert_eq!(
234 tree.entries(true, 0)
235 .map(|entry| (entry.path.as_ref(), entry.is_external))
236 .collect::<Vec<_>>(),
237 vec![
238 (Path::new(""), false),
239 (Path::new("deps"), false),
240 (Path::new("deps/dep-dir2"), true),
241 (Path::new("deps/dep-dir3"), true),
242 (Path::new("src"), false),
243 (Path::new("src/a.rs"), false),
244 (Path::new("src/b.rs"), false),
245 ]
246 );
247
248 assert_eq!(
249 tree.entry_for_path("deps/dep-dir2").unwrap().kind,
250 EntryKind::UnloadedDir
251 );
252 });
253
254 // Expand one of the symlinked directories.
255 tree.read_with(cx, |tree, _| {
256 tree.as_local()
257 .unwrap()
258 .refresh_entries_for_paths(vec![Path::new("deps/dep-dir3").into()])
259 })
260 .recv()
261 .await;
262
263 // The expanded directory's contents are loaded. Subdirectories are
264 // not scanned yet.
265 tree.read_with(cx, |tree, _| {
266 assert_eq!(
267 tree.entries(true, 0)
268 .map(|entry| (entry.path.as_ref(), entry.is_external))
269 .collect::<Vec<_>>(),
270 vec![
271 (Path::new(""), false),
272 (Path::new("deps"), false),
273 (Path::new("deps/dep-dir2"), true),
274 (Path::new("deps/dep-dir3"), true),
275 (Path::new("deps/dep-dir3/deps"), true),
276 (Path::new("deps/dep-dir3/src"), true),
277 (Path::new("src"), false),
278 (Path::new("src/a.rs"), false),
279 (Path::new("src/b.rs"), false),
280 ]
281 );
282 });
283 assert_eq!(
284 mem::take(&mut *tree_updates.lock()),
285 &[
286 (Path::new("deps/dep-dir3").into(), PathChange::Loaded),
287 (Path::new("deps/dep-dir3/deps").into(), PathChange::Loaded),
288 (Path::new("deps/dep-dir3/src").into(), PathChange::Loaded)
289 ]
290 );
291
292 // Expand a subdirectory of one of the symlinked directories.
293 tree.read_with(cx, |tree, _| {
294 tree.as_local()
295 .unwrap()
296 .refresh_entries_for_paths(vec![Path::new("deps/dep-dir3/src").into()])
297 })
298 .recv()
299 .await;
300
301 // The expanded subdirectory's contents are loaded.
302 tree.read_with(cx, |tree, _| {
303 assert_eq!(
304 tree.entries(true, 0)
305 .map(|entry| (entry.path.as_ref(), entry.is_external))
306 .collect::<Vec<_>>(),
307 vec![
308 (Path::new(""), false),
309 (Path::new("deps"), false),
310 (Path::new("deps/dep-dir2"), true),
311 (Path::new("deps/dep-dir3"), true),
312 (Path::new("deps/dep-dir3/deps"), true),
313 (Path::new("deps/dep-dir3/src"), true),
314 (Path::new("deps/dep-dir3/src/e.rs"), true),
315 (Path::new("deps/dep-dir3/src/f.rs"), true),
316 (Path::new("src"), false),
317 (Path::new("src/a.rs"), false),
318 (Path::new("src/b.rs"), false),
319 ]
320 );
321 });
322
323 assert_eq!(
324 mem::take(&mut *tree_updates.lock()),
325 &[
326 (Path::new("deps/dep-dir3/src").into(), PathChange::Loaded),
327 (
328 Path::new("deps/dep-dir3/src/e.rs").into(),
329 PathChange::Loaded
330 ),
331 (
332 Path::new("deps/dep-dir3/src/f.rs").into(),
333 PathChange::Loaded
334 )
335 ]
336 );
337}
338
339#[cfg(target_os = "macos")]
340#[gpui::test]
341async fn test_renaming_case_only(cx: &mut TestAppContext) {
342 cx.executor().allow_parking();
343 init_test(cx);
344
345 const OLD_NAME: &str = "aaa.rs";
346 const NEW_NAME: &str = "AAA.rs";
347
348 let fs = Arc::new(RealFs::default());
349 let temp_root = temp_tree(json!({
350 OLD_NAME: "",
351 }));
352
353 let tree = Worktree::local(
354 temp_root.path(),
355 true,
356 fs.clone(),
357 Default::default(),
358 &mut cx.to_async(),
359 )
360 .await
361 .unwrap();
362
363 cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
364 .await;
365 tree.read_with(cx, |tree, _| {
366 assert_eq!(
367 tree.entries(true, 0)
368 .map(|entry| entry.path.as_ref())
369 .collect::<Vec<_>>(),
370 vec![Path::new(""), Path::new(OLD_NAME)]
371 );
372 });
373
374 fs.rename(
375 &temp_root.path().join(OLD_NAME),
376 &temp_root.path().join(NEW_NAME),
377 fs::RenameOptions {
378 overwrite: true,
379 ignore_if_exists: true,
380 },
381 )
382 .await
383 .unwrap();
384
385 tree.flush_fs_events(cx).await;
386
387 tree.read_with(cx, |tree, _| {
388 assert_eq!(
389 tree.entries(true, 0)
390 .map(|entry| entry.path.as_ref())
391 .collect::<Vec<_>>(),
392 vec![Path::new(""), Path::new(NEW_NAME)]
393 );
394 });
395}
396
397#[gpui::test]
398async fn test_open_gitignored_files(cx: &mut TestAppContext) {
399 init_test(cx);
400 let fs = FakeFs::new(cx.background_executor.clone());
401 fs.insert_tree(
402 "/root",
403 json!({
404 ".gitignore": "node_modules\n",
405 "one": {
406 "node_modules": {
407 "a": {
408 "a1.js": "a1",
409 "a2.js": "a2",
410 },
411 "b": {
412 "b1.js": "b1",
413 "b2.js": "b2",
414 },
415 "c": {
416 "c1.js": "c1",
417 "c2.js": "c2",
418 }
419 },
420 },
421 "two": {
422 "x.js": "",
423 "y.js": "",
424 },
425 }),
426 )
427 .await;
428
429 let tree = Worktree::local(
430 Path::new("/root"),
431 true,
432 fs.clone(),
433 Default::default(),
434 &mut cx.to_async(),
435 )
436 .await
437 .unwrap();
438
439 cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
440 .await;
441
442 tree.read_with(cx, |tree, _| {
443 assert_eq!(
444 tree.entries(true, 0)
445 .map(|entry| (entry.path.as_ref(), entry.is_ignored))
446 .collect::<Vec<_>>(),
447 vec![
448 (Path::new(""), false),
449 (Path::new(".gitignore"), false),
450 (Path::new("one"), false),
451 (Path::new("one/node_modules"), true),
452 (Path::new("two"), false),
453 (Path::new("two/x.js"), false),
454 (Path::new("two/y.js"), false),
455 ]
456 );
457 });
458
459 // Open a file that is nested inside of a gitignored directory that
460 // has not yet been expanded.
461 let prev_read_dir_count = fs.read_dir_call_count();
462 let loaded = tree
463 .update(cx, |tree, cx| {
464 tree.load_file("one/node_modules/b/b1.js".as_ref(), cx)
465 })
466 .await
467 .unwrap();
468
469 tree.read_with(cx, |tree, _| {
470 assert_eq!(
471 tree.entries(true, 0)
472 .map(|entry| (entry.path.as_ref(), entry.is_ignored))
473 .collect::<Vec<_>>(),
474 vec![
475 (Path::new(""), false),
476 (Path::new(".gitignore"), false),
477 (Path::new("one"), false),
478 (Path::new("one/node_modules"), true),
479 (Path::new("one/node_modules/a"), true),
480 (Path::new("one/node_modules/b"), true),
481 (Path::new("one/node_modules/b/b1.js"), true),
482 (Path::new("one/node_modules/b/b2.js"), true),
483 (Path::new("one/node_modules/c"), true),
484 (Path::new("two"), false),
485 (Path::new("two/x.js"), false),
486 (Path::new("two/y.js"), false),
487 ]
488 );
489
490 assert_eq!(
491 loaded.file.path.as_ref(),
492 Path::new("one/node_modules/b/b1.js")
493 );
494
495 // Only the newly-expanded directories are scanned.
496 assert_eq!(fs.read_dir_call_count() - prev_read_dir_count, 2);
497 });
498
499 // Open another file in a different subdirectory of the same
500 // gitignored directory.
501 let prev_read_dir_count = fs.read_dir_call_count();
502 let loaded = tree
503 .update(cx, |tree, cx| {
504 tree.load_file("one/node_modules/a/a2.js".as_ref(), cx)
505 })
506 .await
507 .unwrap();
508
509 tree.read_with(cx, |tree, _| {
510 assert_eq!(
511 tree.entries(true, 0)
512 .map(|entry| (entry.path.as_ref(), entry.is_ignored))
513 .collect::<Vec<_>>(),
514 vec![
515 (Path::new(""), false),
516 (Path::new(".gitignore"), false),
517 (Path::new("one"), false),
518 (Path::new("one/node_modules"), true),
519 (Path::new("one/node_modules/a"), true),
520 (Path::new("one/node_modules/a/a1.js"), true),
521 (Path::new("one/node_modules/a/a2.js"), true),
522 (Path::new("one/node_modules/b"), true),
523 (Path::new("one/node_modules/b/b1.js"), true),
524 (Path::new("one/node_modules/b/b2.js"), true),
525 (Path::new("one/node_modules/c"), true),
526 (Path::new("two"), false),
527 (Path::new("two/x.js"), false),
528 (Path::new("two/y.js"), false),
529 ]
530 );
531
532 assert_eq!(
533 loaded.file.path.as_ref(),
534 Path::new("one/node_modules/a/a2.js")
535 );
536
537 // Only the newly-expanded directory is scanned.
538 assert_eq!(fs.read_dir_call_count() - prev_read_dir_count, 1);
539 });
540
541 let path = PathBuf::from("/root/one/node_modules/c/lib");
542
543 // No work happens when files and directories change within an unloaded directory.
544 let prev_fs_call_count = fs.read_dir_call_count() + fs.metadata_call_count();
545 // When we open a directory, we check each ancestor whether it's a git
546 // repository. That means we have an fs.metadata call per ancestor that we
547 // need to subtract here.
548 let ancestors = path.ancestors().count();
549
550 fs.create_dir(path.as_ref()).await.unwrap();
551 cx.executor().run_until_parked();
552
553 assert_eq!(
554 fs.read_dir_call_count() + fs.metadata_call_count() - prev_fs_call_count - ancestors,
555 0
556 );
557}
558
559#[gpui::test]
560async fn test_dirs_no_longer_ignored(cx: &mut TestAppContext) {
561 init_test(cx);
562 let fs = FakeFs::new(cx.background_executor.clone());
563 fs.insert_tree(
564 "/root",
565 json!({
566 ".gitignore": "node_modules\n",
567 "a": {
568 "a.js": "",
569 },
570 "b": {
571 "b.js": "",
572 },
573 "node_modules": {
574 "c": {
575 "c.js": "",
576 },
577 "d": {
578 "d.js": "",
579 "e": {
580 "e1.js": "",
581 "e2.js": "",
582 },
583 "f": {
584 "f1.js": "",
585 "f2.js": "",
586 }
587 },
588 },
589 }),
590 )
591 .await;
592
593 let tree = Worktree::local(
594 Path::new("/root"),
595 true,
596 fs.clone(),
597 Default::default(),
598 &mut cx.to_async(),
599 )
600 .await
601 .unwrap();
602
603 cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
604 .await;
605
606 // Open a file within the gitignored directory, forcing some of its
607 // subdirectories to be read, but not all.
608 let read_dir_count_1 = fs.read_dir_call_count();
609 tree.read_with(cx, |tree, _| {
610 tree.as_local()
611 .unwrap()
612 .refresh_entries_for_paths(vec![Path::new("node_modules/d/d.js").into()])
613 })
614 .recv()
615 .await;
616
617 // Those subdirectories are now loaded.
618 tree.read_with(cx, |tree, _| {
619 assert_eq!(
620 tree.entries(true, 0)
621 .map(|e| (e.path.as_ref(), e.is_ignored))
622 .collect::<Vec<_>>(),
623 &[
624 (Path::new(""), false),
625 (Path::new(".gitignore"), false),
626 (Path::new("a"), false),
627 (Path::new("a/a.js"), false),
628 (Path::new("b"), false),
629 (Path::new("b/b.js"), false),
630 (Path::new("node_modules"), true),
631 (Path::new("node_modules/c"), true),
632 (Path::new("node_modules/d"), true),
633 (Path::new("node_modules/d/d.js"), true),
634 (Path::new("node_modules/d/e"), true),
635 (Path::new("node_modules/d/f"), true),
636 ]
637 );
638 });
639 let read_dir_count_2 = fs.read_dir_call_count();
640 assert_eq!(read_dir_count_2 - read_dir_count_1, 2);
641
642 // Update the gitignore so that node_modules is no longer ignored,
643 // but a subdirectory is ignored
644 fs.save("/root/.gitignore".as_ref(), &"e".into(), Default::default())
645 .await
646 .unwrap();
647 cx.executor().run_until_parked();
648
649 // All of the directories that are no longer ignored are now loaded.
650 tree.read_with(cx, |tree, _| {
651 assert_eq!(
652 tree.entries(true, 0)
653 .map(|e| (e.path.as_ref(), e.is_ignored))
654 .collect::<Vec<_>>(),
655 &[
656 (Path::new(""), false),
657 (Path::new(".gitignore"), false),
658 (Path::new("a"), false),
659 (Path::new("a/a.js"), false),
660 (Path::new("b"), false),
661 (Path::new("b/b.js"), false),
662 // This directory is no longer ignored
663 (Path::new("node_modules"), false),
664 (Path::new("node_modules/c"), false),
665 (Path::new("node_modules/c/c.js"), false),
666 (Path::new("node_modules/d"), false),
667 (Path::new("node_modules/d/d.js"), false),
668 // This subdirectory is now ignored
669 (Path::new("node_modules/d/e"), true),
670 (Path::new("node_modules/d/f"), false),
671 (Path::new("node_modules/d/f/f1.js"), false),
672 (Path::new("node_modules/d/f/f2.js"), false),
673 ]
674 );
675 });
676
677 // Each of the newly-loaded directories is scanned only once.
678 let read_dir_count_3 = fs.read_dir_call_count();
679 assert_eq!(read_dir_count_3 - read_dir_count_2, 2);
680}
681
682#[gpui::test(iterations = 10)]
683async fn test_rescan_with_gitignore(cx: &mut TestAppContext) {
684 init_test(cx);
685 cx.update(|cx| {
686 cx.update_global::<SettingsStore, _>(|store, cx| {
687 store.update_user_settings::<WorktreeSettings>(cx, |project_settings| {
688 project_settings.file_scan_exclusions = Some(Vec::new());
689 });
690 });
691 });
692 let fs = FakeFs::new(cx.background_executor.clone());
693 fs.insert_tree(
694 "/root",
695 json!({
696 ".gitignore": "ancestor-ignored-file1\nancestor-ignored-file2\n",
697 "tree": {
698 ".git": {},
699 ".gitignore": "ignored-dir\n",
700 "tracked-dir": {
701 "tracked-file1": "",
702 "ancestor-ignored-file1": "",
703 },
704 "ignored-dir": {
705 "ignored-file1": ""
706 }
707 }
708 }),
709 )
710 .await;
711
712 let tree = Worktree::local(
713 "/root/tree".as_ref(),
714 true,
715 fs.clone(),
716 Default::default(),
717 &mut cx.to_async(),
718 )
719 .await
720 .unwrap();
721 cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
722 .await;
723
724 tree.read_with(cx, |tree, _| {
725 tree.as_local()
726 .unwrap()
727 .refresh_entries_for_paths(vec![Path::new("ignored-dir").into()])
728 })
729 .recv()
730 .await;
731
732 cx.read(|cx| {
733 let tree = tree.read(cx);
734 assert_entry_git_state(tree, "tracked-dir/tracked-file1", None, false);
735 assert_entry_git_state(tree, "tracked-dir/ancestor-ignored-file1", None, false);
736 assert_entry_git_state(tree, "ignored-dir/ignored-file1", None, true);
737 });
738
739 fs.set_status_for_repo_via_working_copy_change(
740 Path::new("/root/tree/.git"),
741 &[(Path::new("tracked-dir/tracked-file2"), GitFileStatus::Added)],
742 );
743
744 fs.create_file(
745 "/root/tree/tracked-dir/tracked-file2".as_ref(),
746 Default::default(),
747 )
748 .await
749 .unwrap();
750 fs.create_file(
751 "/root/tree/tracked-dir/ancestor-ignored-file2".as_ref(),
752 Default::default(),
753 )
754 .await
755 .unwrap();
756 fs.create_file(
757 "/root/tree/ignored-dir/ignored-file2".as_ref(),
758 Default::default(),
759 )
760 .await
761 .unwrap();
762
763 cx.executor().run_until_parked();
764 cx.read(|cx| {
765 let tree = tree.read(cx);
766 assert_entry_git_state(
767 tree,
768 "tracked-dir/tracked-file2",
769 Some(GitFileStatus::Added),
770 false,
771 );
772 assert_entry_git_state(tree, "tracked-dir/ancestor-ignored-file2", None, false);
773 assert_entry_git_state(tree, "ignored-dir/ignored-file2", None, true);
774 assert!(tree.entry_for_path(".git").unwrap().is_ignored);
775 });
776}
777
778#[gpui::test]
779async fn test_update_gitignore(cx: &mut TestAppContext) {
780 init_test(cx);
781 let fs = FakeFs::new(cx.background_executor.clone());
782 fs.insert_tree(
783 "/root",
784 json!({
785 ".git": {},
786 ".gitignore": "*.txt\n",
787 "a.xml": "<a></a>",
788 "b.txt": "Some text"
789 }),
790 )
791 .await;
792
793 let tree = Worktree::local(
794 "/root".as_ref(),
795 true,
796 fs.clone(),
797 Default::default(),
798 &mut cx.to_async(),
799 )
800 .await
801 .unwrap();
802 cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
803 .await;
804
805 tree.read_with(cx, |tree, _| {
806 tree.as_local()
807 .unwrap()
808 .refresh_entries_for_paths(vec![Path::new("").into()])
809 })
810 .recv()
811 .await;
812
813 cx.read(|cx| {
814 let tree = tree.read(cx);
815 assert_entry_git_state(tree, "a.xml", None, false);
816 assert_entry_git_state(tree, "b.txt", None, true);
817 });
818
819 fs.atomic_write("/root/.gitignore".into(), "*.xml".into())
820 .await
821 .unwrap();
822
823 fs.set_status_for_repo_via_working_copy_change(
824 Path::new("/root/.git"),
825 &[(Path::new("b.txt"), GitFileStatus::Added)],
826 );
827
828 cx.executor().run_until_parked();
829 cx.read(|cx| {
830 let tree = tree.read(cx);
831 assert_entry_git_state(tree, "a.xml", None, true);
832 assert_entry_git_state(tree, "b.txt", Some(GitFileStatus::Added), false);
833 });
834}
835
836#[gpui::test]
837async fn test_write_file(cx: &mut TestAppContext) {
838 init_test(cx);
839 cx.executor().allow_parking();
840 let dir = temp_tree(json!({
841 ".git": {},
842 ".gitignore": "ignored-dir\n",
843 "tracked-dir": {},
844 "ignored-dir": {}
845 }));
846
847 let tree = Worktree::local(
848 dir.path(),
849 true,
850 Arc::new(RealFs::default()),
851 Default::default(),
852 &mut cx.to_async(),
853 )
854 .await
855 .unwrap();
856
857 #[cfg(not(target_os = "macos"))]
858 fs::fs_watcher::global(|_| {}).unwrap();
859
860 cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
861 .await;
862 tree.flush_fs_events(cx).await;
863
864 tree.update(cx, |tree, cx| {
865 tree.write_file(
866 Path::new("tracked-dir/file.txt"),
867 "hello".into(),
868 Default::default(),
869 cx,
870 )
871 })
872 .await
873 .unwrap();
874 tree.update(cx, |tree, cx| {
875 tree.write_file(
876 Path::new("ignored-dir/file.txt"),
877 "world".into(),
878 Default::default(),
879 cx,
880 )
881 })
882 .await
883 .unwrap();
884
885 tree.read_with(cx, |tree, _| {
886 let tracked = tree.entry_for_path("tracked-dir/file.txt").unwrap();
887 let ignored = tree.entry_for_path("ignored-dir/file.txt").unwrap();
888 assert!(!tracked.is_ignored);
889 assert!(ignored.is_ignored);
890 });
891}
892
893#[gpui::test]
894async fn test_file_scan_inclusions(cx: &mut TestAppContext) {
895 init_test(cx);
896 cx.executor().allow_parking();
897 let dir = temp_tree(json!({
898 ".gitignore": "**/target\n/node_modules\ntop_level.txt\n",
899 "target": {
900 "index": "blah2"
901 },
902 "node_modules": {
903 ".DS_Store": "",
904 "prettier": {
905 "package.json": "{}",
906 },
907 },
908 "src": {
909 ".DS_Store": "",
910 "foo": {
911 "foo.rs": "mod another;\n",
912 "another.rs": "// another",
913 },
914 "bar": {
915 "bar.rs": "// bar",
916 },
917 "lib.rs": "mod foo;\nmod bar;\n",
918 },
919 "top_level.txt": "top level file",
920 ".DS_Store": "",
921 }));
922 cx.update(|cx| {
923 cx.update_global::<SettingsStore, _>(|store, cx| {
924 store.update_user_settings::<WorktreeSettings>(cx, |project_settings| {
925 project_settings.file_scan_exclusions = Some(vec![]);
926 project_settings.file_scan_inclusions = Some(vec![
927 "node_modules/**/package.json".to_string(),
928 "**/.DS_Store".to_string(),
929 ]);
930 });
931 });
932 });
933
934 let tree = Worktree::local(
935 dir.path(),
936 true,
937 Arc::new(RealFs::default()),
938 Default::default(),
939 &mut cx.to_async(),
940 )
941 .await
942 .unwrap();
943 cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
944 .await;
945 tree.flush_fs_events(cx).await;
946 tree.read_with(cx, |tree, _| {
947 // Assert that file_scan_inclusions overrides file_scan_exclusions.
948 check_worktree_entries(
949 tree,
950 &[],
951 &["target", "node_modules"],
952 &["src/lib.rs", "src/bar/bar.rs", ".gitignore"],
953 &[
954 "node_modules/prettier/package.json",
955 ".DS_Store",
956 "node_modules/.DS_Store",
957 "src/.DS_Store",
958 ],
959 )
960 });
961}
962
963#[gpui::test]
964async fn test_file_scan_exclusions_overrules_inclusions(cx: &mut TestAppContext) {
965 init_test(cx);
966 cx.executor().allow_parking();
967 let dir = temp_tree(json!({
968 ".gitignore": "**/target\n/node_modules\n",
969 "target": {
970 "index": "blah2"
971 },
972 "node_modules": {
973 ".DS_Store": "",
974 "prettier": {
975 "package.json": "{}",
976 },
977 },
978 "src": {
979 ".DS_Store": "",
980 "foo": {
981 "foo.rs": "mod another;\n",
982 "another.rs": "// another",
983 },
984 },
985 ".DS_Store": "",
986 }));
987
988 cx.update(|cx| {
989 cx.update_global::<SettingsStore, _>(|store, cx| {
990 store.update_user_settings::<WorktreeSettings>(cx, |project_settings| {
991 project_settings.file_scan_exclusions = Some(vec!["**/.DS_Store".to_string()]);
992 project_settings.file_scan_inclusions = Some(vec!["**/.DS_Store".to_string()]);
993 });
994 });
995 });
996
997 let tree = Worktree::local(
998 dir.path(),
999 true,
1000 Arc::new(RealFs::default()),
1001 Default::default(),
1002 &mut cx.to_async(),
1003 )
1004 .await
1005 .unwrap();
1006 cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
1007 .await;
1008 tree.flush_fs_events(cx).await;
1009 tree.read_with(cx, |tree, _| {
1010 // Assert that file_scan_inclusions overrides file_scan_exclusions.
1011 check_worktree_entries(
1012 tree,
1013 &[".DS_Store, src/.DS_Store"],
1014 &["target", "node_modules"],
1015 &["src/foo/another.rs", "src/foo/foo.rs", ".gitignore"],
1016 &[],
1017 )
1018 });
1019}
1020
1021#[gpui::test]
1022async fn test_file_scan_inclusions_reindexes_on_setting_change(cx: &mut TestAppContext) {
1023 init_test(cx);
1024 cx.executor().allow_parking();
1025 let dir = temp_tree(json!({
1026 ".gitignore": "**/target\n/node_modules/\n",
1027 "target": {
1028 "index": "blah2"
1029 },
1030 "node_modules": {
1031 ".DS_Store": "",
1032 "prettier": {
1033 "package.json": "{}",
1034 },
1035 },
1036 "src": {
1037 ".DS_Store": "",
1038 "foo": {
1039 "foo.rs": "mod another;\n",
1040 "another.rs": "// another",
1041 },
1042 },
1043 ".DS_Store": "",
1044 }));
1045
1046 cx.update(|cx| {
1047 cx.update_global::<SettingsStore, _>(|store, cx| {
1048 store.update_user_settings::<WorktreeSettings>(cx, |project_settings| {
1049 project_settings.file_scan_exclusions = Some(vec![]);
1050 project_settings.file_scan_inclusions = Some(vec!["node_modules/**".to_string()]);
1051 });
1052 });
1053 });
1054 let tree = Worktree::local(
1055 dir.path(),
1056 true,
1057 Arc::new(RealFs::default()),
1058 Default::default(),
1059 &mut cx.to_async(),
1060 )
1061 .await
1062 .unwrap();
1063 cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
1064 .await;
1065 tree.flush_fs_events(cx).await;
1066
1067 tree.read_with(cx, |tree, _| {
1068 assert!(tree
1069 .entry_for_path("node_modules")
1070 .is_some_and(|f| f.is_always_included));
1071 assert!(tree
1072 .entry_for_path("node_modules/prettier/package.json")
1073 .is_some_and(|f| f.is_always_included));
1074 });
1075
1076 cx.update(|cx| {
1077 cx.update_global::<SettingsStore, _>(|store, cx| {
1078 store.update_user_settings::<WorktreeSettings>(cx, |project_settings| {
1079 project_settings.file_scan_exclusions = Some(vec![]);
1080 project_settings.file_scan_inclusions = Some(vec![]);
1081 });
1082 });
1083 });
1084 cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
1085 .await;
1086 tree.flush_fs_events(cx).await;
1087
1088 tree.read_with(cx, |tree, _| {
1089 assert!(tree
1090 .entry_for_path("node_modules")
1091 .is_some_and(|f| !f.is_always_included));
1092 assert!(tree
1093 .entry_for_path("node_modules/prettier/package.json")
1094 .is_some_and(|f| !f.is_always_included));
1095 });
1096}
1097
1098#[gpui::test]
1099async fn test_file_scan_exclusions(cx: &mut TestAppContext) {
1100 init_test(cx);
1101 cx.executor().allow_parking();
1102 let dir = temp_tree(json!({
1103 ".gitignore": "**/target\n/node_modules\n",
1104 "target": {
1105 "index": "blah2"
1106 },
1107 "node_modules": {
1108 ".DS_Store": "",
1109 "prettier": {
1110 "package.json": "{}",
1111 },
1112 },
1113 "src": {
1114 ".DS_Store": "",
1115 "foo": {
1116 "foo.rs": "mod another;\n",
1117 "another.rs": "// another",
1118 },
1119 "bar": {
1120 "bar.rs": "// bar",
1121 },
1122 "lib.rs": "mod foo;\nmod bar;\n",
1123 },
1124 ".DS_Store": "",
1125 }));
1126 cx.update(|cx| {
1127 cx.update_global::<SettingsStore, _>(|store, cx| {
1128 store.update_user_settings::<WorktreeSettings>(cx, |project_settings| {
1129 project_settings.file_scan_exclusions =
1130 Some(vec!["**/foo/**".to_string(), "**/.DS_Store".to_string()]);
1131 });
1132 });
1133 });
1134
1135 let tree = Worktree::local(
1136 dir.path(),
1137 true,
1138 Arc::new(RealFs::default()),
1139 Default::default(),
1140 &mut cx.to_async(),
1141 )
1142 .await
1143 .unwrap();
1144 cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
1145 .await;
1146 tree.flush_fs_events(cx).await;
1147 tree.read_with(cx, |tree, _| {
1148 check_worktree_entries(
1149 tree,
1150 &[
1151 "src/foo/foo.rs",
1152 "src/foo/another.rs",
1153 "node_modules/.DS_Store",
1154 "src/.DS_Store",
1155 ".DS_Store",
1156 ],
1157 &["target", "node_modules"],
1158 &["src/lib.rs", "src/bar/bar.rs", ".gitignore"],
1159 &[],
1160 )
1161 });
1162
1163 cx.update(|cx| {
1164 cx.update_global::<SettingsStore, _>(|store, cx| {
1165 store.update_user_settings::<WorktreeSettings>(cx, |project_settings| {
1166 project_settings.file_scan_exclusions =
1167 Some(vec!["**/node_modules/**".to_string()]);
1168 });
1169 });
1170 });
1171 tree.flush_fs_events(cx).await;
1172 cx.executor().run_until_parked();
1173 tree.read_with(cx, |tree, _| {
1174 check_worktree_entries(
1175 tree,
1176 &[
1177 "node_modules/prettier/package.json",
1178 "node_modules/.DS_Store",
1179 "node_modules",
1180 ],
1181 &["target"],
1182 &[
1183 ".gitignore",
1184 "src/lib.rs",
1185 "src/bar/bar.rs",
1186 "src/foo/foo.rs",
1187 "src/foo/another.rs",
1188 "src/.DS_Store",
1189 ".DS_Store",
1190 ],
1191 &[],
1192 )
1193 });
1194}
1195
1196#[gpui::test]
1197async fn test_fs_events_in_exclusions(cx: &mut TestAppContext) {
1198 init_test(cx);
1199 cx.executor().allow_parking();
1200 let dir = temp_tree(json!({
1201 ".git": {
1202 "HEAD": "ref: refs/heads/main\n",
1203 "foo": "bar",
1204 },
1205 ".gitignore": "**/target\n/node_modules\ntest_output\n",
1206 "target": {
1207 "index": "blah2"
1208 },
1209 "node_modules": {
1210 ".DS_Store": "",
1211 "prettier": {
1212 "package.json": "{}",
1213 },
1214 },
1215 "src": {
1216 ".DS_Store": "",
1217 "foo": {
1218 "foo.rs": "mod another;\n",
1219 "another.rs": "// another",
1220 },
1221 "bar": {
1222 "bar.rs": "// bar",
1223 },
1224 "lib.rs": "mod foo;\nmod bar;\n",
1225 },
1226 ".DS_Store": "",
1227 }));
1228 cx.update(|cx| {
1229 cx.update_global::<SettingsStore, _>(|store, cx| {
1230 store.update_user_settings::<WorktreeSettings>(cx, |project_settings| {
1231 project_settings.file_scan_exclusions = Some(vec![
1232 "**/.git".to_string(),
1233 "node_modules/".to_string(),
1234 "build_output".to_string(),
1235 ]);
1236 });
1237 });
1238 });
1239
1240 let tree = Worktree::local(
1241 dir.path(),
1242 true,
1243 Arc::new(RealFs::default()),
1244 Default::default(),
1245 &mut cx.to_async(),
1246 )
1247 .await
1248 .unwrap();
1249 cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
1250 .await;
1251 tree.flush_fs_events(cx).await;
1252 tree.read_with(cx, |tree, _| {
1253 check_worktree_entries(
1254 tree,
1255 &[
1256 ".git/HEAD",
1257 ".git/foo",
1258 "node_modules",
1259 "node_modules/.DS_Store",
1260 "node_modules/prettier",
1261 "node_modules/prettier/package.json",
1262 ],
1263 &["target"],
1264 &[
1265 ".DS_Store",
1266 "src/.DS_Store",
1267 "src/lib.rs",
1268 "src/foo/foo.rs",
1269 "src/foo/another.rs",
1270 "src/bar/bar.rs",
1271 ".gitignore",
1272 ],
1273 &[],
1274 )
1275 });
1276
1277 let new_excluded_dir = dir.path().join("build_output");
1278 let new_ignored_dir = dir.path().join("test_output");
1279 std::fs::create_dir_all(&new_excluded_dir)
1280 .unwrap_or_else(|e| panic!("Failed to create a {new_excluded_dir:?} directory: {e}"));
1281 std::fs::create_dir_all(&new_ignored_dir)
1282 .unwrap_or_else(|e| panic!("Failed to create a {new_ignored_dir:?} directory: {e}"));
1283 let node_modules_dir = dir.path().join("node_modules");
1284 let dot_git_dir = dir.path().join(".git");
1285 let src_dir = dir.path().join("src");
1286 for existing_dir in [&node_modules_dir, &dot_git_dir, &src_dir] {
1287 assert!(
1288 existing_dir.is_dir(),
1289 "Expect {existing_dir:?} to be present in the FS already"
1290 );
1291 }
1292
1293 for directory_for_new_file in [
1294 new_excluded_dir,
1295 new_ignored_dir,
1296 node_modules_dir,
1297 dot_git_dir,
1298 src_dir,
1299 ] {
1300 std::fs::write(directory_for_new_file.join("new_file"), "new file contents")
1301 .unwrap_or_else(|e| {
1302 panic!("Failed to create in {directory_for_new_file:?} a new file: {e}")
1303 });
1304 }
1305 tree.flush_fs_events(cx).await;
1306
1307 tree.read_with(cx, |tree, _| {
1308 check_worktree_entries(
1309 tree,
1310 &[
1311 ".git/HEAD",
1312 ".git/foo",
1313 ".git/new_file",
1314 "node_modules",
1315 "node_modules/.DS_Store",
1316 "node_modules/prettier",
1317 "node_modules/prettier/package.json",
1318 "node_modules/new_file",
1319 "build_output",
1320 "build_output/new_file",
1321 "test_output/new_file",
1322 ],
1323 &["target", "test_output"],
1324 &[
1325 ".DS_Store",
1326 "src/.DS_Store",
1327 "src/lib.rs",
1328 "src/foo/foo.rs",
1329 "src/foo/another.rs",
1330 "src/bar/bar.rs",
1331 "src/new_file",
1332 ".gitignore",
1333 ],
1334 &[],
1335 )
1336 });
1337}
1338
1339#[gpui::test]
1340async fn test_fs_events_in_dot_git_worktree(cx: &mut TestAppContext) {
1341 init_test(cx);
1342 cx.executor().allow_parking();
1343 let dir = temp_tree(json!({
1344 ".git": {
1345 "HEAD": "ref: refs/heads/main\n",
1346 "foo": "foo contents",
1347 },
1348 }));
1349 let dot_git_worktree_dir = dir.path().join(".git");
1350
1351 let tree = Worktree::local(
1352 dot_git_worktree_dir.clone(),
1353 true,
1354 Arc::new(RealFs::default()),
1355 Default::default(),
1356 &mut cx.to_async(),
1357 )
1358 .await
1359 .unwrap();
1360 cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
1361 .await;
1362 tree.flush_fs_events(cx).await;
1363 tree.read_with(cx, |tree, _| {
1364 check_worktree_entries(tree, &[], &["HEAD", "foo"], &[], &[])
1365 });
1366
1367 std::fs::write(dot_git_worktree_dir.join("new_file"), "new file contents")
1368 .unwrap_or_else(|e| panic!("Failed to create in {dot_git_worktree_dir:?} a new file: {e}"));
1369 tree.flush_fs_events(cx).await;
1370 tree.read_with(cx, |tree, _| {
1371 check_worktree_entries(tree, &[], &["HEAD", "foo", "new_file"], &[], &[])
1372 });
1373}
1374
1375#[gpui::test(iterations = 30)]
1376async fn test_create_directory_during_initial_scan(cx: &mut TestAppContext) {
1377 init_test(cx);
1378 let fs = FakeFs::new(cx.background_executor.clone());
1379 fs.insert_tree(
1380 "/root",
1381 json!({
1382 "b": {},
1383 "c": {},
1384 "d": {},
1385 }),
1386 )
1387 .await;
1388
1389 let tree = Worktree::local(
1390 "/root".as_ref(),
1391 true,
1392 fs,
1393 Default::default(),
1394 &mut cx.to_async(),
1395 )
1396 .await
1397 .unwrap();
1398
1399 let snapshot1 = tree.update(cx, |tree, cx| {
1400 let tree = tree.as_local_mut().unwrap();
1401 let snapshot = Arc::new(Mutex::new(tree.snapshot()));
1402 tree.observe_updates(0, cx, {
1403 let snapshot = snapshot.clone();
1404 let settings = tree.settings().clone();
1405 move |update| {
1406 snapshot
1407 .lock()
1408 .apply_remote_update(update, &settings.file_scan_inclusions)
1409 .unwrap();
1410 async { true }
1411 }
1412 });
1413 snapshot
1414 });
1415
1416 let entry = tree
1417 .update(cx, |tree, cx| {
1418 tree.as_local_mut()
1419 .unwrap()
1420 .create_entry("a/e".as_ref(), true, cx)
1421 })
1422 .await
1423 .unwrap()
1424 .to_included()
1425 .unwrap();
1426 assert!(entry.is_dir());
1427
1428 cx.executor().run_until_parked();
1429 tree.read_with(cx, |tree, _| {
1430 assert_eq!(tree.entry_for_path("a/e").unwrap().kind, EntryKind::Dir);
1431 });
1432
1433 let snapshot2 = tree.update(cx, |tree, _| tree.as_local().unwrap().snapshot());
1434 assert_eq!(
1435 snapshot1.lock().entries(true, 0).collect::<Vec<_>>(),
1436 snapshot2.entries(true, 0).collect::<Vec<_>>()
1437 );
1438}
1439
1440#[gpui::test]
1441async fn test_bump_mtime_of_git_repo_workdir(cx: &mut TestAppContext) {
1442 init_test(cx);
1443
1444 // Create a worktree with a git directory.
1445 let fs = FakeFs::new(cx.background_executor.clone());
1446 fs.insert_tree(
1447 "/root",
1448 json!({
1449 ".git": {},
1450 "a.txt": "",
1451 "b": {
1452 "c.txt": "",
1453 },
1454 }),
1455 )
1456 .await;
1457
1458 let tree = Worktree::local(
1459 "/root".as_ref(),
1460 true,
1461 fs.clone(),
1462 Default::default(),
1463 &mut cx.to_async(),
1464 )
1465 .await
1466 .unwrap();
1467 cx.executor().run_until_parked();
1468
1469 let (old_entry_ids, old_mtimes) = tree.read_with(cx, |tree, _| {
1470 (
1471 tree.entries(true, 0).map(|e| e.id).collect::<Vec<_>>(),
1472 tree.entries(true, 0).map(|e| e.mtime).collect::<Vec<_>>(),
1473 )
1474 });
1475
1476 // Regression test: after the directory is scanned, touch the git repo's
1477 // working directory, bumping its mtime. That directory keeps its project
1478 // entry id after the directories are re-scanned.
1479 fs.touch_path("/root").await;
1480 cx.executor().run_until_parked();
1481
1482 let (new_entry_ids, new_mtimes) = tree.read_with(cx, |tree, _| {
1483 (
1484 tree.entries(true, 0).map(|e| e.id).collect::<Vec<_>>(),
1485 tree.entries(true, 0).map(|e| e.mtime).collect::<Vec<_>>(),
1486 )
1487 });
1488 assert_eq!(new_entry_ids, old_entry_ids);
1489 assert_ne!(new_mtimes, old_mtimes);
1490
1491 // Regression test: changes to the git repository should still be
1492 // detected.
1493 fs.set_status_for_repo_via_git_operation(
1494 Path::new("/root/.git"),
1495 &[(Path::new("b/c.txt"), GitFileStatus::Modified)],
1496 );
1497 cx.executor().run_until_parked();
1498
1499 let snapshot = tree.read_with(cx, |tree, _| tree.snapshot());
1500
1501 check_git_statuses(
1502 &snapshot,
1503 &[
1504 (Path::new(""), Some(GitFileStatus::Modified)),
1505 (Path::new("a.txt"), None),
1506 (Path::new("b/c.txt"), Some(GitFileStatus::Modified)),
1507 ],
1508 );
1509}
1510
1511#[gpui::test]
1512async fn test_create_dir_all_on_create_entry(cx: &mut TestAppContext) {
1513 init_test(cx);
1514 cx.executor().allow_parking();
1515
1516 let fs_fake = FakeFs::new(cx.background_executor.clone());
1517 fs_fake
1518 .insert_tree(
1519 "/root",
1520 json!({
1521 "a": {},
1522 }),
1523 )
1524 .await;
1525
1526 let tree_fake = Worktree::local(
1527 "/root".as_ref(),
1528 true,
1529 fs_fake,
1530 Default::default(),
1531 &mut cx.to_async(),
1532 )
1533 .await
1534 .unwrap();
1535
1536 let entry = tree_fake
1537 .update(cx, |tree, cx| {
1538 tree.as_local_mut()
1539 .unwrap()
1540 .create_entry("a/b/c/d.txt".as_ref(), false, cx)
1541 })
1542 .await
1543 .unwrap()
1544 .to_included()
1545 .unwrap();
1546 assert!(entry.is_file());
1547
1548 cx.executor().run_until_parked();
1549 tree_fake.read_with(cx, |tree, _| {
1550 assert!(tree.entry_for_path("a/b/c/d.txt").unwrap().is_file());
1551 assert!(tree.entry_for_path("a/b/c/").unwrap().is_dir());
1552 assert!(tree.entry_for_path("a/b/").unwrap().is_dir());
1553 });
1554
1555 let fs_real = Arc::new(RealFs::default());
1556 let temp_root = temp_tree(json!({
1557 "a": {}
1558 }));
1559
1560 let tree_real = Worktree::local(
1561 temp_root.path(),
1562 true,
1563 fs_real,
1564 Default::default(),
1565 &mut cx.to_async(),
1566 )
1567 .await
1568 .unwrap();
1569
1570 let entry = tree_real
1571 .update(cx, |tree, cx| {
1572 tree.as_local_mut()
1573 .unwrap()
1574 .create_entry("a/b/c/d.txt".as_ref(), false, cx)
1575 })
1576 .await
1577 .unwrap()
1578 .to_included()
1579 .unwrap();
1580 assert!(entry.is_file());
1581
1582 cx.executor().run_until_parked();
1583 tree_real.read_with(cx, |tree, _| {
1584 assert!(tree.entry_for_path("a/b/c/d.txt").unwrap().is_file());
1585 assert!(tree.entry_for_path("a/b/c/").unwrap().is_dir());
1586 assert!(tree.entry_for_path("a/b/").unwrap().is_dir());
1587 });
1588
1589 // Test smallest change
1590 let entry = tree_real
1591 .update(cx, |tree, cx| {
1592 tree.as_local_mut()
1593 .unwrap()
1594 .create_entry("a/b/c/e.txt".as_ref(), false, cx)
1595 })
1596 .await
1597 .unwrap()
1598 .to_included()
1599 .unwrap();
1600 assert!(entry.is_file());
1601
1602 cx.executor().run_until_parked();
1603 tree_real.read_with(cx, |tree, _| {
1604 assert!(tree.entry_for_path("a/b/c/e.txt").unwrap().is_file());
1605 });
1606
1607 // Test largest change
1608 let entry = tree_real
1609 .update(cx, |tree, cx| {
1610 tree.as_local_mut()
1611 .unwrap()
1612 .create_entry("d/e/f/g.txt".as_ref(), false, cx)
1613 })
1614 .await
1615 .unwrap()
1616 .to_included()
1617 .unwrap();
1618 assert!(entry.is_file());
1619
1620 cx.executor().run_until_parked();
1621 tree_real.read_with(cx, |tree, _| {
1622 assert!(tree.entry_for_path("d/e/f/g.txt").unwrap().is_file());
1623 assert!(tree.entry_for_path("d/e/f").unwrap().is_dir());
1624 assert!(tree.entry_for_path("d/e/").unwrap().is_dir());
1625 assert!(tree.entry_for_path("d/").unwrap().is_dir());
1626 });
1627}
1628
1629#[gpui::test(iterations = 100)]
1630async fn test_random_worktree_operations_during_initial_scan(
1631 cx: &mut TestAppContext,
1632 mut rng: StdRng,
1633) {
1634 init_test(cx);
1635 let operations = env::var("OPERATIONS")
1636 .map(|o| o.parse().unwrap())
1637 .unwrap_or(5);
1638 let initial_entries = env::var("INITIAL_ENTRIES")
1639 .map(|o| o.parse().unwrap())
1640 .unwrap_or(20);
1641
1642 let root_dir = Path::new("/test");
1643 let fs = FakeFs::new(cx.background_executor.clone()) as Arc<dyn Fs>;
1644 fs.as_fake().insert_tree(root_dir, json!({})).await;
1645 for _ in 0..initial_entries {
1646 randomly_mutate_fs(&fs, root_dir, 1.0, &mut rng).await;
1647 }
1648 log::info!("generated initial tree");
1649
1650 let worktree = Worktree::local(
1651 root_dir,
1652 true,
1653 fs.clone(),
1654 Default::default(),
1655 &mut cx.to_async(),
1656 )
1657 .await
1658 .unwrap();
1659
1660 let mut snapshots = vec![worktree.read_with(cx, |tree, _| tree.as_local().unwrap().snapshot())];
1661 let updates = Arc::new(Mutex::new(Vec::new()));
1662 worktree.update(cx, |tree, cx| {
1663 check_worktree_change_events(tree, cx);
1664
1665 tree.as_local_mut().unwrap().observe_updates(0, cx, {
1666 let updates = updates.clone();
1667 move |update| {
1668 updates.lock().push(update);
1669 async { true }
1670 }
1671 });
1672 });
1673
1674 for _ in 0..operations {
1675 worktree
1676 .update(cx, |worktree, cx| {
1677 randomly_mutate_worktree(worktree, &mut rng, cx)
1678 })
1679 .await
1680 .log_err();
1681 worktree.read_with(cx, |tree, _| {
1682 tree.as_local().unwrap().snapshot().check_invariants(true)
1683 });
1684
1685 if rng.gen_bool(0.6) {
1686 snapshots.push(worktree.read_with(cx, |tree, _| tree.as_local().unwrap().snapshot()));
1687 }
1688 }
1689
1690 worktree
1691 .update(cx, |tree, _| tree.as_local_mut().unwrap().scan_complete())
1692 .await;
1693
1694 cx.executor().run_until_parked();
1695
1696 let final_snapshot = worktree.read_with(cx, |tree, _| {
1697 let tree = tree.as_local().unwrap();
1698 let snapshot = tree.snapshot();
1699 snapshot.check_invariants(true);
1700 snapshot
1701 });
1702
1703 let settings = worktree.read_with(cx, |tree, _| tree.as_local().unwrap().settings());
1704
1705 for (i, snapshot) in snapshots.into_iter().enumerate().rev() {
1706 let mut updated_snapshot = snapshot.clone();
1707 for update in updates.lock().iter() {
1708 if update.scan_id >= updated_snapshot.scan_id() as u64 {
1709 updated_snapshot
1710 .apply_remote_update(update.clone(), &settings.file_scan_inclusions)
1711 .unwrap();
1712 }
1713 }
1714
1715 assert_eq!(
1716 updated_snapshot.entries(true, 0).collect::<Vec<_>>(),
1717 final_snapshot.entries(true, 0).collect::<Vec<_>>(),
1718 "wrong updates after snapshot {i}: {snapshot:#?} {updates:#?}",
1719 );
1720 }
1721}
1722
1723#[gpui::test(iterations = 100)]
1724async fn test_random_worktree_changes(cx: &mut TestAppContext, mut rng: StdRng) {
1725 init_test(cx);
1726 let operations = env::var("OPERATIONS")
1727 .map(|o| o.parse().unwrap())
1728 .unwrap_or(40);
1729 let initial_entries = env::var("INITIAL_ENTRIES")
1730 .map(|o| o.parse().unwrap())
1731 .unwrap_or(20);
1732
1733 let root_dir = Path::new("/test");
1734 let fs = FakeFs::new(cx.background_executor.clone()) as Arc<dyn Fs>;
1735 fs.as_fake().insert_tree(root_dir, json!({})).await;
1736 for _ in 0..initial_entries {
1737 randomly_mutate_fs(&fs, root_dir, 1.0, &mut rng).await;
1738 }
1739 log::info!("generated initial tree");
1740
1741 let worktree = Worktree::local(
1742 root_dir,
1743 true,
1744 fs.clone(),
1745 Default::default(),
1746 &mut cx.to_async(),
1747 )
1748 .await
1749 .unwrap();
1750
1751 let updates = Arc::new(Mutex::new(Vec::new()));
1752 worktree.update(cx, |tree, cx| {
1753 check_worktree_change_events(tree, cx);
1754
1755 tree.as_local_mut().unwrap().observe_updates(0, cx, {
1756 let updates = updates.clone();
1757 move |update| {
1758 updates.lock().push(update);
1759 async { true }
1760 }
1761 });
1762 });
1763
1764 worktree
1765 .update(cx, |tree, _| tree.as_local_mut().unwrap().scan_complete())
1766 .await;
1767
1768 fs.as_fake().pause_events();
1769 let mut snapshots = Vec::new();
1770 let mut mutations_len = operations;
1771 while mutations_len > 1 {
1772 if rng.gen_bool(0.2) {
1773 worktree
1774 .update(cx, |worktree, cx| {
1775 randomly_mutate_worktree(worktree, &mut rng, cx)
1776 })
1777 .await
1778 .log_err();
1779 } else {
1780 randomly_mutate_fs(&fs, root_dir, 1.0, &mut rng).await;
1781 }
1782
1783 let buffered_event_count = fs.as_fake().buffered_event_count();
1784 if buffered_event_count > 0 && rng.gen_bool(0.3) {
1785 let len = rng.gen_range(0..=buffered_event_count);
1786 log::info!("flushing {} events", len);
1787 fs.as_fake().flush_events(len);
1788 } else {
1789 randomly_mutate_fs(&fs, root_dir, 0.6, &mut rng).await;
1790 mutations_len -= 1;
1791 }
1792
1793 cx.executor().run_until_parked();
1794 if rng.gen_bool(0.2) {
1795 log::info!("storing snapshot {}", snapshots.len());
1796 let snapshot = worktree.read_with(cx, |tree, _| tree.as_local().unwrap().snapshot());
1797 snapshots.push(snapshot);
1798 }
1799 }
1800
1801 log::info!("quiescing");
1802 fs.as_fake().flush_events(usize::MAX);
1803 cx.executor().run_until_parked();
1804
1805 let snapshot = worktree.read_with(cx, |tree, _| tree.as_local().unwrap().snapshot());
1806 snapshot.check_invariants(true);
1807 let expanded_paths = snapshot
1808 .expanded_entries()
1809 .map(|e| e.path.clone())
1810 .collect::<Vec<_>>();
1811
1812 {
1813 let new_worktree = Worktree::local(
1814 root_dir,
1815 true,
1816 fs.clone(),
1817 Default::default(),
1818 &mut cx.to_async(),
1819 )
1820 .await
1821 .unwrap();
1822 new_worktree
1823 .update(cx, |tree, _| tree.as_local_mut().unwrap().scan_complete())
1824 .await;
1825 new_worktree
1826 .update(cx, |tree, _| {
1827 tree.as_local_mut()
1828 .unwrap()
1829 .refresh_entries_for_paths(expanded_paths)
1830 })
1831 .recv()
1832 .await;
1833 let new_snapshot =
1834 new_worktree.read_with(cx, |tree, _| tree.as_local().unwrap().snapshot());
1835 assert_eq!(
1836 snapshot.entries_without_ids(true),
1837 new_snapshot.entries_without_ids(true)
1838 );
1839 }
1840
1841 let settings = worktree.read_with(cx, |tree, _| tree.as_local().unwrap().settings());
1842
1843 for (i, mut prev_snapshot) in snapshots.into_iter().enumerate().rev() {
1844 for update in updates.lock().iter() {
1845 if update.scan_id >= prev_snapshot.scan_id() as u64 {
1846 prev_snapshot
1847 .apply_remote_update(update.clone(), &settings.file_scan_inclusions)
1848 .unwrap();
1849 }
1850 }
1851
1852 assert_eq!(
1853 prev_snapshot
1854 .entries(true, 0)
1855 .map(ignore_pending_dir)
1856 .collect::<Vec<_>>(),
1857 snapshot
1858 .entries(true, 0)
1859 .map(ignore_pending_dir)
1860 .collect::<Vec<_>>(),
1861 "wrong updates after snapshot {i}: {updates:#?}",
1862 );
1863 }
1864
1865 fn ignore_pending_dir(entry: &Entry) -> Entry {
1866 let mut entry = entry.clone();
1867 if entry.kind.is_dir() {
1868 entry.kind = EntryKind::Dir
1869 }
1870 entry
1871 }
1872}
1873
1874// The worktree's `UpdatedEntries` event can be used to follow along with
1875// all changes to the worktree's snapshot.
1876fn check_worktree_change_events(tree: &mut Worktree, cx: &mut ModelContext<Worktree>) {
1877 let mut entries = tree.entries(true, 0).cloned().collect::<Vec<_>>();
1878 cx.subscribe(&cx.handle(), move |tree, _, event, _| {
1879 if let Event::UpdatedEntries(changes) = event {
1880 for (path, _, change_type) in changes.iter() {
1881 let entry = tree.entry_for_path(path).cloned();
1882 let ix = match entries.binary_search_by_key(&path, |e| &e.path) {
1883 Ok(ix) | Err(ix) => ix,
1884 };
1885 match change_type {
1886 PathChange::Added => entries.insert(ix, entry.unwrap()),
1887 PathChange::Removed => drop(entries.remove(ix)),
1888 PathChange::Updated => {
1889 let entry = entry.unwrap();
1890 let existing_entry = entries.get_mut(ix).unwrap();
1891 assert_eq!(existing_entry.path, entry.path);
1892 *existing_entry = entry;
1893 }
1894 PathChange::AddedOrUpdated | PathChange::Loaded => {
1895 let entry = entry.unwrap();
1896 if entries.get(ix).map(|e| &e.path) == Some(&entry.path) {
1897 *entries.get_mut(ix).unwrap() = entry;
1898 } else {
1899 entries.insert(ix, entry);
1900 }
1901 }
1902 }
1903 }
1904
1905 let new_entries = tree.entries(true, 0).cloned().collect::<Vec<_>>();
1906 assert_eq!(entries, new_entries, "incorrect changes: {:?}", changes);
1907 }
1908 })
1909 .detach();
1910}
1911
1912fn randomly_mutate_worktree(
1913 worktree: &mut Worktree,
1914 rng: &mut impl Rng,
1915 cx: &mut ModelContext<Worktree>,
1916) -> Task<Result<()>> {
1917 log::info!("mutating worktree");
1918 let worktree = worktree.as_local_mut().unwrap();
1919 let snapshot = worktree.snapshot();
1920 let entry = snapshot.entries(false, 0).choose(rng).unwrap();
1921
1922 match rng.gen_range(0_u32..100) {
1923 0..=33 if entry.path.as_ref() != Path::new("") => {
1924 log::info!("deleting entry {:?} ({})", entry.path, entry.id.0);
1925 worktree.delete_entry(entry.id, false, cx).unwrap()
1926 }
1927 ..=66 if entry.path.as_ref() != Path::new("") => {
1928 let other_entry = snapshot.entries(false, 0).choose(rng).unwrap();
1929 let new_parent_path = if other_entry.is_dir() {
1930 other_entry.path.clone()
1931 } else {
1932 other_entry.path.parent().unwrap().into()
1933 };
1934 let mut new_path = new_parent_path.join(random_filename(rng));
1935 if new_path.starts_with(&entry.path) {
1936 new_path = random_filename(rng).into();
1937 }
1938
1939 log::info!(
1940 "renaming entry {:?} ({}) to {:?}",
1941 entry.path,
1942 entry.id.0,
1943 new_path
1944 );
1945 let task = worktree.rename_entry(entry.id, new_path, cx);
1946 cx.background_executor().spawn(async move {
1947 task.await?.to_included().unwrap();
1948 Ok(())
1949 })
1950 }
1951 _ => {
1952 if entry.is_dir() {
1953 let child_path = entry.path.join(random_filename(rng));
1954 let is_dir = rng.gen_bool(0.3);
1955 log::info!(
1956 "creating {} at {:?}",
1957 if is_dir { "dir" } else { "file" },
1958 child_path,
1959 );
1960 let task = worktree.create_entry(child_path, is_dir, cx);
1961 cx.background_executor().spawn(async move {
1962 task.await?;
1963 Ok(())
1964 })
1965 } else {
1966 log::info!("overwriting file {:?} ({})", entry.path, entry.id.0);
1967 let task =
1968 worktree.write_file(entry.path.clone(), "".into(), Default::default(), cx);
1969 cx.background_executor().spawn(async move {
1970 task.await?;
1971 Ok(())
1972 })
1973 }
1974 }
1975 }
1976}
1977
1978async fn randomly_mutate_fs(
1979 fs: &Arc<dyn Fs>,
1980 root_path: &Path,
1981 insertion_probability: f64,
1982 rng: &mut impl Rng,
1983) {
1984 log::info!("mutating fs");
1985 let mut files = Vec::new();
1986 let mut dirs = Vec::new();
1987 for path in fs.as_fake().paths(false) {
1988 if path.starts_with(root_path) {
1989 if fs.is_file(&path).await {
1990 files.push(path);
1991 } else {
1992 dirs.push(path);
1993 }
1994 }
1995 }
1996
1997 if (files.is_empty() && dirs.len() == 1) || rng.gen_bool(insertion_probability) {
1998 let path = dirs.choose(rng).unwrap();
1999 let new_path = path.join(random_filename(rng));
2000
2001 if rng.gen() {
2002 log::info!(
2003 "creating dir {:?}",
2004 new_path.strip_prefix(root_path).unwrap()
2005 );
2006 fs.create_dir(&new_path).await.unwrap();
2007 } else {
2008 log::info!(
2009 "creating file {:?}",
2010 new_path.strip_prefix(root_path).unwrap()
2011 );
2012 fs.create_file(&new_path, Default::default()).await.unwrap();
2013 }
2014 } else if rng.gen_bool(0.05) {
2015 let ignore_dir_path = dirs.choose(rng).unwrap();
2016 let ignore_path = ignore_dir_path.join(*GITIGNORE);
2017
2018 let subdirs = dirs
2019 .iter()
2020 .filter(|d| d.starts_with(ignore_dir_path))
2021 .cloned()
2022 .collect::<Vec<_>>();
2023 let subfiles = files
2024 .iter()
2025 .filter(|d| d.starts_with(ignore_dir_path))
2026 .cloned()
2027 .collect::<Vec<_>>();
2028 let files_to_ignore = {
2029 let len = rng.gen_range(0..=subfiles.len());
2030 subfiles.choose_multiple(rng, len)
2031 };
2032 let dirs_to_ignore = {
2033 let len = rng.gen_range(0..subdirs.len());
2034 subdirs.choose_multiple(rng, len)
2035 };
2036
2037 let mut ignore_contents = String::new();
2038 for path_to_ignore in files_to_ignore.chain(dirs_to_ignore) {
2039 writeln!(
2040 ignore_contents,
2041 "{}",
2042 path_to_ignore
2043 .strip_prefix(ignore_dir_path)
2044 .unwrap()
2045 .to_str()
2046 .unwrap()
2047 )
2048 .unwrap();
2049 }
2050 log::info!(
2051 "creating gitignore {:?} with contents:\n{}",
2052 ignore_path.strip_prefix(root_path).unwrap(),
2053 ignore_contents
2054 );
2055 fs.save(
2056 &ignore_path,
2057 &ignore_contents.as_str().into(),
2058 Default::default(),
2059 )
2060 .await
2061 .unwrap();
2062 } else {
2063 let old_path = {
2064 let file_path = files.choose(rng);
2065 let dir_path = dirs[1..].choose(rng);
2066 file_path.into_iter().chain(dir_path).choose(rng).unwrap()
2067 };
2068
2069 let is_rename = rng.gen();
2070 if is_rename {
2071 let new_path_parent = dirs
2072 .iter()
2073 .filter(|d| !d.starts_with(old_path))
2074 .choose(rng)
2075 .unwrap();
2076
2077 let overwrite_existing_dir =
2078 !old_path.starts_with(new_path_parent) && rng.gen_bool(0.3);
2079 let new_path = if overwrite_existing_dir {
2080 fs.remove_dir(
2081 new_path_parent,
2082 RemoveOptions {
2083 recursive: true,
2084 ignore_if_not_exists: true,
2085 },
2086 )
2087 .await
2088 .unwrap();
2089 new_path_parent.to_path_buf()
2090 } else {
2091 new_path_parent.join(random_filename(rng))
2092 };
2093
2094 log::info!(
2095 "renaming {:?} to {}{:?}",
2096 old_path.strip_prefix(root_path).unwrap(),
2097 if overwrite_existing_dir {
2098 "overwrite "
2099 } else {
2100 ""
2101 },
2102 new_path.strip_prefix(root_path).unwrap()
2103 );
2104 fs.rename(
2105 old_path,
2106 &new_path,
2107 fs::RenameOptions {
2108 overwrite: true,
2109 ignore_if_exists: true,
2110 },
2111 )
2112 .await
2113 .unwrap();
2114 } else if fs.is_file(old_path).await {
2115 log::info!(
2116 "deleting file {:?}",
2117 old_path.strip_prefix(root_path).unwrap()
2118 );
2119 fs.remove_file(old_path, Default::default()).await.unwrap();
2120 } else {
2121 log::info!(
2122 "deleting dir {:?}",
2123 old_path.strip_prefix(root_path).unwrap()
2124 );
2125 fs.remove_dir(
2126 old_path,
2127 RemoveOptions {
2128 recursive: true,
2129 ignore_if_not_exists: true,
2130 },
2131 )
2132 .await
2133 .unwrap();
2134 }
2135 }
2136}
2137
2138fn random_filename(rng: &mut impl Rng) -> String {
2139 (0..6)
2140 .map(|_| rng.sample(rand::distributions::Alphanumeric))
2141 .map(char::from)
2142 .collect()
2143}
2144
2145#[gpui::test]
2146async fn test_rename_work_directory(cx: &mut TestAppContext) {
2147 init_test(cx);
2148 cx.executor().allow_parking();
2149 let root = temp_tree(json!({
2150 "projects": {
2151 "project1": {
2152 "a": "",
2153 "b": "",
2154 }
2155 },
2156
2157 }));
2158 let root_path = root.path();
2159
2160 let tree = Worktree::local(
2161 root_path,
2162 true,
2163 Arc::new(RealFs::default()),
2164 Default::default(),
2165 &mut cx.to_async(),
2166 )
2167 .await
2168 .unwrap();
2169
2170 let repo = git_init(&root_path.join("projects/project1"));
2171 git_add("a", &repo);
2172 git_commit("init", &repo);
2173 std::fs::write(root_path.join("projects/project1/a"), "aa").ok();
2174
2175 cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
2176 .await;
2177
2178 tree.flush_fs_events(cx).await;
2179
2180 cx.read(|cx| {
2181 let tree = tree.read(cx);
2182 let repo = tree.repositories().next().unwrap();
2183 assert_eq!(repo.path.as_ref(), Path::new("projects/project1"));
2184 assert_eq!(
2185 tree.status_for_file(Path::new("projects/project1/a")),
2186 Some(GitFileStatus::Modified)
2187 );
2188 assert_eq!(
2189 tree.status_for_file(Path::new("projects/project1/b")),
2190 Some(GitFileStatus::Untracked)
2191 );
2192 });
2193
2194 std::fs::rename(
2195 root_path.join("projects/project1"),
2196 root_path.join("projects/project2"),
2197 )
2198 .ok();
2199 tree.flush_fs_events(cx).await;
2200
2201 cx.read(|cx| {
2202 let tree = tree.read(cx);
2203 let repo = tree.repositories().next().unwrap();
2204 assert_eq!(repo.path.as_ref(), Path::new("projects/project2"));
2205 assert_eq!(
2206 tree.status_for_file(Path::new("projects/project2/a")),
2207 Some(GitFileStatus::Modified)
2208 );
2209 assert_eq!(
2210 tree.status_for_file(Path::new("projects/project2/b")),
2211 Some(GitFileStatus::Untracked)
2212 );
2213 });
2214}
2215
2216#[gpui::test]
2217async fn test_git_repository_for_path(cx: &mut TestAppContext) {
2218 init_test(cx);
2219 cx.executor().allow_parking();
2220 let root = temp_tree(json!({
2221 "c.txt": "",
2222 "dir1": {
2223 ".git": {},
2224 "deps": {
2225 "dep1": {
2226 ".git": {},
2227 "src": {
2228 "a.txt": ""
2229 }
2230 }
2231 },
2232 "src": {
2233 "b.txt": ""
2234 }
2235 },
2236 }));
2237
2238 let tree = Worktree::local(
2239 root.path(),
2240 true,
2241 Arc::new(RealFs::default()),
2242 Default::default(),
2243 &mut cx.to_async(),
2244 )
2245 .await
2246 .unwrap();
2247
2248 cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
2249 .await;
2250 tree.flush_fs_events(cx).await;
2251
2252 tree.read_with(cx, |tree, _cx| {
2253 let tree = tree.as_local().unwrap();
2254
2255 assert!(tree.repository_for_path("c.txt".as_ref()).is_none());
2256
2257 let repo = tree.repository_for_path("dir1/src/b.txt".as_ref()).unwrap();
2258 assert_eq!(repo.path.as_ref(), Path::new("dir1"));
2259
2260 let repo = tree
2261 .repository_for_path("dir1/deps/dep1/src/a.txt".as_ref())
2262 .unwrap();
2263 assert_eq!(repo.path.as_ref(), Path::new("dir1/deps/dep1"));
2264
2265 let entries = tree.files(false, 0);
2266
2267 let paths_with_repos = tree
2268 .entries_with_repositories(entries)
2269 .map(|(entry, repo)| {
2270 (
2271 entry.path.as_ref(),
2272 repo.map(|repo| repo.path.to_path_buf()),
2273 )
2274 })
2275 .collect::<Vec<_>>();
2276
2277 assert_eq!(
2278 paths_with_repos,
2279 &[
2280 (Path::new("c.txt"), None),
2281 (
2282 Path::new("dir1/deps/dep1/src/a.txt"),
2283 Some(Path::new("dir1/deps/dep1").into())
2284 ),
2285 (Path::new("dir1/src/b.txt"), Some(Path::new("dir1").into())),
2286 ]
2287 );
2288 });
2289
2290 let repo_update_events = Arc::new(Mutex::new(vec![]));
2291 tree.update(cx, |_, cx| {
2292 let repo_update_events = repo_update_events.clone();
2293 cx.subscribe(&tree, move |_, _, event, _| {
2294 if let Event::UpdatedGitRepositories(update) = event {
2295 repo_update_events.lock().push(update.clone());
2296 }
2297 })
2298 .detach();
2299 });
2300
2301 std::fs::write(root.path().join("dir1/.git/random_new_file"), "hello").unwrap();
2302 tree.flush_fs_events(cx).await;
2303
2304 assert_eq!(
2305 repo_update_events.lock()[0]
2306 .iter()
2307 .map(|e| e.0.clone())
2308 .collect::<Vec<Arc<Path>>>(),
2309 vec![Path::new("dir1").into()]
2310 );
2311
2312 std::fs::remove_dir_all(root.path().join("dir1/.git")).unwrap();
2313 tree.flush_fs_events(cx).await;
2314
2315 tree.read_with(cx, |tree, _cx| {
2316 let tree = tree.as_local().unwrap();
2317
2318 assert!(tree
2319 .repository_for_path("dir1/src/b.txt".as_ref())
2320 .is_none());
2321 });
2322}
2323
2324#[gpui::test]
2325async fn test_file_status(cx: &mut TestAppContext) {
2326 init_test(cx);
2327 cx.executor().allow_parking();
2328 const IGNORE_RULE: &str = "**/target";
2329
2330 let root = temp_tree(json!({
2331 "project": {
2332 "a.txt": "a",
2333 "b.txt": "bb",
2334 "c": {
2335 "d": {
2336 "e.txt": "eee"
2337 }
2338 },
2339 "f.txt": "ffff",
2340 "target": {
2341 "build_file": "???"
2342 },
2343 ".gitignore": IGNORE_RULE
2344 },
2345
2346 }));
2347
2348 const A_TXT: &str = "a.txt";
2349 const B_TXT: &str = "b.txt";
2350 const E_TXT: &str = "c/d/e.txt";
2351 const F_TXT: &str = "f.txt";
2352 const DOTGITIGNORE: &str = ".gitignore";
2353 const BUILD_FILE: &str = "target/build_file";
2354 let project_path = Path::new("project");
2355
2356 // Set up git repository before creating the worktree.
2357 let work_dir = root.path().join("project");
2358 let mut repo = git_init(work_dir.as_path());
2359 repo.add_ignore_rule(IGNORE_RULE).unwrap();
2360 git_add(A_TXT, &repo);
2361 git_add(E_TXT, &repo);
2362 git_add(DOTGITIGNORE, &repo);
2363 git_commit("Initial commit", &repo);
2364
2365 let tree = Worktree::local(
2366 root.path(),
2367 true,
2368 Arc::new(RealFs::default()),
2369 Default::default(),
2370 &mut cx.to_async(),
2371 )
2372 .await
2373 .unwrap();
2374
2375 tree.flush_fs_events(cx).await;
2376 cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
2377 .await;
2378 cx.executor().run_until_parked();
2379
2380 // Check that the right git state is observed on startup
2381 tree.read_with(cx, |tree, _cx| {
2382 let snapshot = tree.snapshot();
2383 assert_eq!(snapshot.repositories().count(), 1);
2384 let repo_entry = snapshot.repositories().next().unwrap();
2385 assert_eq!(repo_entry.path.as_ref(), Path::new("project"));
2386 assert!(repo_entry.location_in_repo.is_none());
2387
2388 assert_eq!(
2389 snapshot.status_for_file(project_path.join(B_TXT)),
2390 Some(GitFileStatus::Untracked)
2391 );
2392 assert_eq!(
2393 snapshot.status_for_file(project_path.join(F_TXT)),
2394 Some(GitFileStatus::Untracked)
2395 );
2396 });
2397
2398 // Modify a file in the working copy.
2399 std::fs::write(work_dir.join(A_TXT), "aa").unwrap();
2400 tree.flush_fs_events(cx).await;
2401 cx.executor().run_until_parked();
2402
2403 // The worktree detects that the file's git status has changed.
2404 tree.read_with(cx, |tree, _cx| {
2405 let snapshot = tree.snapshot();
2406 assert_eq!(
2407 snapshot.status_for_file(project_path.join(A_TXT)),
2408 Some(GitFileStatus::Modified)
2409 );
2410 });
2411
2412 // Create a commit in the git repository.
2413 git_add(A_TXT, &repo);
2414 git_add(B_TXT, &repo);
2415 git_commit("Committing modified and added", &repo);
2416 tree.flush_fs_events(cx).await;
2417 cx.executor().run_until_parked();
2418
2419 // The worktree detects that the files' git status have changed.
2420 tree.read_with(cx, |tree, _cx| {
2421 let snapshot = tree.snapshot();
2422 assert_eq!(
2423 snapshot.status_for_file(project_path.join(F_TXT)),
2424 Some(GitFileStatus::Untracked)
2425 );
2426 assert_eq!(snapshot.status_for_file(project_path.join(B_TXT)), None);
2427 assert_eq!(snapshot.status_for_file(project_path.join(A_TXT)), None);
2428 });
2429
2430 // Modify files in the working copy and perform git operations on other files.
2431 git_reset(0, &repo);
2432 git_remove_index(Path::new(B_TXT), &repo);
2433 git_stash(&mut repo);
2434 std::fs::write(work_dir.join(E_TXT), "eeee").unwrap();
2435 std::fs::write(work_dir.join(BUILD_FILE), "this should be ignored").unwrap();
2436 tree.flush_fs_events(cx).await;
2437 cx.executor().run_until_parked();
2438
2439 // Check that more complex repo changes are tracked
2440 tree.read_with(cx, |tree, _cx| {
2441 let snapshot = tree.snapshot();
2442
2443 assert_eq!(snapshot.status_for_file(project_path.join(A_TXT)), None);
2444 assert_eq!(
2445 snapshot.status_for_file(project_path.join(B_TXT)),
2446 Some(GitFileStatus::Untracked)
2447 );
2448 assert_eq!(
2449 snapshot.status_for_file(project_path.join(E_TXT)),
2450 Some(GitFileStatus::Modified)
2451 );
2452 });
2453
2454 std::fs::remove_file(work_dir.join(B_TXT)).unwrap();
2455 std::fs::remove_dir_all(work_dir.join("c")).unwrap();
2456 std::fs::write(
2457 work_dir.join(DOTGITIGNORE),
2458 [IGNORE_RULE, "f.txt"].join("\n"),
2459 )
2460 .unwrap();
2461
2462 git_add(Path::new(DOTGITIGNORE), &repo);
2463 git_commit("Committing modified git ignore", &repo);
2464
2465 tree.flush_fs_events(cx).await;
2466 cx.executor().run_until_parked();
2467
2468 let mut renamed_dir_name = "first_directory/second_directory";
2469 const RENAMED_FILE: &str = "rf.txt";
2470
2471 std::fs::create_dir_all(work_dir.join(renamed_dir_name)).unwrap();
2472 std::fs::write(
2473 work_dir.join(renamed_dir_name).join(RENAMED_FILE),
2474 "new-contents",
2475 )
2476 .unwrap();
2477
2478 tree.flush_fs_events(cx).await;
2479 cx.executor().run_until_parked();
2480
2481 tree.read_with(cx, |tree, _cx| {
2482 let snapshot = tree.snapshot();
2483 assert_eq!(
2484 snapshot.status_for_file(project_path.join(renamed_dir_name).join(RENAMED_FILE)),
2485 Some(GitFileStatus::Untracked)
2486 );
2487 });
2488
2489 renamed_dir_name = "new_first_directory/second_directory";
2490
2491 std::fs::rename(
2492 work_dir.join("first_directory"),
2493 work_dir.join("new_first_directory"),
2494 )
2495 .unwrap();
2496
2497 tree.flush_fs_events(cx).await;
2498 cx.executor().run_until_parked();
2499
2500 tree.read_with(cx, |tree, _cx| {
2501 let snapshot = tree.snapshot();
2502
2503 assert_eq!(
2504 snapshot.status_for_file(
2505 project_path
2506 .join(Path::new(renamed_dir_name))
2507 .join(RENAMED_FILE)
2508 ),
2509 Some(GitFileStatus::Untracked)
2510 );
2511 });
2512}
2513
2514#[gpui::test]
2515async fn test_git_repository_status(cx: &mut TestAppContext) {
2516 init_test(cx);
2517 cx.executor().allow_parking();
2518
2519 let root = temp_tree(json!({
2520 "project": {
2521 "a.txt": "a", // Modified
2522 "b.txt": "bb", // Added
2523 "c.txt": "ccc", // Unchanged
2524 "d.txt": "dddd", // Deleted
2525 },
2526
2527 }));
2528
2529 // Set up git repository before creating the worktree.
2530 let work_dir = root.path().join("project");
2531 let repo = git_init(work_dir.as_path());
2532 git_add("a.txt", &repo);
2533 git_add("c.txt", &repo);
2534 git_add("d.txt", &repo);
2535 git_commit("Initial commit", &repo);
2536 std::fs::remove_file(work_dir.join("d.txt")).unwrap();
2537 std::fs::write(work_dir.join("a.txt"), "aa").unwrap();
2538
2539 let tree = Worktree::local(
2540 root.path(),
2541 true,
2542 Arc::new(RealFs::default()),
2543 Default::default(),
2544 &mut cx.to_async(),
2545 )
2546 .await
2547 .unwrap();
2548
2549 tree.flush_fs_events(cx).await;
2550 cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
2551 .await;
2552 cx.executor().run_until_parked();
2553
2554 // Check that the right git state is observed on startup
2555 tree.read_with(cx, |tree, _cx| {
2556 let snapshot = tree.snapshot();
2557 let repo = snapshot.repositories().next().unwrap();
2558 let entries = repo.status().collect::<Vec<_>>();
2559
2560 assert_eq!(entries.len(), 3);
2561 assert_eq!(entries[0].repo_path.as_ref(), Path::new("a.txt"));
2562 assert_eq!(entries[0].status, GitFileStatus::Modified);
2563 assert_eq!(entries[1].repo_path.as_ref(), Path::new("b.txt"));
2564 assert_eq!(entries[1].status, GitFileStatus::Untracked);
2565 assert_eq!(entries[2].repo_path.as_ref(), Path::new("d.txt"));
2566 assert_eq!(entries[2].status, GitFileStatus::Deleted);
2567 });
2568
2569 std::fs::write(work_dir.join("c.txt"), "some changes").unwrap();
2570 eprintln!("File c.txt has been modified");
2571
2572 tree.flush_fs_events(cx).await;
2573 cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
2574 .await;
2575 cx.executor().run_until_parked();
2576
2577 tree.read_with(cx, |tree, _cx| {
2578 let snapshot = tree.snapshot();
2579 let repository = snapshot.repositories().next().unwrap();
2580 let entries = repository.status().collect::<Vec<_>>();
2581
2582 std::assert_eq!(entries.len(), 4, "entries: {entries:?}");
2583 assert_eq!(entries[0].repo_path.as_ref(), Path::new("a.txt"));
2584 assert_eq!(entries[0].status, GitFileStatus::Modified);
2585 assert_eq!(entries[1].repo_path.as_ref(), Path::new("b.txt"));
2586 assert_eq!(entries[1].status, GitFileStatus::Untracked);
2587 // Status updated
2588 assert_eq!(entries[2].repo_path.as_ref(), Path::new("c.txt"));
2589 assert_eq!(entries[2].status, GitFileStatus::Modified);
2590 assert_eq!(entries[3].repo_path.as_ref(), Path::new("d.txt"));
2591 assert_eq!(entries[3].status, GitFileStatus::Deleted);
2592 });
2593
2594 git_add("a.txt", &repo);
2595 git_add("c.txt", &repo);
2596 git_remove_index(Path::new("d.txt"), &repo);
2597 git_commit("Another commit", &repo);
2598 tree.flush_fs_events(cx).await;
2599 cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
2600 .await;
2601 cx.executor().run_until_parked();
2602
2603 std::fs::remove_file(work_dir.join("a.txt")).unwrap();
2604 std::fs::remove_file(work_dir.join("b.txt")).unwrap();
2605 tree.flush_fs_events(cx).await;
2606 cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
2607 .await;
2608 cx.executor().run_until_parked();
2609
2610 tree.read_with(cx, |tree, _cx| {
2611 let snapshot = tree.snapshot();
2612 let repo = snapshot.repositories().next().unwrap();
2613 let entries = repo.status().collect::<Vec<_>>();
2614
2615 // Deleting an untracked entry, b.txt, should leave no status
2616 // a.txt was tracked, and so should have a status
2617 assert_eq!(
2618 entries.len(),
2619 1,
2620 "Entries length was incorrect\n{:#?}",
2621 &entries
2622 );
2623 assert_eq!(entries[0].repo_path.as_ref(), Path::new("a.txt"));
2624 assert_eq!(entries[0].status, GitFileStatus::Deleted);
2625 });
2626}
2627
2628#[gpui::test]
2629async fn test_repository_subfolder_git_status(cx: &mut TestAppContext) {
2630 init_test(cx);
2631 cx.executor().allow_parking();
2632
2633 let root = temp_tree(json!({
2634 "my-repo": {
2635 // .git folder will go here
2636 "a.txt": "a",
2637 "sub-folder-1": {
2638 "sub-folder-2": {
2639 "c.txt": "cc",
2640 "d": {
2641 "e.txt": "eee"
2642 }
2643 },
2644 }
2645 },
2646
2647 }));
2648
2649 const C_TXT: &str = "sub-folder-1/sub-folder-2/c.txt";
2650 const E_TXT: &str = "sub-folder-1/sub-folder-2/d/e.txt";
2651
2652 // Set up git repository before creating the worktree.
2653 let git_repo_work_dir = root.path().join("my-repo");
2654 let repo = git_init(git_repo_work_dir.as_path());
2655 git_add(C_TXT, &repo);
2656 git_commit("Initial commit", &repo);
2657
2658 // Open the worktree in subfolder
2659 let project_root = Path::new("my-repo/sub-folder-1/sub-folder-2");
2660 let tree = Worktree::local(
2661 root.path().join(project_root),
2662 true,
2663 Arc::new(RealFs::default()),
2664 Default::default(),
2665 &mut cx.to_async(),
2666 )
2667 .await
2668 .unwrap();
2669
2670 tree.flush_fs_events(cx).await;
2671 tree.flush_fs_events_in_root_git_repository(cx).await;
2672 cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
2673 .await;
2674 cx.executor().run_until_parked();
2675
2676 // Ensure that the git status is loaded correctly
2677 tree.read_with(cx, |tree, _cx| {
2678 let snapshot = tree.snapshot();
2679 assert_eq!(snapshot.repositories().count(), 1);
2680 let repo = snapshot.repositories().next().unwrap();
2681 // Path is blank because the working directory of
2682 // the git repository is located at the root of the project
2683 assert_eq!(repo.path.as_ref(), Path::new(""));
2684
2685 // This is the missing path between the root of the project (sub-folder-2) and its
2686 // location relative to the root of the repository.
2687 assert_eq!(
2688 repo.location_in_repo,
2689 Some(Arc::from(Path::new("sub-folder-1/sub-folder-2")))
2690 );
2691
2692 assert_eq!(snapshot.status_for_file("c.txt"), None);
2693 assert_eq!(
2694 snapshot.status_for_file("d/e.txt"),
2695 Some(GitFileStatus::Untracked)
2696 );
2697 });
2698
2699 // Now we simulate FS events, but ONLY in the .git folder that's outside
2700 // of out project root.
2701 // Meaning: we don't produce any FS events for files inside the project.
2702 git_add(E_TXT, &repo);
2703 git_commit("Second commit", &repo);
2704 tree.flush_fs_events_in_root_git_repository(cx).await;
2705 cx.executor().run_until_parked();
2706
2707 tree.read_with(cx, |tree, _cx| {
2708 let snapshot = tree.snapshot();
2709
2710 assert!(snapshot.repositories().next().is_some());
2711
2712 assert_eq!(snapshot.status_for_file("c.txt"), None);
2713 assert_eq!(snapshot.status_for_file("d/e.txt"), None);
2714 });
2715}
2716
2717#[gpui::test]
2718async fn test_traverse_with_git_status(cx: &mut TestAppContext) {
2719 init_test(cx);
2720 let fs = FakeFs::new(cx.background_executor.clone());
2721 fs.insert_tree(
2722 "/root",
2723 json!({
2724 "x": {
2725 ".git": {},
2726 "x1.txt": "foo",
2727 "x2.txt": "bar",
2728 "y": {
2729 ".git": {},
2730 "y1.txt": "baz",
2731 "y2.txt": "qux"
2732 },
2733 "z.txt": "sneaky..."
2734 },
2735 "z": {
2736 ".git": {},
2737 "z1.txt": "quux",
2738 "z2.txt": "quuux"
2739 }
2740 }),
2741 )
2742 .await;
2743
2744 fs.set_status_for_repo_via_git_operation(
2745 Path::new("/root/x/.git"),
2746 &[
2747 (Path::new("x2.txt"), GitFileStatus::Modified),
2748 (Path::new("z.txt"), GitFileStatus::Added),
2749 ],
2750 );
2751 fs.set_status_for_repo_via_git_operation(
2752 Path::new("/root/x/y/.git"),
2753 &[(Path::new("y1.txt"), GitFileStatus::Conflict)],
2754 );
2755 fs.set_status_for_repo_via_git_operation(
2756 Path::new("/root/z/.git"),
2757 &[(Path::new("z2.txt"), GitFileStatus::Added)],
2758 );
2759
2760 let tree = Worktree::local(
2761 Path::new("/root"),
2762 true,
2763 fs.clone(),
2764 Default::default(),
2765 &mut cx.to_async(),
2766 )
2767 .await
2768 .unwrap();
2769
2770 tree.flush_fs_events(cx).await;
2771 cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
2772 .await;
2773 cx.executor().run_until_parked();
2774
2775 let snapshot = tree.read_with(cx, |tree, _| tree.snapshot());
2776
2777 let mut traversal = snapshot
2778 .traverse_from_path(true, false, true, Path::new("x"))
2779 .with_git_statuses();
2780
2781 let entry = traversal.next().unwrap();
2782 assert_eq!(entry.path.as_ref(), Path::new("x/x1.txt"));
2783 assert_eq!(entry.git_status, None);
2784 let entry = traversal.next().unwrap();
2785 assert_eq!(entry.path.as_ref(), Path::new("x/x2.txt"));
2786 assert_eq!(entry.git_status, Some(GitFileStatus::Modified));
2787 let entry = traversal.next().unwrap();
2788 assert_eq!(entry.path.as_ref(), Path::new("x/y/y1.txt"));
2789 assert_eq!(entry.git_status, Some(GitFileStatus::Conflict));
2790 let entry = traversal.next().unwrap();
2791 assert_eq!(entry.path.as_ref(), Path::new("x/y/y2.txt"));
2792 assert_eq!(entry.git_status, None);
2793 let entry = traversal.next().unwrap();
2794 assert_eq!(entry.path.as_ref(), Path::new("x/z.txt"));
2795 assert_eq!(entry.git_status, Some(GitFileStatus::Added));
2796 let entry = traversal.next().unwrap();
2797 assert_eq!(entry.path.as_ref(), Path::new("z/z1.txt"));
2798 assert_eq!(entry.git_status, None);
2799 let entry = traversal.next().unwrap();
2800 assert_eq!(entry.path.as_ref(), Path::new("z/z2.txt"));
2801 assert_eq!(entry.git_status, Some(GitFileStatus::Added));
2802}
2803
2804#[gpui::test]
2805async fn test_propagate_git_statuses(cx: &mut TestAppContext) {
2806 init_test(cx);
2807 let fs = FakeFs::new(cx.background_executor.clone());
2808 fs.insert_tree(
2809 "/root",
2810 json!({
2811 ".git": {},
2812 "a": {
2813 "b": {
2814 "c1.txt": "",
2815 "c2.txt": "",
2816 },
2817 "d": {
2818 "e1.txt": "",
2819 "e2.txt": "",
2820 "e3.txt": "",
2821 }
2822 },
2823 "f": {
2824 "no-status.txt": ""
2825 },
2826 "g": {
2827 "h1.txt": "",
2828 "h2.txt": ""
2829 },
2830 }),
2831 )
2832 .await;
2833
2834 fs.set_status_for_repo_via_git_operation(
2835 Path::new("/root/.git"),
2836 &[
2837 (Path::new("a/b/c1.txt"), GitFileStatus::Added),
2838 (Path::new("a/d/e2.txt"), GitFileStatus::Modified),
2839 (Path::new("g/h2.txt"), GitFileStatus::Conflict),
2840 ],
2841 );
2842
2843 let tree = Worktree::local(
2844 Path::new("/root"),
2845 true,
2846 fs.clone(),
2847 Default::default(),
2848 &mut cx.to_async(),
2849 )
2850 .await
2851 .unwrap();
2852
2853 cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
2854 .await;
2855
2856 cx.executor().run_until_parked();
2857 let snapshot = tree.read_with(cx, |tree, _| tree.snapshot());
2858
2859 check_git_statuses(
2860 &snapshot,
2861 &[
2862 (Path::new(""), Some(GitFileStatus::Conflict)),
2863 (Path::new("g"), Some(GitFileStatus::Conflict)),
2864 (Path::new("g/h2.txt"), Some(GitFileStatus::Conflict)),
2865 ],
2866 );
2867
2868 check_git_statuses(
2869 &snapshot,
2870 &[
2871 (Path::new(""), Some(GitFileStatus::Conflict)),
2872 (Path::new("a"), Some(GitFileStatus::Modified)),
2873 (Path::new("a/b"), Some(GitFileStatus::Added)),
2874 (Path::new("a/b/c1.txt"), Some(GitFileStatus::Added)),
2875 (Path::new("a/b/c2.txt"), None),
2876 (Path::new("a/d"), Some(GitFileStatus::Modified)),
2877 (Path::new("a/d/e2.txt"), Some(GitFileStatus::Modified)),
2878 (Path::new("f"), None),
2879 (Path::new("f/no-status.txt"), None),
2880 (Path::new("g"), Some(GitFileStatus::Conflict)),
2881 (Path::new("g/h2.txt"), Some(GitFileStatus::Conflict)),
2882 ],
2883 );
2884
2885 check_git_statuses(
2886 &snapshot,
2887 &[
2888 (Path::new("a/b"), Some(GitFileStatus::Added)),
2889 (Path::new("a/b/c1.txt"), Some(GitFileStatus::Added)),
2890 (Path::new("a/b/c2.txt"), None),
2891 (Path::new("a/d"), Some(GitFileStatus::Modified)),
2892 (Path::new("a/d/e1.txt"), None),
2893 (Path::new("a/d/e2.txt"), Some(GitFileStatus::Modified)),
2894 (Path::new("f"), None),
2895 (Path::new("f/no-status.txt"), None),
2896 (Path::new("g"), Some(GitFileStatus::Conflict)),
2897 ],
2898 );
2899
2900 check_git_statuses(
2901 &snapshot,
2902 &[
2903 (Path::new("a/b/c1.txt"), Some(GitFileStatus::Added)),
2904 (Path::new("a/b/c2.txt"), None),
2905 (Path::new("a/d/e1.txt"), None),
2906 (Path::new("a/d/e2.txt"), Some(GitFileStatus::Modified)),
2907 (Path::new("f/no-status.txt"), None),
2908 ],
2909 );
2910}
2911
2912#[gpui::test]
2913async fn test_propagate_statuses_for_repos_under_project(cx: &mut TestAppContext) {
2914 init_test(cx);
2915 let fs = FakeFs::new(cx.background_executor.clone());
2916 fs.insert_tree(
2917 "/root",
2918 json!({
2919 "x": {
2920 ".git": {},
2921 "x1.txt": "foo",
2922 "x2.txt": "bar"
2923 },
2924 "y": {
2925 ".git": {},
2926 "y1.txt": "baz",
2927 "y2.txt": "qux"
2928 },
2929 "z": {
2930 ".git": {},
2931 "z1.txt": "quux",
2932 "z2.txt": "quuux"
2933 }
2934 }),
2935 )
2936 .await;
2937
2938 fs.set_status_for_repo_via_git_operation(
2939 Path::new("/root/x/.git"),
2940 &[(Path::new("x1.txt"), GitFileStatus::Added)],
2941 );
2942 fs.set_status_for_repo_via_git_operation(
2943 Path::new("/root/y/.git"),
2944 &[
2945 (Path::new("y1.txt"), GitFileStatus::Conflict),
2946 (Path::new("y2.txt"), GitFileStatus::Modified),
2947 ],
2948 );
2949 fs.set_status_for_repo_via_git_operation(
2950 Path::new("/root/z/.git"),
2951 &[(Path::new("z2.txt"), GitFileStatus::Modified)],
2952 );
2953
2954 let tree = Worktree::local(
2955 Path::new("/root"),
2956 true,
2957 fs.clone(),
2958 Default::default(),
2959 &mut cx.to_async(),
2960 )
2961 .await
2962 .unwrap();
2963
2964 tree.flush_fs_events(cx).await;
2965 cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
2966 .await;
2967 cx.executor().run_until_parked();
2968
2969 let snapshot = tree.read_with(cx, |tree, _| tree.snapshot());
2970
2971 check_git_statuses(
2972 &snapshot,
2973 &[
2974 (Path::new("x"), Some(GitFileStatus::Added)),
2975 (Path::new("x/x1.txt"), Some(GitFileStatus::Added)),
2976 ],
2977 );
2978
2979 check_git_statuses(
2980 &snapshot,
2981 &[
2982 (Path::new("y"), Some(GitFileStatus::Conflict)),
2983 (Path::new("y/y1.txt"), Some(GitFileStatus::Conflict)),
2984 (Path::new("y/y2.txt"), Some(GitFileStatus::Modified)),
2985 ],
2986 );
2987
2988 check_git_statuses(
2989 &snapshot,
2990 &[
2991 (Path::new("z"), Some(GitFileStatus::Modified)),
2992 (Path::new("z/z2.txt"), Some(GitFileStatus::Modified)),
2993 ],
2994 );
2995
2996 check_git_statuses(
2997 &snapshot,
2998 &[
2999 (Path::new("x"), Some(GitFileStatus::Added)),
3000 (Path::new("x/x1.txt"), Some(GitFileStatus::Added)),
3001 ],
3002 );
3003
3004 check_git_statuses(
3005 &snapshot,
3006 &[
3007 (Path::new("x"), Some(GitFileStatus::Added)),
3008 (Path::new("x/x1.txt"), Some(GitFileStatus::Added)),
3009 (Path::new("x/x2.txt"), None),
3010 (Path::new("y"), Some(GitFileStatus::Conflict)),
3011 (Path::new("y/y1.txt"), Some(GitFileStatus::Conflict)),
3012 (Path::new("y/y2.txt"), Some(GitFileStatus::Modified)),
3013 (Path::new("z"), Some(GitFileStatus::Modified)),
3014 (Path::new("z/z1.txt"), None),
3015 (Path::new("z/z2.txt"), Some(GitFileStatus::Modified)),
3016 ],
3017 );
3018}
3019
3020#[gpui::test]
3021async fn test_propagate_statuses_for_nested_repos(cx: &mut TestAppContext) {
3022 init_test(cx);
3023 let fs = FakeFs::new(cx.background_executor.clone());
3024 fs.insert_tree(
3025 "/root",
3026 json!({
3027 "x": {
3028 ".git": {},
3029 "x1.txt": "foo",
3030 "x2.txt": "bar",
3031 "y": {
3032 ".git": {},
3033 "y1.txt": "baz",
3034 "y2.txt": "qux"
3035 },
3036 "z.txt": "sneaky..."
3037 },
3038 "z": {
3039 ".git": {},
3040 "z1.txt": "quux",
3041 "z2.txt": "quuux"
3042 }
3043 }),
3044 )
3045 .await;
3046
3047 fs.set_status_for_repo_via_git_operation(
3048 Path::new("/root/x/.git"),
3049 &[
3050 (Path::new("x2.txt"), GitFileStatus::Modified),
3051 (Path::new("z.txt"), GitFileStatus::Added),
3052 ],
3053 );
3054 fs.set_status_for_repo_via_git_operation(
3055 Path::new("/root/x/y/.git"),
3056 &[(Path::new("y1.txt"), GitFileStatus::Conflict)],
3057 );
3058
3059 fs.set_status_for_repo_via_git_operation(
3060 Path::new("/root/z/.git"),
3061 &[(Path::new("z2.txt"), GitFileStatus::Added)],
3062 );
3063
3064 let tree = Worktree::local(
3065 Path::new("/root"),
3066 true,
3067 fs.clone(),
3068 Default::default(),
3069 &mut cx.to_async(),
3070 )
3071 .await
3072 .unwrap();
3073
3074 tree.flush_fs_events(cx).await;
3075 cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
3076 .await;
3077 cx.executor().run_until_parked();
3078
3079 let snapshot = tree.read_with(cx, |tree, _| tree.snapshot());
3080
3081 // Sanity check the propagation for x/y and z
3082 check_git_statuses(
3083 &snapshot,
3084 &[
3085 (Path::new("x/y"), Some(GitFileStatus::Conflict)), // the y git repository has conflict file in it, and so should have a conflict status
3086 (Path::new("x/y/y1.txt"), Some(GitFileStatus::Conflict)),
3087 (Path::new("x/y/y2.txt"), None),
3088 ],
3089 );
3090 check_git_statuses(
3091 &snapshot,
3092 &[
3093 (Path::new("z"), Some(GitFileStatus::Added)),
3094 (Path::new("z/z1.txt"), None),
3095 (Path::new("z/z2.txt"), Some(GitFileStatus::Added)),
3096 ],
3097 );
3098
3099 // Test one of the fundamental cases of propagation blocking, the transition from one git repository to another
3100 check_git_statuses(
3101 &snapshot,
3102 &[
3103 (Path::new("x"), Some(GitFileStatus::Modified)),
3104 (Path::new("x/y"), Some(GitFileStatus::Conflict)),
3105 (Path::new("x/y/y1.txt"), Some(GitFileStatus::Conflict)),
3106 ],
3107 );
3108
3109 // Sanity check everything around it
3110 check_git_statuses(
3111 &snapshot,
3112 &[
3113 (Path::new("x"), Some(GitFileStatus::Modified)),
3114 (Path::new("x/x1.txt"), None),
3115 (Path::new("x/x2.txt"), Some(GitFileStatus::Modified)),
3116 (Path::new("x/y"), Some(GitFileStatus::Conflict)),
3117 (Path::new("x/y/y1.txt"), Some(GitFileStatus::Conflict)),
3118 (Path::new("x/y/y2.txt"), None),
3119 (Path::new("x/z.txt"), Some(GitFileStatus::Added)),
3120 ],
3121 );
3122
3123 // Test the other fundamental case, transitioning from git repository to non-git repository
3124 check_git_statuses(
3125 &snapshot,
3126 &[
3127 (Path::new(""), None),
3128 (Path::new("x"), Some(GitFileStatus::Modified)),
3129 (Path::new("x/x1.txt"), None),
3130 ],
3131 );
3132
3133 // And all together now
3134 check_git_statuses(
3135 &snapshot,
3136 &[
3137 (Path::new(""), None),
3138 (Path::new("x"), Some(GitFileStatus::Modified)),
3139 (Path::new("x/x1.txt"), None),
3140 (Path::new("x/x2.txt"), Some(GitFileStatus::Modified)),
3141 (Path::new("x/y"), Some(GitFileStatus::Conflict)),
3142 (Path::new("x/y/y1.txt"), Some(GitFileStatus::Conflict)),
3143 (Path::new("x/y/y2.txt"), None),
3144 (Path::new("x/z.txt"), Some(GitFileStatus::Added)),
3145 (Path::new("z"), Some(GitFileStatus::Added)),
3146 (Path::new("z/z1.txt"), None),
3147 (Path::new("z/z2.txt"), Some(GitFileStatus::Added)),
3148 ],
3149 );
3150}
3151
3152#[gpui::test]
3153async fn test_private_single_file_worktree(cx: &mut TestAppContext) {
3154 init_test(cx);
3155 let fs = FakeFs::new(cx.background_executor.clone());
3156 fs.insert_tree("/", json!({".env": "PRIVATE=secret\n"}))
3157 .await;
3158 let tree = Worktree::local(
3159 Path::new("/.env"),
3160 true,
3161 fs.clone(),
3162 Default::default(),
3163 &mut cx.to_async(),
3164 )
3165 .await
3166 .unwrap();
3167 cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
3168 .await;
3169 tree.read_with(cx, |tree, _| {
3170 let entry = tree.entry_for_path("").unwrap();
3171 assert!(entry.is_private);
3172 });
3173}
3174
3175#[track_caller]
3176fn check_git_statuses(snapshot: &Snapshot, expected_statuses: &[(&Path, Option<GitFileStatus>)]) {
3177 let mut traversal = snapshot
3178 .traverse_from_path(true, true, false, "".as_ref())
3179 .with_git_statuses();
3180 let found_statuses = expected_statuses
3181 .iter()
3182 .map(|&(path, _)| {
3183 let git_entry = traversal
3184 .find(|git_entry| &*git_entry.path == path)
3185 .expect("Traversal has no entry for {path:?}");
3186 (path, git_entry.git_status)
3187 })
3188 .collect::<Vec<_>>();
3189 assert_eq!(found_statuses, expected_statuses);
3190}
3191
3192#[track_caller]
3193fn git_init(path: &Path) -> git2::Repository {
3194 git2::Repository::init(path).expect("Failed to initialize git repository")
3195}
3196
3197#[track_caller]
3198fn git_add<P: AsRef<Path>>(path: P, repo: &git2::Repository) {
3199 let path = path.as_ref();
3200 let mut index = repo.index().expect("Failed to get index");
3201 index.add_path(path).expect("Failed to add file");
3202 index.write().expect("Failed to write index");
3203}
3204
3205#[track_caller]
3206fn git_remove_index(path: &Path, repo: &git2::Repository) {
3207 let mut index = repo.index().expect("Failed to get index");
3208 index.remove_path(path).expect("Failed to add file");
3209 index.write().expect("Failed to write index");
3210}
3211
3212#[track_caller]
3213fn git_commit(msg: &'static str, repo: &git2::Repository) {
3214 use git2::Signature;
3215
3216 let signature = Signature::now("test", "test@zed.dev").unwrap();
3217 let oid = repo.index().unwrap().write_tree().unwrap();
3218 let tree = repo.find_tree(oid).unwrap();
3219 if let Ok(head) = repo.head() {
3220 let parent_obj = head.peel(git2::ObjectType::Commit).unwrap();
3221
3222 let parent_commit = parent_obj.as_commit().unwrap();
3223
3224 repo.commit(
3225 Some("HEAD"),
3226 &signature,
3227 &signature,
3228 msg,
3229 &tree,
3230 &[parent_commit],
3231 )
3232 .expect("Failed to commit with parent");
3233 } else {
3234 repo.commit(Some("HEAD"), &signature, &signature, msg, &tree, &[])
3235 .expect("Failed to commit");
3236 }
3237}
3238
3239#[track_caller]
3240fn git_stash(repo: &mut git2::Repository) {
3241 use git2::Signature;
3242
3243 let signature = Signature::now("test", "test@zed.dev").unwrap();
3244 repo.stash_save(&signature, "N/A", None)
3245 .expect("Failed to stash");
3246}
3247
3248#[track_caller]
3249fn git_reset(offset: usize, repo: &git2::Repository) {
3250 let head = repo.head().expect("Couldn't get repo head");
3251 let object = head.peel(git2::ObjectType::Commit).unwrap();
3252 let commit = object.as_commit().unwrap();
3253 let new_head = commit
3254 .parents()
3255 .inspect(|parnet| {
3256 parnet.message();
3257 })
3258 .nth(offset)
3259 .expect("Not enough history");
3260 repo.reset(new_head.as_object(), git2::ResetType::Soft, None)
3261 .expect("Could not reset");
3262}
3263
3264#[allow(dead_code)]
3265#[track_caller]
3266fn git_status(repo: &git2::Repository) -> collections::HashMap<String, git2::Status> {
3267 repo.statuses(None)
3268 .unwrap()
3269 .iter()
3270 .map(|status| (status.path().unwrap().to_string(), status.status()))
3271 .collect()
3272}
3273
3274#[track_caller]
3275fn check_worktree_entries(
3276 tree: &Worktree,
3277 expected_excluded_paths: &[&str],
3278 expected_ignored_paths: &[&str],
3279 expected_tracked_paths: &[&str],
3280 expected_included_paths: &[&str],
3281) {
3282 for path in expected_excluded_paths {
3283 let entry = tree.entry_for_path(path);
3284 assert!(
3285 entry.is_none(),
3286 "expected path '{path}' to be excluded, but got entry: {entry:?}",
3287 );
3288 }
3289 for path in expected_ignored_paths {
3290 let entry = tree
3291 .entry_for_path(path)
3292 .unwrap_or_else(|| panic!("Missing entry for expected ignored path '{path}'"));
3293 assert!(
3294 entry.is_ignored,
3295 "expected path '{path}' to be ignored, but got entry: {entry:?}",
3296 );
3297 }
3298 for path in expected_tracked_paths {
3299 let entry = tree
3300 .entry_for_path(path)
3301 .unwrap_or_else(|| panic!("Missing entry for expected tracked path '{path}'"));
3302 assert!(
3303 !entry.is_ignored || entry.is_always_included,
3304 "expected path '{path}' to be tracked, but got entry: {entry:?}",
3305 );
3306 }
3307 for path in expected_included_paths {
3308 let entry = tree
3309 .entry_for_path(path)
3310 .unwrap_or_else(|| panic!("Missing entry for expected included path '{path}'"));
3311 assert!(
3312 entry.is_always_included,
3313 "expected path '{path}' to always be included, but got entry: {entry:?}",
3314 );
3315 }
3316}
3317
3318fn init_test(cx: &mut gpui::TestAppContext) {
3319 if std::env::var("RUST_LOG").is_ok() {
3320 env_logger::try_init().ok();
3321 }
3322
3323 cx.update(|cx| {
3324 let settings_store = SettingsStore::test(cx);
3325 cx.set_global(settings_store);
3326 WorktreeSettings::register(cx);
3327 });
3328}
3329
3330fn assert_entry_git_state(
3331 tree: &Worktree,
3332 path: &str,
3333 git_status: Option<GitFileStatus>,
3334 is_ignored: bool,
3335) {
3336 let entry = tree.entry_for_path(path).expect("entry {path} not found");
3337 assert_eq!(
3338 tree.status_for_file(Path::new(path)),
3339 git_status,
3340 "expected {path} to have git status: {git_status:?}"
3341 );
3342 assert_eq!(
3343 entry.is_ignored, is_ignored,
3344 "expected {path} to have is_ignored: {is_ignored}"
3345 );
3346}