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