mod conflict_set_tests { use std::sync::mpsc; use crate::Project; use fs::FakeFs; use git::{ repository::{RepoPath, repo_path}, status::{UnmergedStatus, UnmergedStatusCode}, }; use gpui::{BackgroundExecutor, TestAppContext}; use project::git_store::*; use serde_json::json; use text::{Buffer, BufferId, OffsetRangeExt, Point, ReplicaId, ToOffset as _}; use unindent::Unindent as _; use util::{path, rel_path::rel_path}; #[test] fn test_parse_conflicts_in_buffer() { // Create a buffer with conflict markers let test_content = r#" This is some text before the conflict. <<<<<<< HEAD This is our version ======= This is their version >>>>>>> branch-name Another conflict: <<<<<<< HEAD Our second change ||||||| merged common ancestors Original content ======= Their second change >>>>>>> branch-name "# .unindent(); let buffer_id = BufferId::new(1).unwrap(); let buffer = Buffer::new(ReplicaId::LOCAL, buffer_id, test_content); let snapshot = buffer.snapshot(); let conflict_snapshot = ConflictSet::parse(&snapshot); assert_eq!(conflict_snapshot.conflicts.len(), 2); let first = &conflict_snapshot.conflicts[0]; assert!(first.base.is_none()); assert_eq!(first.ours_branch_name.as_ref(), "HEAD"); assert_eq!(first.theirs_branch_name.as_ref(), "branch-name"); let our_text = snapshot .text_for_range(first.ours.clone()) .collect::(); let their_text = snapshot .text_for_range(first.theirs.clone()) .collect::(); assert_eq!(our_text, "This is our version\n"); assert_eq!(their_text, "This is their version\n"); let second = &conflict_snapshot.conflicts[1]; assert!(second.base.is_some()); assert_eq!(second.ours_branch_name.as_ref(), "HEAD"); assert_eq!(second.theirs_branch_name.as_ref(), "branch-name"); let our_text = snapshot .text_for_range(second.ours.clone()) .collect::(); let their_text = snapshot .text_for_range(second.theirs.clone()) .collect::(); let base_text = snapshot .text_for_range(second.base.as_ref().unwrap().clone()) .collect::(); assert_eq!(our_text, "Our second change\n"); assert_eq!(their_text, "Their second change\n"); assert_eq!(base_text, "Original content\n"); // Test conflicts_in_range let range = snapshot.anchor_before(0)..snapshot.anchor_before(snapshot.len()); let conflicts_in_range = conflict_snapshot.conflicts_in_range(range, &snapshot); assert_eq!(conflicts_in_range.len(), 2); // Test with a range that includes only the first conflict let first_conflict_end = conflict_snapshot.conflicts[0].range.end; let range = snapshot.anchor_before(0)..first_conflict_end; let conflicts_in_range = conflict_snapshot.conflicts_in_range(range, &snapshot); assert_eq!(conflicts_in_range.len(), 1); // Test with a range that includes only the second conflict let second_conflict_start = conflict_snapshot.conflicts[1].range.start; let range = second_conflict_start..snapshot.anchor_before(snapshot.len()); let conflicts_in_range = conflict_snapshot.conflicts_in_range(range, &snapshot); assert_eq!(conflicts_in_range.len(), 1); // Test with a range that doesn't include any conflicts let range = buffer.anchor_after(first_conflict_end.to_next_offset(&buffer)) ..buffer.anchor_before(second_conflict_start.to_previous_offset(&buffer)); let conflicts_in_range = conflict_snapshot.conflicts_in_range(range, &snapshot); assert_eq!(conflicts_in_range.len(), 0); } #[test] fn test_nested_conflict_markers() { // Create a buffer with nested conflict markers let test_content = r#" This is some text before the conflict. <<<<<<< HEAD This is our version <<<<<<< HEAD This is a nested conflict marker ======= This is their version in a nested conflict >>>>>>> branch-nested ======= This is their version >>>>>>> branch-name "# .unindent(); let buffer_id = BufferId::new(1).unwrap(); let buffer = Buffer::new(ReplicaId::LOCAL, buffer_id, test_content); let snapshot = buffer.snapshot(); let conflict_snapshot = ConflictSet::parse(&snapshot); assert_eq!(conflict_snapshot.conflicts.len(), 1); // The conflict should have our version, their version, but no base let conflict = &conflict_snapshot.conflicts[0]; assert!(conflict.base.is_none()); assert_eq!(conflict.ours_branch_name.as_ref(), "HEAD"); assert_eq!(conflict.theirs_branch_name.as_ref(), "branch-nested"); // Check that the nested conflict was detected correctly let our_text = snapshot .text_for_range(conflict.ours.clone()) .collect::(); assert_eq!(our_text, "This is a nested conflict marker\n"); let their_text = snapshot .text_for_range(conflict.theirs.clone()) .collect::(); assert_eq!(their_text, "This is their version in a nested conflict\n"); } #[test] fn test_conflict_markers_at_eof() { let test_content = r#" <<<<<<< ours ======= This is their version >>>>>>> "# .unindent(); let buffer_id = BufferId::new(1).unwrap(); let buffer = Buffer::new(ReplicaId::LOCAL, buffer_id, test_content); let snapshot = buffer.snapshot(); let conflict_snapshot = ConflictSet::parse(&snapshot); assert_eq!(conflict_snapshot.conflicts.len(), 1); assert_eq!( conflict_snapshot.conflicts[0].ours_branch_name.as_ref(), "ours" ); assert_eq!( conflict_snapshot.conflicts[0].theirs_branch_name.as_ref(), "Origin" // default branch name if there is none ); } #[test] fn test_conflicts_in_range() { // Create a buffer with conflict markers let test_content = r#" one <<<<<<< HEAD1 two ======= three >>>>>>> branch1 four five <<<<<<< HEAD2 six ======= seven >>>>>>> branch2 eight nine <<<<<<< HEAD3 ten ======= eleven >>>>>>> branch3 twelve <<<<<<< HEAD4 thirteen ======= fourteen >>>>>>> branch4 fifteen "# .unindent(); let buffer_id = BufferId::new(1).unwrap(); let buffer = Buffer::new(ReplicaId::LOCAL, buffer_id, test_content.clone()); let snapshot = buffer.snapshot(); let conflict_snapshot = ConflictSet::parse(&snapshot); assert_eq!(conflict_snapshot.conflicts.len(), 4); assert_eq!( conflict_snapshot.conflicts[0].ours_branch_name.as_ref(), "HEAD1" ); assert_eq!( conflict_snapshot.conflicts[0].theirs_branch_name.as_ref(), "branch1" ); assert_eq!( conflict_snapshot.conflicts[1].ours_branch_name.as_ref(), "HEAD2" ); assert_eq!( conflict_snapshot.conflicts[1].theirs_branch_name.as_ref(), "branch2" ); assert_eq!( conflict_snapshot.conflicts[2].ours_branch_name.as_ref(), "HEAD3" ); assert_eq!( conflict_snapshot.conflicts[2].theirs_branch_name.as_ref(), "branch3" ); assert_eq!( conflict_snapshot.conflicts[3].ours_branch_name.as_ref(), "HEAD4" ); assert_eq!( conflict_snapshot.conflicts[3].theirs_branch_name.as_ref(), "branch4" ); let range = test_content.find("seven").unwrap()..test_content.find("eleven").unwrap(); let range = buffer.anchor_before(range.start)..buffer.anchor_after(range.end); assert_eq!( conflict_snapshot.conflicts_in_range(range, &snapshot), &conflict_snapshot.conflicts[1..=2] ); let range = test_content.find("one").unwrap()..test_content.find("<<<<<<< HEAD2").unwrap(); let range = buffer.anchor_before(range.start)..buffer.anchor_after(range.end); assert_eq!( conflict_snapshot.conflicts_in_range(range, &snapshot), &conflict_snapshot.conflicts[0..=1] ); let range = test_content.find("eight").unwrap() - 1..test_content.find(">>>>>>> branch3").unwrap(); let range = buffer.anchor_before(range.start)..buffer.anchor_after(range.end); assert_eq!( conflict_snapshot.conflicts_in_range(range, &snapshot), &conflict_snapshot.conflicts[1..=2] ); let range = test_content.find("thirteen").unwrap() - 1..test_content.len(); let range = buffer.anchor_before(range.start)..buffer.anchor_after(range.end); assert_eq!( conflict_snapshot.conflicts_in_range(range, &snapshot), &conflict_snapshot.conflicts[3..=3] ); } #[gpui::test] async fn test_conflict_updates(executor: BackgroundExecutor, cx: &mut TestAppContext) { zlog::init_test(); cx.update(|cx| { settings::init(cx); }); let initial_text = " one two three four five " .unindent(); let fs = FakeFs::new(executor); fs.insert_tree( path!("/project"), json!({ ".git": {}, "a.txt": initial_text, }), ) .await; let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; let (git_store, buffer) = project.update(cx, |project, cx| { ( project.git_store().clone(), project.open_local_buffer(path!("/project/a.txt"), cx), ) }); let buffer = buffer.await.unwrap(); let conflict_set = git_store.update(cx, |git_store, cx| { git_store.open_conflict_set(buffer.clone(), cx) }); let (events_tx, events_rx) = mpsc::channel::(); let _conflict_set_subscription = cx.update(|cx| { cx.subscribe(&conflict_set, move |_, event, _| { events_tx.send(event.clone()).ok(); }) }); let conflicts_snapshot = conflict_set.read_with(cx, |conflict_set, _| conflict_set.snapshot()); assert!(conflicts_snapshot.conflicts.is_empty()); buffer.update(cx, |buffer, cx| { buffer.edit( [ (4..4, "<<<<<<< HEAD\n"), (14..14, "=======\nTWO\n>>>>>>> branch\n"), ], None, cx, ); }); cx.run_until_parked(); events_rx.try_recv().expect_err( "no conflicts should be registered as long as the file's status is unchanged", ); fs.with_git_state(path!("/project/.git").as_ref(), true, |state| { state.unmerged_paths.insert( repo_path("a.txt"), UnmergedStatus { first_head: UnmergedStatusCode::Updated, second_head: UnmergedStatusCode::Updated, }, ); // Cause the repository to update cached conflicts state.refs.insert("MERGE_HEAD".into(), "123".into()) }) .unwrap(); cx.run_until_parked(); let update = events_rx .try_recv() .expect("status change should trigger conflict parsing"); assert_eq!(update.old_range, 0..0); assert_eq!(update.new_range, 0..1); let conflict = conflict_set.read_with(cx, |conflict_set, _| { conflict_set.snapshot().conflicts[0].clone() }); cx.update(|cx| { conflict.resolve(buffer.clone(), std::slice::from_ref(&conflict.theirs), cx); }); cx.run_until_parked(); let update = events_rx .try_recv() .expect("conflicts should be removed after resolution"); assert_eq!(update.old_range, 0..1); assert_eq!(update.new_range, 0..0); } #[gpui::test] async fn test_conflict_updates_without_merge_head( executor: BackgroundExecutor, cx: &mut TestAppContext, ) { zlog::init_test(); cx.update(|cx| { settings::init(cx); }); let initial_text = " zero <<<<<<< HEAD one ======= two >>>>>>> Stashed Changes three " .unindent(); let fs = FakeFs::new(executor); fs.insert_tree( path!("/project"), json!({ ".git": {}, "a.txt": initial_text, }), ) .await; let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; let (git_store, buffer) = project.update(cx, |project, cx| { ( project.git_store().clone(), project.open_local_buffer(path!("/project/a.txt"), cx), ) }); cx.run_until_parked(); fs.with_git_state(path!("/project/.git").as_ref(), true, |state| { state.unmerged_paths.insert( RepoPath::from_rel_path(rel_path("a.txt")), UnmergedStatus { first_head: UnmergedStatusCode::Updated, second_head: UnmergedStatusCode::Updated, }, ) }) .unwrap(); let buffer = buffer.await.unwrap(); // Open the conflict set for a file that currently has conflicts. let conflict_set = git_store.update(cx, |git_store, cx| { git_store.open_conflict_set(buffer.clone(), cx) }); cx.run_until_parked(); conflict_set.update(cx, |conflict_set, cx| { let conflict_range = conflict_set.snapshot().conflicts[0] .range .to_point(buffer.read(cx)); assert_eq!(conflict_range, Point::new(1, 0)..Point::new(6, 0)); }); // Simulate the conflict being removed by e.g. staging the file. fs.with_git_state(path!("/project/.git").as_ref(), true, |state| { state.unmerged_paths.remove(&repo_path("a.txt")) }) .unwrap(); cx.run_until_parked(); conflict_set.update(cx, |conflict_set, _| { assert!(!conflict_set.has_conflict); assert_eq!(conflict_set.snapshot.conflicts.len(), 0); }); // Simulate the conflict being re-added. fs.with_git_state(path!("/project/.git").as_ref(), true, |state| { state.unmerged_paths.insert( repo_path("a.txt"), UnmergedStatus { first_head: UnmergedStatusCode::Updated, second_head: UnmergedStatusCode::Updated, }, ) }) .unwrap(); cx.run_until_parked(); conflict_set.update(cx, |conflict_set, cx| { let conflict_range = conflict_set.snapshot().conflicts[0] .range .to_point(buffer.read(cx)); assert_eq!(conflict_range, Point::new(1, 0)..Point::new(6, 0)); }); } #[gpui::test] async fn test_conflict_updates_with_delayed_merge_head_conflicts( executor: BackgroundExecutor, cx: &mut TestAppContext, ) { zlog::init_test(); cx.update(|cx| { settings::init(cx); }); let initial_text = " one two three four " .unindent(); let conflicted_text = " one <<<<<<< HEAD two ======= TWO >>>>>>> branch three four " .unindent(); let resolved_text = " one TWO three four " .unindent(); let fs = FakeFs::new(executor); fs.insert_tree( path!("/project"), json!({ ".git": {}, "a.txt": initial_text, }), ) .await; let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; let (git_store, buffer) = project.update(cx, |project, cx| { ( project.git_store().clone(), project.open_local_buffer(path!("/project/a.txt"), cx), ) }); let buffer = buffer.await.unwrap(); let conflict_set = git_store.update(cx, |git_store, cx| { git_store.open_conflict_set(buffer.clone(), cx) }); let (events_tx, events_rx) = mpsc::channel::(); let _conflict_set_subscription = cx.update(|cx| { cx.subscribe(&conflict_set, move |_, event, _| { events_tx.send(event.clone()).ok(); }) }); cx.run_until_parked(); events_rx .try_recv() .expect_err("conflict set should start empty"); fs.with_git_state(path!("/project/.git").as_ref(), true, |state| { state.refs.insert("MERGE_HEAD".into(), "123".into()) }) .unwrap(); cx.run_until_parked(); events_rx .try_recv() .expect_err("merge head without conflicted paths should not publish conflicts"); conflict_set.update(cx, |conflict_set, _| { assert!(!conflict_set.has_conflict); assert_eq!(conflict_set.snapshot.conflicts.len(), 0); }); buffer.update(cx, |buffer, cx| { buffer.set_text(conflicted_text.clone(), cx); }); fs.with_git_state(path!("/project/.git").as_ref(), true, |state| { state.unmerged_paths.insert( repo_path("a.txt"), UnmergedStatus { first_head: UnmergedStatusCode::Updated, second_head: UnmergedStatusCode::Updated, }, ); }) .unwrap(); cx.run_until_parked(); let update = events_rx .try_recv() .expect("conflicts should appear once conflicted paths are visible"); assert_eq!(update.old_range, 0..0); assert_eq!(update.new_range, 0..1); conflict_set.update(cx, |conflict_set, cx| { assert!(conflict_set.has_conflict); let conflict_range = conflict_set.snapshot().conflicts[0] .range .to_point(buffer.read(cx)); assert_eq!(conflict_range, Point::new(1, 0)..Point::new(6, 0)); }); buffer.update(cx, |buffer, cx| { buffer.set_text(resolved_text.clone(), cx); }); cx.run_until_parked(); let update = events_rx .try_recv() .expect("resolved buffer text should clear visible conflict markers"); assert_eq!(update.old_range, 0..1); assert_eq!(update.new_range, 0..0); conflict_set.update(cx, |conflict_set, _| { assert!(conflict_set.has_conflict); assert_eq!(conflict_set.snapshot.conflicts.len(), 0); }); fs.with_git_state(path!("/project/.git").as_ref(), true, |state| { state.refs.insert("MERGE_HEAD".into(), "456".into()); }) .unwrap(); cx.run_until_parked(); events_rx.try_recv().expect_err( "merge-head change without unmerged-path changes should not emit marker updates", ); conflict_set.update(cx, |conflict_set, _| { assert!(conflict_set.has_conflict); assert_eq!(conflict_set.snapshot.conflicts.len(), 0); }); fs.with_git_state(path!("/project/.git").as_ref(), true, |state| { state.unmerged_paths.remove(&repo_path("a.txt")); state.refs.remove("MERGE_HEAD"); }) .unwrap(); cx.run_until_parked(); let update = events_rx.try_recv().expect( "status catch-up should emit a no-op update when clearing stale conflict state", ); assert_eq!(update.old_range, 0..0); assert_eq!(update.new_range, 0..0); assert!(update.buffer_range.is_none()); conflict_set.update(cx, |conflict_set, _| { assert!(!conflict_set.has_conflict); assert_eq!(conflict_set.snapshot.conflicts.len(), 0); }); } } mod git_traversal { use std::{path::Path, time::Duration}; use collections::HashMap; use project::{ Project, git_store::{RepositoryId, RepositorySnapshot}, }; use fs::FakeFs; use git::status::{ FileStatus, GitSummary, StatusCode, TrackedSummary, UnmergedStatus, UnmergedStatusCode, }; use gpui::TestAppContext; use project::GitTraversal; use serde_json::json; use settings::SettingsStore; use util::{ path, rel_path::{RelPath, rel_path}, }; const CONFLICT: FileStatus = FileStatus::Unmerged(UnmergedStatus { first_head: UnmergedStatusCode::Updated, second_head: UnmergedStatusCode::Updated, }); const ADDED: GitSummary = GitSummary { index: TrackedSummary::ADDED, count: 1, ..GitSummary::UNCHANGED }; const MODIFIED: GitSummary = GitSummary { index: TrackedSummary::MODIFIED, count: 1, ..GitSummary::UNCHANGED }; #[gpui::test] async fn test_git_traversal_with_one_repo(cx: &mut TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.background_executor.clone()); fs.insert_tree( path!("/root"), json!({ "x": { ".git": {}, "x1.txt": "foo", "x2.txt": "bar", "y": { ".git": {}, "y1.txt": "baz", "y2.txt": "qux" }, "z.txt": "sneaky..." }, "z": { ".git": {}, "z1.txt": "quux", "z2.txt": "quuux" } }), ) .await; fs.set_status_for_repo( Path::new(path!("/root/x/.git")), &[ ("x2.txt", StatusCode::Modified.index()), ("z.txt", StatusCode::Added.index()), ], ); fs.set_status_for_repo(Path::new(path!("/root/x/y/.git")), &[("y1.txt", CONFLICT)]); fs.set_status_for_repo( Path::new(path!("/root/z/.git")), &[("z2.txt", StatusCode::Added.index())], ); let project = Project::test(fs, [path!("/root").as_ref()], cx).await; cx.executor().run_until_parked(); let (repo_snapshots, worktree_snapshot) = project.read_with(cx, |project, cx| { ( project.git_store().read(cx).repo_snapshots(cx), project.worktrees(cx).next().unwrap().read(cx).snapshot(), ) }); let traversal = GitTraversal::new( &repo_snapshots, worktree_snapshot.traverse_from_path(true, false, true, RelPath::unix("x").unwrap()), ); let entries = traversal .map(|entry| (entry.path.clone(), entry.git_summary)) .collect::>(); pretty_assertions::assert_eq!( entries, [ (rel_path("x/x1.txt").into(), GitSummary::UNCHANGED), (rel_path("x/x2.txt").into(), MODIFIED), (rel_path("x/y/y1.txt").into(), GitSummary::CONFLICT), (rel_path("x/y/y2.txt").into(), GitSummary::UNCHANGED), (rel_path("x/z.txt").into(), ADDED), (rel_path("z/z1.txt").into(), GitSummary::UNCHANGED), (rel_path("z/z2.txt").into(), ADDED), ] ) } #[gpui::test] async fn test_git_traversal_with_nested_repos(cx: &mut TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.background_executor.clone()); fs.insert_tree( path!("/root"), json!({ "x": { ".git": {}, "x1.txt": "foo", "x2.txt": "bar", "y": { ".git": {}, "y1.txt": "baz", "y2.txt": "qux" }, "z.txt": "sneaky..." }, "z": { ".git": {}, "z1.txt": "quux", "z2.txt": "quuux" } }), ) .await; fs.set_status_for_repo( Path::new(path!("/root/x/.git")), &[ ("x2.txt", StatusCode::Modified.index()), ("z.txt", StatusCode::Added.index()), ], ); fs.set_status_for_repo(Path::new(path!("/root/x/y/.git")), &[("y1.txt", CONFLICT)]); fs.set_status_for_repo( Path::new(path!("/root/z/.git")), &[("z2.txt", StatusCode::Added.index())], ); let project = Project::test(fs, [path!("/root").as_ref()], cx).await; cx.executor().run_until_parked(); let (repo_snapshots, worktree_snapshot) = project.read_with(cx, |project, cx| { ( project.git_store().read(cx).repo_snapshots(cx), project.worktrees(cx).next().unwrap().read(cx).snapshot(), ) }); // Sanity check the propagation for x/y and z check_git_statuses( &repo_snapshots, &worktree_snapshot, &[ ("x/y", GitSummary::CONFLICT), ("x/y/y1.txt", GitSummary::CONFLICT), ("x/y/y2.txt", GitSummary::UNCHANGED), ], ); check_git_statuses( &repo_snapshots, &worktree_snapshot, &[ ("z", ADDED), ("z/z1.txt", GitSummary::UNCHANGED), ("z/z2.txt", ADDED), ], ); // Test one of the fundamental cases of propagation blocking, the transition from one git repository to another check_git_statuses( &repo_snapshots, &worktree_snapshot, &[ ("x", MODIFIED + ADDED), ("x/y", GitSummary::CONFLICT), ("x/y/y1.txt", GitSummary::CONFLICT), ], ); // Sanity check everything around it check_git_statuses( &repo_snapshots, &worktree_snapshot, &[ ("x", MODIFIED + ADDED), ("x/x1.txt", GitSummary::UNCHANGED), ("x/x2.txt", MODIFIED), ("x/y", GitSummary::CONFLICT), ("x/y/y1.txt", GitSummary::CONFLICT), ("x/y/y2.txt", GitSummary::UNCHANGED), ("x/z.txt", ADDED), ], ); // Test the other fundamental case, transitioning from git repository to non-git repository check_git_statuses( &repo_snapshots, &worktree_snapshot, &[ ("", GitSummary::UNCHANGED), ("x", MODIFIED + ADDED), ("x/x1.txt", GitSummary::UNCHANGED), ], ); // And all together now check_git_statuses( &repo_snapshots, &worktree_snapshot, &[ ("", GitSummary::UNCHANGED), ("x", MODIFIED + ADDED), ("x/x1.txt", GitSummary::UNCHANGED), ("x/x2.txt", MODIFIED), ("x/y", GitSummary::CONFLICT), ("x/y/y1.txt", GitSummary::CONFLICT), ("x/y/y2.txt", GitSummary::UNCHANGED), ("x/z.txt", ADDED), ("z", ADDED), ("z/z1.txt", GitSummary::UNCHANGED), ("z/z2.txt", ADDED), ], ); } #[gpui::test] async fn test_git_traversal_simple(cx: &mut TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.background_executor.clone()); fs.insert_tree( path!("/root"), json!({ ".git": {}, "a": { "b": { "c1.txt": "", "c2.txt": "", }, "d": { "e1.txt": "", "e2.txt": "", "e3.txt": "", } }, "f": { "no-status.txt": "" }, "g": { "h1.txt": "", "h2.txt": "" }, }), ) .await; fs.set_status_for_repo( Path::new(path!("/root/.git")), &[ ("a/b/c1.txt", StatusCode::Added.index()), ("a/d/e2.txt", StatusCode::Modified.index()), ("g/h2.txt", CONFLICT), ], ); let project = Project::test(fs, [path!("/root").as_ref()], cx).await; cx.executor().run_until_parked(); let (repo_snapshots, worktree_snapshot) = project.read_with(cx, |project, cx| { ( project.git_store().read(cx).repo_snapshots(cx), project.worktrees(cx).next().unwrap().read(cx).snapshot(), ) }); check_git_statuses( &repo_snapshots, &worktree_snapshot, &[ ("", GitSummary::CONFLICT + MODIFIED + ADDED), ("g", GitSummary::CONFLICT), ("g/h2.txt", GitSummary::CONFLICT), ], ); check_git_statuses( &repo_snapshots, &worktree_snapshot, &[ ("", GitSummary::CONFLICT + ADDED + MODIFIED), ("a", ADDED + MODIFIED), ("a/b", ADDED), ("a/b/c1.txt", ADDED), ("a/b/c2.txt", GitSummary::UNCHANGED), ("a/d", MODIFIED), ("a/d/e2.txt", MODIFIED), ("f", GitSummary::UNCHANGED), ("f/no-status.txt", GitSummary::UNCHANGED), ("g", GitSummary::CONFLICT), ("g/h2.txt", GitSummary::CONFLICT), ], ); check_git_statuses( &repo_snapshots, &worktree_snapshot, &[ ("a/b", ADDED), ("a/b/c1.txt", ADDED), ("a/b/c2.txt", GitSummary::UNCHANGED), ("a/d", MODIFIED), ("a/d/e1.txt", GitSummary::UNCHANGED), ("a/d/e2.txt", MODIFIED), ("f", GitSummary::UNCHANGED), ("f/no-status.txt", GitSummary::UNCHANGED), ("g", GitSummary::CONFLICT), ], ); check_git_statuses( &repo_snapshots, &worktree_snapshot, &[ ("a/b/c1.txt", ADDED), ("a/b/c2.txt", GitSummary::UNCHANGED), ("a/d/e1.txt", GitSummary::UNCHANGED), ("a/d/e2.txt", MODIFIED), ("f/no-status.txt", GitSummary::UNCHANGED), ], ); } #[gpui::test] async fn test_git_traversal_with_repos_under_project(cx: &mut TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.background_executor.clone()); fs.insert_tree( path!("/root"), json!({ "x": { ".git": {}, "x1.txt": "foo", "x2.txt": "bar" }, "y": { ".git": {}, "y1.txt": "baz", "y2.txt": "qux" }, "z": { ".git": {}, "z1.txt": "quux", "z2.txt": "quuux" } }), ) .await; fs.set_status_for_repo( Path::new(path!("/root/x/.git")), &[("x1.txt", StatusCode::Added.index())], ); fs.set_status_for_repo( Path::new(path!("/root/y/.git")), &[ ("y1.txt", CONFLICT), ("y2.txt", StatusCode::Modified.index()), ], ); fs.set_status_for_repo( Path::new(path!("/root/z/.git")), &[("z2.txt", StatusCode::Modified.index())], ); let project = Project::test(fs, [path!("/root").as_ref()], cx).await; cx.executor().run_until_parked(); let (repo_snapshots, worktree_snapshot) = project.read_with(cx, |project, cx| { ( project.git_store().read(cx).repo_snapshots(cx), project.worktrees(cx).next().unwrap().read(cx).snapshot(), ) }); check_git_statuses( &repo_snapshots, &worktree_snapshot, &[("x", ADDED), ("x/x1.txt", ADDED)], ); check_git_statuses( &repo_snapshots, &worktree_snapshot, &[ ("y", GitSummary::CONFLICT + MODIFIED), ("y/y1.txt", GitSummary::CONFLICT), ("y/y2.txt", MODIFIED), ], ); check_git_statuses( &repo_snapshots, &worktree_snapshot, &[("z", MODIFIED), ("z/z2.txt", MODIFIED)], ); check_git_statuses( &repo_snapshots, &worktree_snapshot, &[("x", ADDED), ("x/x1.txt", ADDED)], ); check_git_statuses( &repo_snapshots, &worktree_snapshot, &[ ("x", ADDED), ("x/x1.txt", ADDED), ("x/x2.txt", GitSummary::UNCHANGED), ("y", GitSummary::CONFLICT + MODIFIED), ("y/y1.txt", GitSummary::CONFLICT), ("y/y2.txt", MODIFIED), ("z", MODIFIED), ("z/z1.txt", GitSummary::UNCHANGED), ("z/z2.txt", MODIFIED), ], ); } fn init_test(cx: &mut gpui::TestAppContext) { zlog::init_test(); cx.update(|cx| { let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); }); } #[gpui::test] async fn test_bump_mtime_of_git_repo_workdir(cx: &mut TestAppContext) { init_test(cx); // Create a worktree with a git directory. let fs = FakeFs::new(cx.background_executor.clone()); fs.insert_tree( path!("/root"), json!({ ".git": {}, "a.txt": "", "b": { "c.txt": "", }, }), ) .await; fs.set_head_and_index_for_repo( path!("/root/.git").as_ref(), &[("a.txt", "".into()), ("b/c.txt", "".into())], ); cx.run_until_parked(); let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; cx.executor().run_until_parked(); let (old_entry_ids, old_mtimes) = project.read_with(cx, |project, cx| { let tree = project.worktrees(cx).next().unwrap().read(cx); ( tree.entries(true, 0).map(|e| e.id).collect::>(), tree.entries(true, 0).map(|e| e.mtime).collect::>(), ) }); // Regression test: after the directory is scanned, touch the git repo's // working directory, bumping its mtime. That directory keeps its project // entry id after the directories are re-scanned. fs.touch_path(path!("/root")).await; cx.executor().run_until_parked(); let (new_entry_ids, new_mtimes) = project.read_with(cx, |project, cx| { let tree = project.worktrees(cx).next().unwrap().read(cx); ( tree.entries(true, 0).map(|e| e.id).collect::>(), tree.entries(true, 0).map(|e| e.mtime).collect::>(), ) }); assert_eq!(new_entry_ids, old_entry_ids); assert_ne!(new_mtimes, old_mtimes); // Regression test: changes to the git repository should still be // detected. fs.set_head_for_repo( path!("/root/.git").as_ref(), &[("a.txt", "".into()), ("b/c.txt", "something-else".into())], "deadbeef", ); cx.executor().run_until_parked(); cx.executor().advance_clock(Duration::from_secs(1)); let (repo_snapshots, worktree_snapshot) = project.read_with(cx, |project, cx| { ( project.git_store().read(cx).repo_snapshots(cx), project.worktrees(cx).next().unwrap().read(cx).snapshot(), ) }); check_git_statuses( &repo_snapshots, &worktree_snapshot, &[ ("", MODIFIED), ("a.txt", GitSummary::UNCHANGED), ("b/c.txt", MODIFIED), ], ); } #[track_caller] fn check_git_statuses( repo_snapshots: &HashMap, worktree_snapshot: &worktree::Snapshot, expected_statuses: &[(&str, GitSummary)], ) { let mut traversal = GitTraversal::new( repo_snapshots, worktree_snapshot.traverse_from_path(true, true, false, RelPath::empty()), ); let found_statuses = expected_statuses .iter() .map(|&(path, _)| { let git_entry = traversal .find(|git_entry| git_entry.path.as_ref() == rel_path(path)) .unwrap_or_else(|| panic!("Traversal has no entry for {path:?}")); (path, git_entry.git_summary) }) .collect::>(); pretty_assertions::assert_eq!(found_statuses, expected_statuses); } } mod git_worktrees { use fs::{FakeFs, Fs}; use gpui::TestAppContext; use project::worktrees_directory_for_repo; use serde_json::json; use settings::SettingsStore; use std::path::{Path, PathBuf}; use util::{path, paths::PathStyle}; fn init_test(cx: &mut gpui::TestAppContext) { zlog::init_test(); cx.update(|cx| { let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); }); } #[test] fn test_validate_worktree_directory() { let work_dir = Path::new("/code/my-project"); // Valid: sibling assert!(worktrees_directory_for_repo(work_dir, "../worktrees", PathStyle::Posix).is_ok()); // Valid: subdirectory assert!( worktrees_directory_for_repo(work_dir, ".git/zed-worktrees", PathStyle::Posix).is_ok() ); assert!(worktrees_directory_for_repo(work_dir, "my-worktrees", PathStyle::Posix).is_ok()); // Invalid: just ".." would resolve back to the working directory itself let err = worktrees_directory_for_repo(work_dir, "..", PathStyle::Posix).unwrap_err(); assert!(err.to_string().contains("must not be \"..\"")); // Invalid: ".." with trailing separators let err = worktrees_directory_for_repo(work_dir, "..\\", PathStyle::Posix).unwrap_err(); assert!(err.to_string().contains("must not be \"..\"")); let err = worktrees_directory_for_repo(work_dir, "../", PathStyle::Posix).unwrap_err(); assert!(err.to_string().contains("must not be \"..\"")); // Invalid: empty string would resolve to the working directory itself let err = worktrees_directory_for_repo(work_dir, "", PathStyle::Posix).unwrap_err(); assert!(err.to_string().contains("must not be empty")); // Invalid: absolute path let err = worktrees_directory_for_repo(work_dir, "/tmp/worktrees", PathStyle::Posix).unwrap_err(); assert!(err.to_string().contains("relative path")); // Invalid: "/" is absolute on Unix let err = worktrees_directory_for_repo(work_dir, "/", PathStyle::Posix).unwrap_err(); assert!(err.to_string().contains("relative path")); // Invalid: "///" is absolute let err = worktrees_directory_for_repo(work_dir, "///", PathStyle::Posix).unwrap_err(); assert!(err.to_string().contains("relative path")); // Invalid: escapes too far up let err = worktrees_directory_for_repo(work_dir, "../../other-project/wt", PathStyle::Posix) .unwrap_err(); assert!(err.to_string().contains("outside")); } #[test] fn test_worktree_directory_uses_remote_path_style() { let work_dir = Path::new("/home/user/dev/lsp-tests"); let directory = worktrees_directory_for_repo(work_dir, "../worktrees", PathStyle::Posix).unwrap(); assert_eq!( directory, PathBuf::from("/home/user/dev/worktrees/lsp-tests") ); } #[gpui::test] async fn test_git_worktrees_list_and_create(cx: &mut TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.background_executor.clone()); fs.insert_tree( path!("/root"), json!({ ".git": {}, "file.txt": "content", }), ) .await; let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; cx.executor().run_until_parked(); let repository = project.read_with(cx, |project, cx| { project.repositories(cx).values().next().unwrap().clone() }); let worktrees = cx .update(|cx| repository.update(cx, |repository, _| repository.worktrees())) .await .unwrap() .unwrap(); assert_eq!(worktrees.len(), 1); assert_eq!(worktrees[0].path, PathBuf::from(path!("/root"))); let worktrees_directory = PathBuf::from(path!("/root")); let worktree_1_directory = worktrees_directory.join("feature-branch"); cx.update(|cx| { repository.update(cx, |repository, _| { repository.create_worktree( git::repository::CreateWorktreeTarget::NewBranch { branch_name: "feature-branch".to_string(), base_sha: Some("abc123".to_string()), }, worktree_1_directory.clone(), ) }) }) .await .unwrap() .unwrap(); cx.executor().run_until_parked(); let worktrees = cx .update(|cx| repository.update(cx, |repository, _| repository.worktrees())) .await .unwrap() .unwrap(); assert_eq!(worktrees.len(), 2); assert_eq!(worktrees[0].path, PathBuf::from(path!("/root"))); assert_eq!(worktrees[1].path, worktree_1_directory); assert_eq!( worktrees[1].ref_name, Some("refs/heads/feature-branch".into()) ); assert_eq!(worktrees[1].sha.as_ref(), "abc123"); let worktree_2_directory = worktrees_directory.join("bugfix-branch"); cx.update(|cx| { repository.update(cx, |repository, _| { repository.create_worktree( git::repository::CreateWorktreeTarget::NewBranch { branch_name: "bugfix-branch".to_string(), base_sha: None, }, worktree_2_directory.clone(), ) }) }) .await .unwrap() .unwrap(); cx.executor().run_until_parked(); // List worktrees — should now have main + two created let worktrees = cx .update(|cx| repository.update(cx, |repository, _| repository.worktrees())) .await .unwrap() .unwrap(); assert_eq!(worktrees.len(), 3); let worktree_1 = worktrees .iter() .find(|worktree| worktree.ref_name == Some("refs/heads/feature-branch".into())) .expect("should find feature-branch worktree"); assert_eq!(worktree_1.path, worktree_1_directory); let worktree_2 = worktrees .iter() .find(|worktree| worktree.ref_name == Some("refs/heads/bugfix-branch".into())) .expect("should find bugfix-branch worktree"); assert_eq!(worktree_2.path, worktree_2_directory); assert_eq!(worktree_2.sha.as_ref(), "fake-sha"); } #[gpui::test] async fn test_remove_worktree_removes_managed_parent_directories(cx: &mut TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.background_executor.clone()); fs.insert_tree( path!("/root"), json!({ ".git": {}, "file.txt": "content", }), ) .await; let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; cx.executor().run_until_parked(); let repository = project.read_with(cx, |project, cx| { project.repositories(cx).values().next().unwrap().clone() }); let worktree_path = PathBuf::from(path!("/worktrees/root/feature/nested/root")); let worktree_parent = PathBuf::from(path!("/worktrees/root/feature/nested")); let worktree_intermediate_parent = PathBuf::from(path!("/worktrees/root/feature")); let worktree_base = PathBuf::from(path!("/worktrees/root")); cx.update(|cx| { repository.update(cx, |repository, _| { repository.create_worktree( git::repository::CreateWorktreeTarget::NewBranch { branch_name: "feature/nested".to_string(), base_sha: Some("abc123".to_string()), }, worktree_path.clone(), ) }) }) .await .unwrap() .unwrap(); assert!(Fs::is_dir(fs.as_ref(), &worktree_path).await); assert!(Fs::is_dir(fs.as_ref(), &worktree_parent).await); assert!(Fs::is_dir(fs.as_ref(), &worktree_intermediate_parent).await); assert!(Fs::is_dir(fs.as_ref(), &worktree_base).await); cx.update(|cx| { repository.update(cx, |repository, _| { repository.remove_worktree(worktree_path.clone(), false) }) }) .await .unwrap() .unwrap(); cx.executor().run_until_parked(); assert!(!Fs::is_dir(fs.as_ref(), &worktree_path).await); assert!(!Fs::is_dir(fs.as_ref(), &worktree_parent).await); assert!(!Fs::is_dir(fs.as_ref(), &worktree_intermediate_parent).await); assert!(Fs::is_dir(fs.as_ref(), &worktree_base).await); } use crate::Project; } mod trust_tests { use collections::HashSet; use fs::FakeFs; use gpui::TestAppContext; use project::trusted_worktrees::*; use serde_json::json; use settings::SettingsStore; use util::path; use crate::Project; fn init_test(cx: &mut TestAppContext) { zlog::init_test(); cx.update(|cx| { let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); }); } #[gpui::test] async fn test_repository_defaults_to_untrusted_without_trust_system(cx: &mut TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.background_executor.clone()); fs.insert_tree( path!("/project"), json!({ ".git": {}, "a.txt": "hello", }), ) .await; // Create project without trust system — repos should default to untrusted. let project = Project::test(fs, [path!("/project").as_ref()], cx).await; cx.executor().run_until_parked(); let repository = project.read_with(cx, |project, cx| { project.repositories(cx).values().next().unwrap().clone() }); repository.read_with(cx, |repo, _| { assert!( !repo.is_trusted(), "repository should default to untrusted when no trust system is initialized" ); }); } #[gpui::test] async fn test_multiple_repos_trust_with_single_worktree(cx: &mut TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.background_executor.clone()); fs.insert_tree( path!("/project"), json!({ ".git": {}, "a.txt": "hello", "sub": { ".git": {}, "b.txt": "world", }, }), ) .await; cx.update(|cx| { init(DbTrustedPaths::default(), cx); }); let project = Project::test_with_worktree_trust(fs.clone(), [path!("/project").as_ref()], cx).await; cx.executor().run_until_parked(); let worktree_store = project.read_with(cx, |project, _| project.worktree_store()); let worktree_id = worktree_store.read_with(cx, |store, cx| { store.worktrees().next().unwrap().read(cx).id() }); let repos = project.read_with(cx, |project, cx| { project .repositories(cx) .values() .cloned() .collect::>() }); assert_eq!(repos.len(), 2, "should have two repositories"); for repo in &repos { repo.read_with(cx, |repo, _| { assert!( !repo.is_trusted(), "all repos should be untrusted initially" ); }); } let trusted_worktrees = cx .update(|cx| TrustedWorktrees::try_get_global(cx).expect("trust global should be set")); trusted_worktrees.update(cx, |store, cx| { store.trust( &worktree_store, HashSet::from_iter([PathTrust::Worktree(worktree_id)]), cx, ); }); cx.executor().run_until_parked(); for repo in &repos { repo.read_with(cx, |repo, _| { assert!( repo.is_trusted(), "all repos should be trusted after worktree is trusted" ); }); } } #[gpui::test] async fn test_repository_trust_restrict_trust_cycle(cx: &mut TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.background_executor.clone()); fs.insert_tree( path!("/project"), json!({ ".git": {}, "a.txt": "hello", }), ) .await; cx.update(|cx| { project::trusted_worktrees::init(DbTrustedPaths::default(), cx); }); let project = Project::test_with_worktree_trust(fs.clone(), [path!("/project").as_ref()], cx).await; cx.executor().run_until_parked(); let worktree_store = project.read_with(cx, |project, _| project.worktree_store()); let worktree_id = worktree_store.read_with(cx, |store, cx| { store.worktrees().next().unwrap().read(cx).id() }); let repository = project.read_with(cx, |project, cx| { project.repositories(cx).values().next().unwrap().clone() }); repository.read_with(cx, |repo, _| { assert!(!repo.is_trusted(), "repository should start untrusted"); }); let trusted_worktrees = cx .update(|cx| TrustedWorktrees::try_get_global(cx).expect("trust global should be set")); trusted_worktrees.update(cx, |store, cx| { store.trust( &worktree_store, HashSet::from_iter([PathTrust::Worktree(worktree_id)]), cx, ); }); cx.executor().run_until_parked(); repository.read_with(cx, |repo, _| { assert!( repo.is_trusted(), "repository should be trusted after worktree is trusted" ); }); trusted_worktrees.update(cx, |store, cx| { store.restrict( worktree_store.downgrade(), HashSet::from_iter([PathTrust::Worktree(worktree_id)]), cx, ); }); cx.executor().run_until_parked(); repository.read_with(cx, |repo, _| { assert!( !repo.is_trusted(), "repository should be untrusted after worktree is restricted" ); }); trusted_worktrees.update(cx, |store, cx| { store.trust( &worktree_store, HashSet::from_iter([PathTrust::Worktree(worktree_id)]), cx, ); }); cx.executor().run_until_parked(); repository.read_with(cx, |repo, _| { assert!( repo.is_trusted(), "repository should be trusted again after second trust" ); }); } } mod resolve_worktree_tests { use fs::FakeFs; use gpui::TestAppContext; use project::{ git_store::resolve_git_worktree_to_main_repo, linked_worktree_short_name, repo_identity_path, }; use serde_json::json; use std::path::{Path, PathBuf}; #[gpui::test] async fn test_resolve_git_worktree_to_main_repo(cx: &mut TestAppContext) { let fs = FakeFs::new(cx.executor()); // Set up a main repo with a worktree entry fs.insert_tree( "/main-repo", json!({ ".git": { "worktrees": { "feature": { "commondir": "../../", "HEAD": "ref: refs/heads/feature" } } }, "src": { "main.rs": "" } }), ) .await; // Set up a worktree checkout pointing back to the main repo fs.insert_tree( "/worktree-checkout", json!({ ".git": "gitdir: /main-repo/.git/worktrees/feature", "src": { "main.rs": "" } }), ) .await; let result = resolve_git_worktree_to_main_repo(fs.as_ref(), Path::new("/worktree-checkout")).await; assert_eq!(result, Some(PathBuf::from("/main-repo"))); } #[gpui::test] async fn test_resolve_git_worktree_normal_repo_returns_none(cx: &mut TestAppContext) { let fs = FakeFs::new(cx.executor()); fs.insert_tree( "/repo", json!({ ".git": {}, "src": { "main.rs": "" } }), ) .await; let result = resolve_git_worktree_to_main_repo(fs.as_ref(), Path::new("/repo")).await; assert_eq!(result, None); } #[gpui::test] async fn test_resolve_git_worktree_bare_repo_identity_path(cx: &mut TestAppContext) { let fs = FakeFs::new(cx.executor()); fs.insert_tree( "/monty/.bare", json!({ "worktrees": { "feature-a": { "commondir": "../../", "HEAD": "ref: refs/heads/feature-a" } } }), ) .await; fs.insert_tree( "/monty/feature-a", json!({ ".git": "gitdir: /monty/.bare/worktrees/feature-a", "src": { "main.rs": "" } }), ) .await; let result = resolve_git_worktree_to_main_repo(fs.as_ref(), Path::new("/monty/feature-a")).await; assert_eq!(result, Some(PathBuf::from("/monty"))); } #[gpui::test] async fn test_resolve_git_worktree_no_git_returns_none(cx: &mut TestAppContext) { let fs = FakeFs::new(cx.executor()); fs.insert_tree( "/plain", json!({ "src": { "main.rs": "" } }), ) .await; let result = resolve_git_worktree_to_main_repo(fs.as_ref(), Path::new("/plain")).await; assert_eq!(result, None); } #[gpui::test] async fn test_resolve_git_worktree_nonexistent_returns_none(cx: &mut TestAppContext) { let fs = FakeFs::new(cx.executor()); let result = resolve_git_worktree_to_main_repo(fs.as_ref(), Path::new("/does-not-exist")).await; assert_eq!(result, None); } #[test] fn test_repo_identity_path() { let examples = [ // Normal checkout: `.git` starts with `.`, so parent is the worktree ("/home/bob/zed/.git", "/home/bob/zed"), // Bare clone named `.bare`: starts with `.`, so parent is the project dir ("/repos/project/.bare", "/repos/project"), // Bare clone with `.git` extension: does not start with `.`, kept as-is ("/repos/zed.git", "/repos/zed.git"), // Bare clone with arbitrary plain name: kept as-is ("/repos/project", "/repos/project"), ]; for (common_dir, expected) in examples { assert_eq!( repo_identity_path(Path::new(common_dir)), Path::new(expected), "identity path for common_dir {common_dir:?} should be {expected:?}" ); } } #[test] fn test_linked_worktree_short_name() { let examples = [ ( "/home/bob/zed", "/home/bob/worktrees/olivetti/zed", Some("olivetti".into()), ), ("/home/bob/zed", "/home/bob/zed2", Some("zed2".into())), ( "/home/bob/zed", "/home/bob/worktrees/zed/selectric", Some("selectric".into()), ), ("/home/bob/zed", "/home/bob/zed", None), ]; for (main_worktree_path, linked_worktree_path, expected) in examples { let short_name = linked_worktree_short_name( Path::new(main_worktree_path), Path::new(linked_worktree_path), ); assert_eq!( short_name, expected, "short name for {linked_worktree_path:?}, linked worktree of {main_worktree_path:?}, should be {expected:?}" ); } } }