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