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