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_home_dir_as_git_repository(cx: &mut TestAppContext) {
2246 init_test(cx);
2247 cx.executor().allow_parking();
2248 let fs = FakeFs::new(cx.background_executor.clone());
2249 fs.insert_tree(
2250 "/root",
2251 json!({
2252 "home": {
2253 ".git": {},
2254 "project": {
2255 "a.txt": "A"
2256 },
2257 },
2258 }),
2259 )
2260 .await;
2261 fs.set_home_dir(Path::new(path!("/root/home")).to_owned());
2262
2263 let tree = Worktree::local(
2264 Path::new(path!("/root/home/project")),
2265 true,
2266 fs.clone(),
2267 Default::default(),
2268 &mut cx.to_async(),
2269 )
2270 .await
2271 .unwrap();
2272
2273 cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
2274 .await;
2275 tree.flush_fs_events(cx).await;
2276
2277 tree.read_with(cx, |tree, _cx| {
2278 let tree = tree.as_local().unwrap();
2279
2280 let repo = tree.repository_for_path(path!("a.txt").as_ref());
2281 assert!(repo.is_none());
2282 });
2283
2284 let home_tree = Worktree::local(
2285 Path::new(path!("/root/home")),
2286 true,
2287 fs.clone(),
2288 Default::default(),
2289 &mut cx.to_async(),
2290 )
2291 .await
2292 .unwrap();
2293
2294 cx.read(|cx| home_tree.read(cx).as_local().unwrap().scan_complete())
2295 .await;
2296 home_tree.flush_fs_events(cx).await;
2297
2298 home_tree.read_with(cx, |home_tree, _cx| {
2299 let home_tree = home_tree.as_local().unwrap();
2300
2301 let repo = home_tree.repository_for_path(path!("project/a.txt").as_ref());
2302 assert_eq!(
2303 repo.map(|repo| &repo.work_directory),
2304 Some(&WorkDirectory::InProject {
2305 relative_path: Path::new("").into()
2306 })
2307 );
2308 })
2309}
2310
2311#[gpui::test]
2312async fn test_git_repository_for_path(cx: &mut TestAppContext) {
2313 init_test(cx);
2314 cx.executor().allow_parking();
2315 let root = TempTree::new(json!({
2316 "c.txt": "",
2317 "dir1": {
2318 ".git": {},
2319 "deps": {
2320 "dep1": {
2321 ".git": {},
2322 "src": {
2323 "a.txt": ""
2324 }
2325 }
2326 },
2327 "src": {
2328 "b.txt": ""
2329 }
2330 },
2331 }));
2332
2333 let tree = Worktree::local(
2334 root.path(),
2335 true,
2336 Arc::new(RealFs::default()),
2337 Default::default(),
2338 &mut cx.to_async(),
2339 )
2340 .await
2341 .unwrap();
2342
2343 cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
2344 .await;
2345 tree.flush_fs_events(cx).await;
2346
2347 tree.read_with(cx, |tree, _cx| {
2348 let tree = tree.as_local().unwrap();
2349
2350 assert!(tree.repository_for_path("c.txt".as_ref()).is_none());
2351
2352 let repo = tree.repository_for_path("dir1/src/b.txt".as_ref()).unwrap();
2353 assert_eq!(repo.work_directory, WorkDirectory::in_project("dir1"));
2354
2355 let repo = tree
2356 .repository_for_path("dir1/deps/dep1/src/a.txt".as_ref())
2357 .unwrap();
2358 assert_eq!(
2359 repo.work_directory,
2360 WorkDirectory::in_project("dir1/deps/dep1")
2361 );
2362
2363 let entries = tree.files(false, 0);
2364
2365 let paths_with_repos = tree
2366 .entries_with_repositories(entries)
2367 .map(|(entry, repo)| {
2368 (
2369 entry.path.as_ref(),
2370 repo.map(|repo| repo.work_directory.clone()),
2371 )
2372 })
2373 .collect::<Vec<_>>();
2374
2375 assert_eq!(
2376 paths_with_repos,
2377 &[
2378 (Path::new("c.txt"), None),
2379 (
2380 Path::new("dir1/deps/dep1/src/a.txt"),
2381 Some(WorkDirectory::in_project("dir1/deps/dep1"))
2382 ),
2383 (
2384 Path::new("dir1/src/b.txt"),
2385 Some(WorkDirectory::in_project("dir1"))
2386 ),
2387 ]
2388 );
2389 });
2390
2391 let repo_update_events = Arc::new(Mutex::new(vec![]));
2392 tree.update(cx, |_, cx| {
2393 let repo_update_events = repo_update_events.clone();
2394 cx.subscribe(&tree, move |_, _, event, _| {
2395 if let Event::UpdatedGitRepositories(update) = event {
2396 repo_update_events.lock().push(update.clone());
2397 }
2398 })
2399 .detach();
2400 });
2401
2402 std::fs::write(root.path().join("dir1/.git/random_new_file"), "hello").unwrap();
2403 tree.flush_fs_events(cx).await;
2404
2405 assert_eq!(
2406 repo_update_events.lock()[0]
2407 .iter()
2408 .map(|e| e.0.clone())
2409 .collect::<Vec<Arc<Path>>>(),
2410 vec![Path::new("dir1").into()]
2411 );
2412
2413 std::fs::remove_dir_all(root.path().join("dir1/.git")).unwrap();
2414 tree.flush_fs_events(cx).await;
2415
2416 tree.read_with(cx, |tree, _cx| {
2417 let tree = tree.as_local().unwrap();
2418
2419 assert!(tree
2420 .repository_for_path("dir1/src/b.txt".as_ref())
2421 .is_none());
2422 });
2423}
2424
2425// NOTE:
2426// This test always fails on Windows, because on Windows, unlike on Unix, you can't rename
2427// a directory which some program has already open.
2428// This is a limitation of the Windows.
2429// See: https://stackoverflow.com/questions/41365318/access-is-denied-when-renaming-folder
2430#[gpui::test]
2431#[cfg_attr(target_os = "windows", ignore)]
2432async fn test_file_status(cx: &mut TestAppContext) {
2433 init_test(cx);
2434 cx.executor().allow_parking();
2435 const IGNORE_RULE: &str = "**/target";
2436
2437 let root = TempTree::new(json!({
2438 "project": {
2439 "a.txt": "a",
2440 "b.txt": "bb",
2441 "c": {
2442 "d": {
2443 "e.txt": "eee"
2444 }
2445 },
2446 "f.txt": "ffff",
2447 "target": {
2448 "build_file": "???"
2449 },
2450 ".gitignore": IGNORE_RULE
2451 },
2452
2453 }));
2454
2455 const A_TXT: &str = "a.txt";
2456 const B_TXT: &str = "b.txt";
2457 const E_TXT: &str = "c/d/e.txt";
2458 const F_TXT: &str = "f.txt";
2459 const DOTGITIGNORE: &str = ".gitignore";
2460 const BUILD_FILE: &str = "target/build_file";
2461 let project_path = Path::new("project");
2462
2463 // Set up git repository before creating the worktree.
2464 let work_dir = root.path().join("project");
2465 let mut repo = git_init(work_dir.as_path());
2466 repo.add_ignore_rule(IGNORE_RULE).unwrap();
2467 git_add(A_TXT, &repo);
2468 git_add(E_TXT, &repo);
2469 git_add(DOTGITIGNORE, &repo);
2470 git_commit("Initial commit", &repo);
2471
2472 let tree = Worktree::local(
2473 root.path(),
2474 true,
2475 Arc::new(RealFs::default()),
2476 Default::default(),
2477 &mut cx.to_async(),
2478 )
2479 .await
2480 .unwrap();
2481
2482 tree.flush_fs_events(cx).await;
2483 cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
2484 .await;
2485 cx.executor().run_until_parked();
2486
2487 // Check that the right git state is observed on startup
2488 tree.read_with(cx, |tree, _cx| {
2489 let snapshot = tree.snapshot();
2490 assert_eq!(snapshot.repositories().iter().count(), 1);
2491 let repo_entry = snapshot.repositories().iter().next().unwrap();
2492 assert_eq!(
2493 repo_entry.work_directory,
2494 WorkDirectory::in_project("project")
2495 );
2496
2497 assert_eq!(
2498 snapshot.status_for_file(project_path.join(B_TXT)),
2499 Some(FileStatus::Untracked),
2500 );
2501 assert_eq!(
2502 snapshot.status_for_file(project_path.join(F_TXT)),
2503 Some(FileStatus::Untracked),
2504 );
2505 });
2506
2507 // Modify a file in the working copy.
2508 std::fs::write(work_dir.join(A_TXT), "aa").unwrap();
2509 tree.flush_fs_events(cx).await;
2510 cx.executor().run_until_parked();
2511
2512 // The worktree detects that the file's git status has changed.
2513 tree.read_with(cx, |tree, _cx| {
2514 let snapshot = tree.snapshot();
2515 assert_eq!(
2516 snapshot.status_for_file(project_path.join(A_TXT)),
2517 Some(StatusCode::Modified.worktree()),
2518 );
2519 });
2520
2521 // Create a commit in the git repository.
2522 git_add(A_TXT, &repo);
2523 git_add(B_TXT, &repo);
2524 git_commit("Committing modified and added", &repo);
2525 tree.flush_fs_events(cx).await;
2526 cx.executor().run_until_parked();
2527
2528 // The worktree detects that the files' git status have changed.
2529 tree.read_with(cx, |tree, _cx| {
2530 let snapshot = tree.snapshot();
2531 assert_eq!(
2532 snapshot.status_for_file(project_path.join(F_TXT)),
2533 Some(FileStatus::Untracked),
2534 );
2535 assert_eq!(snapshot.status_for_file(project_path.join(B_TXT)), None);
2536 assert_eq!(snapshot.status_for_file(project_path.join(A_TXT)), None);
2537 });
2538
2539 // Modify files in the working copy and perform git operations on other files.
2540 git_reset(0, &repo);
2541 git_remove_index(Path::new(B_TXT), &repo);
2542 git_stash(&mut repo);
2543 std::fs::write(work_dir.join(E_TXT), "eeee").unwrap();
2544 std::fs::write(work_dir.join(BUILD_FILE), "this should be ignored").unwrap();
2545 tree.flush_fs_events(cx).await;
2546 cx.executor().run_until_parked();
2547
2548 // Check that more complex repo changes are tracked
2549 tree.read_with(cx, |tree, _cx| {
2550 let snapshot = tree.snapshot();
2551
2552 assert_eq!(snapshot.status_for_file(project_path.join(A_TXT)), None);
2553 assert_eq!(
2554 snapshot.status_for_file(project_path.join(B_TXT)),
2555 Some(FileStatus::Untracked),
2556 );
2557 assert_eq!(
2558 snapshot.status_for_file(project_path.join(E_TXT)),
2559 Some(StatusCode::Modified.worktree()),
2560 );
2561 });
2562
2563 std::fs::remove_file(work_dir.join(B_TXT)).unwrap();
2564 std::fs::remove_dir_all(work_dir.join("c")).unwrap();
2565 std::fs::write(
2566 work_dir.join(DOTGITIGNORE),
2567 [IGNORE_RULE, "f.txt"].join("\n"),
2568 )
2569 .unwrap();
2570
2571 git_add(Path::new(DOTGITIGNORE), &repo);
2572 git_commit("Committing modified git ignore", &repo);
2573
2574 tree.flush_fs_events(cx).await;
2575 cx.executor().run_until_parked();
2576
2577 let mut renamed_dir_name = "first_directory/second_directory";
2578 const RENAMED_FILE: &str = "rf.txt";
2579
2580 std::fs::create_dir_all(work_dir.join(renamed_dir_name)).unwrap();
2581 std::fs::write(
2582 work_dir.join(renamed_dir_name).join(RENAMED_FILE),
2583 "new-contents",
2584 )
2585 .unwrap();
2586
2587 tree.flush_fs_events(cx).await;
2588 cx.executor().run_until_parked();
2589
2590 tree.read_with(cx, |tree, _cx| {
2591 let snapshot = tree.snapshot();
2592 assert_eq!(
2593 snapshot.status_for_file(project_path.join(renamed_dir_name).join(RENAMED_FILE)),
2594 Some(FileStatus::Untracked),
2595 );
2596 });
2597
2598 renamed_dir_name = "new_first_directory/second_directory";
2599
2600 std::fs::rename(
2601 work_dir.join("first_directory"),
2602 work_dir.join("new_first_directory"),
2603 )
2604 .unwrap();
2605
2606 tree.flush_fs_events(cx).await;
2607 cx.executor().run_until_parked();
2608
2609 tree.read_with(cx, |tree, _cx| {
2610 let snapshot = tree.snapshot();
2611
2612 assert_eq!(
2613 snapshot.status_for_file(
2614 project_path
2615 .join(Path::new(renamed_dir_name))
2616 .join(RENAMED_FILE)
2617 ),
2618 Some(FileStatus::Untracked),
2619 );
2620 });
2621}
2622
2623#[gpui::test]
2624async fn test_git_repository_status(cx: &mut TestAppContext) {
2625 init_test(cx);
2626 cx.executor().allow_parking();
2627
2628 let root = TempTree::new(json!({
2629 "project": {
2630 "a.txt": "a", // Modified
2631 "b.txt": "bb", // Added
2632 "c.txt": "ccc", // Unchanged
2633 "d.txt": "dddd", // Deleted
2634 },
2635
2636 }));
2637
2638 // Set up git repository before creating the worktree.
2639 let work_dir = root.path().join("project");
2640 let repo = git_init(work_dir.as_path());
2641 git_add("a.txt", &repo);
2642 git_add("c.txt", &repo);
2643 git_add("d.txt", &repo);
2644 git_commit("Initial commit", &repo);
2645 std::fs::remove_file(work_dir.join("d.txt")).unwrap();
2646 std::fs::write(work_dir.join("a.txt"), "aa").unwrap();
2647
2648 let tree = Worktree::local(
2649 root.path(),
2650 true,
2651 Arc::new(RealFs::default()),
2652 Default::default(),
2653 &mut cx.to_async(),
2654 )
2655 .await
2656 .unwrap();
2657
2658 tree.flush_fs_events(cx).await;
2659 cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
2660 .await;
2661 cx.executor().run_until_parked();
2662
2663 // Check that the right git state is observed on startup
2664 tree.read_with(cx, |tree, _cx| {
2665 let snapshot = tree.snapshot();
2666 let repo = snapshot.repositories().iter().next().unwrap();
2667 let entries = repo.status().collect::<Vec<_>>();
2668
2669 assert_eq!(entries.len(), 3);
2670 assert_eq!(entries[0].repo_path.as_ref(), Path::new("a.txt"));
2671 assert_eq!(entries[0].status, StatusCode::Modified.worktree());
2672 assert_eq!(entries[1].repo_path.as_ref(), Path::new("b.txt"));
2673 assert_eq!(entries[1].status, FileStatus::Untracked);
2674 assert_eq!(entries[2].repo_path.as_ref(), Path::new("d.txt"));
2675 assert_eq!(entries[2].status, StatusCode::Deleted.worktree());
2676 });
2677
2678 std::fs::write(work_dir.join("c.txt"), "some changes").unwrap();
2679 eprintln!("File c.txt has been modified");
2680
2681 tree.flush_fs_events(cx).await;
2682 cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
2683 .await;
2684 cx.executor().run_until_parked();
2685
2686 tree.read_with(cx, |tree, _cx| {
2687 let snapshot = tree.snapshot();
2688 let repository = snapshot.repositories().iter().next().unwrap();
2689 let entries = repository.status().collect::<Vec<_>>();
2690
2691 std::assert_eq!(entries.len(), 4, "entries: {entries:?}");
2692 assert_eq!(entries[0].repo_path.as_ref(), Path::new("a.txt"));
2693 assert_eq!(entries[0].status, StatusCode::Modified.worktree());
2694 assert_eq!(entries[1].repo_path.as_ref(), Path::new("b.txt"));
2695 assert_eq!(entries[1].status, FileStatus::Untracked);
2696 // Status updated
2697 assert_eq!(entries[2].repo_path.as_ref(), Path::new("c.txt"));
2698 assert_eq!(entries[2].status, StatusCode::Modified.worktree());
2699 assert_eq!(entries[3].repo_path.as_ref(), Path::new("d.txt"));
2700 assert_eq!(entries[3].status, StatusCode::Deleted.worktree());
2701 });
2702
2703 git_add("a.txt", &repo);
2704 git_add("c.txt", &repo);
2705 git_remove_index(Path::new("d.txt"), &repo);
2706 git_commit("Another commit", &repo);
2707 tree.flush_fs_events(cx).await;
2708 cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
2709 .await;
2710 cx.executor().run_until_parked();
2711
2712 std::fs::remove_file(work_dir.join("a.txt")).unwrap();
2713 std::fs::remove_file(work_dir.join("b.txt")).unwrap();
2714 tree.flush_fs_events(cx).await;
2715 cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
2716 .await;
2717 cx.executor().run_until_parked();
2718
2719 tree.read_with(cx, |tree, _cx| {
2720 let snapshot = tree.snapshot();
2721 let repo = snapshot.repositories().iter().next().unwrap();
2722 let entries = repo.status().collect::<Vec<_>>();
2723
2724 // Deleting an untracked entry, b.txt, should leave no status
2725 // a.txt was tracked, and so should have a status
2726 assert_eq!(
2727 entries.len(),
2728 1,
2729 "Entries length was incorrect\n{:#?}",
2730 &entries
2731 );
2732 assert_eq!(entries[0].repo_path.as_ref(), Path::new("a.txt"));
2733 assert_eq!(entries[0].status, StatusCode::Deleted.worktree());
2734 });
2735}
2736
2737#[gpui::test]
2738async fn test_git_status_postprocessing(cx: &mut TestAppContext) {
2739 init_test(cx);
2740 cx.executor().allow_parking();
2741
2742 let root = TempTree::new(json!({
2743 "project": {
2744 "sub": {},
2745 "a.txt": "",
2746 },
2747 }));
2748
2749 let work_dir = root.path().join("project");
2750 let repo = git_init(work_dir.as_path());
2751 // a.txt exists in HEAD and the working copy but is deleted in the index.
2752 git_add("a.txt", &repo);
2753 git_commit("Initial commit", &repo);
2754 git_remove_index("a.txt".as_ref(), &repo);
2755 // `sub` is a nested git repository.
2756 let _sub = git_init(&work_dir.join("sub"));
2757
2758 let tree = Worktree::local(
2759 root.path(),
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 cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
2770 .await;
2771 cx.executor().run_until_parked();
2772
2773 tree.read_with(cx, |tree, _cx| {
2774 let snapshot = tree.snapshot();
2775 let repo = snapshot.repositories().iter().next().unwrap();
2776 let entries = repo.status().collect::<Vec<_>>();
2777
2778 // `sub` doesn't appear in our computed statuses.
2779 assert_eq!(entries.len(), 1);
2780 assert_eq!(entries[0].repo_path.as_ref(), Path::new("a.txt"));
2781 // a.txt appears with a combined `DA` status.
2782 assert_eq!(
2783 entries[0].status,
2784 TrackedStatus {
2785 index_status: StatusCode::Deleted,
2786 worktree_status: StatusCode::Added
2787 }
2788 .into()
2789 );
2790 });
2791}
2792
2793#[gpui::test]
2794async fn test_repository_subfolder_git_status(cx: &mut TestAppContext) {
2795 init_test(cx);
2796 cx.executor().allow_parking();
2797
2798 let root = TempTree::new(json!({
2799 "my-repo": {
2800 // .git folder will go here
2801 "a.txt": "a",
2802 "sub-folder-1": {
2803 "sub-folder-2": {
2804 "c.txt": "cc",
2805 "d": {
2806 "e.txt": "eee"
2807 }
2808 },
2809 }
2810 },
2811
2812 }));
2813
2814 const C_TXT: &str = "sub-folder-1/sub-folder-2/c.txt";
2815 const E_TXT: &str = "sub-folder-1/sub-folder-2/d/e.txt";
2816
2817 // Set up git repository before creating the worktree.
2818 let git_repo_work_dir = root.path().join("my-repo");
2819 let repo = git_init(git_repo_work_dir.as_path());
2820 git_add(C_TXT, &repo);
2821 git_commit("Initial commit", &repo);
2822
2823 // Open the worktree in subfolder
2824 let project_root = Path::new("my-repo/sub-folder-1/sub-folder-2");
2825 let tree = Worktree::local(
2826 root.path().join(project_root),
2827 true,
2828 Arc::new(RealFs::default()),
2829 Default::default(),
2830 &mut cx.to_async(),
2831 )
2832 .await
2833 .unwrap();
2834
2835 tree.flush_fs_events(cx).await;
2836 tree.flush_fs_events_in_root_git_repository(cx).await;
2837 cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
2838 .await;
2839 cx.executor().run_until_parked();
2840
2841 // Ensure that the git status is loaded correctly
2842 tree.read_with(cx, |tree, _cx| {
2843 let snapshot = tree.snapshot();
2844 assert_eq!(snapshot.repositories().iter().count(), 1);
2845 let repo = snapshot.repositories().iter().next().unwrap();
2846 assert_eq!(
2847 repo.work_directory.canonicalize(),
2848 WorkDirectory::AboveProject {
2849 absolute_path: Arc::from(root.path().join("my-repo").canonicalize().unwrap()),
2850 location_in_repo: Arc::from(Path::new(util::separator!(
2851 "sub-folder-1/sub-folder-2"
2852 )))
2853 }
2854 );
2855
2856 assert_eq!(snapshot.status_for_file("c.txt"), None);
2857 assert_eq!(
2858 snapshot.status_for_file("d/e.txt"),
2859 Some(FileStatus::Untracked)
2860 );
2861 });
2862
2863 // Now we simulate FS events, but ONLY in the .git folder that's outside
2864 // of out project root.
2865 // Meaning: we don't produce any FS events for files inside the project.
2866 git_add(E_TXT, &repo);
2867 git_commit("Second commit", &repo);
2868 tree.flush_fs_events_in_root_git_repository(cx).await;
2869 cx.executor().run_until_parked();
2870
2871 tree.read_with(cx, |tree, _cx| {
2872 let snapshot = tree.snapshot();
2873
2874 assert!(snapshot.repositories().iter().next().is_some());
2875
2876 assert_eq!(snapshot.status_for_file("c.txt"), None);
2877 assert_eq!(snapshot.status_for_file("d/e.txt"), None);
2878 });
2879}
2880
2881#[gpui::test]
2882async fn test_traverse_with_git_status(cx: &mut TestAppContext) {
2883 init_test(cx);
2884 let fs = FakeFs::new(cx.background_executor.clone());
2885 fs.insert_tree(
2886 "/root",
2887 json!({
2888 "x": {
2889 ".git": {},
2890 "x1.txt": "foo",
2891 "x2.txt": "bar",
2892 "y": {
2893 ".git": {},
2894 "y1.txt": "baz",
2895 "y2.txt": "qux"
2896 },
2897 "z.txt": "sneaky..."
2898 },
2899 "z": {
2900 ".git": {},
2901 "z1.txt": "quux",
2902 "z2.txt": "quuux"
2903 }
2904 }),
2905 )
2906 .await;
2907
2908 fs.set_status_for_repo_via_git_operation(
2909 Path::new("/root/x/.git"),
2910 &[
2911 (Path::new("x2.txt"), StatusCode::Modified.index()),
2912 (Path::new("z.txt"), StatusCode::Added.index()),
2913 ],
2914 );
2915 fs.set_status_for_repo_via_git_operation(
2916 Path::new("/root/x/y/.git"),
2917 &[(Path::new("y1.txt"), CONFLICT)],
2918 );
2919 fs.set_status_for_repo_via_git_operation(
2920 Path::new("/root/z/.git"),
2921 &[(Path::new("z2.txt"), StatusCode::Added.index())],
2922 );
2923
2924 let tree = Worktree::local(
2925 Path::new("/root"),
2926 true,
2927 fs.clone(),
2928 Default::default(),
2929 &mut cx.to_async(),
2930 )
2931 .await
2932 .unwrap();
2933
2934 tree.flush_fs_events(cx).await;
2935 cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
2936 .await;
2937 cx.executor().run_until_parked();
2938
2939 let snapshot = tree.read_with(cx, |tree, _| tree.snapshot());
2940
2941 let mut traversal = snapshot
2942 .traverse_from_path(true, false, true, Path::new("x"))
2943 .with_git_statuses();
2944
2945 let entry = traversal.next().unwrap();
2946 assert_eq!(entry.path.as_ref(), Path::new("x/x1.txt"));
2947 assert_eq!(entry.git_summary, GitSummary::UNCHANGED);
2948 let entry = traversal.next().unwrap();
2949 assert_eq!(entry.path.as_ref(), Path::new("x/x2.txt"));
2950 assert_eq!(entry.git_summary, MODIFIED);
2951 let entry = traversal.next().unwrap();
2952 assert_eq!(entry.path.as_ref(), Path::new("x/y/y1.txt"));
2953 assert_eq!(entry.git_summary, GitSummary::CONFLICT);
2954 let entry = traversal.next().unwrap();
2955 assert_eq!(entry.path.as_ref(), Path::new("x/y/y2.txt"));
2956 assert_eq!(entry.git_summary, GitSummary::UNCHANGED);
2957 let entry = traversal.next().unwrap();
2958 assert_eq!(entry.path.as_ref(), Path::new("x/z.txt"));
2959 assert_eq!(entry.git_summary, ADDED);
2960 let entry = traversal.next().unwrap();
2961 assert_eq!(entry.path.as_ref(), Path::new("z/z1.txt"));
2962 assert_eq!(entry.git_summary, GitSummary::UNCHANGED);
2963 let entry = traversal.next().unwrap();
2964 assert_eq!(entry.path.as_ref(), Path::new("z/z2.txt"));
2965 assert_eq!(entry.git_summary, ADDED);
2966}
2967
2968#[gpui::test]
2969async fn test_propagate_git_statuses(cx: &mut TestAppContext) {
2970 init_test(cx);
2971 let fs = FakeFs::new(cx.background_executor.clone());
2972 fs.insert_tree(
2973 "/root",
2974 json!({
2975 ".git": {},
2976 "a": {
2977 "b": {
2978 "c1.txt": "",
2979 "c2.txt": "",
2980 },
2981 "d": {
2982 "e1.txt": "",
2983 "e2.txt": "",
2984 "e3.txt": "",
2985 }
2986 },
2987 "f": {
2988 "no-status.txt": ""
2989 },
2990 "g": {
2991 "h1.txt": "",
2992 "h2.txt": ""
2993 },
2994 }),
2995 )
2996 .await;
2997
2998 fs.set_status_for_repo_via_git_operation(
2999 Path::new("/root/.git"),
3000 &[
3001 (Path::new("a/b/c1.txt"), StatusCode::Added.index()),
3002 (Path::new("a/d/e2.txt"), StatusCode::Modified.index()),
3003 (Path::new("g/h2.txt"), CONFLICT),
3004 ],
3005 );
3006
3007 let tree = Worktree::local(
3008 Path::new("/root"),
3009 true,
3010 fs.clone(),
3011 Default::default(),
3012 &mut cx.to_async(),
3013 )
3014 .await
3015 .unwrap();
3016
3017 cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
3018 .await;
3019
3020 cx.executor().run_until_parked();
3021 let snapshot = tree.read_with(cx, |tree, _| tree.snapshot());
3022
3023 check_git_statuses(
3024 &snapshot,
3025 &[
3026 (Path::new(""), GitSummary::CONFLICT + MODIFIED + ADDED),
3027 (Path::new("g"), GitSummary::CONFLICT),
3028 (Path::new("g/h2.txt"), GitSummary::CONFLICT),
3029 ],
3030 );
3031
3032 check_git_statuses(
3033 &snapshot,
3034 &[
3035 (Path::new(""), GitSummary::CONFLICT + ADDED + MODIFIED),
3036 (Path::new("a"), ADDED + MODIFIED),
3037 (Path::new("a/b"), ADDED),
3038 (Path::new("a/b/c1.txt"), ADDED),
3039 (Path::new("a/b/c2.txt"), GitSummary::UNCHANGED),
3040 (Path::new("a/d"), MODIFIED),
3041 (Path::new("a/d/e2.txt"), MODIFIED),
3042 (Path::new("f"), GitSummary::UNCHANGED),
3043 (Path::new("f/no-status.txt"), GitSummary::UNCHANGED),
3044 (Path::new("g"), GitSummary::CONFLICT),
3045 (Path::new("g/h2.txt"), GitSummary::CONFLICT),
3046 ],
3047 );
3048
3049 check_git_statuses(
3050 &snapshot,
3051 &[
3052 (Path::new("a/b"), ADDED),
3053 (Path::new("a/b/c1.txt"), ADDED),
3054 (Path::new("a/b/c2.txt"), GitSummary::UNCHANGED),
3055 (Path::new("a/d"), MODIFIED),
3056 (Path::new("a/d/e1.txt"), GitSummary::UNCHANGED),
3057 (Path::new("a/d/e2.txt"), MODIFIED),
3058 (Path::new("f"), GitSummary::UNCHANGED),
3059 (Path::new("f/no-status.txt"), GitSummary::UNCHANGED),
3060 (Path::new("g"), GitSummary::CONFLICT),
3061 ],
3062 );
3063
3064 check_git_statuses(
3065 &snapshot,
3066 &[
3067 (Path::new("a/b/c1.txt"), ADDED),
3068 (Path::new("a/b/c2.txt"), GitSummary::UNCHANGED),
3069 (Path::new("a/d/e1.txt"), GitSummary::UNCHANGED),
3070 (Path::new("a/d/e2.txt"), MODIFIED),
3071 (Path::new("f/no-status.txt"), GitSummary::UNCHANGED),
3072 ],
3073 );
3074}
3075
3076#[gpui::test]
3077async fn test_propagate_statuses_for_repos_under_project(cx: &mut TestAppContext) {
3078 init_test(cx);
3079 let fs = FakeFs::new(cx.background_executor.clone());
3080 fs.insert_tree(
3081 "/root",
3082 json!({
3083 "x": {
3084 ".git": {},
3085 "x1.txt": "foo",
3086 "x2.txt": "bar"
3087 },
3088 "y": {
3089 ".git": {},
3090 "y1.txt": "baz",
3091 "y2.txt": "qux"
3092 },
3093 "z": {
3094 ".git": {},
3095 "z1.txt": "quux",
3096 "z2.txt": "quuux"
3097 }
3098 }),
3099 )
3100 .await;
3101
3102 fs.set_status_for_repo_via_git_operation(
3103 Path::new("/root/x/.git"),
3104 &[(Path::new("x1.txt"), StatusCode::Added.index())],
3105 );
3106 fs.set_status_for_repo_via_git_operation(
3107 Path::new("/root/y/.git"),
3108 &[
3109 (Path::new("y1.txt"), CONFLICT),
3110 (Path::new("y2.txt"), StatusCode::Modified.index()),
3111 ],
3112 );
3113 fs.set_status_for_repo_via_git_operation(
3114 Path::new("/root/z/.git"),
3115 &[(Path::new("z2.txt"), StatusCode::Modified.index())],
3116 );
3117
3118 let tree = Worktree::local(
3119 Path::new("/root"),
3120 true,
3121 fs.clone(),
3122 Default::default(),
3123 &mut cx.to_async(),
3124 )
3125 .await
3126 .unwrap();
3127
3128 tree.flush_fs_events(cx).await;
3129 cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
3130 .await;
3131 cx.executor().run_until_parked();
3132
3133 let snapshot = tree.read_with(cx, |tree, _| tree.snapshot());
3134
3135 check_git_statuses(
3136 &snapshot,
3137 &[(Path::new("x"), ADDED), (Path::new("x/x1.txt"), ADDED)],
3138 );
3139
3140 check_git_statuses(
3141 &snapshot,
3142 &[
3143 (Path::new("y"), GitSummary::CONFLICT + MODIFIED),
3144 (Path::new("y/y1.txt"), GitSummary::CONFLICT),
3145 (Path::new("y/y2.txt"), MODIFIED),
3146 ],
3147 );
3148
3149 check_git_statuses(
3150 &snapshot,
3151 &[
3152 (Path::new("z"), MODIFIED),
3153 (Path::new("z/z2.txt"), MODIFIED),
3154 ],
3155 );
3156
3157 check_git_statuses(
3158 &snapshot,
3159 &[(Path::new("x"), ADDED), (Path::new("x/x1.txt"), ADDED)],
3160 );
3161
3162 check_git_statuses(
3163 &snapshot,
3164 &[
3165 (Path::new("x"), ADDED),
3166 (Path::new("x/x1.txt"), ADDED),
3167 (Path::new("x/x2.txt"), GitSummary::UNCHANGED),
3168 (Path::new("y"), GitSummary::CONFLICT + MODIFIED),
3169 (Path::new("y/y1.txt"), GitSummary::CONFLICT),
3170 (Path::new("y/y2.txt"), MODIFIED),
3171 (Path::new("z"), MODIFIED),
3172 (Path::new("z/z1.txt"), GitSummary::UNCHANGED),
3173 (Path::new("z/z2.txt"), MODIFIED),
3174 ],
3175 );
3176}
3177
3178#[gpui::test]
3179async fn test_propagate_statuses_for_nested_repos(cx: &mut TestAppContext) {
3180 init_test(cx);
3181 let fs = FakeFs::new(cx.background_executor.clone());
3182 fs.insert_tree(
3183 "/root",
3184 json!({
3185 "x": {
3186 ".git": {},
3187 "x1.txt": "foo",
3188 "x2.txt": "bar",
3189 "y": {
3190 ".git": {},
3191 "y1.txt": "baz",
3192 "y2.txt": "qux"
3193 },
3194 "z.txt": "sneaky..."
3195 },
3196 "z": {
3197 ".git": {},
3198 "z1.txt": "quux",
3199 "z2.txt": "quuux"
3200 }
3201 }),
3202 )
3203 .await;
3204
3205 fs.set_status_for_repo_via_git_operation(
3206 Path::new("/root/x/.git"),
3207 &[
3208 (Path::new("x2.txt"), StatusCode::Modified.index()),
3209 (Path::new("z.txt"), StatusCode::Added.index()),
3210 ],
3211 );
3212 fs.set_status_for_repo_via_git_operation(
3213 Path::new("/root/x/y/.git"),
3214 &[(Path::new("y1.txt"), CONFLICT)],
3215 );
3216
3217 fs.set_status_for_repo_via_git_operation(
3218 Path::new("/root/z/.git"),
3219 &[(Path::new("z2.txt"), StatusCode::Added.index())],
3220 );
3221
3222 let tree = Worktree::local(
3223 Path::new("/root"),
3224 true,
3225 fs.clone(),
3226 Default::default(),
3227 &mut cx.to_async(),
3228 )
3229 .await
3230 .unwrap();
3231
3232 tree.flush_fs_events(cx).await;
3233 cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
3234 .await;
3235 cx.executor().run_until_parked();
3236
3237 let snapshot = tree.read_with(cx, |tree, _| tree.snapshot());
3238
3239 // Sanity check the propagation for x/y and z
3240 check_git_statuses(
3241 &snapshot,
3242 &[
3243 (Path::new("x/y"), GitSummary::CONFLICT),
3244 (Path::new("x/y/y1.txt"), GitSummary::CONFLICT),
3245 (Path::new("x/y/y2.txt"), GitSummary::UNCHANGED),
3246 ],
3247 );
3248 check_git_statuses(
3249 &snapshot,
3250 &[
3251 (Path::new("z"), ADDED),
3252 (Path::new("z/z1.txt"), GitSummary::UNCHANGED),
3253 (Path::new("z/z2.txt"), ADDED),
3254 ],
3255 );
3256
3257 // Test one of the fundamental cases of propagation blocking, the transition from one git repository to another
3258 check_git_statuses(
3259 &snapshot,
3260 &[
3261 (Path::new("x"), MODIFIED + ADDED),
3262 (Path::new("x/y"), GitSummary::CONFLICT),
3263 (Path::new("x/y/y1.txt"), GitSummary::CONFLICT),
3264 ],
3265 );
3266
3267 // Sanity check everything around it
3268 check_git_statuses(
3269 &snapshot,
3270 &[
3271 (Path::new("x"), MODIFIED + ADDED),
3272 (Path::new("x/x1.txt"), GitSummary::UNCHANGED),
3273 (Path::new("x/x2.txt"), MODIFIED),
3274 (Path::new("x/y"), GitSummary::CONFLICT),
3275 (Path::new("x/y/y1.txt"), GitSummary::CONFLICT),
3276 (Path::new("x/y/y2.txt"), GitSummary::UNCHANGED),
3277 (Path::new("x/z.txt"), ADDED),
3278 ],
3279 );
3280
3281 // Test the other fundamental case, transitioning from git repository to non-git repository
3282 check_git_statuses(
3283 &snapshot,
3284 &[
3285 (Path::new(""), GitSummary::UNCHANGED),
3286 (Path::new("x"), MODIFIED + ADDED),
3287 (Path::new("x/x1.txt"), GitSummary::UNCHANGED),
3288 ],
3289 );
3290
3291 // And all together now
3292 check_git_statuses(
3293 &snapshot,
3294 &[
3295 (Path::new(""), GitSummary::UNCHANGED),
3296 (Path::new("x"), MODIFIED + ADDED),
3297 (Path::new("x/x1.txt"), GitSummary::UNCHANGED),
3298 (Path::new("x/x2.txt"), MODIFIED),
3299 (Path::new("x/y"), GitSummary::CONFLICT),
3300 (Path::new("x/y/y1.txt"), GitSummary::CONFLICT),
3301 (Path::new("x/y/y2.txt"), GitSummary::UNCHANGED),
3302 (Path::new("x/z.txt"), ADDED),
3303 (Path::new("z"), ADDED),
3304 (Path::new("z/z1.txt"), GitSummary::UNCHANGED),
3305 (Path::new("z/z2.txt"), ADDED),
3306 ],
3307 );
3308}
3309
3310#[gpui::test]
3311async fn test_private_single_file_worktree(cx: &mut TestAppContext) {
3312 init_test(cx);
3313 let fs = FakeFs::new(cx.background_executor.clone());
3314 fs.insert_tree("/", json!({".env": "PRIVATE=secret\n"}))
3315 .await;
3316 let tree = Worktree::local(
3317 Path::new("/.env"),
3318 true,
3319 fs.clone(),
3320 Default::default(),
3321 &mut cx.to_async(),
3322 )
3323 .await
3324 .unwrap();
3325 cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
3326 .await;
3327 tree.read_with(cx, |tree, _| {
3328 let entry = tree.entry_for_path("").unwrap();
3329 assert!(entry.is_private);
3330 });
3331}
3332
3333#[track_caller]
3334fn check_git_statuses(snapshot: &Snapshot, expected_statuses: &[(&Path, GitSummary)]) {
3335 let mut traversal = snapshot
3336 .traverse_from_path(true, true, false, "".as_ref())
3337 .with_git_statuses();
3338 let found_statuses = expected_statuses
3339 .iter()
3340 .map(|&(path, _)| {
3341 let git_entry = traversal
3342 .find(|git_entry| &*git_entry.path == path)
3343 .unwrap_or_else(|| panic!("Traversal has no entry for {path:?}"));
3344 (path, git_entry.git_summary)
3345 })
3346 .collect::<Vec<_>>();
3347 assert_eq!(found_statuses, expected_statuses);
3348}
3349
3350const ADDED: GitSummary = GitSummary {
3351 index: TrackedSummary::ADDED,
3352 count: 1,
3353 ..GitSummary::UNCHANGED
3354};
3355const MODIFIED: GitSummary = GitSummary {
3356 index: TrackedSummary::MODIFIED,
3357 count: 1,
3358 ..GitSummary::UNCHANGED
3359};
3360
3361#[track_caller]
3362fn git_init(path: &Path) -> git2::Repository {
3363 git2::Repository::init(path).expect("Failed to initialize git repository")
3364}
3365
3366#[track_caller]
3367fn git_add<P: AsRef<Path>>(path: P, repo: &git2::Repository) {
3368 let path = path.as_ref();
3369 let mut index = repo.index().expect("Failed to get index");
3370 index.add_path(path).expect("Failed to add file");
3371 index.write().expect("Failed to write index");
3372}
3373
3374#[track_caller]
3375fn git_remove_index(path: &Path, repo: &git2::Repository) {
3376 let mut index = repo.index().expect("Failed to get index");
3377 index.remove_path(path).expect("Failed to add file");
3378 index.write().expect("Failed to write index");
3379}
3380
3381#[track_caller]
3382fn git_commit(msg: &'static str, repo: &git2::Repository) {
3383 use git2::Signature;
3384
3385 let signature = Signature::now("test", "test@zed.dev").unwrap();
3386 let oid = repo.index().unwrap().write_tree().unwrap();
3387 let tree = repo.find_tree(oid).unwrap();
3388 if let Ok(head) = repo.head() {
3389 let parent_obj = head.peel(git2::ObjectType::Commit).unwrap();
3390
3391 let parent_commit = parent_obj.as_commit().unwrap();
3392
3393 repo.commit(
3394 Some("HEAD"),
3395 &signature,
3396 &signature,
3397 msg,
3398 &tree,
3399 &[parent_commit],
3400 )
3401 .expect("Failed to commit with parent");
3402 } else {
3403 repo.commit(Some("HEAD"), &signature, &signature, msg, &tree, &[])
3404 .expect("Failed to commit");
3405 }
3406}
3407
3408#[track_caller]
3409fn git_stash(repo: &mut git2::Repository) {
3410 use git2::Signature;
3411
3412 let signature = Signature::now("test", "test@zed.dev").unwrap();
3413 repo.stash_save(&signature, "N/A", None)
3414 .expect("Failed to stash");
3415}
3416
3417#[track_caller]
3418fn git_reset(offset: usize, repo: &git2::Repository) {
3419 let head = repo.head().expect("Couldn't get repo head");
3420 let object = head.peel(git2::ObjectType::Commit).unwrap();
3421 let commit = object.as_commit().unwrap();
3422 let new_head = commit
3423 .parents()
3424 .inspect(|parnet| {
3425 parnet.message();
3426 })
3427 .nth(offset)
3428 .expect("Not enough history");
3429 repo.reset(new_head.as_object(), git2::ResetType::Soft, None)
3430 .expect("Could not reset");
3431}
3432
3433#[allow(dead_code)]
3434#[track_caller]
3435fn git_status(repo: &git2::Repository) -> collections::HashMap<String, git2::Status> {
3436 repo.statuses(None)
3437 .unwrap()
3438 .iter()
3439 .map(|status| (status.path().unwrap().to_string(), status.status()))
3440 .collect()
3441}
3442
3443#[track_caller]
3444fn check_worktree_entries(
3445 tree: &Worktree,
3446 expected_excluded_paths: &[&str],
3447 expected_ignored_paths: &[&str],
3448 expected_tracked_paths: &[&str],
3449 expected_included_paths: &[&str],
3450) {
3451 for path in expected_excluded_paths {
3452 let entry = tree.entry_for_path(path);
3453 assert!(
3454 entry.is_none(),
3455 "expected path '{path}' to be excluded, but got entry: {entry:?}",
3456 );
3457 }
3458 for path in expected_ignored_paths {
3459 let entry = tree
3460 .entry_for_path(path)
3461 .unwrap_or_else(|| panic!("Missing entry for expected ignored path '{path}'"));
3462 assert!(
3463 entry.is_ignored,
3464 "expected path '{path}' to be ignored, but got entry: {entry:?}",
3465 );
3466 }
3467 for path in expected_tracked_paths {
3468 let entry = tree
3469 .entry_for_path(path)
3470 .unwrap_or_else(|| panic!("Missing entry for expected tracked path '{path}'"));
3471 assert!(
3472 !entry.is_ignored || entry.is_always_included,
3473 "expected path '{path}' to be tracked, but got entry: {entry:?}",
3474 );
3475 }
3476 for path in expected_included_paths {
3477 let entry = tree
3478 .entry_for_path(path)
3479 .unwrap_or_else(|| panic!("Missing entry for expected included path '{path}'"));
3480 assert!(
3481 entry.is_always_included,
3482 "expected path '{path}' to always be included, but got entry: {entry:?}",
3483 );
3484 }
3485}
3486
3487fn init_test(cx: &mut gpui::TestAppContext) {
3488 if std::env::var("RUST_LOG").is_ok() {
3489 env_logger::try_init().ok();
3490 }
3491
3492 cx.update(|cx| {
3493 let settings_store = SettingsStore::test(cx);
3494 cx.set_global(settings_store);
3495 WorktreeSettings::register(cx);
3496 });
3497}
3498
3499fn assert_entry_git_state(
3500 tree: &Worktree,
3501 path: &str,
3502 index_status: Option<StatusCode>,
3503 is_ignored: bool,
3504) {
3505 let entry = tree.entry_for_path(path).expect("entry {path} not found");
3506 let status = tree.status_for_file(Path::new(path));
3507 let expected = index_status.map(|index_status| {
3508 TrackedStatus {
3509 index_status,
3510 worktree_status: StatusCode::Unmodified,
3511 }
3512 .into()
3513 });
3514 assert_eq!(
3515 status, expected,
3516 "expected {path} to have git status: {expected:?}"
3517 );
3518 assert_eq!(
3519 entry.is_ignored, is_ignored,
3520 "expected {path} to have is_ignored: {is_ignored}"
3521 );
3522}