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 update cached conflicts
 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    #[gpui::test]
 466    async fn test_conflict_updates_with_delayed_merge_head_conflicts(
 467        executor: BackgroundExecutor,
 468        cx: &mut TestAppContext,
 469    ) {
 470        zlog::init_test();
 471        cx.update(|cx| {
 472            settings::init(cx);
 473        });
 474
 475        let initial_text = "
 476            one
 477            two
 478            three
 479            four
 480        "
 481        .unindent();
 482
 483        let conflicted_text = "
 484            one
 485            <<<<<<< HEAD
 486            two
 487            =======
 488            TWO
 489            >>>>>>> branch
 490            three
 491            four
 492        "
 493        .unindent();
 494
 495        let resolved_text = "
 496            one
 497            TWO
 498            three
 499            four
 500        "
 501        .unindent();
 502
 503        let fs = FakeFs::new(executor);
 504        fs.insert_tree(
 505            path!("/project"),
 506            json!({
 507                ".git": {},
 508                "a.txt": initial_text,
 509            }),
 510        )
 511        .await;
 512
 513        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
 514        let (git_store, buffer) = project.update(cx, |project, cx| {
 515            (
 516                project.git_store().clone(),
 517                project.open_local_buffer(path!("/project/a.txt"), cx),
 518            )
 519        });
 520        let buffer = buffer.await.unwrap();
 521        let conflict_set = git_store.update(cx, |git_store, cx| {
 522            git_store.open_conflict_set(buffer.clone(), cx)
 523        });
 524
 525        let (events_tx, events_rx) = mpsc::channel::<ConflictSetUpdate>();
 526        let _conflict_set_subscription = cx.update(|cx| {
 527            cx.subscribe(&conflict_set, move |_, event, _| {
 528                events_tx.send(event.clone()).ok();
 529            })
 530        });
 531
 532        cx.run_until_parked();
 533        events_rx
 534            .try_recv()
 535            .expect_err("conflict set should start empty");
 536
 537        fs.with_git_state(path!("/project/.git").as_ref(), true, |state| {
 538            state.refs.insert("MERGE_HEAD".into(), "123".into())
 539        })
 540        .unwrap();
 541
 542        cx.run_until_parked();
 543        events_rx
 544            .try_recv()
 545            .expect_err("merge head without conflicted paths should not publish conflicts");
 546        conflict_set.update(cx, |conflict_set, _| {
 547            assert!(!conflict_set.has_conflict);
 548            assert_eq!(conflict_set.snapshot.conflicts.len(), 0);
 549        });
 550
 551        buffer.update(cx, |buffer, cx| {
 552            buffer.set_text(conflicted_text.clone(), cx);
 553        });
 554        fs.with_git_state(path!("/project/.git").as_ref(), true, |state| {
 555            state.unmerged_paths.insert(
 556                repo_path("a.txt"),
 557                UnmergedStatus {
 558                    first_head: UnmergedStatusCode::Updated,
 559                    second_head: UnmergedStatusCode::Updated,
 560                },
 561            );
 562        })
 563        .unwrap();
 564
 565        cx.run_until_parked();
 566        let update = events_rx
 567            .try_recv()
 568            .expect("conflicts should appear once conflicted paths are visible");
 569        assert_eq!(update.old_range, 0..0);
 570        assert_eq!(update.new_range, 0..1);
 571        conflict_set.update(cx, |conflict_set, cx| {
 572            assert!(conflict_set.has_conflict);
 573            let conflict_range = conflict_set.snapshot().conflicts[0]
 574                .range
 575                .to_point(buffer.read(cx));
 576            assert_eq!(conflict_range, Point::new(1, 0)..Point::new(6, 0));
 577        });
 578
 579        buffer.update(cx, |buffer, cx| {
 580            buffer.set_text(resolved_text.clone(), cx);
 581        });
 582
 583        cx.run_until_parked();
 584        let update = events_rx
 585            .try_recv()
 586            .expect("resolved buffer text should clear visible conflict markers");
 587        assert_eq!(update.old_range, 0..1);
 588        assert_eq!(update.new_range, 0..0);
 589        conflict_set.update(cx, |conflict_set, _| {
 590            assert!(conflict_set.has_conflict);
 591            assert_eq!(conflict_set.snapshot.conflicts.len(), 0);
 592        });
 593
 594        fs.with_git_state(path!("/project/.git").as_ref(), true, |state| {
 595            state.refs.insert("MERGE_HEAD".into(), "456".into());
 596        })
 597        .unwrap();
 598
 599        cx.run_until_parked();
 600        events_rx.try_recv().expect_err(
 601            "merge-head change without unmerged-path changes should not emit marker updates",
 602        );
 603        conflict_set.update(cx, |conflict_set, _| {
 604            assert!(conflict_set.has_conflict);
 605            assert_eq!(conflict_set.snapshot.conflicts.len(), 0);
 606        });
 607
 608        fs.with_git_state(path!("/project/.git").as_ref(), true, |state| {
 609            state.unmerged_paths.remove(&repo_path("a.txt"));
 610            state.refs.remove("MERGE_HEAD");
 611        })
 612        .unwrap();
 613
 614        cx.run_until_parked();
 615        let update = events_rx.try_recv().expect(
 616            "status catch-up should emit a no-op update when clearing stale conflict state",
 617        );
 618        assert_eq!(update.old_range, 0..0);
 619        assert_eq!(update.new_range, 0..0);
 620        assert!(update.buffer_range.is_none());
 621        conflict_set.update(cx, |conflict_set, _| {
 622            assert!(!conflict_set.has_conflict);
 623            assert_eq!(conflict_set.snapshot.conflicts.len(), 0);
 624        });
 625    }
 626}
 627
 628mod git_traversal {
 629    use std::{path::Path, time::Duration};
 630
 631    use collections::HashMap;
 632    use project::{
 633        Project,
 634        git_store::{RepositoryId, RepositorySnapshot},
 635    };
 636
 637    use fs::FakeFs;
 638    use git::status::{
 639        FileStatus, GitSummary, StatusCode, TrackedSummary, UnmergedStatus, UnmergedStatusCode,
 640    };
 641    use gpui::TestAppContext;
 642    use project::GitTraversal;
 643
 644    use serde_json::json;
 645    use settings::SettingsStore;
 646    use util::{
 647        path,
 648        rel_path::{RelPath, rel_path},
 649    };
 650
 651    const CONFLICT: FileStatus = FileStatus::Unmerged(UnmergedStatus {
 652        first_head: UnmergedStatusCode::Updated,
 653        second_head: UnmergedStatusCode::Updated,
 654    });
 655    const ADDED: GitSummary = GitSummary {
 656        index: TrackedSummary::ADDED,
 657        count: 1,
 658        ..GitSummary::UNCHANGED
 659    };
 660    const MODIFIED: GitSummary = GitSummary {
 661        index: TrackedSummary::MODIFIED,
 662        count: 1,
 663        ..GitSummary::UNCHANGED
 664    };
 665
 666    #[gpui::test]
 667    async fn test_git_traversal_with_one_repo(cx: &mut TestAppContext) {
 668        init_test(cx);
 669        let fs = FakeFs::new(cx.background_executor.clone());
 670        fs.insert_tree(
 671            path!("/root"),
 672            json!({
 673                "x": {
 674                    ".git": {},
 675                    "x1.txt": "foo",
 676                    "x2.txt": "bar",
 677                    "y": {
 678                        ".git": {},
 679                        "y1.txt": "baz",
 680                        "y2.txt": "qux"
 681                    },
 682                    "z.txt": "sneaky..."
 683                },
 684                "z": {
 685                    ".git": {},
 686                    "z1.txt": "quux",
 687                    "z2.txt": "quuux"
 688                }
 689            }),
 690        )
 691        .await;
 692
 693        fs.set_status_for_repo(
 694            Path::new(path!("/root/x/.git")),
 695            &[
 696                ("x2.txt", StatusCode::Modified.index()),
 697                ("z.txt", StatusCode::Added.index()),
 698            ],
 699        );
 700        fs.set_status_for_repo(Path::new(path!("/root/x/y/.git")), &[("y1.txt", CONFLICT)]);
 701        fs.set_status_for_repo(
 702            Path::new(path!("/root/z/.git")),
 703            &[("z2.txt", StatusCode::Added.index())],
 704        );
 705
 706        let project = Project::test(fs, [path!("/root").as_ref()], cx).await;
 707        cx.executor().run_until_parked();
 708
 709        let (repo_snapshots, worktree_snapshot) = project.read_with(cx, |project, cx| {
 710            (
 711                project.git_store().read(cx).repo_snapshots(cx),
 712                project.worktrees(cx).next().unwrap().read(cx).snapshot(),
 713            )
 714        });
 715
 716        let traversal = GitTraversal::new(
 717            &repo_snapshots,
 718            worktree_snapshot.traverse_from_path(true, false, true, RelPath::unix("x").unwrap()),
 719        );
 720        let entries = traversal
 721            .map(|entry| (entry.path.clone(), entry.git_summary))
 722            .collect::<Vec<_>>();
 723        pretty_assertions::assert_eq!(
 724            entries,
 725            [
 726                (rel_path("x/x1.txt").into(), GitSummary::UNCHANGED),
 727                (rel_path("x/x2.txt").into(), MODIFIED),
 728                (rel_path("x/y/y1.txt").into(), GitSummary::CONFLICT),
 729                (rel_path("x/y/y2.txt").into(), GitSummary::UNCHANGED),
 730                (rel_path("x/z.txt").into(), ADDED),
 731                (rel_path("z/z1.txt").into(), GitSummary::UNCHANGED),
 732                (rel_path("z/z2.txt").into(), ADDED),
 733            ]
 734        )
 735    }
 736
 737    #[gpui::test]
 738    async fn test_git_traversal_with_nested_repos(cx: &mut TestAppContext) {
 739        init_test(cx);
 740        let fs = FakeFs::new(cx.background_executor.clone());
 741        fs.insert_tree(
 742            path!("/root"),
 743            json!({
 744                "x": {
 745                    ".git": {},
 746                    "x1.txt": "foo",
 747                    "x2.txt": "bar",
 748                    "y": {
 749                        ".git": {},
 750                        "y1.txt": "baz",
 751                        "y2.txt": "qux"
 752                    },
 753                    "z.txt": "sneaky..."
 754                },
 755                "z": {
 756                    ".git": {},
 757                    "z1.txt": "quux",
 758                    "z2.txt": "quuux"
 759                }
 760            }),
 761        )
 762        .await;
 763
 764        fs.set_status_for_repo(
 765            Path::new(path!("/root/x/.git")),
 766            &[
 767                ("x2.txt", StatusCode::Modified.index()),
 768                ("z.txt", StatusCode::Added.index()),
 769            ],
 770        );
 771        fs.set_status_for_repo(Path::new(path!("/root/x/y/.git")), &[("y1.txt", CONFLICT)]);
 772
 773        fs.set_status_for_repo(
 774            Path::new(path!("/root/z/.git")),
 775            &[("z2.txt", StatusCode::Added.index())],
 776        );
 777
 778        let project = Project::test(fs, [path!("/root").as_ref()], cx).await;
 779        cx.executor().run_until_parked();
 780
 781        let (repo_snapshots, worktree_snapshot) = project.read_with(cx, |project, cx| {
 782            (
 783                project.git_store().read(cx).repo_snapshots(cx),
 784                project.worktrees(cx).next().unwrap().read(cx).snapshot(),
 785            )
 786        });
 787
 788        // Sanity check the propagation for x/y and z
 789        check_git_statuses(
 790            &repo_snapshots,
 791            &worktree_snapshot,
 792            &[
 793                ("x/y", GitSummary::CONFLICT),
 794                ("x/y/y1.txt", GitSummary::CONFLICT),
 795                ("x/y/y2.txt", GitSummary::UNCHANGED),
 796            ],
 797        );
 798        check_git_statuses(
 799            &repo_snapshots,
 800            &worktree_snapshot,
 801            &[
 802                ("z", ADDED),
 803                ("z/z1.txt", GitSummary::UNCHANGED),
 804                ("z/z2.txt", ADDED),
 805            ],
 806        );
 807
 808        // Test one of the fundamental cases of propagation blocking, the transition from one git repository to another
 809        check_git_statuses(
 810            &repo_snapshots,
 811            &worktree_snapshot,
 812            &[
 813                ("x", MODIFIED + ADDED),
 814                ("x/y", GitSummary::CONFLICT),
 815                ("x/y/y1.txt", GitSummary::CONFLICT),
 816            ],
 817        );
 818
 819        // Sanity check everything around it
 820        check_git_statuses(
 821            &repo_snapshots,
 822            &worktree_snapshot,
 823            &[
 824                ("x", MODIFIED + ADDED),
 825                ("x/x1.txt", GitSummary::UNCHANGED),
 826                ("x/x2.txt", MODIFIED),
 827                ("x/y", GitSummary::CONFLICT),
 828                ("x/y/y1.txt", GitSummary::CONFLICT),
 829                ("x/y/y2.txt", GitSummary::UNCHANGED),
 830                ("x/z.txt", ADDED),
 831            ],
 832        );
 833
 834        // Test the other fundamental case, transitioning from git repository to non-git repository
 835        check_git_statuses(
 836            &repo_snapshots,
 837            &worktree_snapshot,
 838            &[
 839                ("", GitSummary::UNCHANGED),
 840                ("x", MODIFIED + ADDED),
 841                ("x/x1.txt", GitSummary::UNCHANGED),
 842            ],
 843        );
 844
 845        // And all together now
 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                ("x/x2.txt", MODIFIED),
 854                ("x/y", GitSummary::CONFLICT),
 855                ("x/y/y1.txt", GitSummary::CONFLICT),
 856                ("x/y/y2.txt", GitSummary::UNCHANGED),
 857                ("x/z.txt", ADDED),
 858                ("z", ADDED),
 859                ("z/z1.txt", GitSummary::UNCHANGED),
 860                ("z/z2.txt", ADDED),
 861            ],
 862        );
 863    }
 864
 865    #[gpui::test]
 866    async fn test_git_traversal_simple(cx: &mut TestAppContext) {
 867        init_test(cx);
 868        let fs = FakeFs::new(cx.background_executor.clone());
 869        fs.insert_tree(
 870            path!("/root"),
 871            json!({
 872                ".git": {},
 873                "a": {
 874                    "b": {
 875                        "c1.txt": "",
 876                        "c2.txt": "",
 877                    },
 878                    "d": {
 879                        "e1.txt": "",
 880                        "e2.txt": "",
 881                        "e3.txt": "",
 882                    }
 883                },
 884                "f": {
 885                    "no-status.txt": ""
 886                },
 887                "g": {
 888                    "h1.txt": "",
 889                    "h2.txt": ""
 890                },
 891            }),
 892        )
 893        .await;
 894
 895        fs.set_status_for_repo(
 896            Path::new(path!("/root/.git")),
 897            &[
 898                ("a/b/c1.txt", StatusCode::Added.index()),
 899                ("a/d/e2.txt", StatusCode::Modified.index()),
 900                ("g/h2.txt", CONFLICT),
 901            ],
 902        );
 903
 904        let project = Project::test(fs, [path!("/root").as_ref()], cx).await;
 905        cx.executor().run_until_parked();
 906
 907        let (repo_snapshots, worktree_snapshot) = project.read_with(cx, |project, cx| {
 908            (
 909                project.git_store().read(cx).repo_snapshots(cx),
 910                project.worktrees(cx).next().unwrap().read(cx).snapshot(),
 911            )
 912        });
 913
 914        check_git_statuses(
 915            &repo_snapshots,
 916            &worktree_snapshot,
 917            &[
 918                ("", GitSummary::CONFLICT + MODIFIED + ADDED),
 919                ("g", GitSummary::CONFLICT),
 920                ("g/h2.txt", GitSummary::CONFLICT),
 921            ],
 922        );
 923
 924        check_git_statuses(
 925            &repo_snapshots,
 926            &worktree_snapshot,
 927            &[
 928                ("", GitSummary::CONFLICT + ADDED + MODIFIED),
 929                ("a", ADDED + MODIFIED),
 930                ("a/b", ADDED),
 931                ("a/b/c1.txt", ADDED),
 932                ("a/b/c2.txt", GitSummary::UNCHANGED),
 933                ("a/d", MODIFIED),
 934                ("a/d/e2.txt", MODIFIED),
 935                ("f", GitSummary::UNCHANGED),
 936                ("f/no-status.txt", GitSummary::UNCHANGED),
 937                ("g", GitSummary::CONFLICT),
 938                ("g/h2.txt", GitSummary::CONFLICT),
 939            ],
 940        );
 941
 942        check_git_statuses(
 943            &repo_snapshots,
 944            &worktree_snapshot,
 945            &[
 946                ("a/b", ADDED),
 947                ("a/b/c1.txt", ADDED),
 948                ("a/b/c2.txt", GitSummary::UNCHANGED),
 949                ("a/d", MODIFIED),
 950                ("a/d/e1.txt", GitSummary::UNCHANGED),
 951                ("a/d/e2.txt", MODIFIED),
 952                ("f", GitSummary::UNCHANGED),
 953                ("f/no-status.txt", GitSummary::UNCHANGED),
 954                ("g", GitSummary::CONFLICT),
 955            ],
 956        );
 957
 958        check_git_statuses(
 959            &repo_snapshots,
 960            &worktree_snapshot,
 961            &[
 962                ("a/b/c1.txt", ADDED),
 963                ("a/b/c2.txt", GitSummary::UNCHANGED),
 964                ("a/d/e1.txt", GitSummary::UNCHANGED),
 965                ("a/d/e2.txt", MODIFIED),
 966                ("f/no-status.txt", GitSummary::UNCHANGED),
 967            ],
 968        );
 969    }
 970
 971    #[gpui::test]
 972    async fn test_git_traversal_with_repos_under_project(cx: &mut TestAppContext) {
 973        init_test(cx);
 974        let fs = FakeFs::new(cx.background_executor.clone());
 975        fs.insert_tree(
 976            path!("/root"),
 977            json!({
 978                "x": {
 979                    ".git": {},
 980                    "x1.txt": "foo",
 981                    "x2.txt": "bar"
 982                },
 983                "y": {
 984                    ".git": {},
 985                    "y1.txt": "baz",
 986                    "y2.txt": "qux"
 987                },
 988                "z": {
 989                    ".git": {},
 990                    "z1.txt": "quux",
 991                    "z2.txt": "quuux"
 992                }
 993            }),
 994        )
 995        .await;
 996
 997        fs.set_status_for_repo(
 998            Path::new(path!("/root/x/.git")),
 999            &[("x1.txt", StatusCode::Added.index())],
1000        );
1001        fs.set_status_for_repo(
1002            Path::new(path!("/root/y/.git")),
1003            &[
1004                ("y1.txt", CONFLICT),
1005                ("y2.txt", StatusCode::Modified.index()),
1006            ],
1007        );
1008        fs.set_status_for_repo(
1009            Path::new(path!("/root/z/.git")),
1010            &[("z2.txt", StatusCode::Modified.index())],
1011        );
1012
1013        let project = Project::test(fs, [path!("/root").as_ref()], cx).await;
1014        cx.executor().run_until_parked();
1015
1016        let (repo_snapshots, worktree_snapshot) = project.read_with(cx, |project, cx| {
1017            (
1018                project.git_store().read(cx).repo_snapshots(cx),
1019                project.worktrees(cx).next().unwrap().read(cx).snapshot(),
1020            )
1021        });
1022
1023        check_git_statuses(
1024            &repo_snapshots,
1025            &worktree_snapshot,
1026            &[("x", ADDED), ("x/x1.txt", ADDED)],
1027        );
1028
1029        check_git_statuses(
1030            &repo_snapshots,
1031            &worktree_snapshot,
1032            &[
1033                ("y", GitSummary::CONFLICT + MODIFIED),
1034                ("y/y1.txt", GitSummary::CONFLICT),
1035                ("y/y2.txt", MODIFIED),
1036            ],
1037        );
1038
1039        check_git_statuses(
1040            &repo_snapshots,
1041            &worktree_snapshot,
1042            &[("z", MODIFIED), ("z/z2.txt", MODIFIED)],
1043        );
1044
1045        check_git_statuses(
1046            &repo_snapshots,
1047            &worktree_snapshot,
1048            &[("x", ADDED), ("x/x1.txt", ADDED)],
1049        );
1050
1051        check_git_statuses(
1052            &repo_snapshots,
1053            &worktree_snapshot,
1054            &[
1055                ("x", ADDED),
1056                ("x/x1.txt", ADDED),
1057                ("x/x2.txt", GitSummary::UNCHANGED),
1058                ("y", GitSummary::CONFLICT + MODIFIED),
1059                ("y/y1.txt", GitSummary::CONFLICT),
1060                ("y/y2.txt", MODIFIED),
1061                ("z", MODIFIED),
1062                ("z/z1.txt", GitSummary::UNCHANGED),
1063                ("z/z2.txt", MODIFIED),
1064            ],
1065        );
1066    }
1067
1068    fn init_test(cx: &mut gpui::TestAppContext) {
1069        zlog::init_test();
1070
1071        cx.update(|cx| {
1072            let settings_store = SettingsStore::test(cx);
1073            cx.set_global(settings_store);
1074        });
1075    }
1076
1077    #[gpui::test]
1078    async fn test_bump_mtime_of_git_repo_workdir(cx: &mut TestAppContext) {
1079        init_test(cx);
1080
1081        // Create a worktree with a git directory.
1082        let fs = FakeFs::new(cx.background_executor.clone());
1083        fs.insert_tree(
1084            path!("/root"),
1085            json!({
1086                ".git": {},
1087                "a.txt": "",
1088                "b": {
1089                    "c.txt": "",
1090                },
1091            }),
1092        )
1093        .await;
1094        fs.set_head_and_index_for_repo(
1095            path!("/root/.git").as_ref(),
1096            &[("a.txt", "".into()), ("b/c.txt", "".into())],
1097        );
1098        cx.run_until_parked();
1099
1100        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
1101        cx.executor().run_until_parked();
1102
1103        let (old_entry_ids, old_mtimes) = project.read_with(cx, |project, cx| {
1104            let tree = project.worktrees(cx).next().unwrap().read(cx);
1105            (
1106                tree.entries(true, 0).map(|e| e.id).collect::<Vec<_>>(),
1107                tree.entries(true, 0).map(|e| e.mtime).collect::<Vec<_>>(),
1108            )
1109        });
1110
1111        // Regression test: after the directory is scanned, touch the git repo's
1112        // working directory, bumping its mtime. That directory keeps its project
1113        // entry id after the directories are re-scanned.
1114        fs.touch_path(path!("/root")).await;
1115        cx.executor().run_until_parked();
1116
1117        let (new_entry_ids, new_mtimes) = project.read_with(cx, |project, cx| {
1118            let tree = project.worktrees(cx).next().unwrap().read(cx);
1119            (
1120                tree.entries(true, 0).map(|e| e.id).collect::<Vec<_>>(),
1121                tree.entries(true, 0).map(|e| e.mtime).collect::<Vec<_>>(),
1122            )
1123        });
1124        assert_eq!(new_entry_ids, old_entry_ids);
1125        assert_ne!(new_mtimes, old_mtimes);
1126
1127        // Regression test: changes to the git repository should still be
1128        // detected.
1129        fs.set_head_for_repo(
1130            path!("/root/.git").as_ref(),
1131            &[("a.txt", "".into()), ("b/c.txt", "something-else".into())],
1132            "deadbeef",
1133        );
1134        cx.executor().run_until_parked();
1135        cx.executor().advance_clock(Duration::from_secs(1));
1136
1137        let (repo_snapshots, worktree_snapshot) = project.read_with(cx, |project, cx| {
1138            (
1139                project.git_store().read(cx).repo_snapshots(cx),
1140                project.worktrees(cx).next().unwrap().read(cx).snapshot(),
1141            )
1142        });
1143
1144        check_git_statuses(
1145            &repo_snapshots,
1146            &worktree_snapshot,
1147            &[
1148                ("", MODIFIED),
1149                ("a.txt", GitSummary::UNCHANGED),
1150                ("b/c.txt", MODIFIED),
1151            ],
1152        );
1153    }
1154
1155    #[track_caller]
1156    fn check_git_statuses(
1157        repo_snapshots: &HashMap<RepositoryId, RepositorySnapshot>,
1158        worktree_snapshot: &worktree::Snapshot,
1159        expected_statuses: &[(&str, GitSummary)],
1160    ) {
1161        let mut traversal = GitTraversal::new(
1162            repo_snapshots,
1163            worktree_snapshot.traverse_from_path(true, true, false, RelPath::empty()),
1164        );
1165        let found_statuses = expected_statuses
1166            .iter()
1167            .map(|&(path, _)| {
1168                let git_entry = traversal
1169                    .find(|git_entry| git_entry.path.as_ref() == rel_path(path))
1170                    .unwrap_or_else(|| panic!("Traversal has no entry for {path:?}"));
1171                (path, git_entry.git_summary)
1172            })
1173            .collect::<Vec<_>>();
1174        pretty_assertions::assert_eq!(found_statuses, expected_statuses);
1175    }
1176}