git_tests.rs

   1use std::path::{self, Path, PathBuf};
   2
   3use call::ActiveCall;
   4use client::RECEIVE_TIMEOUT;
   5use collections::HashMap;
   6use git::{
   7    Oid,
   8    repository::{CommitData, RepoPath, Worktree as GitWorktree},
   9    status::{DiffStat, FileStatus, StatusCode, TrackedStatus},
  10};
  11use git_ui::{git_panel::GitPanel, project_diff::ProjectDiff};
  12use gpui::{AppContext as _, BackgroundExecutor, SharedString, TestAppContext, VisualTestContext};
  13use project::{
  14    ProjectPath,
  15    git_store::{CommitDataState, Repository},
  16};
  17use serde_json::json;
  18
  19use util::{path, rel_path::rel_path};
  20use workspace::{MultiWorkspace, Workspace};
  21
  22use crate::TestServer;
  23
  24#[gpui::test]
  25async fn test_root_repo_common_dir_sync(
  26    executor: BackgroundExecutor,
  27    cx_a: &mut TestAppContext,
  28    cx_b: &mut TestAppContext,
  29) {
  30    let mut server = TestServer::start(executor.clone()).await;
  31    let client_a = server.create_client(cx_a, "user_a").await;
  32    let client_b = server.create_client(cx_b, "user_b").await;
  33    server
  34        .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)])
  35        .await;
  36    let active_call_a = cx_a.read(ActiveCall::global);
  37
  38    // Set up a project whose root IS a git repository.
  39    client_a
  40        .fs()
  41        .insert_tree(
  42            path!("/project"),
  43            json!({ ".git": {}, "file.txt": "content" }),
  44        )
  45        .await;
  46
  47    let (project_a, _) = client_a.build_local_project(path!("/project"), cx_a).await;
  48    executor.run_until_parked();
  49
  50    // Host should see root_repo_common_dir pointing to .git at the root.
  51    let host_common_dir = project_a.read_with(cx_a, |project, cx| {
  52        let worktree = project.worktrees(cx).next().unwrap();
  53        worktree.read(cx).snapshot().root_repo_common_dir().cloned()
  54    });
  55    assert_eq!(
  56        host_common_dir.as_deref(),
  57        Some(path::Path::new(path!("/project/.git"))),
  58    );
  59
  60    // Share the project and have client B join.
  61    let project_id = active_call_a
  62        .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
  63        .await
  64        .unwrap();
  65    let project_b = client_b.join_remote_project(project_id, cx_b).await;
  66    executor.run_until_parked();
  67
  68    // Guest should see the same root_repo_common_dir as the host.
  69    let guest_common_dir = project_b.read_with(cx_b, |project, cx| {
  70        let worktree = project.worktrees(cx).next().unwrap();
  71        worktree.read(cx).snapshot().root_repo_common_dir().cloned()
  72    });
  73    assert_eq!(
  74        guest_common_dir, host_common_dir,
  75        "guest should see the same root_repo_common_dir as host",
  76    );
  77}
  78
  79fn collect_diff_stats<C: gpui::AppContext>(
  80    panel: &gpui::Entity<GitPanel>,
  81    cx: &C,
  82) -> HashMap<RepoPath, DiffStat> {
  83    panel.read_with(cx, |panel, cx| {
  84        let Some(repo) = panel.active_repository() else {
  85            return HashMap::default();
  86        };
  87        let snapshot = repo.read(cx).snapshot();
  88        let mut stats = HashMap::default();
  89        for entry in snapshot.statuses_by_path.iter() {
  90            if let Some(diff_stat) = entry.diff_stat {
  91                stats.insert(entry.repo_path.clone(), diff_stat);
  92            }
  93        }
  94        stats
  95    })
  96}
  97
  98async fn load_commit_data_batch(
  99    repository: &gpui::Entity<Repository>,
 100    shas: &[Oid],
 101    executor: &BackgroundExecutor,
 102    cx: &mut TestAppContext,
 103) -> HashMap<Oid, CommitData> {
 104    let states = cx.update(|cx| {
 105        shas.iter()
 106            .map(|sha| {
 107                (
 108                    *sha,
 109                    repository.update(cx, |repository, cx| {
 110                        repository.fetch_commit_data(*sha, true, cx).clone()
 111                    }),
 112                )
 113            })
 114            .collect::<Vec<_>>()
 115    });
 116
 117    executor.run_until_parked();
 118
 119    let mut commit_data = HashMap::default();
 120    for (sha, state) in states {
 121        let data = match state {
 122            CommitDataState::Loaded(data) => data.as_ref().clone(),
 123            CommitDataState::Loading(Some(shared)) => shared.await.unwrap().as_ref().clone(),
 124            CommitDataState::Loading(None) => {
 125                panic!("fetch_commit_data(..., true) should return an await-result state")
 126            }
 127        };
 128        commit_data.insert(sha, data);
 129    }
 130
 131    commit_data
 132}
 133
 134fn branch_list_snapshot(
 135    project: &gpui::Entity<project::Project>,
 136    cx: &mut TestAppContext,
 137) -> (Option<String>, Vec<String>) {
 138    project.read_with(cx, |project, cx| {
 139        let repos = project.repositories(cx);
 140        assert_eq!(repos.len(), 1, "project should have exactly 1 repository");
 141        let repo = repos.values().next().unwrap();
 142        let snapshot = repo.read(cx).snapshot();
 143        (
 144            snapshot
 145                .branch
 146                .as_ref()
 147                .map(|branch| branch.name().to_string()),
 148            snapshot
 149                .branch_list
 150                .iter()
 151                .map(|branch| branch.ref_name.to_string())
 152                .collect(),
 153        )
 154    })
 155}
 156
 157fn assert_remote_cache_matches_local_cache(
 158    local_repository: &gpui::Entity<Repository>,
 159    remote_repository: &gpui::Entity<Repository>,
 160    cx_local: &mut TestAppContext,
 161    cx_remote: &mut TestAppContext,
 162) {
 163    let local_cache = cx_local.update(|cx| {
 164        local_repository.update(cx, |repository, _| repository.loaded_commit_data_for_test())
 165    });
 166    let remote_cache = cx_remote.update(|cx| {
 167        remote_repository.update(cx, |repository, _| repository.loaded_commit_data_for_test())
 168    });
 169
 170    for (sha, remote_commit_data) in &remote_cache {
 171        let local_commit_data = local_cache
 172            .get(sha)
 173            .unwrap_or_else(|| panic!("local cache missing commit data for {sha}"));
 174        assert_eq!(
 175            local_commit_data.sha, remote_commit_data.sha,
 176            "local and remote cache should agree on sha for {sha}"
 177        );
 178        assert_eq!(
 179            local_commit_data.parents, remote_commit_data.parents,
 180            "local and remote cache should agree on parents for {sha}"
 181        );
 182        assert_eq!(
 183            local_commit_data.author_name, remote_commit_data.author_name,
 184            "local and remote cache should agree on author_name for {sha}"
 185        );
 186        assert_eq!(
 187            local_commit_data.author_email, remote_commit_data.author_email,
 188            "local and remote cache should agree on author_email for {sha}"
 189        );
 190        assert_eq!(
 191            local_commit_data.commit_timestamp, remote_commit_data.commit_timestamp,
 192            "local and remote cache should agree on commit_timestamp for {sha}"
 193        );
 194        assert_eq!(
 195            local_commit_data.subject, remote_commit_data.subject,
 196            "local and remote cache should agree on subject for {sha}"
 197        );
 198        assert_eq!(
 199            local_commit_data.message, remote_commit_data.message,
 200            "local and remote cache should agree on message for {sha}"
 201        );
 202    }
 203}
 204
 205#[gpui::test]
 206async fn test_project_diff(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) {
 207    let mut server = TestServer::start(cx_a.background_executor.clone()).await;
 208    let client_a = server.create_client(cx_a, "user_a").await;
 209    let client_b = server.create_client(cx_b, "user_b").await;
 210    cx_a.set_name("cx_a");
 211    cx_b.set_name("cx_b");
 212
 213    server
 214        .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)])
 215        .await;
 216
 217    client_a
 218        .fs()
 219        .insert_tree(
 220            path!("/a"),
 221            json!({
 222                ".git": {},
 223                "changed.txt": "after\n",
 224                "unchanged.txt": "unchanged\n",
 225                "created.txt": "created\n",
 226                "secret.pem": "secret-changed\n",
 227            }),
 228        )
 229        .await;
 230
 231    client_a.fs().set_head_and_index_for_repo(
 232        Path::new(path!("/a/.git")),
 233        &[
 234            ("changed.txt", "before\n".to_string()),
 235            ("unchanged.txt", "unchanged\n".to_string()),
 236            ("deleted.txt", "deleted\n".to_string()),
 237            ("secret.pem", "shh\n".to_string()),
 238        ],
 239    );
 240    let (project_a, worktree_id) = client_a.build_local_project(path!("/a"), cx_a).await;
 241    let active_call_a = cx_a.read(ActiveCall::global);
 242    let project_id = active_call_a
 243        .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
 244        .await
 245        .unwrap();
 246
 247    cx_b.update(editor::init);
 248    cx_b.update(git_ui::init);
 249    let project_b = client_b.join_remote_project(project_id, cx_b).await;
 250    let window_b = cx_b.add_window(|window, cx| {
 251        let workspace = cx.new(|cx| {
 252            Workspace::new(
 253                None,
 254                project_b.clone(),
 255                client_b.app_state.clone(),
 256                window,
 257                cx,
 258            )
 259        });
 260        MultiWorkspace::new(workspace, window, cx)
 261    });
 262    let cx_b = &mut VisualTestContext::from_window(*window_b, cx_b);
 263    let workspace_b = window_b
 264        .root(cx_b)
 265        .unwrap()
 266        .read_with(cx_b, |multi_workspace, _| {
 267            multi_workspace.workspace().clone()
 268        });
 269
 270    cx_b.update(|window, cx| {
 271        window
 272            .focused(cx)
 273            .unwrap()
 274            .dispatch_action(&git_ui::project_diff::Diff, window, cx)
 275    });
 276    let diff = workspace_b.update(cx_b, |workspace, cx| {
 277        workspace.active_item(cx).unwrap().act_as::<ProjectDiff>(cx)
 278    });
 279    let diff = diff.unwrap();
 280    cx_b.run_until_parked();
 281
 282    diff.update(cx_b, |diff, cx| {
 283        assert_eq!(
 284            diff.excerpt_paths(cx),
 285            vec![
 286                rel_path("changed.txt").into_arc(),
 287                rel_path("deleted.txt").into_arc(),
 288                rel_path("created.txt").into_arc()
 289            ]
 290        );
 291    });
 292
 293    client_a
 294        .fs()
 295        .insert_tree(
 296            path!("/a"),
 297            json!({
 298                ".git": {},
 299                "changed.txt": "before\n",
 300                "unchanged.txt": "changed\n",
 301                "created.txt": "created\n",
 302                "secret.pem": "secret-changed\n",
 303            }),
 304        )
 305        .await;
 306    cx_b.run_until_parked();
 307
 308    project_b.update(cx_b, |project, cx| {
 309        let project_path = ProjectPath {
 310            worktree_id,
 311            path: rel_path("unchanged.txt").into(),
 312        };
 313        let status = project.project_path_git_status(&project_path, cx);
 314        assert_eq!(
 315            status.unwrap(),
 316            FileStatus::Tracked(TrackedStatus {
 317                worktree_status: StatusCode::Modified,
 318                index_status: StatusCode::Unmodified,
 319            })
 320        );
 321    });
 322
 323    diff.update(cx_b, |diff, cx| {
 324        assert_eq!(
 325            diff.excerpt_paths(cx),
 326            vec![
 327                rel_path("deleted.txt").into_arc(),
 328                rel_path("unchanged.txt").into_arc(),
 329                rel_path("created.txt").into_arc()
 330            ]
 331        );
 332    });
 333}
 334
 335#[gpui::test]
 336async fn test_remote_git_worktrees(
 337    executor: BackgroundExecutor,
 338    cx_a: &mut TestAppContext,
 339    cx_b: &mut TestAppContext,
 340) {
 341    let mut server = TestServer::start(executor.clone()).await;
 342    let client_a = server.create_client(cx_a, "user_a").await;
 343    let client_b = server.create_client(cx_b, "user_b").await;
 344    server
 345        .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)])
 346        .await;
 347    let active_call_a = cx_a.read(ActiveCall::global);
 348
 349    client_a
 350        .fs()
 351        .insert_tree(
 352            path!("/project"),
 353            json!({ ".git": {}, "file.txt": "content" }),
 354        )
 355        .await;
 356
 357    let (project_a, _) = client_a.build_local_project(path!("/project"), cx_a).await;
 358
 359    let project_id = active_call_a
 360        .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
 361        .await
 362        .unwrap();
 363    let project_b = client_b.join_remote_project(project_id, cx_b).await;
 364
 365    executor.run_until_parked();
 366
 367    let repo_b = cx_b.update(|cx| project_b.read(cx).active_repository(cx).unwrap());
 368
 369    // Initially only the main worktree (the repo itself) should be present
 370    let worktrees = cx_b
 371        .update(|cx| repo_b.update(cx, |repository, _| repository.worktrees()))
 372        .await
 373        .unwrap()
 374        .unwrap();
 375    assert_eq!(worktrees.len(), 1);
 376    assert_eq!(worktrees[0].path, PathBuf::from(path!("/project")));
 377
 378    // Client B creates a git worktree via the remote project
 379    let worktree_directory = PathBuf::from(path!("/project"));
 380    cx_b.update(|cx| {
 381        repo_b.update(cx, |repository, _| {
 382            repository.create_worktree(
 383                git::repository::CreateWorktreeTarget::NewBranch {
 384                    branch_name: "feature-branch".to_string(),
 385                    base_sha: Some("abc123".to_string()),
 386                },
 387                worktree_directory.join("feature-branch"),
 388            )
 389        })
 390    })
 391    .await
 392    .unwrap()
 393    .unwrap();
 394
 395    executor.run_until_parked();
 396
 397    // Client B lists worktrees — should see main + the one just created
 398    let worktrees = cx_b
 399        .update(|cx| repo_b.update(cx, |repository, _| repository.worktrees()))
 400        .await
 401        .unwrap()
 402        .unwrap();
 403    assert_eq!(worktrees.len(), 2);
 404    assert_eq!(worktrees[0].path, PathBuf::from(path!("/project")));
 405    assert_eq!(worktrees[1].path, worktree_directory.join("feature-branch"));
 406    assert_eq!(
 407        worktrees[1].ref_name,
 408        Some("refs/heads/feature-branch".into())
 409    );
 410    assert_eq!(worktrees[1].sha.as_ref(), "abc123");
 411
 412    // Verify from the host side that the worktree was actually created
 413    let host_worktrees = {
 414        let repo_a = cx_a.update(|cx| {
 415            project_a
 416                .read(cx)
 417                .repositories(cx)
 418                .values()
 419                .next()
 420                .unwrap()
 421                .clone()
 422        });
 423        cx_a.update(|cx| repo_a.update(cx, |repository, _| repository.worktrees()))
 424            .await
 425            .unwrap()
 426            .unwrap()
 427    };
 428    assert_eq!(host_worktrees.len(), 2);
 429    assert_eq!(host_worktrees[0].path, PathBuf::from(path!("/project")));
 430    assert_eq!(
 431        host_worktrees[1].path,
 432        worktree_directory.join("feature-branch")
 433    );
 434
 435    // Client B creates a second git worktree without an explicit commit
 436    cx_b.update(|cx| {
 437        repo_b.update(cx, |repository, _| {
 438            repository.create_worktree(
 439                git::repository::CreateWorktreeTarget::NewBranch {
 440                    branch_name: "bugfix-branch".to_string(),
 441                    base_sha: None,
 442                },
 443                worktree_directory.join("bugfix-branch"),
 444            )
 445        })
 446    })
 447    .await
 448    .unwrap()
 449    .unwrap();
 450
 451    executor.run_until_parked();
 452
 453    // Client B lists worktrees — should now have main + two created
 454    let worktrees = cx_b
 455        .update(|cx| repo_b.update(cx, |repository, _| repository.worktrees()))
 456        .await
 457        .unwrap()
 458        .unwrap();
 459    assert_eq!(worktrees.len(), 3);
 460
 461    let feature_worktree = worktrees
 462        .iter()
 463        .find(|worktree| worktree.ref_name == Some("refs/heads/feature-branch".into()))
 464        .expect("should find feature-branch worktree");
 465    assert_eq!(
 466        feature_worktree.path,
 467        worktree_directory.join("feature-branch")
 468    );
 469
 470    let bugfix_worktree = worktrees
 471        .iter()
 472        .find(|worktree| worktree.ref_name == Some("refs/heads/bugfix-branch".into()))
 473        .expect("should find bugfix-branch worktree");
 474    assert_eq!(
 475        bugfix_worktree.path,
 476        worktree_directory.join("bugfix-branch")
 477    );
 478    assert_eq!(bugfix_worktree.sha.as_ref(), "fake-sha");
 479
 480    // Client B (guest) attempts to rename a worktree. This should fail
 481    // because worktree renaming is not forwarded through collab
 482    let rename_result = cx_b
 483        .update(|cx| {
 484            repo_b.update(cx, |repository, _| {
 485                repository.rename_worktree(
 486                    worktree_directory.join("feature-branch"),
 487                    worktree_directory.join("renamed-branch"),
 488                )
 489            })
 490        })
 491        .await
 492        .unwrap();
 493    assert!(
 494        rename_result.is_err(),
 495        "Guest should not be able to rename worktrees via collab"
 496    );
 497
 498    executor.run_until_parked();
 499
 500    // Verify worktrees are unchanged — still 3
 501    let worktrees = cx_b
 502        .update(|cx| repo_b.update(cx, |repository, _| repository.worktrees()))
 503        .await
 504        .unwrap()
 505        .unwrap();
 506    assert_eq!(
 507        worktrees.len(),
 508        3,
 509        "Worktree count should be unchanged after failed rename"
 510    );
 511
 512    // Client B (guest) attempts to remove a worktree. This should fail
 513    // because worktree removal is not forwarded through collab
 514    let remove_result = cx_b
 515        .update(|cx| {
 516            repo_b.update(cx, |repository, _| {
 517                repository.remove_worktree(worktree_directory.join("feature-branch"), false)
 518            })
 519        })
 520        .await
 521        .unwrap();
 522    assert!(
 523        remove_result.is_err(),
 524        "Guest should not be able to remove worktrees via collab"
 525    );
 526
 527    executor.run_until_parked();
 528
 529    // Verify worktrees are unchanged — still 3
 530    let worktrees = cx_b
 531        .update(|cx| repo_b.update(cx, |repository, _| repository.worktrees()))
 532        .await
 533        .unwrap()
 534        .unwrap();
 535    assert_eq!(
 536        worktrees.len(),
 537        3,
 538        "Worktree count should be unchanged after failed removal"
 539    );
 540}
 541
 542#[gpui::test]
 543async fn test_remote_git_head_sha(
 544    executor: BackgroundExecutor,
 545    cx_a: &mut TestAppContext,
 546    cx_b: &mut TestAppContext,
 547) {
 548    let mut server = TestServer::start(executor.clone()).await;
 549    let client_a = server.create_client(cx_a, "user_a").await;
 550    let client_b = server.create_client(cx_b, "user_b").await;
 551    server
 552        .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)])
 553        .await;
 554    let active_call_a = cx_a.read(ActiveCall::global);
 555
 556    client_a
 557        .fs()
 558        .insert_tree(
 559            path!("/project"),
 560            json!({ ".git": {}, "file.txt": "content" }),
 561        )
 562        .await;
 563
 564    let (project_a, _) = client_a.build_local_project(path!("/project"), cx_a).await;
 565    let local_head_sha = cx_a.update(|cx| {
 566        project_a
 567            .read(cx)
 568            .active_repository(cx)
 569            .unwrap()
 570            .update(cx, |repository, _| repository.head_sha())
 571    });
 572    let local_head_sha = local_head_sha.await.unwrap().unwrap();
 573
 574    let project_id = active_call_a
 575        .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
 576        .await
 577        .unwrap();
 578    let project_b = client_b.join_remote_project(project_id, cx_b).await;
 579
 580    executor.run_until_parked();
 581
 582    let remote_head_sha = cx_b.update(|cx| {
 583        project_b
 584            .read(cx)
 585            .active_repository(cx)
 586            .unwrap()
 587            .update(cx, |repository, _| repository.head_sha())
 588    });
 589    let remote_head_sha = remote_head_sha.await.unwrap();
 590
 591    assert_eq!(remote_head_sha.unwrap(), local_head_sha);
 592}
 593
 594#[gpui::test]
 595async fn test_remote_git_commit_data_batches(
 596    executor: BackgroundExecutor,
 597    cx_a: &mut TestAppContext,
 598    cx_b: &mut TestAppContext,
 599) {
 600    let mut server = TestServer::start(executor.clone()).await;
 601    let client_a = server.create_client(cx_a, "user_a").await;
 602    let client_b = server.create_client(cx_b, "user_b").await;
 603    server
 604        .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)])
 605        .await;
 606    let active_call_a = cx_a.read(ActiveCall::global);
 607
 608    client_a
 609        .fs()
 610        .insert_tree(
 611            path!("/project"),
 612            json!({ ".git": {}, "file.txt": "content" }),
 613        )
 614        .await;
 615
 616    let commit_shas = [
 617        "0123456789abcdef0123456789abcdef01234567"
 618            .parse::<Oid>()
 619            .unwrap(),
 620        "1111111111111111111111111111111111111111"
 621            .parse::<Oid>()
 622            .unwrap(),
 623        "2222222222222222222222222222222222222222"
 624            .parse::<Oid>()
 625            .unwrap(),
 626        "3333333333333333333333333333333333333333"
 627            .parse::<Oid>()
 628            .unwrap(),
 629    ];
 630
 631    client_a.fs().set_commit_data(
 632        Path::new(path!("/project/.git")),
 633        commit_shas.iter().enumerate().map(|(index, sha)| {
 634            (
 635                CommitData {
 636                    sha: *sha,
 637                    parents: Default::default(),
 638                    author_name: SharedString::from(format!("Author {index}")),
 639                    author_email: SharedString::from(format!("author{index}@example.com")),
 640                    commit_timestamp: 1_700_000_000 + index as i64,
 641                    subject: SharedString::from(format!("Subject {index}")),
 642                    message: SharedString::from(format!("Subject {index}\n\nBody {index}")),
 643                },
 644                false,
 645            )
 646        }),
 647    );
 648
 649    let (project_a, _) = client_a.build_local_project(path!("/project"), cx_a).await;
 650    executor.run_until_parked();
 651
 652    let repo_a = cx_a.update(|cx| project_a.read(cx).active_repository(cx).unwrap());
 653
 654    let primed_before = load_commit_data_batch(&repo_a, &commit_shas[..2], &executor, cx_a).await;
 655    assert_eq!(
 656        primed_before.len(),
 657        2,
 658        "host should prime two commits before sharing"
 659    );
 660
 661    let project_id = active_call_a
 662        .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
 663        .await
 664        .unwrap();
 665    let project_b = client_b.join_remote_project(project_id, cx_b).await;
 666
 667    executor.run_until_parked();
 668
 669    let repo_b = cx_b.update(|cx| project_b.read(cx).active_repository(cx).unwrap());
 670
 671    let remote_batch_one =
 672        load_commit_data_batch(&repo_b, &commit_shas[..3], &executor, cx_b).await;
 673    assert_eq!(remote_batch_one.len(), 3);
 674    for (index, sha) in commit_shas[..3].iter().enumerate() {
 675        let commit_data = remote_batch_one.get(sha).unwrap();
 676        assert_eq!(commit_data.sha, *sha);
 677        assert_eq!(commit_data.subject.as_ref(), format!("Subject {index}"));
 678        assert_eq!(
 679            commit_data.message.as_ref(),
 680            format!("Subject {index}\n\nBody {index}")
 681        );
 682    }
 683
 684    let primed_after = load_commit_data_batch(&repo_a, &commit_shas[2..], &executor, cx_a).await;
 685    assert_eq!(
 686        primed_after.len(),
 687        2,
 688        "host should prime remaining commits after remote fetches"
 689    );
 690
 691    let remote_batch_two =
 692        load_commit_data_batch(&repo_b, &commit_shas[1..], &executor, cx_b).await;
 693    assert_eq!(remote_batch_two.len(), 3);
 694
 695    assert_remote_cache_matches_local_cache(&repo_a, &repo_b, cx_a, cx_b);
 696}
 697
 698#[gpui::test]
 699async fn test_branch_list_sync(
 700    executor: BackgroundExecutor,
 701    cx_a: &mut TestAppContext,
 702    cx_b: &mut TestAppContext,
 703) {
 704    let mut server = TestServer::start(executor.clone()).await;
 705    let client_a = server.create_client(cx_a, "user_a").await;
 706    let client_b = server.create_client(cx_b, "user_b").await;
 707    server
 708        .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)])
 709        .await;
 710    let active_call_a = cx_a.read(ActiveCall::global);
 711
 712    client_a
 713        .fs()
 714        .insert_tree(
 715            path!("/project"),
 716            json!({ ".git": {}, "file.txt": "content" }),
 717        )
 718        .await;
 719    client_a.fs().insert_branches(
 720        Path::new(path!("/project/.git")),
 721        &["main", "feature-1", "feature-2"],
 722    );
 723
 724    let (project_a, _) = client_a.build_local_project(path!("/project"), cx_a).await;
 725    executor.run_until_parked();
 726
 727    let host_snapshot = branch_list_snapshot(&project_a, cx_a);
 728    assert_eq!(host_snapshot.0.as_deref(), Some("main"));
 729    assert_eq!(
 730        host_snapshot.1,
 731        vec![
 732            "refs/heads/feature-1".to_string(),
 733            "refs/heads/feature-2".to_string(),
 734            "refs/heads/main".to_string(),
 735        ]
 736    );
 737
 738    let project_id = active_call_a
 739        .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
 740        .await
 741        .unwrap();
 742    let project_b = client_b.join_remote_project(project_id, cx_b).await;
 743
 744    executor.run_until_parked();
 745
 746    let repo_b = cx_b.update(|cx| project_b.read(cx).active_repository(cx).unwrap());
 747
 748    cx_b.update(|cx| {
 749        repo_b.update(cx, |repository, _cx| {
 750            repository.create_branch("totally-new-branch".to_string(), None)
 751        })
 752    })
 753    .await
 754    .unwrap()
 755    .unwrap();
 756
 757    cx_b.update(|cx| {
 758        repo_b.update(cx, |repository, _cx| {
 759            repository.change_branch("totally-new-branch".to_string())
 760        })
 761    })
 762    .await
 763    .unwrap()
 764    .unwrap();
 765
 766    executor.run_until_parked();
 767
 768    let host_snapshot_after_update = branch_list_snapshot(&project_a, cx_a);
 769    assert_eq!(
 770        host_snapshot_after_update.0.as_deref(),
 771        Some("totally-new-branch")
 772    );
 773    assert_eq!(
 774        host_snapshot_after_update.1,
 775        vec![
 776            "refs/heads/feature-1".to_string(),
 777            "refs/heads/feature-2".to_string(),
 778            "refs/heads/main".to_string(),
 779            "refs/heads/totally-new-branch".to_string(),
 780        ]
 781    );
 782
 783    let guest_snapshot_after_update = branch_list_snapshot(&project_b, cx_b);
 784    assert_eq!(guest_snapshot_after_update, host_snapshot_after_update);
 785}
 786
 787#[gpui::test]
 788async fn test_linked_worktrees_sync(
 789    executor: BackgroundExecutor,
 790    cx_a: &mut TestAppContext,
 791    cx_b: &mut TestAppContext,
 792    cx_c: &mut TestAppContext,
 793) {
 794    let mut server = TestServer::start(executor.clone()).await;
 795    let client_a = server.create_client(cx_a, "user_a").await;
 796    let client_b = server.create_client(cx_b, "user_b").await;
 797    let client_c = server.create_client(cx_c, "user_c").await;
 798    server
 799        .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b), (&client_c, cx_c)])
 800        .await;
 801    let active_call_a = cx_a.read(ActiveCall::global);
 802
 803    // Set up a git repo with two linked worktrees already present.
 804    client_a
 805        .fs()
 806        .insert_tree(
 807            path!("/project"),
 808            json!({ ".git": {}, "file.txt": "content" }),
 809        )
 810        .await;
 811
 812    let fs = client_a.fs();
 813    fs.add_linked_worktree_for_repo(
 814        Path::new(path!("/project/.git")),
 815        true,
 816        GitWorktree {
 817            path: PathBuf::from(path!("/worktrees/feature-branch")),
 818            ref_name: Some("refs/heads/feature-branch".into()),
 819            sha: "bbb222".into(),
 820            is_main: false,
 821            is_bare: false,
 822        },
 823    )
 824    .await;
 825    fs.add_linked_worktree_for_repo(
 826        Path::new(path!("/project/.git")),
 827        true,
 828        GitWorktree {
 829            path: PathBuf::from(path!("/worktrees/bugfix-branch")),
 830            ref_name: Some("refs/heads/bugfix-branch".into()),
 831            sha: "ccc333".into(),
 832            is_main: false,
 833            is_bare: false,
 834        },
 835    )
 836    .await;
 837
 838    let (project_a, _) = client_a.build_local_project(path!("/project"), cx_a).await;
 839
 840    // Wait for git scanning to complete on the host.
 841    executor.run_until_parked();
 842
 843    // Verify the host sees 2 linked worktrees (main worktree is filtered out).
 844    let host_linked = project_a.read_with(cx_a, |project, cx| {
 845        let repos = project.repositories(cx);
 846        assert_eq!(repos.len(), 1, "host should have exactly 1 repository");
 847        let repo = repos.values().next().unwrap();
 848        repo.read(cx).linked_worktrees().to_vec()
 849    });
 850    assert_eq!(
 851        host_linked.len(),
 852        2,
 853        "host should have 2 linked worktrees (main filtered out)"
 854    );
 855    assert_eq!(
 856        host_linked[0].path,
 857        PathBuf::from(path!("/worktrees/bugfix-branch"))
 858    );
 859    assert_eq!(
 860        host_linked[0].ref_name,
 861        Some("refs/heads/bugfix-branch".into())
 862    );
 863    assert_eq!(host_linked[0].sha.as_ref(), "ccc333");
 864    assert_eq!(
 865        host_linked[1].path,
 866        PathBuf::from(path!("/worktrees/feature-branch"))
 867    );
 868    assert_eq!(
 869        host_linked[1].ref_name,
 870        Some("refs/heads/feature-branch".into())
 871    );
 872    assert_eq!(host_linked[1].sha.as_ref(), "bbb222");
 873
 874    // Share the project and have client B join.
 875    let project_id = active_call_a
 876        .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
 877        .await
 878        .unwrap();
 879    let project_b = client_b.join_remote_project(project_id, cx_b).await;
 880
 881    executor.run_until_parked();
 882
 883    // Verify the guest sees the same linked worktrees as the host.
 884    let guest_linked = project_b.read_with(cx_b, |project, cx| {
 885        let repos = project.repositories(cx);
 886        assert_eq!(repos.len(), 1, "guest should have exactly 1 repository");
 887        let repo = repos.values().next().unwrap();
 888        repo.read(cx).linked_worktrees().to_vec()
 889    });
 890    assert_eq!(
 891        guest_linked, host_linked,
 892        "guest's linked_worktrees should match host's after initial sync"
 893    );
 894
 895    // Now mutate: add a third linked worktree on the host side.
 896    client_a
 897        .fs()
 898        .add_linked_worktree_for_repo(
 899            Path::new(path!("/project/.git")),
 900            true,
 901            GitWorktree {
 902                path: PathBuf::from(path!("/worktrees/hotfix-branch")),
 903                ref_name: Some("refs/heads/hotfix-branch".into()),
 904                sha: "ddd444".into(),
 905                is_main: false,
 906                is_bare: false,
 907            },
 908        )
 909        .await;
 910
 911    // Wait for the host to re-scan and propagate the update.
 912    executor.run_until_parked();
 913
 914    // Verify host now sees 3 linked worktrees.
 915    let host_linked_updated = project_a.read_with(cx_a, |project, cx| {
 916        let repos = project.repositories(cx);
 917        let repo = repos.values().next().unwrap();
 918        repo.read(cx).linked_worktrees().to_vec()
 919    });
 920    assert_eq!(
 921        host_linked_updated.len(),
 922        3,
 923        "host should now have 3 linked worktrees"
 924    );
 925    assert_eq!(
 926        host_linked_updated[2].path,
 927        PathBuf::from(path!("/worktrees/hotfix-branch"))
 928    );
 929
 930    // Verify the guest also received the update.
 931    let guest_linked_updated = project_b.read_with(cx_b, |project, cx| {
 932        let repos = project.repositories(cx);
 933        let repo = repos.values().next().unwrap();
 934        repo.read(cx).linked_worktrees().to_vec()
 935    });
 936    assert_eq!(
 937        guest_linked_updated, host_linked_updated,
 938        "guest's linked_worktrees should match host's after update"
 939    );
 940
 941    // Now mutate: remove one linked worktree from the host side.
 942    client_a
 943        .fs()
 944        .remove_worktree_for_repo(
 945            Path::new(path!("/project/.git")),
 946            true,
 947            "refs/heads/bugfix-branch",
 948        )
 949        .await;
 950
 951    executor.run_until_parked();
 952
 953    // Verify host now sees 2 linked worktrees (feature-branch and hotfix-branch).
 954    let host_linked_after_removal = project_a.read_with(cx_a, |project, cx| {
 955        let repos = project.repositories(cx);
 956        let repo = repos.values().next().unwrap();
 957        repo.read(cx).linked_worktrees().to_vec()
 958    });
 959    assert_eq!(
 960        host_linked_after_removal.len(),
 961        2,
 962        "host should have 2 linked worktrees after removal"
 963    );
 964    assert!(
 965        host_linked_after_removal
 966            .iter()
 967            .all(|wt| wt.ref_name != Some("refs/heads/bugfix-branch".into())),
 968        "bugfix-branch should have been removed"
 969    );
 970
 971    // Verify the guest also reflects the removal.
 972    let guest_linked_after_removal = project_b.read_with(cx_b, |project, cx| {
 973        let repos = project.repositories(cx);
 974        let repo = repos.values().next().unwrap();
 975        repo.read(cx).linked_worktrees().to_vec()
 976    });
 977    assert_eq!(
 978        guest_linked_after_removal, host_linked_after_removal,
 979        "guest's linked_worktrees should match host's after removal"
 980    );
 981
 982    // Test DB roundtrip: client C joins late, getting state from the database.
 983    // This verifies that linked_worktrees are persisted and restored correctly.
 984    let project_c = client_c.join_remote_project(project_id, cx_c).await;
 985    executor.run_until_parked();
 986
 987    let late_joiner_linked = project_c.read_with(cx_c, |project, cx| {
 988        let repos = project.repositories(cx);
 989        assert_eq!(
 990            repos.len(),
 991            1,
 992            "late joiner should have exactly 1 repository"
 993        );
 994        let repo = repos.values().next().unwrap();
 995        repo.read(cx).linked_worktrees().to_vec()
 996    });
 997    assert_eq!(
 998        late_joiner_linked, host_linked_after_removal,
 999        "late-joining client's linked_worktrees should match host's (DB roundtrip)"
1000    );
1001
1002    // Test reconnection: disconnect client B (guest) and reconnect.
1003    // After rejoining, client B should get linked_worktrees back from the DB.
1004    server.disconnect_client(client_b.peer_id().unwrap());
1005    executor.advance_clock(RECEIVE_TIMEOUT);
1006    executor.run_until_parked();
1007
1008    // Client B reconnects automatically.
1009    executor.advance_clock(RECEIVE_TIMEOUT);
1010    executor.run_until_parked();
1011
1012    // Verify client B still has the correct linked worktrees after reconnection.
1013    let guest_linked_after_reconnect = project_b.read_with(cx_b, |project, cx| {
1014        let repos = project.repositories(cx);
1015        assert_eq!(
1016            repos.len(),
1017            1,
1018            "guest should still have exactly 1 repository after reconnect"
1019        );
1020        let repo = repos.values().next().unwrap();
1021        repo.read(cx).linked_worktrees().to_vec()
1022    });
1023    assert_eq!(
1024        guest_linked_after_reconnect, host_linked_after_removal,
1025        "guest's linked_worktrees should survive guest disconnect/reconnect"
1026    );
1027}
1028
1029#[gpui::test]
1030async fn test_diff_stat_sync_between_host_and_downstream_client(
1031    cx_a: &mut TestAppContext,
1032    cx_b: &mut TestAppContext,
1033    cx_c: &mut TestAppContext,
1034) {
1035    let mut server = TestServer::start(cx_a.background_executor.clone()).await;
1036    let client_a = server.create_client(cx_a, "user_a").await;
1037    let client_b = server.create_client(cx_b, "user_b").await;
1038    let client_c = server.create_client(cx_c, "user_c").await;
1039
1040    server
1041        .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b), (&client_c, cx_c)])
1042        .await;
1043
1044    let fs = client_a.fs();
1045    fs.insert_tree(
1046        path!("/code"),
1047        json!({
1048            "project1": {
1049                ".git": {},
1050                "src": {
1051                    "lib.rs": "line1\nline2\nline3\n",
1052                    "new_file.rs": "added1\nadded2\n",
1053                },
1054                "README.md": "# project 1",
1055            }
1056        }),
1057    )
1058    .await;
1059
1060    let dot_git = Path::new(path!("/code/project1/.git"));
1061    fs.set_head_for_repo(
1062        dot_git,
1063        &[
1064            ("src/lib.rs", "line1\nold_line2\n".into()),
1065            ("src/deleted.rs", "was_here\n".into()),
1066        ],
1067        "deadbeef",
1068    );
1069    fs.set_index_for_repo(
1070        dot_git,
1071        &[
1072            ("src/lib.rs", "line1\nold_line2\nline3\nline4\n".into()),
1073            ("src/staged_only.rs", "x\ny\n".into()),
1074            ("src/new_file.rs", "added1\nadded2\n".into()),
1075            ("README.md", "# project 1".into()),
1076        ],
1077    );
1078
1079    let (project_a, worktree_id) = client_a
1080        .build_local_project(path!("/code/project1"), cx_a)
1081        .await;
1082    let active_call_a = cx_a.read(ActiveCall::global);
1083    let project_id = active_call_a
1084        .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
1085        .await
1086        .unwrap();
1087    let project_b = client_b.join_remote_project(project_id, cx_b).await;
1088    let _project_c = client_c.join_remote_project(project_id, cx_c).await;
1089    cx_a.run_until_parked();
1090
1091    let (workspace_a, cx_a) = client_a.build_workspace(&project_a, cx_a);
1092    let (workspace_b, cx_b) = client_b.build_workspace(&project_b, cx_b);
1093
1094    let panel_a = workspace_a.update_in(cx_a, GitPanel::new_test);
1095    workspace_a.update_in(cx_a, |workspace, window, cx| {
1096        workspace.add_panel(panel_a.clone(), window, cx);
1097    });
1098
1099    let panel_b = workspace_b.update_in(cx_b, GitPanel::new_test);
1100    workspace_b.update_in(cx_b, |workspace, window, cx| {
1101        workspace.add_panel(panel_b.clone(), window, cx);
1102    });
1103
1104    cx_a.run_until_parked();
1105
1106    let stats_a = collect_diff_stats(&panel_a, cx_a);
1107    let stats_b = collect_diff_stats(&panel_b, cx_b);
1108
1109    let mut expected: HashMap<RepoPath, DiffStat> = HashMap::default();
1110    expected.insert(
1111        RepoPath::new("src/lib.rs").unwrap(),
1112        DiffStat {
1113            added: 3,
1114            deleted: 2,
1115        },
1116    );
1117    expected.insert(
1118        RepoPath::new("src/deleted.rs").unwrap(),
1119        DiffStat {
1120            added: 0,
1121            deleted: 1,
1122        },
1123    );
1124    expected.insert(
1125        RepoPath::new("src/new_file.rs").unwrap(),
1126        DiffStat {
1127            added: 2,
1128            deleted: 0,
1129        },
1130    );
1131    expected.insert(
1132        RepoPath::new("README.md").unwrap(),
1133        DiffStat {
1134            added: 1,
1135            deleted: 0,
1136        },
1137    );
1138    assert_eq!(stats_a, expected, "host diff stats should match expected");
1139    assert_eq!(stats_a, stats_b, "host and remote should agree");
1140
1141    let buffer_a = project_a
1142        .update(cx_a, |p, cx| {
1143            p.open_buffer((worktree_id, rel_path("src/lib.rs")), cx)
1144        })
1145        .await
1146        .unwrap();
1147
1148    let _buffer_b = project_b
1149        .update(cx_b, |p, cx| {
1150            p.open_buffer((worktree_id, rel_path("src/lib.rs")), cx)
1151        })
1152        .await
1153        .unwrap();
1154    cx_a.run_until_parked();
1155
1156    buffer_a.update(cx_a, |buf, cx| {
1157        buf.edit([(buf.len()..buf.len(), "line4\n")], None, cx);
1158    });
1159    project_a
1160        .update(cx_a, |project, cx| {
1161            project.save_buffer(buffer_a.clone(), cx)
1162        })
1163        .await
1164        .unwrap();
1165    cx_a.run_until_parked();
1166
1167    let stats_a = collect_diff_stats(&panel_a, cx_a);
1168    let stats_b = collect_diff_stats(&panel_b, cx_b);
1169
1170    let mut expected_after_edit = expected.clone();
1171    expected_after_edit.insert(
1172        RepoPath::new("src/lib.rs").unwrap(),
1173        DiffStat {
1174            added: 4,
1175            deleted: 2,
1176        },
1177    );
1178    assert_eq!(
1179        stats_a, expected_after_edit,
1180        "host diff stats should reflect the edit"
1181    );
1182    assert_eq!(
1183        stats_b, expected_after_edit,
1184        "remote diff stats should reflect the host's edit"
1185    );
1186
1187    let active_call_b = cx_b.read(ActiveCall::global);
1188    active_call_b
1189        .update(cx_b, |call, cx| call.hang_up(cx))
1190        .await
1191        .unwrap();
1192    cx_a.run_until_parked();
1193
1194    let user_id_b = client_b.current_user_id(cx_b).to_proto();
1195    active_call_a
1196        .update(cx_a, |call, cx| call.invite(user_id_b, None, cx))
1197        .await
1198        .unwrap();
1199    cx_b.run_until_parked();
1200    let active_call_b = cx_b.read(ActiveCall::global);
1201    active_call_b
1202        .update(cx_b, |call, cx| call.accept_incoming(cx))
1203        .await
1204        .unwrap();
1205    cx_a.run_until_parked();
1206
1207    let project_b = client_b.join_remote_project(project_id, cx_b).await;
1208    cx_a.run_until_parked();
1209
1210    let (workspace_b, cx_b) = client_b.build_workspace(&project_b, cx_b);
1211    let panel_b = workspace_b.update_in(cx_b, GitPanel::new_test);
1212    workspace_b.update_in(cx_b, |workspace, window, cx| {
1213        workspace.add_panel(panel_b.clone(), window, cx);
1214    });
1215    cx_b.run_until_parked();
1216
1217    let stats_b = collect_diff_stats(&panel_b, cx_b);
1218    assert_eq!(
1219        stats_b, expected_after_edit,
1220        "remote diff stats should be restored from the database after rejoining the call"
1221    );
1222}