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