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