Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T06:49:06.557Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

git_store.rs

1799 lines · 60.3 KB · rust
1mod conflict_set_tests {
2    use std::sync::mpsc;
3
4    use crate::Project;
5
6    use fs::FakeFs;
7    use git::{
8        repository::{RepoPath, repo_path},
9        status::{UnmergedStatus, UnmergedStatusCode},
10    };
11    use gpui::{BackgroundExecutor, TestAppContext};
12    use project::git_store::*;
13    use serde_json::json;
14    use text::{Buffer, BufferId, OffsetRangeExt, Point, ReplicaId, ToOffset as _};
15    use unindent::Unindent as _;
16    use util::{path, rel_path::rel_path};
17
18    #[test]
19    fn test_parse_conflicts_in_buffer() {
20        // Create a buffer with conflict markers
21        let test_content = r#"
22            This is some text before the conflict.
23            <<<<<<< HEAD
24            This is our version
25            =======
26            This is their version
27            >>>>>>> branch-name
28
29            Another conflict:
30            <<<<<<< HEAD
31            Our second change
32            ||||||| merged common ancestors
33            Original content
34            =======
35            Their second change
36            >>>>>>> branch-name
37        "#
38        .unindent();
39
40        let buffer_id = BufferId::new(1).unwrap();
41        let buffer = Buffer::new(ReplicaId::LOCAL, buffer_id, test_content);
42        let snapshot = buffer.snapshot();
43
44        let conflict_snapshot = ConflictSet::parse(&snapshot);
45        assert_eq!(conflict_snapshot.conflicts.len(), 2);
46
47        let first = &conflict_snapshot.conflicts[0];
48        assert!(first.base.is_none());
49        assert_eq!(first.ours_branch_name.as_ref(), "HEAD");
50        assert_eq!(first.theirs_branch_name.as_ref(), "branch-name");
51        let our_text = snapshot
52            .text_for_range(first.ours.clone())
53            .collect::<String>();
54        let their_text = snapshot
55            .text_for_range(first.theirs.clone())
56            .collect::<String>();
57        assert_eq!(our_text, "This is our version\n");
58        assert_eq!(their_text, "This is their version\n");
59
60        let second = &conflict_snapshot.conflicts[1];
61        assert!(second.base.is_some());
62        assert_eq!(second.ours_branch_name.as_ref(), "HEAD");
63        assert_eq!(second.theirs_branch_name.as_ref(), "branch-name");
64        let our_text = snapshot
65            .text_for_range(second.ours.clone())
66            .collect::<String>();
67        let their_text = snapshot
68            .text_for_range(second.theirs.clone())
69            .collect::<String>();
70        let base_text = snapshot
71            .text_for_range(second.base.as_ref().unwrap().clone())
72            .collect::<String>();
73        assert_eq!(our_text, "Our second change\n");
74        assert_eq!(their_text, "Their second change\n");
75        assert_eq!(base_text, "Original content\n");
76
77        // Test conflicts_in_range
78        let range = snapshot.anchor_before(0)..snapshot.anchor_before(snapshot.len());
79        let conflicts_in_range = conflict_snapshot.conflicts_in_range(range, &snapshot);
80        assert_eq!(conflicts_in_range.len(), 2);
81
82        // Test with a range that includes only the first conflict
83        let first_conflict_end = conflict_snapshot.conflicts[0].range.end;
84        let range = snapshot.anchor_before(0)..first_conflict_end;
85        let conflicts_in_range = conflict_snapshot.conflicts_in_range(range, &snapshot);
86        assert_eq!(conflicts_in_range.len(), 1);
87
88        // Test with a range that includes only the second conflict
89        let second_conflict_start = conflict_snapshot.conflicts[1].range.start;
90        let range = second_conflict_start..snapshot.anchor_before(snapshot.len());
91        let conflicts_in_range = conflict_snapshot.conflicts_in_range(range, &snapshot);
92        assert_eq!(conflicts_in_range.len(), 1);
93
94        // Test with a range that doesn't include any conflicts
95        let range = buffer.anchor_after(first_conflict_end.to_next_offset(&buffer))
96            ..buffer.anchor_before(second_conflict_start.to_previous_offset(&buffer));
97        let conflicts_in_range = conflict_snapshot.conflicts_in_range(range, &snapshot);
98        assert_eq!(conflicts_in_range.len(), 0);
99    }
100
101    #[test]
102    fn test_nested_conflict_markers() {
103        // Create a buffer with nested conflict markers
104        let test_content = r#"
105            This is some text before the conflict.
106            <<<<<<< HEAD
107            This is our version
108            <<<<<<< HEAD
109            This is a nested conflict marker
110            =======
111            This is their version in a nested conflict
112            >>>>>>> branch-nested
113            =======
114            This is their version
115            >>>>>>> branch-name
116        "#
117        .unindent();
118
119        let buffer_id = BufferId::new(1).unwrap();
120        let buffer = Buffer::new(ReplicaId::LOCAL, buffer_id, test_content);
121        let snapshot = buffer.snapshot();
122
123        let conflict_snapshot = ConflictSet::parse(&snapshot);
124
125        assert_eq!(conflict_snapshot.conflicts.len(), 1);
126
127        // The conflict should have our version, their version, but no base
128        let conflict = &conflict_snapshot.conflicts[0];
129        assert!(conflict.base.is_none());
130        assert_eq!(conflict.ours_branch_name.as_ref(), "HEAD");
131        assert_eq!(conflict.theirs_branch_name.as_ref(), "branch-nested");
132
133        // Check that the nested conflict was detected correctly
134        let our_text = snapshot
135            .text_for_range(conflict.ours.clone())
136            .collect::<String>();
137        assert_eq!(our_text, "This is a nested conflict marker\n");
138        let their_text = snapshot
139            .text_for_range(conflict.theirs.clone())
140            .collect::<String>();
141        assert_eq!(their_text, "This is their version in a nested conflict\n");
142    }
143
144    #[test]
145    fn test_conflict_markers_at_eof() {
146        let test_content = r#"
147            <<<<<<< ours
148            =======
149            This is their version
150            >>>>>>> "#
151            .unindent();
152        let buffer_id = BufferId::new(1).unwrap();
153        let buffer = Buffer::new(ReplicaId::LOCAL, buffer_id, test_content);
154        let snapshot = buffer.snapshot();
155
156        let conflict_snapshot = ConflictSet::parse(&snapshot);
157        assert_eq!(conflict_snapshot.conflicts.len(), 1);
158        assert_eq!(
159            conflict_snapshot.conflicts[0].ours_branch_name.as_ref(),
160            "ours"
161        );
162        assert_eq!(
163            conflict_snapshot.conflicts[0].theirs_branch_name.as_ref(),
164            "Origin" // default branch name if there is none
165        );
166    }
167
168    #[test]
169    fn test_conflicts_in_range() {
170        // Create a buffer with conflict markers
171        let test_content = r#"
172            one
173            <<<<<<< HEAD1
174            two
175            =======
176            three
177            >>>>>>> branch1
178            four
179            five
180            <<<<<<< HEAD2
181            six
182            =======
183            seven
184            >>>>>>> branch2
185            eight
186            nine
187            <<<<<<< HEAD3
188            ten
189            =======
190            eleven
191            >>>>>>> branch3
192            twelve
193            <<<<<<< HEAD4
194            thirteen
195            =======
196            fourteen
197            >>>>>>> branch4
198            fifteen
199        "#
200        .unindent();
201
202        let buffer_id = BufferId::new(1).unwrap();
203        let buffer = Buffer::new(ReplicaId::LOCAL, buffer_id, test_content.clone());
204        let snapshot = buffer.snapshot();
205
206        let conflict_snapshot = ConflictSet::parse(&snapshot);
207        assert_eq!(conflict_snapshot.conflicts.len(), 4);
208        assert_eq!(
209            conflict_snapshot.conflicts[0].ours_branch_name.as_ref(),
210            "HEAD1"
211        );
212        assert_eq!(
213            conflict_snapshot.conflicts[0].theirs_branch_name.as_ref(),
214            "branch1"
215        );
216        assert_eq!(
217            conflict_snapshot.conflicts[1].ours_branch_name.as_ref(),
218            "HEAD2"
219        );
220        assert_eq!(
221            conflict_snapshot.conflicts[1].theirs_branch_name.as_ref(),
222            "branch2"
223        );
224        assert_eq!(
225            conflict_snapshot.conflicts[2].ours_branch_name.as_ref(),
226            "HEAD3"
227        );
228        assert_eq!(
229            conflict_snapshot.conflicts[2].theirs_branch_name.as_ref(),
230            "branch3"
231        );
232        assert_eq!(
233            conflict_snapshot.conflicts[3].ours_branch_name.as_ref(),
234            "HEAD4"
235        );
236        assert_eq!(
237            conflict_snapshot.conflicts[3].theirs_branch_name.as_ref(),
238            "branch4"
239        );
240
241        let range = test_content.find("seven").unwrap()..test_content.find("eleven").unwrap();
242        let range = buffer.anchor_before(range.start)..buffer.anchor_after(range.end);
243        assert_eq!(
244            conflict_snapshot.conflicts_in_range(range, &snapshot),
245            &conflict_snapshot.conflicts[1..=2]
246        );
247
248        let range = test_content.find("one").unwrap()..test_content.find("<<<<<<< HEAD2").unwrap();
249        let range = buffer.anchor_before(range.start)..buffer.anchor_after(range.end);
250        assert_eq!(
251            conflict_snapshot.conflicts_in_range(range, &snapshot),
252            &conflict_snapshot.conflicts[0..=1]
253        );
254
255        let range =
256            test_content.find("eight").unwrap() - 1..test_content.find(">>>>>>> branch3").unwrap();
257        let range = buffer.anchor_before(range.start)..buffer.anchor_after(range.end);
258        assert_eq!(
259            conflict_snapshot.conflicts_in_range(range, &snapshot),
260            &conflict_snapshot.conflicts[1..=2]
261        );
262
263        let range = test_content.find("thirteen").unwrap() - 1..test_content.len();
264        let range = buffer.anchor_before(range.start)..buffer.anchor_after(range.end);
265        assert_eq!(
266            conflict_snapshot.conflicts_in_range(range, &snapshot),
267            &conflict_snapshot.conflicts[3..=3]
268        );
269    }
270
271    #[gpui::test]
272    async fn test_conflict_updates(executor: BackgroundExecutor, cx: &mut TestAppContext) {
273        zlog::init_test();
274        cx.update(|cx| {
275            settings::init(cx);
276        });
277        let initial_text = "
278            one
279            two
280            three
281            four
282            five
283        "
284        .unindent();
285        let fs = FakeFs::new(executor);
286        fs.insert_tree(
287            path!("/project"),
288            json!({
289                ".git": {},
290                "a.txt": initial_text,
291            }),
292        )
293        .await;
294        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
295        let (git_store, buffer) = project.update(cx, |project, cx| {
296            (
297                project.git_store().clone(),
298                project.open_local_buffer(path!("/project/a.txt"), cx),
299            )
300        });
301        let buffer = buffer.await.unwrap();
302        let conflict_set = git_store
303            .update(cx, |git_store, cx| {
304                git_store.open_conflict_set(buffer.clone(), cx)
305            })
306            .await;
307        let (events_tx, events_rx) = mpsc::channel::<ConflictSetUpdate>();
308        let _conflict_set_subscription = cx.update(|cx| {
309            cx.subscribe(&conflict_set, move |_, event, _| {
310                events_tx.send(event.clone()).ok();
311            })
312        });
313        let conflicts_snapshot =
314            conflict_set.read_with(cx, |conflict_set, _| conflict_set.snapshot());
315        assert!(conflicts_snapshot.conflicts.is_empty());
316
317        buffer.update(cx, |buffer, cx| {
318            buffer.edit(
319                [
320                    (4..4, "<<<<<<< HEAD\n"),
321                    (14..14, "=======\nTWO\n>>>>>>> branch\n"),
322                ],
323                None,
324                cx,
325            );
326        });
327
328        cx.run_until_parked();
329        events_rx.try_recv().expect_err(
330            "no conflicts should be registered as long as the file's status is unchanged",
331        );
332
333        fs.with_git_state(path!("/project/.git").as_ref(), true, |state| {
334            state.unmerged_paths.insert(
335                repo_path("a.txt"),
336                UnmergedStatus {
337                    first_head: UnmergedStatusCode::Updated,
338                    second_head: UnmergedStatusCode::Updated,
339                },
340            );
341            // Cause the repository to update cached conflicts
342            state.refs.insert("MERGE_HEAD".into(), "123".into())
343        })
344        .unwrap();
345
346        cx.run_until_parked();
347        let update = events_rx
348            .try_recv()
349            .expect("status change should trigger conflict parsing");
350        assert_eq!(update.old_range, 0..0);
351        assert_eq!(update.new_range, 0..1);
352
353        let conflict = conflict_set.read_with(cx, |conflict_set, _| {
354            conflict_set.snapshot().conflicts[0].clone()
355        });
356        cx.update(|cx| {
357            conflict.resolve(buffer.clone(), std::slice::from_ref(&conflict.theirs), cx);
358        });
359
360        cx.run_until_parked();
361        let update = events_rx
362            .try_recv()
363            .expect("conflicts should be removed after resolution");
364        assert_eq!(update.old_range, 0..1);
365        assert_eq!(update.new_range, 0..0);
366    }
367
368    #[gpui::test]
369    async fn test_conflict_updates_without_merge_head(
370        executor: BackgroundExecutor,
371        cx: &mut TestAppContext,
372    ) {
373        zlog::init_test();
374        cx.update(|cx| {
375            settings::init(cx);
376        });
377
378        let initial_text = "
379            zero
380            <<<<<<< HEAD
381            one
382            =======
383            two
384            >>>>>>> Stashed Changes
385            three
386        "
387        .unindent();
388
389        let fs = FakeFs::new(executor);
390        fs.insert_tree(
391            path!("/project"),
392            json!({
393                ".git": {},
394                "a.txt": initial_text,
395            }),
396        )
397        .await;
398
399        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
400        let (git_store, buffer) = project.update(cx, |project, cx| {
401            (
402                project.git_store().clone(),
403                project.open_local_buffer(path!("/project/a.txt"), cx),
404            )
405        });
406
407        cx.run_until_parked();
408        fs.with_git_state(path!("/project/.git").as_ref(), true, |state| {
409            state.unmerged_paths.insert(
410                RepoPath::from_rel_path(rel_path("a.txt")),
411                UnmergedStatus {
412                    first_head: UnmergedStatusCode::Updated,
413                    second_head: UnmergedStatusCode::Updated,
414                },
415            )
416        })
417        .unwrap();
418
419        let buffer = buffer.await.unwrap();
420
421        // Open the conflict set for a file that currently has conflicts.
422        let conflict_set = git_store
423            .update(cx, |git_store, cx| {
424                git_store.open_conflict_set(buffer.clone(), cx)
425            })
426            .await;
427
428        cx.run_until_parked();
429        conflict_set.update(cx, |conflict_set, cx| {
430            let conflict_range = conflict_set.snapshot().conflicts[0]
431                .range
432                .to_point(buffer.read(cx));
433            assert_eq!(conflict_range, Point::new(1, 0)..Point::new(6, 0));
434        });
435
436        // Simulate the conflict being removed by e.g. staging the file.
437        fs.with_git_state(path!("/project/.git").as_ref(), true, |state| {
438            state.unmerged_paths.remove(&repo_path("a.txt"))
439        })
440        .unwrap();
441
442        cx.run_until_parked();
443        conflict_set.update(cx, |conflict_set, _| {
444            assert!(!conflict_set.has_conflict);
445            assert_eq!(conflict_set.snapshot.conflicts.len(), 0);
446        });
447
448        // Simulate the conflict being re-added.
449        fs.with_git_state(path!("/project/.git").as_ref(), true, |state| {
450            state.unmerged_paths.insert(
451                repo_path("a.txt"),
452                UnmergedStatus {
453                    first_head: UnmergedStatusCode::Updated,
454                    second_head: UnmergedStatusCode::Updated,
455                },
456            )
457        })
458        .unwrap();
459
460        cx.run_until_parked();
461        conflict_set.update(cx, |conflict_set, cx| {
462            let conflict_range = conflict_set.snapshot().conflicts[0]
463                .range
464                .to_point(buffer.read(cx));
465            assert_eq!(conflict_range, Point::new(1, 0)..Point::new(6, 0));
466        });
467    }
468
469    #[gpui::test]
470    async fn test_conflict_updates_with_delayed_merge_head_conflicts(
471        executor: BackgroundExecutor,
472        cx: &mut TestAppContext,
473    ) {
474        zlog::init_test();
475        cx.update(|cx| {
476            settings::init(cx);
477        });
478
479        let initial_text = "
480            one
481            two
482            three
483            four
484        "
485        .unindent();
486
487        let conflicted_text = "
488            one
489            <<<<<<< HEAD
490            two
491            =======
492            TWO
493            >>>>>>> branch
494            three
495            four
496        "
497        .unindent();
498
499        let resolved_text = "
500            one
501            TWO
502            three
503            four
504        "
505        .unindent();
506
507        let fs = FakeFs::new(executor);
508        fs.insert_tree(
509            path!("/project"),
510            json!({
511                ".git": {},
512                "a.txt": initial_text,
513            }),
514        )
515        .await;
516
517        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
518        let (git_store, buffer) = project.update(cx, |project, cx| {
519            (
520                project.git_store().clone(),
521                project.open_local_buffer(path!("/project/a.txt"), cx),
522            )
523        });
524        let buffer = buffer.await.unwrap();
525        let conflict_set = git_store
526            .update(cx, |git_store, cx| {
527                git_store.open_conflict_set(buffer.clone(), cx)
528            })
529            .await;
530
531        let (events_tx, events_rx) = mpsc::channel::<ConflictSetUpdate>();
532        let _conflict_set_subscription = cx.update(|cx| {
533            cx.subscribe(&conflict_set, move |_, event, _| {
534                events_tx.send(event.clone()).ok();
535            })
536        });
537
538        cx.run_until_parked();
539        events_rx
540            .try_recv()
541            .expect_err("conflict set should start empty");
542
543        fs.with_git_state(path!("/project/.git").as_ref(), true, |state| {
544            state.refs.insert("MERGE_HEAD".into(), "123".into())
545        })
546        .unwrap();
547
548        cx.run_until_parked();
549        events_rx
550            .try_recv()
551            .expect_err("merge head without conflicted paths should not publish conflicts");
552        conflict_set.update(cx, |conflict_set, _| {
553            assert!(!conflict_set.has_conflict);
554            assert_eq!(conflict_set.snapshot.conflicts.len(), 0);
555        });
556
557        buffer.update(cx, |buffer, cx| {
558            buffer.set_text(conflicted_text.clone(), cx);
559        });
560        fs.with_git_state(path!("/project/.git").as_ref(), true, |state| {
561            state.unmerged_paths.insert(
562                repo_path("a.txt"),
563                UnmergedStatus {
564                    first_head: UnmergedStatusCode::Updated,
565                    second_head: UnmergedStatusCode::Updated,
566                },
567            );
568        })
569        .unwrap();
570
571        cx.run_until_parked();
572        let update = events_rx
573            .try_recv()
574            .expect("conflicts should appear once conflicted paths are visible");
575        assert_eq!(update.old_range, 0..0);
576        assert_eq!(update.new_range, 0..1);
577        conflict_set.update(cx, |conflict_set, cx| {
578            assert!(conflict_set.has_conflict);
579            let conflict_range = conflict_set.snapshot().conflicts[0]
580                .range
581                .to_point(buffer.read(cx));
582            assert_eq!(conflict_range, Point::new(1, 0)..Point::new(6, 0));
583        });
584
585        buffer.update(cx, |buffer, cx| {
586            buffer.set_text(resolved_text.clone(), cx);
587        });
588
589        cx.run_until_parked();
590        let update = events_rx
591            .try_recv()
592            .expect("resolved buffer text should clear visible conflict markers");
593        assert_eq!(update.old_range, 0..1);
594        assert_eq!(update.new_range, 0..0);
595        conflict_set.update(cx, |conflict_set, _| {
596            assert!(conflict_set.has_conflict);
597            assert_eq!(conflict_set.snapshot.conflicts.len(), 0);
598        });
599
600        fs.with_git_state(path!("/project/.git").as_ref(), true, |state| {
601            state.refs.insert("MERGE_HEAD".into(), "456".into());
602        })
603        .unwrap();
604
605        cx.run_until_parked();
606        events_rx.try_recv().expect_err(
607            "merge-head change without unmerged-path changes should not emit marker updates",
608        );
609        conflict_set.update(cx, |conflict_set, _| {
610            assert!(conflict_set.has_conflict);
611            assert_eq!(conflict_set.snapshot.conflicts.len(), 0);
612        });
613
614        fs.with_git_state(path!("/project/.git").as_ref(), true, |state| {
615            state.unmerged_paths.remove(&repo_path("a.txt"));
616            state.refs.remove("MERGE_HEAD");
617        })
618        .unwrap();
619
620        cx.run_until_parked();
621        let update = events_rx.try_recv().expect(
622            "status catch-up should emit a no-op update when clearing stale conflict state",
623        );
624        assert_eq!(update.old_range, 0..0);
625        assert_eq!(update.new_range, 0..0);
626        assert!(update.buffer_range.is_none());
627        conflict_set.update(cx, |conflict_set, _| {
628            assert!(!conflict_set.has_conflict);
629            assert_eq!(conflict_set.snapshot.conflicts.len(), 0);
630        });
631    }
632}
633
634mod git_traversal {
635    use std::{path::Path, time::Duration};
636
637    use collections::HashMap;
638    use project::{
639        Project,
640        git_store::{RepositoryId, RepositorySnapshot},
641    };
642
643    use fs::FakeFs;
644    use git::status::{
645        FileStatus, GitSummary, StatusCode, TrackedSummary, UnmergedStatus, UnmergedStatusCode,
646    };
647    use gpui::TestAppContext;
648    use project::GitTraversal;
649
650    use serde_json::json;
651    use settings::SettingsStore;
652    use util::{
653        path,
654        rel_path::{RelPath, rel_path},
655    };
656
657    const CONFLICT: FileStatus = FileStatus::Unmerged(UnmergedStatus {
658        first_head: UnmergedStatusCode::Updated,
659        second_head: UnmergedStatusCode::Updated,
660    });
661    const ADDED: GitSummary = GitSummary {
662        index: TrackedSummary::ADDED,
663        count: 1,
664        ..GitSummary::UNCHANGED
665    };
666    const MODIFIED: GitSummary = GitSummary {
667        index: TrackedSummary::MODIFIED,
668        count: 1,
669        ..GitSummary::UNCHANGED
670    };
671
672    #[gpui::test]
673    async fn test_git_traversal_with_one_repo(cx: &mut TestAppContext) {
674        init_test(cx);
675        let fs = FakeFs::new(cx.background_executor.clone());
676        fs.insert_tree(
677            path!("/root"),
678            json!({
679                "x": {
680                    ".git": {},
681                    "x1.txt": "foo",
682                    "x2.txt": "bar",
683                    "y": {
684                        ".git": {},
685                        "y1.txt": "baz",
686                        "y2.txt": "qux"
687                    },
688                    "z.txt": "sneaky..."
689                },
690                "z": {
691                    ".git": {},
692                    "z1.txt": "quux",
693                    "z2.txt": "quuux"
694                }
695            }),
696        )
697        .await;
698
699        fs.set_status_for_repo(
700            Path::new(path!("/root/x/.git")),
701            &[
702                ("x2.txt", StatusCode::Modified.index()),
703                ("z.txt", StatusCode::Added.index()),
704            ],
705        );
706        fs.set_status_for_repo(Path::new(path!("/root/x/y/.git")), &[("y1.txt", CONFLICT)]);
707        fs.set_status_for_repo(
708            Path::new(path!("/root/z/.git")),
709            &[("z2.txt", StatusCode::Added.index())],
710        );
711
712        let project = Project::test(fs, [path!("/root").as_ref()], cx).await;
713        cx.executor().run_until_parked();
714
715        let (repo_snapshots, worktree_snapshot) = project.read_with(cx, |project, cx| {
716            (
717                project.git_store().read(cx).repo_snapshots(cx),
718                project.worktrees(cx).next().unwrap().read(cx).snapshot(),
719            )
720        });
721
722        let traversal = GitTraversal::new(
723            &repo_snapshots,
724            worktree_snapshot.traverse_from_path(
725                true,
726                false,
727                true,
728                RelPath::from_unix_str("x").unwrap(),
729            ),
730        );
731        let entries = traversal
732            .map(|entry| (entry.path.clone(), entry.git_summary))
733            .collect::<Vec<_>>();
734        pretty_assertions::assert_eq!(
735            entries,
736            [
737                (rel_path("x/x1.txt").into(), GitSummary::UNCHANGED),
738                (rel_path("x/x2.txt").into(), MODIFIED),
739                (rel_path("x/y/y1.txt").into(), GitSummary::CONFLICT),
740                (rel_path("x/y/y2.txt").into(), GitSummary::UNCHANGED),
741                (rel_path("x/z.txt").into(), ADDED),
742                (rel_path("z/z1.txt").into(), GitSummary::UNCHANGED),
743                (rel_path("z/z2.txt").into(), ADDED),
744            ]
745        )
746    }
747
748    #[gpui::test]
749    async fn test_git_traversal_with_nested_repos(cx: &mut TestAppContext) {
750        init_test(cx);
751        let fs = FakeFs::new(cx.background_executor.clone());
752        fs.insert_tree(
753            path!("/root"),
754            json!({
755                "x": {
756                    ".git": {},
757                    "x1.txt": "foo",
758                    "x2.txt": "bar",
759                    "y": {
760                        ".git": {},
761                        "y1.txt": "baz",
762                        "y2.txt": "qux"
763                    },
764                    "z.txt": "sneaky..."
765                },
766                "z": {
767                    ".git": {},
768                    "z1.txt": "quux",
769                    "z2.txt": "quuux"
770                }
771            }),
772        )
773        .await;
774
775        fs.set_status_for_repo(
776            Path::new(path!("/root/x/.git")),
777            &[
778                ("x2.txt", StatusCode::Modified.index()),
779                ("z.txt", StatusCode::Added.index()),
780            ],
781        );
782        fs.set_status_for_repo(Path::new(path!("/root/x/y/.git")), &[("y1.txt", CONFLICT)]);
783
784        fs.set_status_for_repo(
785            Path::new(path!("/root/z/.git")),
786            &[("z2.txt", StatusCode::Added.index())],
787        );
788
789        let project = Project::test(fs, [path!("/root").as_ref()], cx).await;
790        cx.executor().run_until_parked();
791
792        let (repo_snapshots, worktree_snapshot) = project.read_with(cx, |project, cx| {
793            (
794                project.git_store().read(cx).repo_snapshots(cx),
795                project.worktrees(cx).next().unwrap().read(cx).snapshot(),
796            )
797        });
798
799        // Sanity check the propagation for x/y and z
800        check_git_statuses(
801            &repo_snapshots,
802            &worktree_snapshot,
803            &[
804                ("x/y", GitSummary::CONFLICT),
805                ("x/y/y1.txt", GitSummary::CONFLICT),
806                ("x/y/y2.txt", GitSummary::UNCHANGED),
807            ],
808        );
809        check_git_statuses(
810            &repo_snapshots,
811            &worktree_snapshot,
812            &[
813                ("z", ADDED),
814                ("z/z1.txt", GitSummary::UNCHANGED),
815                ("z/z2.txt", ADDED),
816            ],
817        );
818
819        // Test one of the fundamental cases of propagation blocking, the transition from one git repository to another
820        check_git_statuses(
821            &repo_snapshots,
822            &worktree_snapshot,
823            &[
824                ("x", MODIFIED + ADDED),
825                ("x/y", GitSummary::CONFLICT),
826                ("x/y/y1.txt", GitSummary::CONFLICT),
827            ],
828        );
829
830        // Sanity check everything around it
831        check_git_statuses(
832            &repo_snapshots,
833            &worktree_snapshot,
834            &[
835                ("x", MODIFIED + ADDED),
836                ("x/x1.txt", GitSummary::UNCHANGED),
837                ("x/x2.txt", MODIFIED),
838                ("x/y", GitSummary::CONFLICT),
839                ("x/y/y1.txt", GitSummary::CONFLICT),
840                ("x/y/y2.txt", GitSummary::UNCHANGED),
841                ("x/z.txt", ADDED),
842            ],
843        );
844
845        // Test the other fundamental case, transitioning from git repository to non-git repository
846        check_git_statuses(
847            &repo_snapshots,
848            &worktree_snapshot,
849            &[
850                ("", GitSummary::UNCHANGED),
851                ("x", MODIFIED + ADDED),
852                ("x/x1.txt", GitSummary::UNCHANGED),
853            ],
854        );
855
856        // And all together now
857        check_git_statuses(
858            &repo_snapshots,
859            &worktree_snapshot,
860            &[
861                ("", GitSummary::UNCHANGED),
862                ("x", MODIFIED + ADDED),
863                ("x/x1.txt", GitSummary::UNCHANGED),
864                ("x/x2.txt", MODIFIED),
865                ("x/y", GitSummary::CONFLICT),
866                ("x/y/y1.txt", GitSummary::CONFLICT),
867                ("x/y/y2.txt", GitSummary::UNCHANGED),
868                ("x/z.txt", ADDED),
869                ("z", ADDED),
870                ("z/z1.txt", GitSummary::UNCHANGED),
871                ("z/z2.txt", ADDED),
872            ],
873        );
874    }
875
876    #[gpui::test]
877    async fn test_git_traversal_simple(cx: &mut TestAppContext) {
878        init_test(cx);
879        let fs = FakeFs::new(cx.background_executor.clone());
880        fs.insert_tree(
881            path!("/root"),
882            json!({
883                ".git": {},
884                "a": {
885                    "b": {
886                        "c1.txt": "",
887                        "c2.txt": "",
888                    },
889                    "d": {
890                        "e1.txt": "",
891                        "e2.txt": "",
892                        "e3.txt": "",
893                    }
894                },
895                "f": {
896                    "no-status.txt": ""
897                },
898                "g": {
899                    "h1.txt": "",
900                    "h2.txt": ""
901                },
902            }),
903        )
904        .await;
905
906        fs.set_status_for_repo(
907            Path::new(path!("/root/.git")),
908            &[
909                ("a/b/c1.txt", StatusCode::Added.index()),
910                ("a/d/e2.txt", StatusCode::Modified.index()),
911                ("g/h2.txt", CONFLICT),
912            ],
913        );
914
915        let project = Project::test(fs, [path!("/root").as_ref()], cx).await;
916        cx.executor().run_until_parked();
917
918        let (repo_snapshots, worktree_snapshot) = project.read_with(cx, |project, cx| {
919            (
920                project.git_store().read(cx).repo_snapshots(cx),
921                project.worktrees(cx).next().unwrap().read(cx).snapshot(),
922            )
923        });
924
925        check_git_statuses(
926            &repo_snapshots,
927            &worktree_snapshot,
928            &[
929                ("", GitSummary::CONFLICT + MODIFIED + ADDED),
930                ("g", GitSummary::CONFLICT),
931                ("g/h2.txt", GitSummary::CONFLICT),
932            ],
933        );
934
935        check_git_statuses(
936            &repo_snapshots,
937            &worktree_snapshot,
938            &[
939                ("", GitSummary::CONFLICT + ADDED + MODIFIED),
940                ("a", ADDED + MODIFIED),
941                ("a/b", ADDED),
942                ("a/b/c1.txt", ADDED),
943                ("a/b/c2.txt", GitSummary::UNCHANGED),
944                ("a/d", MODIFIED),
945                ("a/d/e2.txt", MODIFIED),
946                ("f", GitSummary::UNCHANGED),
947                ("f/no-status.txt", GitSummary::UNCHANGED),
948                ("g", GitSummary::CONFLICT),
949                ("g/h2.txt", GitSummary::CONFLICT),
950            ],
951        );
952
953        check_git_statuses(
954            &repo_snapshots,
955            &worktree_snapshot,
956            &[
957                ("a/b", ADDED),
958                ("a/b/c1.txt", ADDED),
959                ("a/b/c2.txt", GitSummary::UNCHANGED),
960                ("a/d", MODIFIED),
961                ("a/d/e1.txt", GitSummary::UNCHANGED),
962                ("a/d/e2.txt", MODIFIED),
963                ("f", GitSummary::UNCHANGED),
964                ("f/no-status.txt", GitSummary::UNCHANGED),
965                ("g", GitSummary::CONFLICT),
966            ],
967        );
968
969        check_git_statuses(
970            &repo_snapshots,
971            &worktree_snapshot,
972            &[
973                ("a/b/c1.txt", ADDED),
974                ("a/b/c2.txt", GitSummary::UNCHANGED),
975                ("a/d/e1.txt", GitSummary::UNCHANGED),
976                ("a/d/e2.txt", MODIFIED),
977                ("f/no-status.txt", GitSummary::UNCHANGED),
978            ],
979        );
980    }
981
982    #[gpui::test]
983    async fn test_git_traversal_with_repos_under_project(cx: &mut TestAppContext) {
984        init_test(cx);
985        let fs = FakeFs::new(cx.background_executor.clone());
986        fs.insert_tree(
987            path!("/root"),
988            json!({
989                "x": {
990                    ".git": {},
991                    "x1.txt": "foo",
992                    "x2.txt": "bar"
993                },
994                "y": {
995                    ".git": {},
996                    "y1.txt": "baz",
997                    "y2.txt": "qux"
998                },
999                "z": {
1000                    ".git": {},
1001                    "z1.txt": "quux",
1002                    "z2.txt": "quuux"
1003                }
1004            }),
1005        )
1006        .await;
1007
1008        fs.set_status_for_repo(
1009            Path::new(path!("/root/x/.git")),
1010            &[("x1.txt", StatusCode::Added.index())],
1011        );
1012        fs.set_status_for_repo(
1013            Path::new(path!("/root/y/.git")),
1014            &[
1015                ("y1.txt", CONFLICT),
1016                ("y2.txt", StatusCode::Modified.index()),
1017            ],
1018        );
1019        fs.set_status_for_repo(
1020            Path::new(path!("/root/z/.git")),
1021            &[("z2.txt", StatusCode::Modified.index())],
1022        );
1023
1024        let project = Project::test(fs, [path!("/root").as_ref()], cx).await;
1025        cx.executor().run_until_parked();
1026
1027        let (repo_snapshots, worktree_snapshot) = project.read_with(cx, |project, cx| {
1028            (
1029                project.git_store().read(cx).repo_snapshots(cx),
1030                project.worktrees(cx).next().unwrap().read(cx).snapshot(),
1031            )
1032        });
1033
1034        check_git_statuses(
1035            &repo_snapshots,
1036            &worktree_snapshot,
1037            &[("x", ADDED), ("x/x1.txt", ADDED)],
1038        );
1039
1040        check_git_statuses(
1041            &repo_snapshots,
1042            &worktree_snapshot,
1043            &[
1044                ("y", GitSummary::CONFLICT + MODIFIED),
1045                ("y/y1.txt", GitSummary::CONFLICT),
1046                ("y/y2.txt", MODIFIED),
1047            ],
1048        );
1049
1050        check_git_statuses(
1051            &repo_snapshots,
1052            &worktree_snapshot,
1053            &[("z", MODIFIED), ("z/z2.txt", MODIFIED)],
1054        );
1055
1056        check_git_statuses(
1057            &repo_snapshots,
1058            &worktree_snapshot,
1059            &[("x", ADDED), ("x/x1.txt", ADDED)],
1060        );
1061
1062        check_git_statuses(
1063            &repo_snapshots,
1064            &worktree_snapshot,
1065            &[
1066                ("x", ADDED),
1067                ("x/x1.txt", ADDED),
1068                ("x/x2.txt", GitSummary::UNCHANGED),
1069                ("y", GitSummary::CONFLICT + MODIFIED),
1070                ("y/y1.txt", GitSummary::CONFLICT),
1071                ("y/y2.txt", MODIFIED),
1072                ("z", MODIFIED),
1073                ("z/z1.txt", GitSummary::UNCHANGED),
1074                ("z/z2.txt", MODIFIED),
1075            ],
1076        );
1077    }
1078
1079    fn init_test(cx: &mut gpui::TestAppContext) {
1080        zlog::init_test();
1081
1082        cx.update(|cx| {
1083            let settings_store = SettingsStore::test(cx);
1084            cx.set_global(settings_store);
1085        });
1086    }
1087
1088    #[gpui::test]
1089    async fn test_bump_mtime_of_git_repo_workdir(cx: &mut TestAppContext) {
1090        init_test(cx);
1091
1092        // Create a worktree with a git directory.
1093        let fs = FakeFs::new(cx.background_executor.clone());
1094        fs.insert_tree(
1095            path!("/root"),
1096            json!({
1097                ".git": {},
1098                "a.txt": "",
1099                "b": {
1100                    "c.txt": "",
1101                },
1102            }),
1103        )
1104        .await;
1105        fs.set_head_and_index_for_repo(
1106            path!("/root/.git").as_ref(),
1107            &[("a.txt", "".into()), ("b/c.txt", "".into())],
1108        );
1109        cx.run_until_parked();
1110
1111        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
1112        cx.executor().run_until_parked();
1113
1114        let (old_entry_ids, old_mtimes) = project.read_with(cx, |project, cx| {
1115            let tree = project.worktrees(cx).next().unwrap().read(cx);
1116            (
1117                tree.entries(true, 0).map(|e| e.id).collect::<Vec<_>>(),
1118                tree.entries(true, 0).map(|e| e.mtime).collect::<Vec<_>>(),
1119            )
1120        });
1121
1122        // Regression test: after the directory is scanned, touch the git repo's
1123        // working directory, bumping its mtime. That directory keeps its project
1124        // entry id after the directories are re-scanned.
1125        fs.touch_path(path!("/root")).await;
1126        cx.executor().run_until_parked();
1127
1128        let (new_entry_ids, new_mtimes) = project.read_with(cx, |project, cx| {
1129            let tree = project.worktrees(cx).next().unwrap().read(cx);
1130            (
1131                tree.entries(true, 0).map(|e| e.id).collect::<Vec<_>>(),
1132                tree.entries(true, 0).map(|e| e.mtime).collect::<Vec<_>>(),
1133            )
1134        });
1135        assert_eq!(new_entry_ids, old_entry_ids);
1136        assert_ne!(new_mtimes, old_mtimes);
1137
1138        // Regression test: changes to the git repository should still be
1139        // detected.
1140        fs.set_head_for_repo(
1141            path!("/root/.git").as_ref(),
1142            &[("a.txt", "".into()), ("b/c.txt", "something-else".into())],
1143            "deadbeef",
1144        );
1145        cx.executor().run_until_parked();
1146        cx.executor().advance_clock(Duration::from_secs(1));
1147
1148        let (repo_snapshots, worktree_snapshot) = project.read_with(cx, |project, cx| {
1149            (
1150                project.git_store().read(cx).repo_snapshots(cx),
1151                project.worktrees(cx).next().unwrap().read(cx).snapshot(),
1152            )
1153        });
1154
1155        check_git_statuses(
1156            &repo_snapshots,
1157            &worktree_snapshot,
1158            &[
1159                ("", MODIFIED),
1160                ("a.txt", GitSummary::UNCHANGED),
1161                ("b/c.txt", MODIFIED),
1162            ],
1163        );
1164    }
1165
1166    #[track_caller]
1167    fn check_git_statuses(
1168        repo_snapshots: &HashMap<RepositoryId, RepositorySnapshot>,
1169        worktree_snapshot: &worktree::Snapshot,
1170        expected_statuses: &[(&str, GitSummary)],
1171    ) {
1172        let mut traversal = GitTraversal::new(
1173            repo_snapshots,
1174            worktree_snapshot.traverse_from_path(true, true, false, RelPath::empty()),
1175        );
1176        let found_statuses = expected_statuses
1177            .iter()
1178            .map(|&(path, _)| {
1179                let git_entry = traversal
1180                    .find(|git_entry| git_entry.path.as_ref() == rel_path(path))
1181                    .unwrap_or_else(|| panic!("Traversal has no entry for {path:?}"));
1182                (path, git_entry.git_summary)
1183            })
1184            .collect::<Vec<_>>();
1185        pretty_assertions::assert_eq!(found_statuses, expected_statuses);
1186    }
1187}
1188
1189mod git_worktrees {
1190    use fs::{FakeFs, Fs};
1191    use gpui::TestAppContext;
1192    use project::worktrees_directory_for_repo;
1193    use serde_json::json;
1194    use settings::SettingsStore;
1195    use std::path::{Path, PathBuf};
1196    use util::{path, paths::PathStyle};
1197
1198    fn init_test(cx: &mut gpui::TestAppContext) {
1199        zlog::init_test();
1200
1201        cx.update(|cx| {
1202            let settings_store = SettingsStore::test(cx);
1203            cx.set_global(settings_store);
1204        });
1205    }
1206
1207    #[test]
1208    fn test_validate_worktree_directory() {
1209        let work_dir = Path::new("/code/my-project");
1210
1211        // Valid: sibling
1212        assert!(worktrees_directory_for_repo(work_dir, "../worktrees", PathStyle::Unix).is_ok());
1213
1214        // Valid: subdirectory
1215        assert!(
1216            worktrees_directory_for_repo(work_dir, ".git/zed-worktrees", PathStyle::Unix).is_ok()
1217        );
1218        assert!(worktrees_directory_for_repo(work_dir, "my-worktrees", PathStyle::Unix).is_ok());
1219
1220        // Invalid: just ".." would resolve back to the working directory itself
1221        let err = worktrees_directory_for_repo(work_dir, "..", PathStyle::Unix).unwrap_err();
1222        assert!(err.to_string().contains("must not be \"..\""));
1223
1224        // Invalid: ".." with trailing separators
1225        let err = worktrees_directory_for_repo(work_dir, "..\\", PathStyle::Unix).unwrap_err();
1226        assert!(err.to_string().contains("must not be \"..\""));
1227        let err = worktrees_directory_for_repo(work_dir, "../", PathStyle::Unix).unwrap_err();
1228        assert!(err.to_string().contains("must not be \"..\""));
1229
1230        // Invalid: empty string would resolve to the working directory itself
1231        let err = worktrees_directory_for_repo(work_dir, "", PathStyle::Unix).unwrap_err();
1232        assert!(err.to_string().contains("must not be empty"));
1233
1234        // Invalid: absolute path
1235        let err =
1236            worktrees_directory_for_repo(work_dir, "/tmp/worktrees", PathStyle::Unix).unwrap_err();
1237        assert!(err.to_string().contains("relative path"));
1238
1239        // Invalid: "/" is absolute on Unix
1240        let err = worktrees_directory_for_repo(work_dir, "/", PathStyle::Unix).unwrap_err();
1241        assert!(err.to_string().contains("relative path"));
1242
1243        // Invalid: "///" is absolute
1244        let err = worktrees_directory_for_repo(work_dir, "///", PathStyle::Unix).unwrap_err();
1245        assert!(err.to_string().contains("relative path"));
1246
1247        // Invalid: escapes too far up
1248        let err = worktrees_directory_for_repo(work_dir, "../../other-project/wt", PathStyle::Unix)
1249            .unwrap_err();
1250        assert!(err.to_string().contains("outside"));
1251    }
1252
1253    #[test]
1254    fn test_worktree_directory_uses_remote_path_style() {
1255        let work_dir = Path::new("/home/user/dev/lsp-tests");
1256
1257        let directory =
1258            worktrees_directory_for_repo(work_dir, "../worktrees", PathStyle::Unix).unwrap();
1259
1260        assert_eq!(
1261            directory,
1262            PathBuf::from("/home/user/dev/worktrees/lsp-tests")
1263        );
1264    }
1265
1266    #[gpui::test]
1267    async fn test_git_worktrees_list_and_create(cx: &mut TestAppContext) {
1268        init_test(cx);
1269        let fs = FakeFs::new(cx.background_executor.clone());
1270        fs.insert_tree(
1271            path!("/root"),
1272            json!({
1273                ".git": {},
1274                "file.txt": "content",
1275            }),
1276        )
1277        .await;
1278
1279        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
1280        cx.executor().run_until_parked();
1281
1282        let repository = project.read_with(cx, |project, cx| {
1283            project.repositories(cx).values().next().unwrap().clone()
1284        });
1285
1286        let worktrees = cx
1287            .update(|cx| repository.update(cx, |repository, _| repository.worktrees()))
1288            .await
1289            .unwrap()
1290            .unwrap();
1291        assert_eq!(worktrees.len(), 1);
1292        assert_eq!(worktrees[0].path, PathBuf::from(path!("/root")));
1293
1294        let worktrees_directory = PathBuf::from(path!("/root"));
1295        let worktree_1_directory = worktrees_directory.join("feature-branch");
1296        cx.update(|cx| {
1297            repository.update(cx, |repository, _| {
1298                repository.create_worktree(
1299                    git::repository::CreateWorktreeTarget::NewBranch {
1300                        branch_name: "feature-branch".to_string(),
1301                        base_sha: Some("abc123".to_string()),
1302                    },
1303                    worktree_1_directory.clone(),
1304                )
1305            })
1306        })
1307        .await
1308        .unwrap()
1309        .unwrap();
1310
1311        cx.executor().run_until_parked();
1312
1313        let worktrees = cx
1314            .update(|cx| repository.update(cx, |repository, _| repository.worktrees()))
1315            .await
1316            .unwrap()
1317            .unwrap();
1318        assert_eq!(worktrees.len(), 2);
1319        assert_eq!(worktrees[0].path, PathBuf::from(path!("/root")));
1320        assert_eq!(worktrees[1].path, worktree_1_directory);
1321        assert_eq!(
1322            worktrees[1].ref_name,
1323            Some("refs/heads/feature-branch".into())
1324        );
1325        assert_eq!(worktrees[1].sha.as_ref(), "abc123");
1326
1327        let worktree_2_directory = worktrees_directory.join("bugfix-branch");
1328        cx.update(|cx| {
1329            repository.update(cx, |repository, _| {
1330                repository.create_worktree(
1331                    git::repository::CreateWorktreeTarget::NewBranch {
1332                        branch_name: "bugfix-branch".to_string(),
1333                        base_sha: None,
1334                    },
1335                    worktree_2_directory.clone(),
1336                )
1337            })
1338        })
1339        .await
1340        .unwrap()
1341        .unwrap();
1342
1343        cx.executor().run_until_parked();
1344
1345        // List worktrees — should now have main + two created
1346        let worktrees = cx
1347            .update(|cx| repository.update(cx, |repository, _| repository.worktrees()))
1348            .await
1349            .unwrap()
1350            .unwrap();
1351        assert_eq!(worktrees.len(), 3);
1352
1353        let worktree_1 = worktrees
1354            .iter()
1355            .find(|worktree| worktree.ref_name == Some("refs/heads/feature-branch".into()))
1356            .expect("should find feature-branch worktree");
1357        assert_eq!(worktree_1.path, worktree_1_directory);
1358
1359        let worktree_2 = worktrees
1360            .iter()
1361            .find(|worktree| worktree.ref_name == Some("refs/heads/bugfix-branch".into()))
1362            .expect("should find bugfix-branch worktree");
1363        assert_eq!(worktree_2.path, worktree_2_directory);
1364        assert_eq!(worktree_2.sha.as_ref(), "fake-sha");
1365    }
1366
1367    #[gpui::test]
1368    async fn test_remove_worktree_removes_managed_parent_directories(cx: &mut TestAppContext) {
1369        init_test(cx);
1370        let fs = FakeFs::new(cx.background_executor.clone());
1371        fs.insert_tree(
1372            path!("/root"),
1373            json!({
1374                ".git": {},
1375                "file.txt": "content",
1376            }),
1377        )
1378        .await;
1379
1380        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
1381        cx.executor().run_until_parked();
1382
1383        let repository = project.read_with(cx, |project, cx| {
1384            project.repositories(cx).values().next().unwrap().clone()
1385        });
1386
1387        let worktree_path = PathBuf::from(path!("/worktrees/root/feature/nested/root"));
1388        let worktree_parent = PathBuf::from(path!("/worktrees/root/feature/nested"));
1389        let worktree_intermediate_parent = PathBuf::from(path!("/worktrees/root/feature"));
1390        let worktree_base = PathBuf::from(path!("/worktrees/root"));
1391
1392        cx.update(|cx| {
1393            repository.update(cx, |repository, _| {
1394                repository.create_worktree(
1395                    git::repository::CreateWorktreeTarget::NewBranch {
1396                        branch_name: "feature/nested".to_string(),
1397                        base_sha: Some("abc123".to_string()),
1398                    },
1399                    worktree_path.clone(),
1400                )
1401            })
1402        })
1403        .await
1404        .unwrap()
1405        .unwrap();
1406
1407        assert!(Fs::is_dir(fs.as_ref(), &worktree_path).await);
1408        assert!(Fs::is_dir(fs.as_ref(), &worktree_parent).await);
1409        assert!(Fs::is_dir(fs.as_ref(), &worktree_intermediate_parent).await);
1410        assert!(Fs::is_dir(fs.as_ref(), &worktree_base).await);
1411
1412        cx.update(|cx| {
1413            repository.update(cx, |repository, _| {
1414                repository.remove_worktree(worktree_path.clone(), false)
1415            })
1416        })
1417        .await
1418        .unwrap()
1419        .unwrap();
1420
1421        cx.executor().run_until_parked();
1422
1423        assert!(!Fs::is_dir(fs.as_ref(), &worktree_path).await);
1424        assert!(!Fs::is_dir(fs.as_ref(), &worktree_parent).await);
1425        assert!(!Fs::is_dir(fs.as_ref(), &worktree_intermediate_parent).await);
1426        assert!(Fs::is_dir(fs.as_ref(), &worktree_base).await);
1427    }
1428
1429    use crate::Project;
1430}
1431
1432mod trust_tests {
1433    use collections::HashSet;
1434    use fs::FakeFs;
1435    use gpui::TestAppContext;
1436    use project::trusted_worktrees::*;
1437
1438    use serde_json::json;
1439    use settings::SettingsStore;
1440    use util::path;
1441
1442    use crate::Project;
1443
1444    fn init_test(cx: &mut TestAppContext) {
1445        zlog::init_test();
1446
1447        cx.update(|cx| {
1448            let settings_store = SettingsStore::test(cx);
1449            cx.set_global(settings_store);
1450        });
1451    }
1452
1453    #[gpui::test]
1454    async fn test_repository_defaults_to_untrusted_without_trust_system(cx: &mut TestAppContext) {
1455        init_test(cx);
1456        let fs = FakeFs::new(cx.background_executor.clone());
1457        fs.insert_tree(
1458            path!("/project"),
1459            json!({
1460                ".git": {},
1461                "a.txt": "hello",
1462            }),
1463        )
1464        .await;
1465
1466        // Create project without trust system — repos should default to untrusted.
1467        let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
1468        cx.executor().run_until_parked();
1469
1470        let repository = project.read_with(cx, |project, cx| {
1471            project.repositories(cx).values().next().unwrap().clone()
1472        });
1473
1474        repository.read_with(cx, |repo, _| {
1475            assert!(
1476                !repo.is_trusted(),
1477                "repository should default to untrusted when no trust system is initialized"
1478            );
1479        });
1480    }
1481
1482    #[gpui::test]
1483    async fn test_multiple_repos_trust_with_single_worktree(cx: &mut TestAppContext) {
1484        init_test(cx);
1485        let fs = FakeFs::new(cx.background_executor.clone());
1486        fs.insert_tree(
1487            path!("/project"),
1488            json!({
1489                ".git": {},
1490                "a.txt": "hello",
1491                "sub": {
1492                    ".git": {},
1493                    "b.txt": "world",
1494                },
1495            }),
1496        )
1497        .await;
1498
1499        cx.update(|cx| {
1500            init(DbTrustedPaths::default(), cx);
1501        });
1502
1503        let project =
1504            Project::test_with_worktree_trust(fs.clone(), [path!("/project").as_ref()], cx).await;
1505        cx.executor().run_until_parked();
1506
1507        let worktree_store = project.read_with(cx, |project, _| project.worktree_store());
1508        let worktree_id = worktree_store.read_with(cx, |store, cx| {
1509            store.worktrees().next().unwrap().read(cx).id()
1510        });
1511
1512        let repos = project.read_with(cx, |project, cx| {
1513            project
1514                .repositories(cx)
1515                .values()
1516                .cloned()
1517                .collect::<Vec<_>>()
1518        });
1519        assert_eq!(repos.len(), 2, "should have two repositories");
1520        for repo in &repos {
1521            repo.read_with(cx, |repo, _| {
1522                assert!(
1523                    !repo.is_trusted(),
1524                    "all repos should be untrusted initially"
1525                );
1526            });
1527        }
1528
1529        let trusted_worktrees = cx
1530            .update(|cx| TrustedWorktrees::try_get_global(cx).expect("trust global should be set"));
1531        trusted_worktrees.update(cx, |store, cx| {
1532            store.trust(
1533                &worktree_store,
1534                HashSet::from_iter([PathTrust::Worktree(worktree_id)]),
1535                cx,
1536            );
1537        });
1538        cx.executor().run_until_parked();
1539
1540        for repo in &repos {
1541            repo.read_with(cx, |repo, _| {
1542                assert!(
1543                    repo.is_trusted(),
1544                    "all repos should be trusted after worktree is trusted"
1545                );
1546            });
1547        }
1548    }
1549
1550    #[gpui::test]
1551    async fn test_repository_trust_restrict_trust_cycle(cx: &mut TestAppContext) {
1552        init_test(cx);
1553        let fs = FakeFs::new(cx.background_executor.clone());
1554        fs.insert_tree(
1555            path!("/project"),
1556            json!({
1557                ".git": {},
1558                "a.txt": "hello",
1559            }),
1560        )
1561        .await;
1562
1563        cx.update(|cx| {
1564            project::trusted_worktrees::init(DbTrustedPaths::default(), cx);
1565        });
1566
1567        let project =
1568            Project::test_with_worktree_trust(fs.clone(), [path!("/project").as_ref()], cx).await;
1569        cx.executor().run_until_parked();
1570
1571        let worktree_store = project.read_with(cx, |project, _| project.worktree_store());
1572        let worktree_id = worktree_store.read_with(cx, |store, cx| {
1573            store.worktrees().next().unwrap().read(cx).id()
1574        });
1575
1576        let repository = project.read_with(cx, |project, cx| {
1577            project.repositories(cx).values().next().unwrap().clone()
1578        });
1579
1580        repository.read_with(cx, |repo, _| {
1581            assert!(!repo.is_trusted(), "repository should start untrusted");
1582        });
1583
1584        let trusted_worktrees = cx
1585            .update(|cx| TrustedWorktrees::try_get_global(cx).expect("trust global should be set"));
1586
1587        trusted_worktrees.update(cx, |store, cx| {
1588            store.trust(
1589                &worktree_store,
1590                HashSet::from_iter([PathTrust::Worktree(worktree_id)]),
1591                cx,
1592            );
1593        });
1594        cx.executor().run_until_parked();
1595
1596        repository.read_with(cx, |repo, _| {
1597            assert!(
1598                repo.is_trusted(),
1599                "repository should be trusted after worktree is trusted"
1600            );
1601        });
1602
1603        trusted_worktrees.update(cx, |store, cx| {
1604            store.restrict(
1605                worktree_store.downgrade(),
1606                HashSet::from_iter([PathTrust::Worktree(worktree_id)]),
1607                cx,
1608            );
1609        });
1610        cx.executor().run_until_parked();
1611
1612        repository.read_with(cx, |repo, _| {
1613            assert!(
1614                !repo.is_trusted(),
1615                "repository should be untrusted after worktree is restricted"
1616            );
1617        });
1618
1619        trusted_worktrees.update(cx, |store, cx| {
1620            store.trust(
1621                &worktree_store,
1622                HashSet::from_iter([PathTrust::Worktree(worktree_id)]),
1623                cx,
1624            );
1625        });
1626        cx.executor().run_until_parked();
1627
1628        repository.read_with(cx, |repo, _| {
1629            assert!(
1630                repo.is_trusted(),
1631                "repository should be trusted again after second trust"
1632            );
1633        });
1634    }
1635}
1636
1637mod resolve_worktree_tests {
1638    use fs::FakeFs;
1639    use gpui::TestAppContext;
1640    use project::{
1641        git_store::resolve_git_worktree_to_main_repo, linked_worktree_short_name,
1642        repo_identity_path,
1643    };
1644    use serde_json::json;
1645    use std::path::{Path, PathBuf};
1646
1647    #[gpui::test]
1648    async fn test_resolve_git_worktree_to_main_repo(cx: &mut TestAppContext) {
1649        let fs = FakeFs::new(cx.executor());
1650        // Set up a main repo with a worktree entry
1651        fs.insert_tree(
1652            "/main-repo",
1653            json!({
1654                ".git": {
1655                    "worktrees": {
1656                        "feature": {
1657                            "commondir": "../../",
1658                            "HEAD": "ref: refs/heads/feature"
1659                        }
1660                    }
1661                },
1662                "src": { "main.rs": "" }
1663            }),
1664        )
1665        .await;
1666        // Set up a worktree checkout pointing back to the main repo
1667        fs.insert_tree(
1668            "/worktree-checkout",
1669            json!({
1670                ".git": "gitdir: /main-repo/.git/worktrees/feature",
1671                "src": { "main.rs": "" }
1672            }),
1673        )
1674        .await;
1675
1676        let result =
1677            resolve_git_worktree_to_main_repo(fs.as_ref(), Path::new("/worktree-checkout")).await;
1678        assert_eq!(result, Some(PathBuf::from("/main-repo")));
1679    }
1680
1681    #[gpui::test]
1682    async fn test_resolve_git_worktree_normal_repo_returns_none(cx: &mut TestAppContext) {
1683        let fs = FakeFs::new(cx.executor());
1684        fs.insert_tree(
1685            "/repo",
1686            json!({
1687                ".git": {},
1688                "src": { "main.rs": "" }
1689            }),
1690        )
1691        .await;
1692
1693        let result = resolve_git_worktree_to_main_repo(fs.as_ref(), Path::new("/repo")).await;
1694        assert_eq!(result, None);
1695    }
1696
1697    #[gpui::test]
1698    async fn test_resolve_git_worktree_bare_repo_identity_path(cx: &mut TestAppContext) {
1699        let fs = FakeFs::new(cx.executor());
1700        fs.insert_tree(
1701            "/monty/.bare",
1702            json!({
1703                "worktrees": {
1704                    "feature-a": {
1705                        "commondir": "../../",
1706                        "HEAD": "ref: refs/heads/feature-a"
1707                    }
1708                }
1709            }),
1710        )
1711        .await;
1712        fs.insert_tree(
1713            "/monty/feature-a",
1714            json!({
1715                ".git": "gitdir: /monty/.bare/worktrees/feature-a",
1716                "src": { "main.rs": "" }
1717            }),
1718        )
1719        .await;
1720
1721        let result =
1722            resolve_git_worktree_to_main_repo(fs.as_ref(), Path::new("/monty/feature-a")).await;
1723        assert_eq!(result, Some(PathBuf::from("/monty")));
1724    }
1725
1726    #[gpui::test]
1727    async fn test_resolve_git_worktree_no_git_returns_none(cx: &mut TestAppContext) {
1728        let fs = FakeFs::new(cx.executor());
1729        fs.insert_tree(
1730            "/plain",
1731            json!({
1732                "src": { "main.rs": "" }
1733            }),
1734        )
1735        .await;
1736
1737        let result = resolve_git_worktree_to_main_repo(fs.as_ref(), Path::new("/plain")).await;
1738        assert_eq!(result, None);
1739    }
1740
1741    #[gpui::test]
1742    async fn test_resolve_git_worktree_nonexistent_returns_none(cx: &mut TestAppContext) {
1743        let fs = FakeFs::new(cx.executor());
1744
1745        let result =
1746            resolve_git_worktree_to_main_repo(fs.as_ref(), Path::new("/does-not-exist")).await;
1747        assert_eq!(result, None);
1748    }
1749
1750    #[test]
1751    fn test_repo_identity_path() {
1752        let examples = [
1753            // Normal checkout: `.git` starts with `.`, so parent is the worktree
1754            ("/home/bob/zed/.git", "/home/bob/zed"),
1755            // Bare clone named `.bare`: starts with `.`, so parent is the project dir
1756            ("/repos/project/.bare", "/repos/project"),
1757            // Bare clone with `.git` extension: does not start with `.`, kept as-is
1758            ("/repos/zed.git", "/repos/zed.git"),
1759            // Bare clone with arbitrary plain name: kept as-is
1760            ("/repos/project", "/repos/project"),
1761        ];
1762        for (common_dir, expected) in examples {
1763            assert_eq!(
1764                repo_identity_path(Path::new(common_dir)),
1765                Path::new(expected),
1766                "identity path for common_dir {common_dir:?} should be {expected:?}"
1767            );
1768        }
1769    }
1770
1771    #[test]
1772    fn test_linked_worktree_short_name() {
1773        let examples = [
1774            (
1775                "/home/bob/zed",
1776                "/home/bob/worktrees/olivetti/zed",
1777                Some("olivetti".into()),
1778            ),
1779            ("/home/bob/zed", "/home/bob/zed2", Some("zed2".into())),
1780            (
1781                "/home/bob/zed",
1782                "/home/bob/worktrees/zed/selectric",
1783                Some("selectric".into()),
1784            ),
1785            ("/home/bob/zed", "/home/bob/zed", None),
1786        ];
1787        for (main_worktree_path, linked_worktree_path, expected) in examples {
1788            let short_name = linked_worktree_short_name(
1789                Path::new(main_worktree_path),
1790                Path::new(linked_worktree_path),
1791            );
1792            assert_eq!(
1793                short_name, expected,
1794                "short name for {linked_worktree_path:?}, linked worktree of {main_worktree_path:?}, should be {expected:?}"
1795            );
1796        }
1797    }
1798}
1799
Served at tenant.openagents/omega Member data and write actions are omitted.