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