git_store.rs

   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.update(cx, |git_store, cx| {
 303            git_store.open_conflict_set(buffer.clone(), cx)
 304        });
 305        let (events_tx, events_rx) = mpsc::channel::<ConflictSetUpdate>();
 306        let _conflict_set_subscription = cx.update(|cx| {
 307            cx.subscribe(&conflict_set, move |_, event, _| {
 308                events_tx.send(event.clone()).ok();
 309            })
 310        });
 311        let conflicts_snapshot =
 312            conflict_set.read_with(cx, |conflict_set, _| conflict_set.snapshot());
 313        assert!(conflicts_snapshot.conflicts.is_empty());
 314
 315        buffer.update(cx, |buffer, cx| {
 316            buffer.edit(
 317                [
 318                    (4..4, "<<<<<<< HEAD\n"),
 319                    (14..14, "=======\nTWO\n>>>>>>> branch\n"),
 320                ],
 321                None,
 322                cx,
 323            );
 324        });
 325
 326        cx.run_until_parked();
 327        events_rx.try_recv().expect_err(
 328            "no conflicts should be registered as long as the file's status is unchanged",
 329        );
 330
 331        fs.with_git_state(path!("/project/.git").as_ref(), true, |state| {
 332            state.unmerged_paths.insert(
 333                repo_path("a.txt"),
 334                UnmergedStatus {
 335                    first_head: UnmergedStatusCode::Updated,
 336                    second_head: UnmergedStatusCode::Updated,
 337                },
 338            );
 339            // Cause the repository to emit MergeHeadsChanged.
 340            state.refs.insert("MERGE_HEAD".into(), "123".into())
 341        })
 342        .unwrap();
 343
 344        cx.run_until_parked();
 345        let update = events_rx
 346            .try_recv()
 347            .expect("status change should trigger conflict parsing");
 348        assert_eq!(update.old_range, 0..0);
 349        assert_eq!(update.new_range, 0..1);
 350
 351        let conflict = conflict_set.read_with(cx, |conflict_set, _| {
 352            conflict_set.snapshot().conflicts[0].clone()
 353        });
 354        cx.update(|cx| {
 355            conflict.resolve(buffer.clone(), std::slice::from_ref(&conflict.theirs), cx);
 356        });
 357
 358        cx.run_until_parked();
 359        let update = events_rx
 360            .try_recv()
 361            .expect("conflicts should be removed after resolution");
 362        assert_eq!(update.old_range, 0..1);
 363        assert_eq!(update.new_range, 0..0);
 364    }
 365
 366    #[gpui::test]
 367    async fn test_conflict_updates_without_merge_head(
 368        executor: BackgroundExecutor,
 369        cx: &mut TestAppContext,
 370    ) {
 371        zlog::init_test();
 372        cx.update(|cx| {
 373            settings::init(cx);
 374        });
 375
 376        let initial_text = "
 377            zero
 378            <<<<<<< HEAD
 379            one
 380            =======
 381            two
 382            >>>>>>> Stashed Changes
 383            three
 384        "
 385        .unindent();
 386
 387        let fs = FakeFs::new(executor);
 388        fs.insert_tree(
 389            path!("/project"),
 390            json!({
 391                ".git": {},
 392                "a.txt": initial_text,
 393            }),
 394        )
 395        .await;
 396
 397        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
 398        let (git_store, buffer) = project.update(cx, |project, cx| {
 399            (
 400                project.git_store().clone(),
 401                project.open_local_buffer(path!("/project/a.txt"), cx),
 402            )
 403        });
 404
 405        cx.run_until_parked();
 406        fs.with_git_state(path!("/project/.git").as_ref(), true, |state| {
 407            state.unmerged_paths.insert(
 408                RepoPath::from_rel_path(rel_path("a.txt")),
 409                UnmergedStatus {
 410                    first_head: UnmergedStatusCode::Updated,
 411                    second_head: UnmergedStatusCode::Updated,
 412                },
 413            )
 414        })
 415        .unwrap();
 416
 417        let buffer = buffer.await.unwrap();
 418
 419        // Open the conflict set for a file that currently has conflicts.
 420        let conflict_set = git_store.update(cx, |git_store, cx| {
 421            git_store.open_conflict_set(buffer.clone(), cx)
 422        });
 423
 424        cx.run_until_parked();
 425        conflict_set.update(cx, |conflict_set, cx| {
 426            let conflict_range = conflict_set.snapshot().conflicts[0]
 427                .range
 428                .to_point(buffer.read(cx));
 429            assert_eq!(conflict_range, Point::new(1, 0)..Point::new(6, 0));
 430        });
 431
 432        // Simulate the conflict being removed by e.g. staging the file.
 433        fs.with_git_state(path!("/project/.git").as_ref(), true, |state| {
 434            state.unmerged_paths.remove(&repo_path("a.txt"))
 435        })
 436        .unwrap();
 437
 438        cx.run_until_parked();
 439        conflict_set.update(cx, |conflict_set, _| {
 440            assert!(!conflict_set.has_conflict);
 441            assert_eq!(conflict_set.snapshot.conflicts.len(), 0);
 442        });
 443
 444        // Simulate the conflict being re-added.
 445        fs.with_git_state(path!("/project/.git").as_ref(), true, |state| {
 446            state.unmerged_paths.insert(
 447                repo_path("a.txt"),
 448                UnmergedStatus {
 449                    first_head: UnmergedStatusCode::Updated,
 450                    second_head: UnmergedStatusCode::Updated,
 451                },
 452            )
 453        })
 454        .unwrap();
 455
 456        cx.run_until_parked();
 457        conflict_set.update(cx, |conflict_set, cx| {
 458            let conflict_range = conflict_set.snapshot().conflicts[0]
 459                .range
 460                .to_point(buffer.read(cx));
 461            assert_eq!(conflict_range, Point::new(1, 0)..Point::new(6, 0));
 462        });
 463    }
 464}
 465
 466mod git_traversal {
 467    use std::{path::Path, time::Duration};
 468
 469    use collections::HashMap;
 470    use project::{
 471        Project,
 472        git_store::{RepositoryId, RepositorySnapshot},
 473    };
 474
 475    use fs::FakeFs;
 476    use git::status::{
 477        FileStatus, GitSummary, StatusCode, TrackedSummary, UnmergedStatus, UnmergedStatusCode,
 478    };
 479    use gpui::TestAppContext;
 480    use project::GitTraversal;
 481
 482    use serde_json::json;
 483    use settings::SettingsStore;
 484    use util::{
 485        path,
 486        rel_path::{RelPath, rel_path},
 487    };
 488
 489    const CONFLICT: FileStatus = FileStatus::Unmerged(UnmergedStatus {
 490        first_head: UnmergedStatusCode::Updated,
 491        second_head: UnmergedStatusCode::Updated,
 492    });
 493    const ADDED: GitSummary = GitSummary {
 494        index: TrackedSummary::ADDED,
 495        count: 1,
 496        ..GitSummary::UNCHANGED
 497    };
 498    const MODIFIED: GitSummary = GitSummary {
 499        index: TrackedSummary::MODIFIED,
 500        count: 1,
 501        ..GitSummary::UNCHANGED
 502    };
 503
 504    #[gpui::test]
 505    async fn test_git_traversal_with_one_repo(cx: &mut TestAppContext) {
 506        init_test(cx);
 507        let fs = FakeFs::new(cx.background_executor.clone());
 508        fs.insert_tree(
 509            path!("/root"),
 510            json!({
 511                "x": {
 512                    ".git": {},
 513                    "x1.txt": "foo",
 514                    "x2.txt": "bar",
 515                    "y": {
 516                        ".git": {},
 517                        "y1.txt": "baz",
 518                        "y2.txt": "qux"
 519                    },
 520                    "z.txt": "sneaky..."
 521                },
 522                "z": {
 523                    ".git": {},
 524                    "z1.txt": "quux",
 525                    "z2.txt": "quuux"
 526                }
 527            }),
 528        )
 529        .await;
 530
 531        fs.set_status_for_repo(
 532            Path::new(path!("/root/x/.git")),
 533            &[
 534                ("x2.txt", StatusCode::Modified.index()),
 535                ("z.txt", StatusCode::Added.index()),
 536            ],
 537        );
 538        fs.set_status_for_repo(Path::new(path!("/root/x/y/.git")), &[("y1.txt", CONFLICT)]);
 539        fs.set_status_for_repo(
 540            Path::new(path!("/root/z/.git")),
 541            &[("z2.txt", StatusCode::Added.index())],
 542        );
 543
 544        let project = Project::test(fs, [path!("/root").as_ref()], cx).await;
 545        cx.executor().run_until_parked();
 546
 547        let (repo_snapshots, worktree_snapshot) = project.read_with(cx, |project, cx| {
 548            (
 549                project.git_store().read(cx).repo_snapshots(cx),
 550                project.worktrees(cx).next().unwrap().read(cx).snapshot(),
 551            )
 552        });
 553
 554        let traversal = GitTraversal::new(
 555            &repo_snapshots,
 556            worktree_snapshot.traverse_from_path(true, false, true, RelPath::unix("x").unwrap()),
 557        );
 558        let entries = traversal
 559            .map(|entry| (entry.path.clone(), entry.git_summary))
 560            .collect::<Vec<_>>();
 561        pretty_assertions::assert_eq!(
 562            entries,
 563            [
 564                (rel_path("x/x1.txt").into(), GitSummary::UNCHANGED),
 565                (rel_path("x/x2.txt").into(), MODIFIED),
 566                (rel_path("x/y/y1.txt").into(), GitSummary::CONFLICT),
 567                (rel_path("x/y/y2.txt").into(), GitSummary::UNCHANGED),
 568                (rel_path("x/z.txt").into(), ADDED),
 569                (rel_path("z/z1.txt").into(), GitSummary::UNCHANGED),
 570                (rel_path("z/z2.txt").into(), ADDED),
 571            ]
 572        )
 573    }
 574
 575    #[gpui::test]
 576    async fn test_git_traversal_with_nested_repos(cx: &mut TestAppContext) {
 577        init_test(cx);
 578        let fs = FakeFs::new(cx.background_executor.clone());
 579        fs.insert_tree(
 580            path!("/root"),
 581            json!({
 582                "x": {
 583                    ".git": {},
 584                    "x1.txt": "foo",
 585                    "x2.txt": "bar",
 586                    "y": {
 587                        ".git": {},
 588                        "y1.txt": "baz",
 589                        "y2.txt": "qux"
 590                    },
 591                    "z.txt": "sneaky..."
 592                },
 593                "z": {
 594                    ".git": {},
 595                    "z1.txt": "quux",
 596                    "z2.txt": "quuux"
 597                }
 598            }),
 599        )
 600        .await;
 601
 602        fs.set_status_for_repo(
 603            Path::new(path!("/root/x/.git")),
 604            &[
 605                ("x2.txt", StatusCode::Modified.index()),
 606                ("z.txt", StatusCode::Added.index()),
 607            ],
 608        );
 609        fs.set_status_for_repo(Path::new(path!("/root/x/y/.git")), &[("y1.txt", CONFLICT)]);
 610
 611        fs.set_status_for_repo(
 612            Path::new(path!("/root/z/.git")),
 613            &[("z2.txt", StatusCode::Added.index())],
 614        );
 615
 616        let project = Project::test(fs, [path!("/root").as_ref()], cx).await;
 617        cx.executor().run_until_parked();
 618
 619        let (repo_snapshots, worktree_snapshot) = project.read_with(cx, |project, cx| {
 620            (
 621                project.git_store().read(cx).repo_snapshots(cx),
 622                project.worktrees(cx).next().unwrap().read(cx).snapshot(),
 623            )
 624        });
 625
 626        // Sanity check the propagation for x/y and z
 627        check_git_statuses(
 628            &repo_snapshots,
 629            &worktree_snapshot,
 630            &[
 631                ("x/y", GitSummary::CONFLICT),
 632                ("x/y/y1.txt", GitSummary::CONFLICT),
 633                ("x/y/y2.txt", GitSummary::UNCHANGED),
 634            ],
 635        );
 636        check_git_statuses(
 637            &repo_snapshots,
 638            &worktree_snapshot,
 639            &[
 640                ("z", ADDED),
 641                ("z/z1.txt", GitSummary::UNCHANGED),
 642                ("z/z2.txt", ADDED),
 643            ],
 644        );
 645
 646        // Test one of the fundamental cases of propagation blocking, the transition from one git repository to another
 647        check_git_statuses(
 648            &repo_snapshots,
 649            &worktree_snapshot,
 650            &[
 651                ("x", MODIFIED + ADDED),
 652                ("x/y", GitSummary::CONFLICT),
 653                ("x/y/y1.txt", GitSummary::CONFLICT),
 654            ],
 655        );
 656
 657        // Sanity check everything around it
 658        check_git_statuses(
 659            &repo_snapshots,
 660            &worktree_snapshot,
 661            &[
 662                ("x", MODIFIED + ADDED),
 663                ("x/x1.txt", GitSummary::UNCHANGED),
 664                ("x/x2.txt", MODIFIED),
 665                ("x/y", GitSummary::CONFLICT),
 666                ("x/y/y1.txt", GitSummary::CONFLICT),
 667                ("x/y/y2.txt", GitSummary::UNCHANGED),
 668                ("x/z.txt", ADDED),
 669            ],
 670        );
 671
 672        // Test the other fundamental case, transitioning from git repository to non-git repository
 673        check_git_statuses(
 674            &repo_snapshots,
 675            &worktree_snapshot,
 676            &[
 677                ("", GitSummary::UNCHANGED),
 678                ("x", MODIFIED + ADDED),
 679                ("x/x1.txt", GitSummary::UNCHANGED),
 680            ],
 681        );
 682
 683        // And all together now
 684        check_git_statuses(
 685            &repo_snapshots,
 686            &worktree_snapshot,
 687            &[
 688                ("", GitSummary::UNCHANGED),
 689                ("x", MODIFIED + ADDED),
 690                ("x/x1.txt", GitSummary::UNCHANGED),
 691                ("x/x2.txt", MODIFIED),
 692                ("x/y", GitSummary::CONFLICT),
 693                ("x/y/y1.txt", GitSummary::CONFLICT),
 694                ("x/y/y2.txt", GitSummary::UNCHANGED),
 695                ("x/z.txt", ADDED),
 696                ("z", ADDED),
 697                ("z/z1.txt", GitSummary::UNCHANGED),
 698                ("z/z2.txt", ADDED),
 699            ],
 700        );
 701    }
 702
 703    #[gpui::test]
 704    async fn test_git_traversal_simple(cx: &mut TestAppContext) {
 705        init_test(cx);
 706        let fs = FakeFs::new(cx.background_executor.clone());
 707        fs.insert_tree(
 708            path!("/root"),
 709            json!({
 710                ".git": {},
 711                "a": {
 712                    "b": {
 713                        "c1.txt": "",
 714                        "c2.txt": "",
 715                    },
 716                    "d": {
 717                        "e1.txt": "",
 718                        "e2.txt": "",
 719                        "e3.txt": "",
 720                    }
 721                },
 722                "f": {
 723                    "no-status.txt": ""
 724                },
 725                "g": {
 726                    "h1.txt": "",
 727                    "h2.txt": ""
 728                },
 729            }),
 730        )
 731        .await;
 732
 733        fs.set_status_for_repo(
 734            Path::new(path!("/root/.git")),
 735            &[
 736                ("a/b/c1.txt", StatusCode::Added.index()),
 737                ("a/d/e2.txt", StatusCode::Modified.index()),
 738                ("g/h2.txt", CONFLICT),
 739            ],
 740        );
 741
 742        let project = Project::test(fs, [path!("/root").as_ref()], cx).await;
 743        cx.executor().run_until_parked();
 744
 745        let (repo_snapshots, worktree_snapshot) = project.read_with(cx, |project, cx| {
 746            (
 747                project.git_store().read(cx).repo_snapshots(cx),
 748                project.worktrees(cx).next().unwrap().read(cx).snapshot(),
 749            )
 750        });
 751
 752        check_git_statuses(
 753            &repo_snapshots,
 754            &worktree_snapshot,
 755            &[
 756                ("", GitSummary::CONFLICT + MODIFIED + ADDED),
 757                ("g", GitSummary::CONFLICT),
 758                ("g/h2.txt", GitSummary::CONFLICT),
 759            ],
 760        );
 761
 762        check_git_statuses(
 763            &repo_snapshots,
 764            &worktree_snapshot,
 765            &[
 766                ("", GitSummary::CONFLICT + ADDED + MODIFIED),
 767                ("a", ADDED + MODIFIED),
 768                ("a/b", ADDED),
 769                ("a/b/c1.txt", ADDED),
 770                ("a/b/c2.txt", GitSummary::UNCHANGED),
 771                ("a/d", MODIFIED),
 772                ("a/d/e2.txt", MODIFIED),
 773                ("f", GitSummary::UNCHANGED),
 774                ("f/no-status.txt", GitSummary::UNCHANGED),
 775                ("g", GitSummary::CONFLICT),
 776                ("g/h2.txt", GitSummary::CONFLICT),
 777            ],
 778        );
 779
 780        check_git_statuses(
 781            &repo_snapshots,
 782            &worktree_snapshot,
 783            &[
 784                ("a/b", ADDED),
 785                ("a/b/c1.txt", ADDED),
 786                ("a/b/c2.txt", GitSummary::UNCHANGED),
 787                ("a/d", MODIFIED),
 788                ("a/d/e1.txt", GitSummary::UNCHANGED),
 789                ("a/d/e2.txt", MODIFIED),
 790                ("f", GitSummary::UNCHANGED),
 791                ("f/no-status.txt", GitSummary::UNCHANGED),
 792                ("g", GitSummary::CONFLICT),
 793            ],
 794        );
 795
 796        check_git_statuses(
 797            &repo_snapshots,
 798            &worktree_snapshot,
 799            &[
 800                ("a/b/c1.txt", ADDED),
 801                ("a/b/c2.txt", GitSummary::UNCHANGED),
 802                ("a/d/e1.txt", GitSummary::UNCHANGED),
 803                ("a/d/e2.txt", MODIFIED),
 804                ("f/no-status.txt", GitSummary::UNCHANGED),
 805            ],
 806        );
 807    }
 808
 809    #[gpui::test]
 810    async fn test_git_traversal_with_repos_under_project(cx: &mut TestAppContext) {
 811        init_test(cx);
 812        let fs = FakeFs::new(cx.background_executor.clone());
 813        fs.insert_tree(
 814            path!("/root"),
 815            json!({
 816                "x": {
 817                    ".git": {},
 818                    "x1.txt": "foo",
 819                    "x2.txt": "bar"
 820                },
 821                "y": {
 822                    ".git": {},
 823                    "y1.txt": "baz",
 824                    "y2.txt": "qux"
 825                },
 826                "z": {
 827                    ".git": {},
 828                    "z1.txt": "quux",
 829                    "z2.txt": "quuux"
 830                }
 831            }),
 832        )
 833        .await;
 834
 835        fs.set_status_for_repo(
 836            Path::new(path!("/root/x/.git")),
 837            &[("x1.txt", StatusCode::Added.index())],
 838        );
 839        fs.set_status_for_repo(
 840            Path::new(path!("/root/y/.git")),
 841            &[
 842                ("y1.txt", CONFLICT),
 843                ("y2.txt", StatusCode::Modified.index()),
 844            ],
 845        );
 846        fs.set_status_for_repo(
 847            Path::new(path!("/root/z/.git")),
 848            &[("z2.txt", StatusCode::Modified.index())],
 849        );
 850
 851        let project = Project::test(fs, [path!("/root").as_ref()], cx).await;
 852        cx.executor().run_until_parked();
 853
 854        let (repo_snapshots, worktree_snapshot) = project.read_with(cx, |project, cx| {
 855            (
 856                project.git_store().read(cx).repo_snapshots(cx),
 857                project.worktrees(cx).next().unwrap().read(cx).snapshot(),
 858            )
 859        });
 860
 861        check_git_statuses(
 862            &repo_snapshots,
 863            &worktree_snapshot,
 864            &[("x", ADDED), ("x/x1.txt", ADDED)],
 865        );
 866
 867        check_git_statuses(
 868            &repo_snapshots,
 869            &worktree_snapshot,
 870            &[
 871                ("y", GitSummary::CONFLICT + MODIFIED),
 872                ("y/y1.txt", GitSummary::CONFLICT),
 873                ("y/y2.txt", MODIFIED),
 874            ],
 875        );
 876
 877        check_git_statuses(
 878            &repo_snapshots,
 879            &worktree_snapshot,
 880            &[("z", MODIFIED), ("z/z2.txt", MODIFIED)],
 881        );
 882
 883        check_git_statuses(
 884            &repo_snapshots,
 885            &worktree_snapshot,
 886            &[("x", ADDED), ("x/x1.txt", ADDED)],
 887        );
 888
 889        check_git_statuses(
 890            &repo_snapshots,
 891            &worktree_snapshot,
 892            &[
 893                ("x", ADDED),
 894                ("x/x1.txt", ADDED),
 895                ("x/x2.txt", GitSummary::UNCHANGED),
 896                ("y", GitSummary::CONFLICT + MODIFIED),
 897                ("y/y1.txt", GitSummary::CONFLICT),
 898                ("y/y2.txt", MODIFIED),
 899                ("z", MODIFIED),
 900                ("z/z1.txt", GitSummary::UNCHANGED),
 901                ("z/z2.txt", MODIFIED),
 902            ],
 903        );
 904    }
 905
 906    fn init_test(cx: &mut gpui::TestAppContext) {
 907        zlog::init_test();
 908
 909        cx.update(|cx| {
 910            let settings_store = SettingsStore::test(cx);
 911            cx.set_global(settings_store);
 912        });
 913    }
 914
 915    #[gpui::test]
 916    async fn test_bump_mtime_of_git_repo_workdir(cx: &mut TestAppContext) {
 917        init_test(cx);
 918
 919        // Create a worktree with a git directory.
 920        let fs = FakeFs::new(cx.background_executor.clone());
 921        fs.insert_tree(
 922            path!("/root"),
 923            json!({
 924                ".git": {},
 925                "a.txt": "",
 926                "b": {
 927                    "c.txt": "",
 928                },
 929            }),
 930        )
 931        .await;
 932        fs.set_head_and_index_for_repo(
 933            path!("/root/.git").as_ref(),
 934            &[("a.txt", "".into()), ("b/c.txt", "".into())],
 935        );
 936        cx.run_until_parked();
 937
 938        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
 939        cx.executor().run_until_parked();
 940
 941        let (old_entry_ids, old_mtimes) = project.read_with(cx, |project, cx| {
 942            let tree = project.worktrees(cx).next().unwrap().read(cx);
 943            (
 944                tree.entries(true, 0).map(|e| e.id).collect::<Vec<_>>(),
 945                tree.entries(true, 0).map(|e| e.mtime).collect::<Vec<_>>(),
 946            )
 947        });
 948
 949        // Regression test: after the directory is scanned, touch the git repo's
 950        // working directory, bumping its mtime. That directory keeps its project
 951        // entry id after the directories are re-scanned.
 952        fs.touch_path(path!("/root")).await;
 953        cx.executor().run_until_parked();
 954
 955        let (new_entry_ids, new_mtimes) = project.read_with(cx, |project, cx| {
 956            let tree = project.worktrees(cx).next().unwrap().read(cx);
 957            (
 958                tree.entries(true, 0).map(|e| e.id).collect::<Vec<_>>(),
 959                tree.entries(true, 0).map(|e| e.mtime).collect::<Vec<_>>(),
 960            )
 961        });
 962        assert_eq!(new_entry_ids, old_entry_ids);
 963        assert_ne!(new_mtimes, old_mtimes);
 964
 965        // Regression test: changes to the git repository should still be
 966        // detected.
 967        fs.set_head_for_repo(
 968            path!("/root/.git").as_ref(),
 969            &[("a.txt", "".into()), ("b/c.txt", "something-else".into())],
 970            "deadbeef",
 971        );
 972        cx.executor().run_until_parked();
 973        cx.executor().advance_clock(Duration::from_secs(1));
 974
 975        let (repo_snapshots, worktree_snapshot) = project.read_with(cx, |project, cx| {
 976            (
 977                project.git_store().read(cx).repo_snapshots(cx),
 978                project.worktrees(cx).next().unwrap().read(cx).snapshot(),
 979            )
 980        });
 981
 982        check_git_statuses(
 983            &repo_snapshots,
 984            &worktree_snapshot,
 985            &[
 986                ("", MODIFIED),
 987                ("a.txt", GitSummary::UNCHANGED),
 988                ("b/c.txt", MODIFIED),
 989            ],
 990        );
 991    }
 992
 993    #[track_caller]
 994    fn check_git_statuses(
 995        repo_snapshots: &HashMap<RepositoryId, RepositorySnapshot>,
 996        worktree_snapshot: &worktree::Snapshot,
 997        expected_statuses: &[(&str, GitSummary)],
 998    ) {
 999        let mut traversal = GitTraversal::new(
1000            repo_snapshots,
1001            worktree_snapshot.traverse_from_path(true, true, false, RelPath::empty()),
1002        );
1003        let found_statuses = expected_statuses
1004            .iter()
1005            .map(|&(path, _)| {
1006                let git_entry = traversal
1007                    .find(|git_entry| git_entry.path.as_ref() == rel_path(path))
1008                    .unwrap_or_else(|| panic!("Traversal has no entry for {path:?}"));
1009                (path, git_entry.git_summary)
1010            })
1011            .collect::<Vec<_>>();
1012        pretty_assertions::assert_eq!(found_statuses, expected_statuses);
1013    }
1014}