From a41d72ee81f1031c62a6b809be41b5a7a2c8325d Mon Sep 17 00:00:00 2001 From: Cole Miller Date: Wed, 15 Jan 2025 19:01:38 -0500 Subject: [PATCH] Represent git statuses more faithfully (#23082) First, parse the output of `git status --porcelain=v1` into a representation that can handle the full "grammar" and doesn't lose information. Second, as part of pushing this throughout the codebase, expand the use of the existing `GitSummary` type to all the places where status propagation is in play (i.e., anywhere we're dealing with a mix of files and directories), and get rid of the previous `GitSummary -> GitFileStatus` conversion. - [x] Synchronize new representation over collab - [x] Update zed.proto - [x] Update DB models - [x] Update `GitSummary` and summarization for the new `FileStatus` - [x] Fix all tests - [x] worktree - [x] collab - [x] Clean up `FILE_*` constants - [x] New collab tests to exercise syncing of complex statuses - [x] Run it locally and make sure it looks good Release Notes: - N/A --------- Co-authored-by: Mikayla Co-authored-by: Conrad --- .../20221109000000_test_schema.sql | 3 + ...13230049_expand_git_status_information.sql | 13 + crates/collab/src/db.rs | 90 +++++ crates/collab/src/db/queries/projects.rs | 18 +- crates/collab/src/db/queries/rooms.rs | 5 +- .../db/tables/worktree_repository_statuses.rs | 15 + crates/collab/src/tests/integration_tests.rs | 81 ++--- .../random_project_collaboration_tests.rs | 54 ++- crates/editor/src/git/project_diff.rs | 42 +-- crates/editor/src/items.rs | 28 +- crates/fs/src/fs.rs | 16 +- crates/git/src/repository.rs | 64 +--- crates/git/src/status.rs | 338 ++++++++++++++++-- crates/git_ui/src/git_panel.rs | 30 +- crates/git_ui/src/git_ui.rs | 27 +- crates/image_viewer/src/image_viewer.rs | 11 +- crates/outline_panel/src/outline_panel.rs | 14 +- crates/project/src/project.rs | 7 +- crates/project_panel/src/project_panel.rs | 18 +- crates/proto/proto/zed.proto | 29 +- crates/tab_switcher/src/tab_switcher.rs | 5 +- crates/worktree/src/worktree.rs | 335 +++++++++-------- crates/worktree/src/worktree_tests.rs | 326 ++++++++++------- script/zed-local | 6 + 24 files changed, 1019 insertions(+), 556 deletions(-) create mode 100644 crates/collab/migrations/20250113230049_expand_git_status_information.sql diff --git a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql index b6bff810b070f4..a45307b831c3ca 100644 --- a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql +++ b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql @@ -112,6 +112,9 @@ CREATE TABLE "worktree_repository_statuses" ( "work_directory_id" INT8 NOT NULL, "repo_path" VARCHAR NOT NULL, "status" INT8 NOT NULL, + "status_kind" INT4 NOT NULL, + "first_status" INT4 NULL, + "second_status" INT4 NULL, "scan_id" INT8 NOT NULL, "is_deleted" BOOL NOT NULL, PRIMARY KEY(project_id, worktree_id, work_directory_id, repo_path), diff --git a/crates/collab/migrations/20250113230049_expand_git_status_information.sql b/crates/collab/migrations/20250113230049_expand_git_status_information.sql new file mode 100644 index 00000000000000..eada39fe304020 --- /dev/null +++ b/crates/collab/migrations/20250113230049_expand_git_status_information.sql @@ -0,0 +1,13 @@ +ALTER TABLE worktree_repository_statuses +ADD COLUMN status_kind INTEGER, +ADD COLUMN first_status INTEGER, +ADD COLUMN second_status INTEGER; + +UPDATE worktree_repository_statuses +SET + status_kind = 0; + +ALTER TABLE worktree_repository_statuses +ALTER COLUMN status_kind +SET + NOT NULL; diff --git a/crates/collab/src/db.rs b/crates/collab/src/db.rs index 81db7158e83ab7..857c54ac99c73c 100644 --- a/crates/collab/src/db.rs +++ b/crates/collab/src/db.rs @@ -35,6 +35,7 @@ use std::{ }; use time::PrimitiveDateTime; use tokio::sync::{Mutex, OwnedMutexGuard}; +use worktree_repository_statuses::StatusKind; use worktree_settings_file::LocalSettingsKind; #[cfg(test)] @@ -805,3 +806,92 @@ impl LocalSettingsKind { } } } + +fn db_status_to_proto( + entry: worktree_repository_statuses::Model, +) -> anyhow::Result { + use proto::git_file_status::{Tracked, Unmerged, Variant}; + + let (simple_status, variant) = + match (entry.status_kind, entry.first_status, entry.second_status) { + (StatusKind::Untracked, None, None) => ( + proto::GitStatus::Added as i32, + Variant::Untracked(Default::default()), + ), + (StatusKind::Ignored, None, None) => ( + proto::GitStatus::Added as i32, + Variant::Ignored(Default::default()), + ), + (StatusKind::Unmerged, Some(first_head), Some(second_head)) => ( + proto::GitStatus::Conflict as i32, + Variant::Unmerged(Unmerged { + first_head, + second_head, + }), + ), + (StatusKind::Tracked, Some(index_status), Some(worktree_status)) => { + let simple_status = if worktree_status != proto::GitStatus::Unmodified as i32 { + worktree_status + } else if index_status != proto::GitStatus::Unmodified as i32 { + index_status + } else { + proto::GitStatus::Unmodified as i32 + }; + ( + simple_status, + Variant::Tracked(Tracked { + index_status, + worktree_status, + }), + ) + } + _ => { + return Err(anyhow!( + "Unexpected combination of status fields: {entry:?}" + )) + } + }; + Ok(proto::StatusEntry { + repo_path: entry.repo_path, + simple_status, + status: Some(proto::GitFileStatus { + variant: Some(variant), + }), + }) +} + +fn proto_status_to_db( + status_entry: proto::StatusEntry, +) -> (String, StatusKind, Option, Option) { + use proto::git_file_status::{Tracked, Unmerged, Variant}; + + let (status_kind, first_status, second_status) = status_entry + .status + .clone() + .and_then(|status| status.variant) + .map_or( + (StatusKind::Untracked, None, None), + |variant| match variant { + Variant::Untracked(_) => (StatusKind::Untracked, None, None), + Variant::Ignored(_) => (StatusKind::Ignored, None, None), + Variant::Unmerged(Unmerged { + first_head, + second_head, + }) => (StatusKind::Unmerged, Some(first_head), Some(second_head)), + Variant::Tracked(Tracked { + index_status, + worktree_status, + }) => ( + StatusKind::Tracked, + Some(index_status), + Some(worktree_status), + ), + }, + ); + ( + status_entry.repo_path, + status_kind, + first_status, + second_status, + ) +} diff --git a/crates/collab/src/db/queries/projects.rs b/crates/collab/src/db/queries/projects.rs index f2a59880640390..fd83cd3da8f9dd 100644 --- a/crates/collab/src/db/queries/projects.rs +++ b/crates/collab/src/db/queries/projects.rs @@ -360,6 +360,8 @@ impl Database { update.updated_repositories.iter().flat_map( |repository: &proto::RepositoryEntry| { repository.updated_statuses.iter().map(|status_entry| { + let (repo_path, status_kind, first_status, second_status) = + proto_status_to_db(status_entry.clone()); worktree_repository_statuses::ActiveModel { project_id: ActiveValue::set(project_id), worktree_id: ActiveValue::set(worktree_id), @@ -368,8 +370,11 @@ impl Database { ), scan_id: ActiveValue::set(update.scan_id as i64), is_deleted: ActiveValue::set(false), - repo_path: ActiveValue::set(status_entry.repo_path.clone()), - status: ActiveValue::set(status_entry.status as i64), + repo_path: ActiveValue::set(repo_path), + status: ActiveValue::set(0), + status_kind: ActiveValue::set(status_kind), + first_status: ActiveValue::set(first_status), + second_status: ActiveValue::set(second_status), } }) }, @@ -384,7 +389,9 @@ impl Database { ]) .update_columns([ worktree_repository_statuses::Column::ScanId, - worktree_repository_statuses::Column::Status, + worktree_repository_statuses::Column::StatusKind, + worktree_repository_statuses::Column::FirstStatus, + worktree_repository_statuses::Column::SecondStatus, ]) .to_owned(), ) @@ -759,10 +766,7 @@ impl Database { let mut updated_statuses = Vec::new(); while let Some(status_entry) = repository_statuses.next().await { let status_entry: worktree_repository_statuses::Model = status_entry?; - updated_statuses.push(proto::StatusEntry { - repo_path: status_entry.repo_path, - status: status_entry.status as i32, - }); + updated_statuses.push(db_status_to_proto(status_entry)?); } worktree.repository_entries.insert( diff --git a/crates/collab/src/db/queries/rooms.rs b/crates/collab/src/db/queries/rooms.rs index 6036a8fddc98f9..4a46e79fa27d80 100644 --- a/crates/collab/src/db/queries/rooms.rs +++ b/crates/collab/src/db/queries/rooms.rs @@ -732,10 +732,7 @@ impl Database { if db_status.is_deleted { removed_statuses.push(db_status.repo_path); } else { - updated_statuses.push(proto::StatusEntry { - repo_path: db_status.repo_path, - status: db_status.status as i32, - }); + updated_statuses.push(db_status_to_proto(db_status)?); } } diff --git a/crates/collab/src/db/tables/worktree_repository_statuses.rs b/crates/collab/src/db/tables/worktree_repository_statuses.rs index cab016749d6b9a..3e4a4f550e6be1 100644 --- a/crates/collab/src/db/tables/worktree_repository_statuses.rs +++ b/crates/collab/src/db/tables/worktree_repository_statuses.rs @@ -12,11 +12,26 @@ pub struct Model { pub work_directory_id: i64, #[sea_orm(primary_key)] pub repo_path: String, + /// Old single-code status field, no longer used but kept here to mirror the DB schema. pub status: i64, + pub status_kind: StatusKind, + /// For unmerged entries, this is the `first_head` status. For tracked entries, this is the `index_status`. + pub first_status: Option, + /// For unmerged entries, this is the `second_head` status. For tracked entries, this is the `worktree_status`. + pub second_status: Option, pub scan_id: i64, pub is_deleted: bool, } +#[derive(Clone, Copy, Debug, PartialEq, Eq, EnumIter, DeriveActiveEnum)] +#[sea_orm(rs_type = "i32", db_type = "Integer")] +pub enum StatusKind { + Untracked = 0, + Ignored = 1, + Unmerged = 2, + Tracked = 3, +} + #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] pub enum Relation {} diff --git a/crates/collab/src/tests/integration_tests.rs b/crates/collab/src/tests/integration_tests.rs index 3dfd9b676c08af..e6bb07c92592f6 100644 --- a/crates/collab/src/tests/integration_tests.rs +++ b/crates/collab/src/tests/integration_tests.rs @@ -13,7 +13,8 @@ use client::{User, RECEIVE_TIMEOUT}; use collections::{HashMap, HashSet}; use fs::{FakeFs, Fs as _, RemoveOptions}; use futures::{channel::mpsc, StreamExt as _}; -use git::repository::GitFileStatus; + +use git::status::{FileStatus, StatusCode, TrackedStatus, UnmergedStatus, UnmergedStatusCode}; use gpui::{ px, size, AppContext, BackgroundExecutor, Model, Modifiers, MouseButton, MouseDownEvent, TestAppContext, UpdateGlobal, @@ -2889,11 +2890,20 @@ async fn test_git_status_sync( const A_TXT: &str = "a.txt"; const B_TXT: &str = "b.txt"; + const A_STATUS_START: FileStatus = FileStatus::Tracked(TrackedStatus { + index_status: StatusCode::Added, + worktree_status: StatusCode::Modified, + }); + const B_STATUS_START: FileStatus = FileStatus::Unmerged(UnmergedStatus { + first_head: UnmergedStatusCode::Updated, + second_head: UnmergedStatusCode::Deleted, + }); + client_a.fs().set_status_for_repo_via_git_operation( Path::new("/dir/.git"), &[ - (Path::new(A_TXT), GitFileStatus::Added), - (Path::new(B_TXT), GitFileStatus::Added), + (Path::new(A_TXT), A_STATUS_START), + (Path::new(B_TXT), B_STATUS_START), ], ); @@ -2913,7 +2923,7 @@ async fn test_git_status_sync( #[track_caller] fn assert_status( file: &impl AsRef, - status: Option, + status: Option, project: &Project, cx: &AppContext, ) { @@ -2926,20 +2936,29 @@ async fn test_git_status_sync( } project_local.read_with(cx_a, |project, cx| { - assert_status(&Path::new(A_TXT), Some(GitFileStatus::Added), project, cx); - assert_status(&Path::new(B_TXT), Some(GitFileStatus::Added), project, cx); + assert_status(&Path::new(A_TXT), Some(A_STATUS_START), project, cx); + assert_status(&Path::new(B_TXT), Some(B_STATUS_START), project, cx); }); project_remote.read_with(cx_b, |project, cx| { - assert_status(&Path::new(A_TXT), Some(GitFileStatus::Added), project, cx); - assert_status(&Path::new(B_TXT), Some(GitFileStatus::Added), project, cx); + assert_status(&Path::new(A_TXT), Some(A_STATUS_START), project, cx); + assert_status(&Path::new(B_TXT), Some(B_STATUS_START), project, cx); + }); + + const A_STATUS_END: FileStatus = FileStatus::Tracked(TrackedStatus { + index_status: StatusCode::Added, + worktree_status: StatusCode::Unmodified, + }); + const B_STATUS_END: FileStatus = FileStatus::Tracked(TrackedStatus { + index_status: StatusCode::Deleted, + worktree_status: StatusCode::Unmodified, }); client_a.fs().set_status_for_repo_via_working_copy_change( Path::new("/dir/.git"), &[ - (Path::new(A_TXT), GitFileStatus::Modified), - (Path::new(B_TXT), GitFileStatus::Modified), + (Path::new(A_TXT), A_STATUS_END), + (Path::new(B_TXT), B_STATUS_END), ], ); @@ -2949,33 +2968,13 @@ async fn test_git_status_sync( // Smoke test status reading project_local.read_with(cx_a, |project, cx| { - assert_status( - &Path::new(A_TXT), - Some(GitFileStatus::Modified), - project, - cx, - ); - assert_status( - &Path::new(B_TXT), - Some(GitFileStatus::Modified), - project, - cx, - ); + assert_status(&Path::new(A_TXT), Some(A_STATUS_END), project, cx); + assert_status(&Path::new(B_TXT), Some(B_STATUS_END), project, cx); }); project_remote.read_with(cx_b, |project, cx| { - assert_status( - &Path::new(A_TXT), - Some(GitFileStatus::Modified), - project, - cx, - ); - assert_status( - &Path::new(B_TXT), - Some(GitFileStatus::Modified), - project, - cx, - ); + assert_status(&Path::new(A_TXT), Some(A_STATUS_END), project, cx); + assert_status(&Path::new(B_TXT), Some(B_STATUS_END), project, cx); }); // And synchronization while joining @@ -2983,18 +2982,8 @@ async fn test_git_status_sync( executor.run_until_parked(); project_remote_c.read_with(cx_c, |project, cx| { - assert_status( - &Path::new(A_TXT), - Some(GitFileStatus::Modified), - project, - cx, - ); - assert_status( - &Path::new(B_TXT), - Some(GitFileStatus::Modified), - project, - cx, - ); + assert_status(&Path::new(A_TXT), Some(A_STATUS_END), project, cx); + assert_status(&Path::new(B_TXT), Some(B_STATUS_END), project, cx); }); } diff --git a/crates/collab/src/tests/random_project_collaboration_tests.rs b/crates/collab/src/tests/random_project_collaboration_tests.rs index 23b380ddaaa3fd..66bae0e9d445fa 100644 --- a/crates/collab/src/tests/random_project_collaboration_tests.rs +++ b/crates/collab/src/tests/random_project_collaboration_tests.rs @@ -6,7 +6,7 @@ use call::ActiveCall; use collections::{BTreeMap, HashMap}; use editor::Bias; use fs::{FakeFs, Fs as _}; -use git::repository::GitFileStatus; +use git::status::{FileStatus, StatusCode, TrackedStatus, UnmergedStatus, UnmergedStatusCode}; use gpui::{BackgroundExecutor, Model, TestAppContext}; use language::{ range_to_lsp, FakeLspAdapter, Language, LanguageConfig, LanguageMatcher, PointUtf16, @@ -127,7 +127,7 @@ enum GitOperation { }, WriteGitStatuses { repo_path: PathBuf, - statuses: Vec<(PathBuf, GitFileStatus)>, + statuses: Vec<(PathBuf, FileStatus)>, git_operation: bool, }, } @@ -1458,17 +1458,7 @@ fn generate_git_operation(rng: &mut StdRng, client: &TestClient) -> GitOperation let statuses = file_paths .into_iter() - .map(|paths| { - ( - paths, - match rng.gen_range(0..3_u32) { - 0 => GitFileStatus::Added, - 1 => GitFileStatus::Modified, - 2 => GitFileStatus::Conflict, - _ => unreachable!(), - }, - ) - }) + .map(|path| (path, gen_status(rng))) .collect::>(); let git_operation = rng.gen::(); @@ -1613,3 +1603,41 @@ fn gen_file_name(rng: &mut StdRng) -> String { } name } + +fn gen_status(rng: &mut StdRng) -> FileStatus { + fn gen_status_code(rng: &mut StdRng) -> StatusCode { + match rng.gen_range(0..7) { + 0 => StatusCode::Modified, + 1 => StatusCode::TypeChanged, + 2 => StatusCode::Added, + 3 => StatusCode::Deleted, + 4 => StatusCode::Renamed, + 5 => StatusCode::Copied, + 6 => StatusCode::Unmodified, + _ => unreachable!(), + } + } + + fn gen_unmerged_status_code(rng: &mut StdRng) -> UnmergedStatusCode { + match rng.gen_range(0..3) { + 0 => UnmergedStatusCode::Updated, + 1 => UnmergedStatusCode::Added, + 2 => UnmergedStatusCode::Deleted, + _ => unreachable!(), + } + } + + match rng.gen_range(0..4) { + 0 => FileStatus::Untracked, + 1 => FileStatus::Ignored, + 2 => FileStatus::Unmerged(UnmergedStatus { + first_head: gen_unmerged_status_code(rng), + second_head: gen_unmerged_status_code(rng), + }), + 3 => FileStatus::Tracked(TrackedStatus { + index_status: gen_status_code(rng), + worktree_status: gen_status_code(rng), + }), + _ => unreachable!(), + } +} diff --git a/crates/editor/src/git/project_diff.rs b/crates/editor/src/git/project_diff.rs index 4acadad41e9380..9379b6242cb684 100644 --- a/crates/editor/src/git/project_diff.rs +++ b/crates/editor/src/git/project_diff.rs @@ -9,10 +9,7 @@ use std::{ use anyhow::{anyhow, Context as _}; use collections::{BTreeMap, HashMap}; use feature_flags::FeatureFlagAppExt; -use git::{ - diff::{BufferDiff, DiffHunk}, - repository::GitFileStatus, -}; +use git::diff::{BufferDiff, DiffHunk}; use gpui::{ actions, AnyElement, AnyView, AppContext, EventEmitter, FocusHandle, FocusableView, InteractiveElement, Model, Render, Subscription, Task, View, WeakView, @@ -54,7 +51,6 @@ struct ProjectDiffEditor { #[derive(Debug)] struct Changes { - _status: GitFileStatus, buffer: Model, hunks: Vec, } @@ -199,14 +195,13 @@ impl ProjectDiffEditor { .repositories() .iter() .flat_map(|entry| { - entry.status().map(|git_entry| { - (git_entry.combined_status(), entry.join(git_entry.repo_path)) - }) + entry + .status() + .map(|git_entry| entry.join(git_entry.repo_path)) }) - .filter_map(|(status, path)| { + .filter_map(|path| { let id = snapshot.entry_for_path(&path)?.id; Some(( - status, id, ProjectPath { worktree_id: snapshot.id(), @@ -218,9 +213,9 @@ impl ProjectDiffEditor { Some( applicable_entries .into_iter() - .map(|(status, entry_id, entry_path)| { + .map(|(entry_id, entry_path)| { let open_task = project.open_path(entry_path.clone(), cx); - (status, entry_id, entry_path, open_task) + (entry_id, entry_path, open_task) }) .collect::>(), ) @@ -234,15 +229,10 @@ impl ProjectDiffEditor { let mut new_entries = Vec::new(); let mut buffers = HashMap::< ProjectEntryId, - ( - GitFileStatus, - text::BufferSnapshot, - Model, - BufferDiff, - ), + (text::BufferSnapshot, Model, BufferDiff), >::default(); let mut change_sets = Vec::new(); - for (status, entry_id, entry_path, open_task) in open_tasks { + for (entry_id, entry_path, open_task) in open_tasks { let Some(buffer) = open_task .await .and_then(|(_, opened_model)| { @@ -272,7 +262,6 @@ impl ProjectDiffEditor { buffers.insert( entry_id, ( - status, buffer.read(cx).text_snapshot(), buffer, change_set.read(cx).diff_to_buffer.clone(), @@ -295,11 +284,10 @@ impl ProjectDiffEditor { .background_executor() .spawn(async move { let mut new_changes = HashMap::::default(); - for (entry_id, (status, buffer_snapshot, buffer, buffer_diff)) in buffers { + for (entry_id, (buffer_snapshot, buffer, buffer_diff)) in buffers { new_changes.insert( entry_id, Changes { - _status: status, buffer, hunks: buffer_diff .hunks_in_row_range(0..BufferRow::MAX, &buffer_snapshot) @@ -1107,6 +1095,7 @@ impl Render for ProjectDiffEditor { #[cfg(test)] mod tests { + use git::status::{StatusCode, TrackedStatus}; use gpui::{SemanticVersion, TestAppContext, VisualTestContext}; use project::buffer_store::BufferChangeSet; use serde_json::json; @@ -1224,7 +1213,14 @@ mod tests { }); fs.set_status_for_repo_via_git_operation( Path::new("/root/.git"), - &[(Path::new("file_a"), GitFileStatus::Modified)], + &[( + Path::new("file_a"), + TrackedStatus { + worktree_status: StatusCode::Modified, + index_status: StatusCode::Unmodified, + } + .into(), + )], ); cx.executor() .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100)); diff --git a/crates/editor/src/items.rs b/crates/editor/src/items.rs index 4cfba64e83abf7..4b4dd50f7f98e3 100644 --- a/crates/editor/src/items.rs +++ b/crates/editor/src/items.rs @@ -9,7 +9,7 @@ use anyhow::{anyhow, Context as _, Result}; use collections::HashSet; use file_icons::FileIcons; use futures::future::try_join_all; -use git::repository::GitFileStatus; +use git::status::GitSummary; use gpui::{ point, AnyElement, AppContext, AsyncWindowContext, Context, Entity, EntityId, EventEmitter, IntoElement, Model, ParentElement, Pixels, SharedString, Styled, Task, View, ViewContext, @@ -27,8 +27,6 @@ use project::{ }; use rpc::proto::{self, update_view, PeerId}; use settings::Settings; -use workspace::item::{Dedup, ItemSettings, SerializableItem, TabContentParams}; - use std::{ any::TypeId, borrow::Cow, @@ -43,6 +41,7 @@ use theme::{Theme, ThemeSettings}; use ui::{h_flex, prelude::*, IconDecorationKind, Label}; use util::{paths::PathExt, ResultExt, TryFutureExt}; use workspace::item::{BreadcrumbText, FollowEvent}; +use workspace::item::{Dedup, ItemSettings, SerializableItem, TabContentParams}; use workspace::{ item::{FollowableItem, Item, ItemEvent, ProjectItem}, searchable::{Direction, SearchEvent, SearchableItem, SearchableItemHandle}, @@ -621,10 +620,10 @@ impl Item for Editor { .worktree_for_id(path.worktree_id, cx)? .read(cx) .snapshot() - .status_for_file(path.path); + .status_for_file(path.path)?; Some(entry_git_aware_label_color( - git_status, + git_status.summary(), entry.is_ignored, params.selected, )) @@ -1560,20 +1559,17 @@ pub fn entry_diagnostic_aware_icon_decoration_and_color( } } -pub fn entry_git_aware_label_color( - git_status: Option, - ignored: bool, - selected: bool, -) -> Color { +pub fn entry_git_aware_label_color(git_status: GitSummary, ignored: bool, selected: bool) -> Color { if ignored { Color::Ignored + } else if git_status.conflict > 0 { + Color::Conflict + } else if git_status.modified > 0 { + Color::Modified + } else if git_status.added > 0 || git_status.untracked > 0 { + Color::Created } else { - match git_status { - Some(GitFileStatus::Added) | Some(GitFileStatus::Untracked) => Color::Created, - Some(GitFileStatus::Modified) => Color::Modified, - Some(GitFileStatus::Conflict) => Color::Conflict, - Some(GitFileStatus::Deleted) | None => entry_label_color(selected), - } + entry_label_color(selected) } } diff --git a/crates/fs/src/fs.rs b/crates/fs/src/fs.rs index e719f3776017f3..07173bc8bd687c 100644 --- a/crates/fs/src/fs.rs +++ b/crates/fs/src/fs.rs @@ -5,6 +5,8 @@ mod mac_watcher; pub mod fs_watcher; use anyhow::{anyhow, Result}; +#[cfg(any(test, feature = "test-support"))] +use git::status::FileStatus; use git::GitHostingProviderRegistry; #[cfg(any(target_os = "linux", target_os = "freebsd"))] @@ -41,7 +43,7 @@ use util::ResultExt; #[cfg(any(test, feature = "test-support"))] use collections::{btree_map, BTreeMap}; #[cfg(any(test, feature = "test-support"))] -use git::repository::{FakeGitRepositoryState, GitFileStatus}; +use git::repository::FakeGitRepositoryState; #[cfg(any(test, feature = "test-support"))] use parking_lot::Mutex; #[cfg(any(test, feature = "test-support"))] @@ -1285,11 +1287,11 @@ impl FakeFs { pub fn set_status_for_repo_via_working_copy_change( &self, dot_git: &Path, - statuses: &[(&Path, GitFileStatus)], + statuses: &[(&Path, FileStatus)], ) { self.with_git_state(dot_git, false, |state| { - state.worktree_statuses.clear(); - state.worktree_statuses.extend( + state.statuses.clear(); + state.statuses.extend( statuses .iter() .map(|(path, content)| ((**path).into(), *content)), @@ -1305,11 +1307,11 @@ impl FakeFs { pub fn set_status_for_repo_via_git_operation( &self, dot_git: &Path, - statuses: &[(&Path, GitFileStatus)], + statuses: &[(&Path, FileStatus)], ) { self.with_git_state(dot_git, true, |state| { - state.worktree_statuses.clear(); - state.worktree_statuses.extend( + state.statuses.clear(); + state.statuses.extend( statuses .iter() .map(|(path, content)| ((**path).into(), *content)), diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs index 121add8b9ae0b7..11a76b0dd89500 100644 --- a/crates/git/src/repository.rs +++ b/crates/git/src/repository.rs @@ -1,4 +1,4 @@ -use crate::status::GitStatusPair; +use crate::status::FileStatus; use crate::GitHostingProviderRegistry; use crate::{blame::Blame, status::GitStatus}; use anyhow::{anyhow, Context, Result}; @@ -7,7 +7,6 @@ use git2::BranchType; use gpui::SharedString; use parking_lot::Mutex; use rope::Rope; -use serde::{Deserialize, Serialize}; use std::borrow::Borrow; use std::sync::LazyLock; use std::{ @@ -294,7 +293,7 @@ pub struct FakeGitRepositoryState { pub event_emitter: smol::channel::Sender, pub index_contents: HashMap, pub blames: HashMap, - pub worktree_statuses: HashMap, + pub statuses: HashMap, pub current_branch_name: Option, pub branches: HashSet, } @@ -312,7 +311,7 @@ impl FakeGitRepositoryState { event_emitter, index_contents: Default::default(), blames: Default::default(), - worktree_statuses: Default::default(), + statuses: Default::default(), current_branch_name: Default::default(), branches: Default::default(), } @@ -349,20 +348,14 @@ impl GitRepository for FakeGitRepository { let state = self.state.lock(); let mut entries = state - .worktree_statuses + .statuses .iter() - .filter_map(|(repo_path, status_worktree)| { + .filter_map(|(repo_path, status)| { if path_prefixes .iter() .any(|path_prefix| repo_path.0.starts_with(path_prefix)) { - Some(( - repo_path.to_owned(), - GitStatusPair { - index_status: None, - worktree_status: Some(*status_worktree), - }, - )) + Some((repo_path.to_owned(), *status)) } else { None } @@ -461,51 +454,6 @@ fn check_path_to_repo_path_errors(relative_file_path: &Path) -> Result<()> { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub enum GitFileStatus { - Added, - Modified, - // TODO conflicts should be represented by the GitStatusPair - Conflict, - Deleted, - Untracked, -} - -impl GitFileStatus { - pub fn merge( - this: Option, - other: Option, - prefer_other: bool, - ) -> Option { - if prefer_other { - return other; - } - - match (this, other) { - (Some(GitFileStatus::Conflict), _) | (_, Some(GitFileStatus::Conflict)) => { - Some(GitFileStatus::Conflict) - } - (Some(GitFileStatus::Modified), _) | (_, Some(GitFileStatus::Modified)) => { - Some(GitFileStatus::Modified) - } - (Some(GitFileStatus::Added), _) | (_, Some(GitFileStatus::Added)) => { - Some(GitFileStatus::Added) - } - _ => None, - } - } - - pub fn from_byte(byte: u8) -> Option { - match byte { - b'M' => Some(GitFileStatus::Modified), - b'A' => Some(GitFileStatus::Added), - b'D' => Some(GitFileStatus::Deleted), - b'?' => Some(GitFileStatus::Untracked), - _ => None, - } - } -} - pub static WORK_DIRECTORY_REPO_PATH: LazyLock = LazyLock::new(|| RepoPath(Path::new("").into())); diff --git a/crates/git/src/status.rs b/crates/git/src/status.rs index de574f5d2121af..edf4d58373a48d 100644 --- a/crates/git/src/status.rs +++ b/crates/git/src/status.rs @@ -1,34 +1,316 @@ -use crate::repository::{GitFileStatus, RepoPath}; +use crate::repository::RepoPath; use anyhow::{anyhow, Result}; +use serde::{Deserialize, Serialize}; use std::{path::Path, process::Stdio, sync::Arc}; +use util::ResultExt; -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct GitStatusPair { - // Not both `None`. - pub index_status: Option, - pub worktree_status: Option, -} - -impl GitStatusPair { - pub fn is_staged(&self) -> Option { - match (self.index_status, self.worktree_status) { - (Some(_), None) => Some(true), - (None, Some(_)) => Some(false), - (Some(GitFileStatus::Untracked), Some(GitFileStatus::Untracked)) => Some(false), - (Some(_), Some(_)) => None, - (None, None) => unreachable!(), +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum FileStatus { + Untracked, + Ignored, + Unmerged(UnmergedStatus), + Tracked(TrackedStatus), +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct UnmergedStatus { + pub first_head: UnmergedStatusCode, + pub second_head: UnmergedStatusCode, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum UnmergedStatusCode { + Added, + Deleted, + Updated, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct TrackedStatus { + pub index_status: StatusCode, + pub worktree_status: StatusCode, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum StatusCode { + Modified, + TypeChanged, + Added, + Deleted, + Renamed, + Copied, + Unmodified, +} + +impl From for FileStatus { + fn from(value: UnmergedStatus) -> Self { + FileStatus::Unmerged(value) + } +} + +impl From for FileStatus { + fn from(value: TrackedStatus) -> Self { + FileStatus::Tracked(value) + } +} + +impl FileStatus { + pub const fn worktree(worktree_status: StatusCode) -> Self { + FileStatus::Tracked(TrackedStatus { + index_status: StatusCode::Unmodified, + worktree_status, + }) + } + + /// Generate a FileStatus Code from a byte pair, as described in + /// https://git-scm.com/docs/git-status#_output + /// + /// NOTE: That instead of '', we use ' ' to denote no change + fn from_bytes(bytes: [u8; 2]) -> anyhow::Result { + let status = match bytes { + [b'?', b'?'] => FileStatus::Untracked, + [b'!', b'!'] => FileStatus::Ignored, + [b'A', b'A'] => UnmergedStatus { + first_head: UnmergedStatusCode::Added, + second_head: UnmergedStatusCode::Added, + } + .into(), + [b'D', b'D'] => UnmergedStatus { + first_head: UnmergedStatusCode::Added, + second_head: UnmergedStatusCode::Added, + } + .into(), + [x, b'U'] => UnmergedStatus { + first_head: UnmergedStatusCode::from_byte(x)?, + second_head: UnmergedStatusCode::Updated, + } + .into(), + [b'U', y] => UnmergedStatus { + first_head: UnmergedStatusCode::Updated, + second_head: UnmergedStatusCode::from_byte(y)?, + } + .into(), + [x, y] => TrackedStatus { + index_status: StatusCode::from_byte(x)?, + worktree_status: StatusCode::from_byte(y)?, + } + .into(), + }; + Ok(status) + } + + pub fn is_staged(self) -> Option { + match self { + FileStatus::Untracked | FileStatus::Ignored | FileStatus::Unmerged { .. } => { + Some(false) + } + FileStatus::Tracked(tracked) => match (tracked.index_status, tracked.worktree_status) { + (StatusCode::Unmodified, _) => Some(false), + (_, StatusCode::Unmodified) => Some(true), + _ => None, + }, } } - // TODO reconsider uses of this - pub fn combined(&self) -> GitFileStatus { - self.index_status.or(self.worktree_status).unwrap() + pub fn is_conflicted(self) -> bool { + match self { + FileStatus::Unmerged { .. } => true, + _ => false, + } + } + + pub fn is_ignored(self) -> bool { + match self { + FileStatus::Ignored => true, + _ => false, + } + } + + pub fn is_modified(self) -> bool { + match self { + FileStatus::Tracked(tracked) => match (tracked.index_status, tracked.worktree_status) { + (StatusCode::Modified, _) | (_, StatusCode::Modified) => true, + _ => false, + }, + _ => false, + } + } + + pub fn is_created(self) -> bool { + match self { + FileStatus::Tracked(tracked) => match (tracked.index_status, tracked.worktree_status) { + (StatusCode::Added, _) | (_, StatusCode::Added) => true, + _ => false, + }, + _ => false, + } + } + + pub fn is_deleted(self) -> bool { + match self { + FileStatus::Tracked(tracked) => match (tracked.index_status, tracked.worktree_status) { + (StatusCode::Deleted, _) | (_, StatusCode::Deleted) => true, + _ => false, + }, + _ => false, + } + } + + pub fn is_untracked(self) -> bool { + match self { + FileStatus::Untracked => true, + _ => false, + } + } + + pub fn summary(self) -> GitSummary { + match self { + FileStatus::Ignored => GitSummary::UNCHANGED, + FileStatus::Untracked => GitSummary::UNTRACKED, + FileStatus::Unmerged(_) => GitSummary::CONFLICT, + FileStatus::Tracked(TrackedStatus { + index_status, + worktree_status, + }) => index_status.summary() + worktree_status.summary(), + } + } +} + +impl StatusCode { + fn from_byte(byte: u8) -> anyhow::Result { + match byte { + b'M' => Ok(StatusCode::Modified), + b'T' => Ok(StatusCode::TypeChanged), + b'A' => Ok(StatusCode::Added), + b'D' => Ok(StatusCode::Deleted), + b'R' => Ok(StatusCode::Renamed), + b'C' => Ok(StatusCode::Copied), + b' ' => Ok(StatusCode::Unmodified), + _ => Err(anyhow!("Invalid status code: {byte}")), + } + } + + fn summary(self) -> GitSummary { + match self { + StatusCode::Modified | StatusCode::TypeChanged => GitSummary::MODIFIED, + StatusCode::Added => GitSummary::ADDED, + StatusCode::Deleted => GitSummary::DELETED, + StatusCode::Renamed | StatusCode::Copied | StatusCode::Unmodified => { + GitSummary::UNCHANGED + } + } + } +} + +impl UnmergedStatusCode { + fn from_byte(byte: u8) -> anyhow::Result { + match byte { + b'A' => Ok(UnmergedStatusCode::Added), + b'D' => Ok(UnmergedStatusCode::Deleted), + b'U' => Ok(UnmergedStatusCode::Updated), + _ => Err(anyhow!("Invalid unmerged status code: {byte}")), + } + } +} + +#[derive(Clone, Debug, Default, Copy, PartialEq, Eq)] +pub struct GitSummary { + pub added: usize, + pub modified: usize, + pub conflict: usize, + pub untracked: usize, + pub deleted: usize, +} + +impl GitSummary { + pub const ADDED: Self = Self { + added: 1, + ..Self::UNCHANGED + }; + + pub const MODIFIED: Self = Self { + modified: 1, + ..Self::UNCHANGED + }; + + pub const CONFLICT: Self = Self { + conflict: 1, + ..Self::UNCHANGED + }; + + pub const DELETED: Self = Self { + deleted: 1, + ..Self::UNCHANGED + }; + + pub const UNTRACKED: Self = Self { + untracked: 1, + ..Self::UNCHANGED + }; + + pub const UNCHANGED: Self = Self { + added: 0, + modified: 0, + conflict: 0, + untracked: 0, + deleted: 0, + }; +} + +impl From for GitSummary { + fn from(status: FileStatus) -> Self { + status.summary() + } +} + +impl sum_tree::Summary for GitSummary { + type Context = (); + + fn zero(_: &Self::Context) -> Self { + Default::default() + } + + fn add_summary(&mut self, rhs: &Self, _: &Self::Context) { + *self += *rhs; + } +} + +impl std::ops::Add for GitSummary { + type Output = Self; + + fn add(mut self, rhs: Self) -> Self { + self += rhs; + self + } +} + +impl std::ops::AddAssign for GitSummary { + fn add_assign(&mut self, rhs: Self) { + self.added += rhs.added; + self.modified += rhs.modified; + self.conflict += rhs.conflict; + self.untracked += rhs.untracked; + self.deleted += rhs.deleted; + } +} + +impl std::ops::Sub for GitSummary { + type Output = GitSummary; + + fn sub(self, rhs: Self) -> Self::Output { + GitSummary { + added: self.added - rhs.added, + modified: self.modified - rhs.modified, + conflict: self.conflict - rhs.conflict, + untracked: self.untracked - rhs.untracked, + deleted: self.deleted - rhs.deleted, + } } } #[derive(Clone)] pub struct GitStatus { - pub entries: Arc<[(RepoPath, GitStatusPair)]>, + pub entries: Arc<[(RepoPath, FileStatus)]>, } impl GitStatus { @@ -77,20 +359,10 @@ impl GitStatus { return None; }; let path = &entry[3..]; - let status = entry[0..2].as_bytes(); - let index_status = GitFileStatus::from_byte(status[0]); - let worktree_status = GitFileStatus::from_byte(status[1]); - if (index_status, worktree_status) == (None, None) { - return None; - } + let status = entry[0..2].as_bytes().try_into().unwrap(); + let status = FileStatus::from_bytes(status).log_err()?; let path = RepoPath(Path::new(path).into()); - Some(( - path, - GitStatusPair { - index_status, - worktree_status, - }, - )) + Some((path, status)) }) .collect::>(); entries.sort_unstable_by(|(a, _), (b, _)| a.cmp(&b)); diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index 4ccda79473c8e0..bc47c2d6408d61 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -8,8 +8,7 @@ use anyhow::{Context as _, Result}; use db::kvp::KEY_VALUE_STORE; use editor::scroll::ScrollbarAutoHide; use editor::{Editor, EditorSettings, ShowScrollbar}; -use git::repository::{GitFileStatus, RepoPath}; -use git::status::GitStatusPair; +use git::{repository::RepoPath, status::FileStatus}; use gpui::*; use language::Buffer; use menu::{SelectFirst, SelectLast, SelectNext, SelectPrev}; @@ -72,7 +71,7 @@ pub struct GitListEntry { depth: usize, display_name: String, repo_path: RepoPath, - status: GitStatusPair, + status: FileStatus, is_staged: Option, } @@ -665,7 +664,7 @@ impl GitPanel { .skip(range.start) .take(range.end - range.start) { - let status = entry.status.clone(); + let status = entry.status; let filename = entry .repo_path .file_name() @@ -1072,22 +1071,23 @@ impl GitPanel { let repo_path = entry_details.repo_path.clone(); let selected = self.selected_entry == Some(ix); let status_style = GitPanelSettings::get_global(cx).status_style; - // TODO revisit, maybe use a different status here? - let status = entry_details.status.combined(); + let status = entry_details.status; let mut label_color = cx.theme().colors().text; if status_style == StatusStyle::LabelColor { - label_color = match status { - GitFileStatus::Added => cx.theme().status().created, - GitFileStatus::Modified => cx.theme().status().modified, - GitFileStatus::Conflict => cx.theme().status().conflict, - GitFileStatus::Deleted => cx.theme().colors().text_disabled, - // TODO: Should we even have this here? - GitFileStatus::Untracked => cx.theme().colors().text_placeholder, + label_color = if status.is_conflicted() { + cx.theme().status().conflict + } else if status.is_modified() { + cx.theme().status().modified + } else if status.is_deleted() { + cx.theme().colors().text_disabled + } else { + cx.theme().status().created } } - let path_color = matches!(status, GitFileStatus::Deleted) + let path_color = status + .is_deleted() .then_some(cx.theme().colors().text_disabled) .unwrap_or(cx.theme().colors().text_muted); @@ -1175,7 +1175,7 @@ impl GitPanel { .child( h_flex() .text_color(label_color) - .when(status == GitFileStatus::Deleted, |this| this.line_through()) + .when(status.is_deleted(), |this| this.line_through()) .when_some(repo_path.parent(), |this, parent| { let parent_str = parent.to_string_lossy(); if !parent_str.is_empty() { diff --git a/crates/git_ui/src/git_ui.rs b/crates/git_ui/src/git_ui.rs index c9564f252e96a0..ba2a3944024822 100644 --- a/crates/git_ui/src/git_ui.rs +++ b/crates/git_ui/src/git_ui.rs @@ -2,7 +2,8 @@ use ::settings::Settings; use collections::HashMap; use futures::channel::mpsc; use futures::StreamExt as _; -use git::repository::{GitFileStatus, GitRepository, RepoPath}; +use git::repository::{GitRepository, RepoPath}; +use git::status::FileStatus; use git_panel_settings::GitPanelSettings; use gpui::{actions, AppContext, Hsla, Model}; use project::{Project, WorktreeId}; @@ -223,17 +224,15 @@ const REMOVED_COLOR: Hsla = Hsla { }; // TODO: Add updated status colors to theme -pub fn git_status_icon(status: GitFileStatus) -> impl IntoElement { - match status { - GitFileStatus::Added | GitFileStatus::Untracked => { - Icon::new(IconName::SquarePlus).color(Color::Custom(ADDED_COLOR)) - } - GitFileStatus::Modified => { - Icon::new(IconName::SquareDot).color(Color::Custom(MODIFIED_COLOR)) - } - GitFileStatus::Conflict => Icon::new(IconName::Warning).color(Color::Custom(REMOVED_COLOR)), - GitFileStatus::Deleted => { - Icon::new(IconName::SquareMinus).color(Color::Custom(REMOVED_COLOR)) - } - } +pub fn git_status_icon(status: FileStatus) -> impl IntoElement { + let (icon_name, color) = if status.is_conflicted() { + (IconName::Warning, REMOVED_COLOR) + } else if status.is_deleted() { + (IconName::SquareMinus, REMOVED_COLOR) + } else if status.is_modified() { + (IconName::SquareDot, MODIFIED_COLOR) + } else { + (IconName::SquarePlus, ADDED_COLOR) + }; + Icon::new(icon_name).color(Color::Custom(color)) } diff --git a/crates/image_viewer/src/image_viewer.rs b/crates/image_viewer/src/image_viewer.rs index b78f1bd085caed..8d09357807fa99 100644 --- a/crates/image_viewer/src/image_viewer.rs +++ b/crates/image_viewer/src/image_viewer.rs @@ -2,18 +2,17 @@ use std::path::PathBuf; use anyhow::Context as _; use editor::items::entry_git_aware_label_color; +use file_icons::FileIcons; use gpui::{ canvas, div, fill, img, opaque_grey, point, size, AnyElement, AppContext, Bounds, EventEmitter, FocusHandle, FocusableView, InteractiveElement, IntoElement, Model, ObjectFit, ParentElement, Render, Styled, Task, View, ViewContext, VisualContext, WeakView, WindowContext, }; use persistence::IMAGE_VIEWER; -use theme::Theme; -use ui::prelude::*; - -use file_icons::FileIcons; use project::{image_store::ImageItemEvent, ImageItem, Project, ProjectPath}; use settings::Settings; +use theme::Theme; +use ui::prelude::*; use util::paths::PathExt; use workspace::{ item::{BreadcrumbText, Item, ProjectItem, SerializableItem, TabContentParams}, @@ -101,7 +100,9 @@ impl Item for ImageView { let git_status = self .project .read(cx) - .project_path_git_status(&project_path, cx); + .project_path_git_status(&project_path, cx) + .map(|status| status.summary()) + .unwrap_or_default(); self.project .read(cx) diff --git a/crates/outline_panel/src/outline_panel.rs b/crates/outline_panel/src/outline_panel.rs index 31c9e76ec2573d..64904b74545314 100644 --- a/crates/outline_panel/src/outline_panel.rs +++ b/crates/outline_panel/src/outline_panel.rs @@ -1982,7 +1982,7 @@ impl OutlinePanel { let is_expanded = !self .collapsed_entries .contains(&CollapsedEntry::Excerpt(excerpt.buffer_id, excerpt.id)); - let color = entry_git_aware_label_color(None, false, is_active); + let color = entry_label_color(is_active); let icon = if has_outlines { FileIcons::get_chevron_icon(is_expanded, cx) .map(|icon_path| Icon::from_path(icon_path).color(color).into_any_element()) @@ -2086,7 +2086,7 @@ impl OutlinePanel { }) => { let name = self.entry_name(worktree_id, entry, cx); let color = - entry_git_aware_label_color(entry.git_status, entry.is_ignored, is_active); + entry_git_aware_label_color(entry.git_summary, entry.is_ignored, is_active); let icon = if settings.file_icons { FileIcons::get_icon(&entry.path, cx) .map(|icon_path| Icon::from_path(icon_path).color(color).into_any_element()) @@ -2114,7 +2114,7 @@ impl OutlinePanel { directory.entry.id, )); let color = entry_git_aware_label_color( - directory.entry.git_status, + directory.entry.git_summary, directory.entry.is_ignored, is_active, ); @@ -2210,7 +2210,8 @@ impl OutlinePanel { let git_status = folded_dir .entries .first() - .and_then(|entry| entry.git_status); + .map(|entry| entry.git_summary) + .unwrap_or_default(); let color = entry_git_aware_label_color(git_status, is_ignored, is_active); let icon = if settings.folder_icons { FileIcons::get_folder_icon(is_expanded, cx) @@ -2556,7 +2557,10 @@ impl OutlinePanel { match entry_id.and_then(|id| worktree.entry_for_id(id)).cloned() { Some(entry) => { let entry = GitEntry { - git_status: worktree.status_for_file(&entry.path), + git_summary: worktree + .status_for_file(&entry.path) + .map(|status| status.summary()) + .unwrap_or_default(), entry, }; let mut traversal = worktree diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index a4986dbc4cd848..c67aeef9ecfd66 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -39,10 +39,7 @@ use futures::{ pub use image_store::{ImageItem, ImageStore}; use image_store::{ImageItemEvent, ImageStoreEvent}; -use git::{ - blame::Blame, - repository::{GitFileStatus, GitRepository}, -}; +use git::{blame::Blame, repository::GitRepository, status::FileStatus}; use gpui::{ AnyModel, AppContext, AsyncAppContext, BorrowAppContext, Context as _, EventEmitter, Hsla, Model, ModelContext, SharedString, Task, WeakModel, WindowContext, @@ -1449,7 +1446,7 @@ impl Project { &self, project_path: &ProjectPath, cx: &AppContext, - ) -> Option { + ) -> Option { self.worktree_for_id(project_path.worktree_id, cx) .and_then(|worktree| worktree.read(cx).status_for_file(&project_path.path)) } diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index a9ed95745be4e6..f75cb8f80328fe 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -15,7 +15,7 @@ use editor::{ Editor, EditorEvent, EditorSettings, ShowScrollbar, }; use file_icons::FileIcons; -use git::repository::GitFileStatus; +use git::status::GitSummary; use gpui::{ actions, anchored, deferred, div, impl_actions, point, px, size, uniform_list, Action, AnyElement, AppContext, AssetSource, AsyncWindowContext, Bounds, ClipboardItem, DismissEvent, @@ -145,7 +145,7 @@ struct EntryDetails { is_cut: bool, filename_text_color: Color, diagnostic_severity: Option, - git_status: Option, + git_status: GitSummary, is_private: bool, worktree_id: WorktreeId, canonical_path: Option>, @@ -1584,9 +1584,7 @@ impl ProjectPanel { } })) && entry.is_file() - && entry - .git_status - .is_some_and(|status| matches!(status, GitFileStatus::Modified)) + && entry.git_summary.modified > 0 }, cx, ); @@ -1664,9 +1662,7 @@ impl ProjectPanel { } })) && entry.is_file() - && entry - .git_status - .is_some_and(|status| matches!(status, GitFileStatus::Modified)) + && entry.git_summary.modified > 0 }, cx, ); @@ -2417,7 +2413,7 @@ impl ProjectPanel { char_bag: entry.char_bag, is_fifo: entry.is_fifo, }, - git_status: entry.git_status, + git_summary: entry.git_summary, }); } let worktree_abs_path = worktree.read(cx).abs_path(); @@ -2815,7 +2811,9 @@ impl ProjectPanel { .collect() }); for entry in visible_worktree_entries[entry_range].iter() { - let status = git_status_setting.then_some(entry.git_status).flatten(); + let status = git_status_setting + .then_some(entry.git_summary) + .unwrap_or_default(); let is_expanded = expanded_entry_ids.binary_search(&entry.id).is_ok(); let icon = match entry.kind { EntryKind::File => { diff --git a/crates/proto/proto/zed.proto b/crates/proto/proto/zed.proto index c035ac1a04d54c..cd7ef7955c473b 100644 --- a/crates/proto/proto/zed.proto +++ b/crates/proto/proto/zed.proto @@ -1784,7 +1784,9 @@ message RepositoryEntry { message StatusEntry { string repo_path = 1; - GitStatus status = 2; + // Can be removed once collab's min version is >=0.171.0. + GitStatus simple_status = 2; + GitFileStatus status = 3; } enum GitStatus { @@ -1792,6 +1794,31 @@ enum GitStatus { Modified = 1; Conflict = 2; Deleted = 3; + Updated = 4; + TypeChanged = 5; + Renamed = 6; + Copied = 7; + Unmodified = 8; +} + +message GitFileStatus { + oneof variant { + Untracked untracked = 1; + Ignored ignored = 2; + Unmerged unmerged = 3; + Tracked tracked = 4; + } + + message Untracked {} + message Ignored {} + message Unmerged { + GitStatus first_head = 1; + GitStatus second_head = 2; + } + message Tracked { + GitStatus index_status = 1; + GitStatus worktree_status = 2; + } } message BufferState { diff --git a/crates/tab_switcher/src/tab_switcher.rs b/crates/tab_switcher/src/tab_switcher.rs index 1b427d9f31a591..87a39f73080ecc 100644 --- a/crates/tab_switcher/src/tab_switcher.rs +++ b/crates/tab_switcher/src/tab_switcher.rs @@ -362,7 +362,10 @@ impl PickerDelegate for TabSwitcherDelegate { .and_then(|path| { let project = self.project.read(cx); let entry = project.entry_for_path(path, cx)?; - let git_status = project.project_path_git_status(path, cx); + let git_status = project + .project_path_git_status(path, cx) + .map(|status| status.summary()) + .unwrap_or_default(); Some((entry, git_status)) }) .map(|(entry, git_status)| { diff --git a/crates/worktree/src/worktree.rs b/crates/worktree/src/worktree.rs index 3f8c113db674c7..a6b6f014ac08db 100644 --- a/crates/worktree/src/worktree.rs +++ b/crates/worktree/src/worktree.rs @@ -18,11 +18,12 @@ use futures::{ FutureExt as _, Stream, StreamExt, }; use fuzzy::CharBag; -use git::GitHostingProviderRegistry; use git::{ - repository::{GitFileStatus, GitRepository, RepoPath}, - status::GitStatusPair, - COOKIES, DOT_GIT, FSMONITOR_DAEMON, GITIGNORE, + repository::{GitRepository, RepoPath}, + status::{ + FileStatus, GitSummary, StatusCode, TrackedStatus, UnmergedStatus, UnmergedStatusCode, + }, + GitHostingProviderRegistry, COOKIES, DOT_GIT, FSMONITOR_DAEMON, GITIGNORE, }; use gpui::{ AppContext, AsyncAppContext, BackgroundExecutor, Context, EventEmitter, Model, ModelContext, @@ -239,10 +240,7 @@ impl RepositoryEntry { updated_statuses: self .statuses_by_path .iter() - .map(|entry| proto::StatusEntry { - repo_path: entry.repo_path.to_string_lossy().to_string(), - status: status_pair_to_proto(entry.status.clone()), - }) + .map(|entry| entry.to_proto()) .collect(), removed_statuses: Default::default(), } @@ -266,7 +264,7 @@ impl RepositoryEntry { current_new_entry = new_statuses.next(); } Ordering::Equal => { - if new_entry.combined_status() != old_entry.combined_status() { + if new_entry.status != old_entry.status { updated_statuses.push(new_entry.to_proto()); } current_old_entry = old_statuses.next(); @@ -2361,13 +2359,13 @@ impl Snapshot { Some(removed_entry.path) } - pub fn status_for_file(&self, path: impl AsRef) -> Option { + pub fn status_for_file(&self, path: impl AsRef) -> Option { let path = path.as_ref(); self.repository_for_path(path).and_then(|repo| { let repo_path = repo.relativize(path).unwrap(); repo.statuses_by_path .get(&PathKey(repo_path.0), &()) - .map(|entry| entry.combined_status()) + .map(|entry| entry.status) }) } @@ -3633,41 +3631,41 @@ pub type UpdatedGitRepositoriesSet = Arc<[(Arc, GitRepositoryChange)]>; #[derive(Clone, Debug, PartialEq, Eq)] pub struct StatusEntry { pub repo_path: RepoPath, - pub status: GitStatusPair, + pub status: FileStatus, } impl StatusEntry { - // TODO revisit uses of this - pub fn combined_status(&self) -> GitFileStatus { - self.status.combined() - } - - pub fn index_status(&self) -> Option { - self.status.index_status - } - - pub fn worktree_status(&self) -> Option { - self.status.worktree_status - } - pub fn is_staged(&self) -> Option { self.status.is_staged() } fn to_proto(&self) -> proto::StatusEntry { + let simple_status = match self.status { + FileStatus::Ignored | FileStatus::Untracked => proto::GitStatus::Added as i32, + FileStatus::Unmerged { .. } => proto::GitStatus::Conflict as i32, + FileStatus::Tracked(TrackedStatus { + index_status, + worktree_status, + }) => tracked_status_to_proto(if worktree_status != StatusCode::Unmodified { + worktree_status + } else { + index_status + }), + }; proto::StatusEntry { repo_path: self.repo_path.to_proto(), - status: status_pair_to_proto(self.status.clone()), + simple_status, + status: Some(status_to_proto(self.status)), } } } impl TryFrom for StatusEntry { type Error = anyhow::Error; + fn try_from(value: proto::StatusEntry) -> Result { let repo_path = RepoPath(Path::new(&value.repo_path).into()); - let status = status_pair_from_proto(value.status) - .ok_or_else(|| anyhow!("Unable to parse status value {}", value.status))?; + let status = status_from_proto(value.simple_status, value.status)?; Ok(Self { repo_path, status }) } } @@ -3734,43 +3732,13 @@ impl sum_tree::KeyedItem for RepositoryEntry { } } -impl sum_tree::Summary for GitStatuses { - type Context = (); - - fn zero(_: &Self::Context) -> Self { - Default::default() - } - - fn add_summary(&mut self, rhs: &Self, _: &Self::Context) { - *self += *rhs; - } -} - impl sum_tree::Item for StatusEntry { - type Summary = PathSummary; + type Summary = PathSummary; fn summary(&self, _: &::Context) -> Self::Summary { PathSummary { max_path: self.repo_path.0.clone(), - item_summary: match self.combined_status() { - GitFileStatus::Added => GitStatuses { - added: 1, - ..Default::default() - }, - GitFileStatus::Modified => GitStatuses { - modified: 1, - ..Default::default() - }, - GitFileStatus::Conflict => GitStatuses { - conflict: 1, - ..Default::default() - }, - GitFileStatus::Deleted => Default::default(), - GitFileStatus::Untracked => GitStatuses { - untracked: 1, - ..Default::default() - }, - }, + item_summary: self.status.summary(), } } } @@ -3783,69 +3751,12 @@ impl sum_tree::KeyedItem for StatusEntry { } } -#[derive(Clone, Debug, Default, Copy, PartialEq, Eq)] -pub struct GitStatuses { - added: usize, - modified: usize, - conflict: usize, - untracked: usize, -} - -impl GitStatuses { - pub fn to_status(&self) -> Option { - if self.conflict > 0 { - Some(GitFileStatus::Conflict) - } else if self.modified > 0 { - Some(GitFileStatus::Modified) - } else if self.added > 0 || self.untracked > 0 { - Some(GitFileStatus::Added) - } else { - None - } - } -} - -impl std::ops::Add for GitStatuses { - type Output = Self; - - fn add(self, rhs: Self) -> Self { - GitStatuses { - added: self.added + rhs.added, - modified: self.modified + rhs.modified, - conflict: self.conflict + rhs.conflict, - untracked: self.untracked + rhs.untracked, - } - } -} - -impl std::ops::AddAssign for GitStatuses { - fn add_assign(&mut self, rhs: Self) { - self.added += rhs.added; - self.modified += rhs.modified; - self.conflict += rhs.conflict; - self.untracked += rhs.untracked; - } -} - -impl std::ops::Sub for GitStatuses { - type Output = GitStatuses; - - fn sub(self, rhs: Self) -> Self::Output { - GitStatuses { - added: self.added - rhs.added, - modified: self.modified - rhs.modified, - conflict: self.conflict - rhs.conflict, - untracked: self.untracked - rhs.untracked, - } - } -} - -impl<'a> sum_tree::Dimension<'a, PathSummary> for GitStatuses { +impl<'a> sum_tree::Dimension<'a, PathSummary> for GitSummary { fn zero(_cx: &()) -> Self { Default::default() } - fn add_summary(&mut self, summary: &'a PathSummary, _: &()) { + fn add_summary(&mut self, summary: &'a PathSummary, _: &()) { *self += summary.item_summary } } @@ -4851,7 +4762,7 @@ impl BackgroundScanner { changed_path_statuses.push(Edit::Insert(StatusEntry { repo_path: repo_path.clone(), - status: status.clone(), + status: *status, })); } @@ -5280,7 +5191,7 @@ impl BackgroundScanner { new_entries_by_path.insert_or_replace( StatusEntry { repo_path: repo_path.clone(), - status: status.clone(), + status: *status, }, &(), ); @@ -5695,14 +5606,14 @@ impl<'a> Default for TraversalProgress<'a> { #[derive(Debug, Clone, Copy)] pub struct GitEntryRef<'a> { pub entry: &'a Entry, - pub git_status: Option, + pub git_summary: GitSummary, } impl<'a> GitEntryRef<'a> { pub fn to_owned(&self) -> GitEntry { GitEntry { entry: self.entry.clone(), - git_status: self.git_status, + git_summary: self.git_summary, } } } @@ -5724,14 +5635,14 @@ impl<'a> AsRef for GitEntryRef<'a> { #[derive(Debug, Clone, PartialEq, Eq)] pub struct GitEntry { pub entry: Entry, - pub git_status: Option, + pub git_summary: GitSummary, } impl GitEntry { pub fn to_ref(&self) -> GitEntryRef { GitEntryRef { entry: &self.entry, - git_status: self.git_status, + git_summary: self.git_summary, } } } @@ -5753,7 +5664,7 @@ impl AsRef for GitEntry { /// Walks the worktree entries and their associated git statuses. pub struct GitTraversal<'a> { traversal: Traversal<'a>, - current_entry_status: Option, + current_entry_summary: Option, repo_location: Option<( &'a RepositoryEntry, Cursor<'a, StatusEntry, PathProgress<'a>>, @@ -5762,7 +5673,7 @@ pub struct GitTraversal<'a> { impl<'a> GitTraversal<'a> { fn synchronize_statuses(&mut self, reset: bool) { - self.current_entry_status = None; + self.current_entry_summary = None; let Some(entry) = self.traversal.cursor.item() else { return; @@ -5787,14 +5698,16 @@ impl<'a> GitTraversal<'a> { if entry.is_dir() { let mut statuses = statuses.clone(); statuses.seek_forward(&PathTarget::Path(repo_path.as_ref()), Bias::Left, &()); - let summary: GitStatuses = + let summary = statuses.summary(&PathTarget::Successor(repo_path.as_ref()), Bias::Left, &()); - self.current_entry_status = summary.to_status(); + self.current_entry_summary = Some(summary); } else if entry.is_file() { // For a file entry, park the cursor on the corresponding status if statuses.seek_forward(&PathTarget::Path(repo_path.as_ref()), Bias::Left, &()) { - self.current_entry_status = Some(statuses.item().unwrap().combined_status()); + self.current_entry_summary = Some(statuses.item().unwrap().status.into()); + } else { + self.current_entry_summary = Some(GitSummary::zero(&())); } } } @@ -5830,10 +5743,9 @@ impl<'a> GitTraversal<'a> { } pub fn entry(&self) -> Option> { - Some(GitEntryRef { - entry: self.traversal.cursor.item()?, - git_status: self.current_entry_status, - }) + let entry = self.traversal.cursor.item()?; + let git_summary = self.current_entry_summary.unwrap_or_default(); + Some(GitEntryRef { entry, git_summary }) } } @@ -5884,7 +5796,7 @@ impl<'a> Traversal<'a> { pub fn with_git_statuses(self) -> GitTraversal<'a> { let mut this = GitTraversal { traversal: self, - current_entry_status: None, + current_entry_summary: None, repo_location: None, }; this.synchronize_statuses(true); @@ -6003,10 +5915,10 @@ impl<'a, 'b, S: Summary> SeekTarget<'a, PathSummary, TraversalProgress<'a>> f } } -impl<'a, 'b> SeekTarget<'a, PathSummary, (TraversalProgress<'a>, GitStatuses)> +impl<'a, 'b> SeekTarget<'a, PathSummary, (TraversalProgress<'a>, GitSummary)> for PathTarget<'b> { - fn cmp(&self, cursor_location: &(TraversalProgress<'a>, GitStatuses), _: &()) -> Ordering { + fn cmp(&self, cursor_location: &(TraversalProgress<'a>, GitSummary), _: &()) -> Ordering { self.cmp_path(&cursor_location.0.max_path) } } @@ -6159,28 +6071,135 @@ impl<'a> TryFrom<(&'a CharBag, &PathMatcher, proto::Entry)> for Entry { } } -// TODO pass the status pair all the way through -fn status_pair_from_proto(proto: i32) -> Option { - let proto = proto::GitStatus::from_i32(proto)?; - let worktree_status = match proto { - proto::GitStatus::Added => GitFileStatus::Added, - proto::GitStatus::Modified => GitFileStatus::Modified, - proto::GitStatus::Conflict => GitFileStatus::Conflict, - proto::GitStatus::Deleted => GitFileStatus::Deleted, +fn status_from_proto( + simple_status: i32, + status: Option, +) -> anyhow::Result { + use proto::git_file_status::Variant; + + let Some(variant) = status.and_then(|status| status.variant) else { + let code = proto::GitStatus::from_i32(simple_status) + .ok_or_else(|| anyhow!("Invalid git status code: {simple_status}"))?; + let result = match code { + proto::GitStatus::Added => TrackedStatus { + worktree_status: StatusCode::Added, + index_status: StatusCode::Unmodified, + } + .into(), + proto::GitStatus::Modified => TrackedStatus { + worktree_status: StatusCode::Modified, + index_status: StatusCode::Unmodified, + } + .into(), + proto::GitStatus::Conflict => UnmergedStatus { + first_head: UnmergedStatusCode::Updated, + second_head: UnmergedStatusCode::Updated, + } + .into(), + proto::GitStatus::Deleted => TrackedStatus { + worktree_status: StatusCode::Deleted, + index_status: StatusCode::Unmodified, + } + .into(), + _ => return Err(anyhow!("Invalid code for simple status: {simple_status}")), + }; + return Ok(result); + }; + + let result = match variant { + Variant::Untracked(_) => FileStatus::Untracked, + Variant::Ignored(_) => FileStatus::Ignored, + Variant::Unmerged(unmerged) => { + let [first_head, second_head] = + [unmerged.first_head, unmerged.second_head].map(|head| { + let code = proto::GitStatus::from_i32(head) + .ok_or_else(|| anyhow!("Invalid git status code: {head}"))?; + let result = match code { + proto::GitStatus::Added => UnmergedStatusCode::Added, + proto::GitStatus::Updated => UnmergedStatusCode::Updated, + proto::GitStatus::Deleted => UnmergedStatusCode::Deleted, + _ => return Err(anyhow!("Invalid code for unmerged status: {code:?}")), + }; + Ok(result) + }); + let [first_head, second_head] = [first_head?, second_head?]; + UnmergedStatus { + first_head, + second_head, + } + .into() + } + Variant::Tracked(tracked) => { + let [index_status, worktree_status] = [tracked.index_status, tracked.worktree_status] + .map(|status| { + let code = proto::GitStatus::from_i32(status) + .ok_or_else(|| anyhow!("Invalid git status code: {status}"))?; + let result = match code { + proto::GitStatus::Modified => StatusCode::Modified, + proto::GitStatus::TypeChanged => StatusCode::TypeChanged, + proto::GitStatus::Added => StatusCode::Added, + proto::GitStatus::Deleted => StatusCode::Deleted, + proto::GitStatus::Renamed => StatusCode::Renamed, + proto::GitStatus::Copied => StatusCode::Copied, + proto::GitStatus::Unmodified => StatusCode::Unmodified, + _ => return Err(anyhow!("Invalid code for tracked status: {code:?}")), + }; + Ok(result) + }); + let [index_status, worktree_status] = [index_status?, worktree_status?]; + TrackedStatus { + index_status, + worktree_status, + } + .into() + } + }; + Ok(result) +} + +fn status_to_proto(status: FileStatus) -> proto::GitFileStatus { + use proto::git_file_status::{Tracked, Unmerged, Variant}; + + let variant = match status { + FileStatus::Untracked => Variant::Untracked(Default::default()), + FileStatus::Ignored => Variant::Ignored(Default::default()), + FileStatus::Unmerged(UnmergedStatus { + first_head, + second_head, + }) => Variant::Unmerged(Unmerged { + first_head: unmerged_status_to_proto(first_head), + second_head: unmerged_status_to_proto(second_head), + }), + FileStatus::Tracked(TrackedStatus { + index_status, + worktree_status, + }) => Variant::Tracked(Tracked { + index_status: tracked_status_to_proto(index_status), + worktree_status: tracked_status_to_proto(worktree_status), + }), }; - Some(GitStatusPair { - index_status: None, - worktree_status: Some(worktree_status), - }) -} - -fn status_pair_to_proto(status: GitStatusPair) -> i32 { - match status.combined() { - GitFileStatus::Added => proto::GitStatus::Added as i32, - GitFileStatus::Modified => proto::GitStatus::Modified as i32, - GitFileStatus::Conflict => proto::GitStatus::Conflict as i32, - GitFileStatus::Deleted => proto::GitStatus::Deleted as i32, - GitFileStatus::Untracked => proto::GitStatus::Added as i32, // TODO + proto::GitFileStatus { + variant: Some(variant), + } +} + +fn unmerged_status_to_proto(code: UnmergedStatusCode) -> i32 { + match code { + UnmergedStatusCode::Added => proto::GitStatus::Added as _, + UnmergedStatusCode::Deleted => proto::GitStatus::Deleted as _, + UnmergedStatusCode::Updated => proto::GitStatus::Updated as _, + } +} + +fn tracked_status_to_proto(code: StatusCode) -> i32 { + match code { + StatusCode::Added => proto::GitStatus::Added as _, + StatusCode::Deleted => proto::GitStatus::Deleted as _, + StatusCode::Modified => proto::GitStatus::Modified as _, + StatusCode::Renamed => proto::GitStatus::Renamed as _, + StatusCode::TypeChanged => proto::GitStatus::TypeChanged as _, + StatusCode::Copied => proto::GitStatus::Copied as _, + StatusCode::Unmodified => proto::GitStatus::Unmodified as _, } } diff --git a/crates/worktree/src/worktree_tests.rs b/crates/worktree/src/worktree_tests.rs index 5f8144347d93fc..87159548af5be4 100644 --- a/crates/worktree/src/worktree_tests.rs +++ b/crates/worktree/src/worktree_tests.rs @@ -4,7 +4,12 @@ use crate::{ }; use anyhow::Result; use fs::{FakeFs, Fs, RealFs, RemoveOptions}; -use git::{repository::GitFileStatus, GITIGNORE}; +use git::{ + status::{ + FileStatus, GitSummary, StatusCode, TrackedStatus, UnmergedStatus, UnmergedStatusCode, + }, + GITIGNORE, +}; use gpui::{BorrowAppContext, ModelContext, Task, TestAppContext}; use parking_lot::Mutex; use postage::stream::Stream; @@ -738,7 +743,10 @@ async fn test_rescan_with_gitignore(cx: &mut TestAppContext) { fs.set_status_for_repo_via_working_copy_change( Path::new("/root/tree/.git"), - &[(Path::new("tracked-dir/tracked-file2"), GitFileStatus::Added)], + &[( + Path::new("tracked-dir/tracked-file2"), + FileStatus::worktree(StatusCode::Added), + )], ); fs.create_file( @@ -766,7 +774,7 @@ async fn test_rescan_with_gitignore(cx: &mut TestAppContext) { assert_entry_git_state( tree, "tracked-dir/tracked-file2", - Some(GitFileStatus::Added), + Some(StatusCode::Added), false, ); assert_entry_git_state(tree, "tracked-dir/ancestor-ignored-file2", None, false); @@ -822,14 +830,14 @@ async fn test_update_gitignore(cx: &mut TestAppContext) { fs.set_status_for_repo_via_working_copy_change( Path::new("/root/.git"), - &[(Path::new("b.txt"), GitFileStatus::Added)], + &[(Path::new("b.txt"), FileStatus::worktree(StatusCode::Added))], ); cx.executor().run_until_parked(); cx.read(|cx| { let tree = tree.read(cx); assert_entry_git_state(tree, "a.xml", None, true); - assert_entry_git_state(tree, "b.txt", Some(GitFileStatus::Added), false); + assert_entry_git_state(tree, "b.txt", Some(StatusCode::Added), false); }); } @@ -1492,7 +1500,10 @@ async fn test_bump_mtime_of_git_repo_workdir(cx: &mut TestAppContext) { // detected. fs.set_status_for_repo_via_git_operation( Path::new("/root/.git"), - &[(Path::new("b/c.txt"), GitFileStatus::Modified)], + &[( + Path::new("b/c.txt"), + FileStatus::worktree(StatusCode::Modified), + )], ); cx.executor().run_until_parked(); @@ -1501,9 +1512,9 @@ async fn test_bump_mtime_of_git_repo_workdir(cx: &mut TestAppContext) { check_git_statuses( &snapshot, &[ - (Path::new(""), Some(GitFileStatus::Modified)), - (Path::new("a.txt"), None), - (Path::new("b/c.txt"), Some(GitFileStatus::Modified)), + (Path::new(""), GitSummary::MODIFIED), + (Path::new("a.txt"), GitSummary::UNCHANGED), + (Path::new("b/c.txt"), GitSummary::MODIFIED), ], ); } @@ -2142,6 +2153,11 @@ fn random_filename(rng: &mut impl Rng) -> String { .collect() } +const CONFLICT: FileStatus = FileStatus::Unmerged(UnmergedStatus { + first_head: UnmergedStatusCode::Updated, + second_head: UnmergedStatusCode::Updated, +}); + #[gpui::test] async fn test_rename_work_directory(cx: &mut TestAppContext) { init_test(cx); @@ -2183,11 +2199,11 @@ async fn test_rename_work_directory(cx: &mut TestAppContext) { assert_eq!(repo.path.as_ref(), Path::new("projects/project1")); assert_eq!( tree.status_for_file(Path::new("projects/project1/a")), - Some(GitFileStatus::Modified) + Some(FileStatus::worktree(StatusCode::Modified)), ); assert_eq!( tree.status_for_file(Path::new("projects/project1/b")), - Some(GitFileStatus::Untracked) + Some(FileStatus::Untracked), ); }); @@ -2204,11 +2220,11 @@ async fn test_rename_work_directory(cx: &mut TestAppContext) { assert_eq!(repo.path.as_ref(), Path::new("projects/project2")); assert_eq!( tree.status_for_file(Path::new("projects/project2/a")), - Some(GitFileStatus::Modified) + Some(FileStatus::worktree(StatusCode::Modified)), ); assert_eq!( tree.status_for_file(Path::new("projects/project2/b")), - Some(GitFileStatus::Untracked) + Some(FileStatus::Untracked), ); }); } @@ -2387,11 +2403,11 @@ async fn test_file_status(cx: &mut TestAppContext) { assert_eq!( snapshot.status_for_file(project_path.join(B_TXT)), - Some(GitFileStatus::Untracked) + Some(FileStatus::Untracked), ); assert_eq!( snapshot.status_for_file(project_path.join(F_TXT)), - Some(GitFileStatus::Untracked) + Some(FileStatus::Untracked), ); }); @@ -2405,7 +2421,7 @@ async fn test_file_status(cx: &mut TestAppContext) { let snapshot = tree.snapshot(); assert_eq!( snapshot.status_for_file(project_path.join(A_TXT)), - Some(GitFileStatus::Modified) + Some(FileStatus::worktree(StatusCode::Modified)), ); }); @@ -2421,7 +2437,7 @@ async fn test_file_status(cx: &mut TestAppContext) { let snapshot = tree.snapshot(); assert_eq!( snapshot.status_for_file(project_path.join(F_TXT)), - Some(GitFileStatus::Untracked) + Some(FileStatus::Untracked), ); assert_eq!(snapshot.status_for_file(project_path.join(B_TXT)), None); assert_eq!(snapshot.status_for_file(project_path.join(A_TXT)), None); @@ -2443,11 +2459,11 @@ async fn test_file_status(cx: &mut TestAppContext) { assert_eq!(snapshot.status_for_file(project_path.join(A_TXT)), None); assert_eq!( snapshot.status_for_file(project_path.join(B_TXT)), - Some(GitFileStatus::Untracked) + Some(FileStatus::Untracked), ); assert_eq!( snapshot.status_for_file(project_path.join(E_TXT)), - Some(GitFileStatus::Modified) + Some(FileStatus::worktree(StatusCode::Modified)), ); }); @@ -2482,7 +2498,7 @@ async fn test_file_status(cx: &mut TestAppContext) { let snapshot = tree.snapshot(); assert_eq!( snapshot.status_for_file(project_path.join(renamed_dir_name).join(RENAMED_FILE)), - Some(GitFileStatus::Untracked) + Some(FileStatus::Untracked), ); }); @@ -2506,7 +2522,7 @@ async fn test_file_status(cx: &mut TestAppContext) { .join(Path::new(renamed_dir_name)) .join(RENAMED_FILE) ), - Some(GitFileStatus::Untracked) + Some(FileStatus::Untracked), ); }); } @@ -2559,11 +2575,14 @@ async fn test_git_repository_status(cx: &mut TestAppContext) { assert_eq!(entries.len(), 3); assert_eq!(entries[0].repo_path.as_ref(), Path::new("a.txt")); - assert_eq!(entries[0].worktree_status(), Some(GitFileStatus::Modified)); + assert_eq!( + entries[0].status, + FileStatus::worktree(StatusCode::Modified) + ); assert_eq!(entries[1].repo_path.as_ref(), Path::new("b.txt")); - assert_eq!(entries[1].worktree_status(), Some(GitFileStatus::Untracked)); + assert_eq!(entries[1].status, FileStatus::Untracked); assert_eq!(entries[2].repo_path.as_ref(), Path::new("d.txt")); - assert_eq!(entries[2].worktree_status(), Some(GitFileStatus::Deleted)); + assert_eq!(entries[2].status, FileStatus::worktree(StatusCode::Deleted)); }); std::fs::write(work_dir.join("c.txt"), "some changes").unwrap(); @@ -2581,14 +2600,20 @@ async fn test_git_repository_status(cx: &mut TestAppContext) { std::assert_eq!(entries.len(), 4, "entries: {entries:?}"); assert_eq!(entries[0].repo_path.as_ref(), Path::new("a.txt")); - assert_eq!(entries[0].worktree_status(), Some(GitFileStatus::Modified)); + assert_eq!( + entries[0].status, + FileStatus::worktree(StatusCode::Modified) + ); assert_eq!(entries[1].repo_path.as_ref(), Path::new("b.txt")); - assert_eq!(entries[1].worktree_status(), Some(GitFileStatus::Untracked)); + assert_eq!(entries[1].status, FileStatus::Untracked); // Status updated assert_eq!(entries[2].repo_path.as_ref(), Path::new("c.txt")); - assert_eq!(entries[2].worktree_status(), Some(GitFileStatus::Modified)); + assert_eq!( + entries[2].status, + FileStatus::worktree(StatusCode::Modified) + ); assert_eq!(entries[3].repo_path.as_ref(), Path::new("d.txt")); - assert_eq!(entries[3].worktree_status(), Some(GitFileStatus::Deleted)); + assert_eq!(entries[3].status, FileStatus::worktree(StatusCode::Deleted)); }); git_add("a.txt", &repo); @@ -2621,7 +2646,7 @@ async fn test_git_repository_status(cx: &mut TestAppContext) { &entries ); assert_eq!(entries[0].repo_path.as_ref(), Path::new("a.txt")); - assert_eq!(entries[0].worktree_status(), Some(GitFileStatus::Deleted)); + assert_eq!(entries[0].status, FileStatus::worktree(StatusCode::Deleted)); }); } @@ -2692,7 +2717,7 @@ async fn test_repository_subfolder_git_status(cx: &mut TestAppContext) { assert_eq!(snapshot.status_for_file("c.txt"), None); assert_eq!( snapshot.status_for_file("d/e.txt"), - Some(GitFileStatus::Untracked) + Some(FileStatus::Untracked) ); }); @@ -2744,17 +2769,20 @@ async fn test_traverse_with_git_status(cx: &mut TestAppContext) { fs.set_status_for_repo_via_git_operation( Path::new("/root/x/.git"), &[ - (Path::new("x2.txt"), GitFileStatus::Modified), - (Path::new("z.txt"), GitFileStatus::Added), + ( + Path::new("x2.txt"), + FileStatus::worktree(StatusCode::Modified), + ), + (Path::new("z.txt"), FileStatus::worktree(StatusCode::Added)), ], ); fs.set_status_for_repo_via_git_operation( Path::new("/root/x/y/.git"), - &[(Path::new("y1.txt"), GitFileStatus::Conflict)], + &[(Path::new("y1.txt"), CONFLICT)], ); fs.set_status_for_repo_via_git_operation( Path::new("/root/z/.git"), - &[(Path::new("z2.txt"), GitFileStatus::Added)], + &[(Path::new("z2.txt"), FileStatus::worktree(StatusCode::Added))], ); let tree = Worktree::local( @@ -2780,25 +2808,25 @@ async fn test_traverse_with_git_status(cx: &mut TestAppContext) { let entry = traversal.next().unwrap(); assert_eq!(entry.path.as_ref(), Path::new("x/x1.txt")); - assert_eq!(entry.git_status, None); + assert_eq!(entry.git_summary, GitSummary::UNCHANGED); let entry = traversal.next().unwrap(); assert_eq!(entry.path.as_ref(), Path::new("x/x2.txt")); - assert_eq!(entry.git_status, Some(GitFileStatus::Modified)); + assert_eq!(entry.git_summary, GitSummary::MODIFIED); let entry = traversal.next().unwrap(); assert_eq!(entry.path.as_ref(), Path::new("x/y/y1.txt")); - assert_eq!(entry.git_status, Some(GitFileStatus::Conflict)); + assert_eq!(entry.git_summary, GitSummary::CONFLICT); let entry = traversal.next().unwrap(); assert_eq!(entry.path.as_ref(), Path::new("x/y/y2.txt")); - assert_eq!(entry.git_status, None); + assert_eq!(entry.git_summary, GitSummary::UNCHANGED); let entry = traversal.next().unwrap(); assert_eq!(entry.path.as_ref(), Path::new("x/z.txt")); - assert_eq!(entry.git_status, Some(GitFileStatus::Added)); + assert_eq!(entry.git_summary, GitSummary::ADDED); let entry = traversal.next().unwrap(); assert_eq!(entry.path.as_ref(), Path::new("z/z1.txt")); - assert_eq!(entry.git_status, None); + assert_eq!(entry.git_summary, GitSummary::UNCHANGED); let entry = traversal.next().unwrap(); assert_eq!(entry.path.as_ref(), Path::new("z/z2.txt")); - assert_eq!(entry.git_status, Some(GitFileStatus::Added)); + assert_eq!(entry.git_summary, GitSummary::ADDED); } #[gpui::test] @@ -2834,9 +2862,15 @@ async fn test_propagate_git_statuses(cx: &mut TestAppContext) { fs.set_status_for_repo_via_git_operation( Path::new("/root/.git"), &[ - (Path::new("a/b/c1.txt"), GitFileStatus::Added), - (Path::new("a/d/e2.txt"), GitFileStatus::Modified), - (Path::new("g/h2.txt"), GitFileStatus::Conflict), + ( + Path::new("a/b/c1.txt"), + FileStatus::worktree(StatusCode::Added), + ), + ( + Path::new("a/d/e2.txt"), + FileStatus::worktree(StatusCode::Modified), + ), + (Path::new("g/h2.txt"), CONFLICT), ], ); @@ -2859,52 +2893,58 @@ async fn test_propagate_git_statuses(cx: &mut TestAppContext) { check_git_statuses( &snapshot, &[ - (Path::new(""), Some(GitFileStatus::Conflict)), - (Path::new("g"), Some(GitFileStatus::Conflict)), - (Path::new("g/h2.txt"), Some(GitFileStatus::Conflict)), + ( + Path::new(""), + GitSummary::CONFLICT + GitSummary::MODIFIED + GitSummary::ADDED, + ), + (Path::new("g"), GitSummary::CONFLICT), + (Path::new("g/h2.txt"), GitSummary::CONFLICT), ], ); check_git_statuses( &snapshot, &[ - (Path::new(""), Some(GitFileStatus::Conflict)), - (Path::new("a"), Some(GitFileStatus::Modified)), - (Path::new("a/b"), Some(GitFileStatus::Added)), - (Path::new("a/b/c1.txt"), Some(GitFileStatus::Added)), - (Path::new("a/b/c2.txt"), None), - (Path::new("a/d"), Some(GitFileStatus::Modified)), - (Path::new("a/d/e2.txt"), Some(GitFileStatus::Modified)), - (Path::new("f"), None), - (Path::new("f/no-status.txt"), None), - (Path::new("g"), Some(GitFileStatus::Conflict)), - (Path::new("g/h2.txt"), Some(GitFileStatus::Conflict)), + ( + Path::new(""), + GitSummary::CONFLICT + GitSummary::ADDED + GitSummary::MODIFIED, + ), + (Path::new("a"), GitSummary::ADDED + GitSummary::MODIFIED), + (Path::new("a/b"), GitSummary::ADDED), + (Path::new("a/b/c1.txt"), GitSummary::ADDED), + (Path::new("a/b/c2.txt"), GitSummary::UNCHANGED), + (Path::new("a/d"), GitSummary::MODIFIED), + (Path::new("a/d/e2.txt"), GitSummary::MODIFIED), + (Path::new("f"), GitSummary::UNCHANGED), + (Path::new("f/no-status.txt"), GitSummary::UNCHANGED), + (Path::new("g"), GitSummary::CONFLICT), + (Path::new("g/h2.txt"), GitSummary::CONFLICT), ], ); check_git_statuses( &snapshot, &[ - (Path::new("a/b"), Some(GitFileStatus::Added)), - (Path::new("a/b/c1.txt"), Some(GitFileStatus::Added)), - (Path::new("a/b/c2.txt"), None), - (Path::new("a/d"), Some(GitFileStatus::Modified)), - (Path::new("a/d/e1.txt"), None), - (Path::new("a/d/e2.txt"), Some(GitFileStatus::Modified)), - (Path::new("f"), None), - (Path::new("f/no-status.txt"), None), - (Path::new("g"), Some(GitFileStatus::Conflict)), + (Path::new("a/b"), GitSummary::ADDED), + (Path::new("a/b/c1.txt"), GitSummary::ADDED), + (Path::new("a/b/c2.txt"), GitSummary::UNCHANGED), + (Path::new("a/d"), GitSummary::MODIFIED), + (Path::new("a/d/e1.txt"), GitSummary::UNCHANGED), + (Path::new("a/d/e2.txt"), GitSummary::MODIFIED), + (Path::new("f"), GitSummary::UNCHANGED), + (Path::new("f/no-status.txt"), GitSummary::UNCHANGED), + (Path::new("g"), GitSummary::CONFLICT), ], ); check_git_statuses( &snapshot, &[ - (Path::new("a/b/c1.txt"), Some(GitFileStatus::Added)), - (Path::new("a/b/c2.txt"), None), - (Path::new("a/d/e1.txt"), None), - (Path::new("a/d/e2.txt"), Some(GitFileStatus::Modified)), - (Path::new("f/no-status.txt"), None), + (Path::new("a/b/c1.txt"), GitSummary::ADDED), + (Path::new("a/b/c2.txt"), GitSummary::UNCHANGED), + (Path::new("a/d/e1.txt"), GitSummary::UNCHANGED), + (Path::new("a/d/e2.txt"), GitSummary::MODIFIED), + (Path::new("f/no-status.txt"), GitSummary::UNCHANGED), ], ); } @@ -2937,18 +2977,24 @@ async fn test_propagate_statuses_for_repos_under_project(cx: &mut TestAppContext fs.set_status_for_repo_via_git_operation( Path::new("/root/x/.git"), - &[(Path::new("x1.txt"), GitFileStatus::Added)], + &[(Path::new("x1.txt"), FileStatus::worktree(StatusCode::Added))], ); fs.set_status_for_repo_via_git_operation( Path::new("/root/y/.git"), &[ - (Path::new("y1.txt"), GitFileStatus::Conflict), - (Path::new("y2.txt"), GitFileStatus::Modified), + (Path::new("y1.txt"), CONFLICT), + ( + Path::new("y2.txt"), + FileStatus::worktree(StatusCode::Modified), + ), ], ); fs.set_status_for_repo_via_git_operation( Path::new("/root/z/.git"), - &[(Path::new("z2.txt"), GitFileStatus::Modified)], + &[( + Path::new("z2.txt"), + FileStatus::worktree(StatusCode::Modified), + )], ); let tree = Worktree::local( @@ -2971,48 +3017,48 @@ async fn test_propagate_statuses_for_repos_under_project(cx: &mut TestAppContext check_git_statuses( &snapshot, &[ - (Path::new("x"), Some(GitFileStatus::Added)), - (Path::new("x/x1.txt"), Some(GitFileStatus::Added)), + (Path::new("x"), GitSummary::ADDED), + (Path::new("x/x1.txt"), GitSummary::ADDED), ], ); check_git_statuses( &snapshot, &[ - (Path::new("y"), Some(GitFileStatus::Conflict)), - (Path::new("y/y1.txt"), Some(GitFileStatus::Conflict)), - (Path::new("y/y2.txt"), Some(GitFileStatus::Modified)), + (Path::new("y"), GitSummary::CONFLICT + GitSummary::MODIFIED), + (Path::new("y/y1.txt"), GitSummary::CONFLICT), + (Path::new("y/y2.txt"), GitSummary::MODIFIED), ], ); check_git_statuses( &snapshot, &[ - (Path::new("z"), Some(GitFileStatus::Modified)), - (Path::new("z/z2.txt"), Some(GitFileStatus::Modified)), + (Path::new("z"), GitSummary::MODIFIED), + (Path::new("z/z2.txt"), GitSummary::MODIFIED), ], ); check_git_statuses( &snapshot, &[ - (Path::new("x"), Some(GitFileStatus::Added)), - (Path::new("x/x1.txt"), Some(GitFileStatus::Added)), + (Path::new("x"), GitSummary::ADDED), + (Path::new("x/x1.txt"), GitSummary::ADDED), ], ); check_git_statuses( &snapshot, &[ - (Path::new("x"), Some(GitFileStatus::Added)), - (Path::new("x/x1.txt"), Some(GitFileStatus::Added)), - (Path::new("x/x2.txt"), None), - (Path::new("y"), Some(GitFileStatus::Conflict)), - (Path::new("y/y1.txt"), Some(GitFileStatus::Conflict)), - (Path::new("y/y2.txt"), Some(GitFileStatus::Modified)), - (Path::new("z"), Some(GitFileStatus::Modified)), - (Path::new("z/z1.txt"), None), - (Path::new("z/z2.txt"), Some(GitFileStatus::Modified)), + (Path::new("x"), GitSummary::ADDED), + (Path::new("x/x1.txt"), GitSummary::ADDED), + (Path::new("x/x2.txt"), GitSummary::UNCHANGED), + (Path::new("y"), GitSummary::CONFLICT + GitSummary::MODIFIED), + (Path::new("y/y1.txt"), GitSummary::CONFLICT), + (Path::new("y/y2.txt"), GitSummary::MODIFIED), + (Path::new("z"), GitSummary::MODIFIED), + (Path::new("z/z1.txt"), GitSummary::UNCHANGED), + (Path::new("z/z2.txt"), GitSummary::MODIFIED), ], ); } @@ -3047,18 +3093,21 @@ async fn test_propagate_statuses_for_nested_repos(cx: &mut TestAppContext) { fs.set_status_for_repo_via_git_operation( Path::new("/root/x/.git"), &[ - (Path::new("x2.txt"), GitFileStatus::Modified), - (Path::new("z.txt"), GitFileStatus::Added), + ( + Path::new("x2.txt"), + FileStatus::worktree(StatusCode::Modified), + ), + (Path::new("z.txt"), FileStatus::worktree(StatusCode::Added)), ], ); fs.set_status_for_repo_via_git_operation( Path::new("/root/x/y/.git"), - &[(Path::new("y1.txt"), GitFileStatus::Conflict)], + &[(Path::new("y1.txt"), CONFLICT)], ); fs.set_status_for_repo_via_git_operation( Path::new("/root/z/.git"), - &[(Path::new("z2.txt"), GitFileStatus::Added)], + &[(Path::new("z2.txt"), FileStatus::worktree(StatusCode::Added))], ); let tree = Worktree::local( @@ -3082,17 +3131,17 @@ async fn test_propagate_statuses_for_nested_repos(cx: &mut TestAppContext) { check_git_statuses( &snapshot, &[ - (Path::new("x/y"), Some(GitFileStatus::Conflict)), // the y git repository has conflict file in it, and so should have a conflict status - (Path::new("x/y/y1.txt"), Some(GitFileStatus::Conflict)), - (Path::new("x/y/y2.txt"), None), + (Path::new("x/y"), GitSummary::CONFLICT), + (Path::new("x/y/y1.txt"), GitSummary::CONFLICT), + (Path::new("x/y/y2.txt"), GitSummary::UNCHANGED), ], ); check_git_statuses( &snapshot, &[ - (Path::new("z"), Some(GitFileStatus::Added)), - (Path::new("z/z1.txt"), None), - (Path::new("z/z2.txt"), Some(GitFileStatus::Added)), + (Path::new("z"), GitSummary::ADDED), + (Path::new("z/z1.txt"), GitSummary::UNCHANGED), + (Path::new("z/z2.txt"), GitSummary::ADDED), ], ); @@ -3100,9 +3149,9 @@ async fn test_propagate_statuses_for_nested_repos(cx: &mut TestAppContext) { check_git_statuses( &snapshot, &[ - (Path::new("x"), Some(GitFileStatus::Modified)), - (Path::new("x/y"), Some(GitFileStatus::Conflict)), - (Path::new("x/y/y1.txt"), Some(GitFileStatus::Conflict)), + (Path::new("x"), GitSummary::MODIFIED + GitSummary::ADDED), + (Path::new("x/y"), GitSummary::CONFLICT), + (Path::new("x/y/y1.txt"), GitSummary::CONFLICT), ], ); @@ -3110,13 +3159,13 @@ async fn test_propagate_statuses_for_nested_repos(cx: &mut TestAppContext) { check_git_statuses( &snapshot, &[ - (Path::new("x"), Some(GitFileStatus::Modified)), - (Path::new("x/x1.txt"), None), - (Path::new("x/x2.txt"), Some(GitFileStatus::Modified)), - (Path::new("x/y"), Some(GitFileStatus::Conflict)), - (Path::new("x/y/y1.txt"), Some(GitFileStatus::Conflict)), - (Path::new("x/y/y2.txt"), None), - (Path::new("x/z.txt"), Some(GitFileStatus::Added)), + (Path::new("x"), GitSummary::MODIFIED + GitSummary::ADDED), + (Path::new("x/x1.txt"), GitSummary::UNCHANGED), + (Path::new("x/x2.txt"), GitSummary::MODIFIED), + (Path::new("x/y"), GitSummary::CONFLICT), + (Path::new("x/y/y1.txt"), GitSummary::CONFLICT), + (Path::new("x/y/y2.txt"), GitSummary::UNCHANGED), + (Path::new("x/z.txt"), GitSummary::ADDED), ], ); @@ -3124,9 +3173,9 @@ async fn test_propagate_statuses_for_nested_repos(cx: &mut TestAppContext) { check_git_statuses( &snapshot, &[ - (Path::new(""), None), - (Path::new("x"), Some(GitFileStatus::Modified)), - (Path::new("x/x1.txt"), None), + (Path::new(""), GitSummary::UNCHANGED), + (Path::new("x"), GitSummary::MODIFIED + GitSummary::ADDED), + (Path::new("x/x1.txt"), GitSummary::UNCHANGED), ], ); @@ -3134,17 +3183,17 @@ async fn test_propagate_statuses_for_nested_repos(cx: &mut TestAppContext) { check_git_statuses( &snapshot, &[ - (Path::new(""), None), - (Path::new("x"), Some(GitFileStatus::Modified)), - (Path::new("x/x1.txt"), None), - (Path::new("x/x2.txt"), Some(GitFileStatus::Modified)), - (Path::new("x/y"), Some(GitFileStatus::Conflict)), - (Path::new("x/y/y1.txt"), Some(GitFileStatus::Conflict)), - (Path::new("x/y/y2.txt"), None), - (Path::new("x/z.txt"), Some(GitFileStatus::Added)), - (Path::new("z"), Some(GitFileStatus::Added)), - (Path::new("z/z1.txt"), None), - (Path::new("z/z2.txt"), Some(GitFileStatus::Added)), + (Path::new(""), GitSummary::UNCHANGED), + (Path::new("x"), GitSummary::MODIFIED + GitSummary::ADDED), + (Path::new("x/x1.txt"), GitSummary::UNCHANGED), + (Path::new("x/x2.txt"), GitSummary::MODIFIED), + (Path::new("x/y"), GitSummary::CONFLICT), + (Path::new("x/y/y1.txt"), GitSummary::CONFLICT), + (Path::new("x/y/y2.txt"), GitSummary::UNCHANGED), + (Path::new("x/z.txt"), GitSummary::ADDED), + (Path::new("z"), GitSummary::ADDED), + (Path::new("z/z1.txt"), GitSummary::UNCHANGED), + (Path::new("z/z2.txt"), GitSummary::ADDED), ], ); } @@ -3173,7 +3222,7 @@ async fn test_private_single_file_worktree(cx: &mut TestAppContext) { } #[track_caller] -fn check_git_statuses(snapshot: &Snapshot, expected_statuses: &[(&Path, Option)]) { +fn check_git_statuses(snapshot: &Snapshot, expected_statuses: &[(&Path, GitSummary)]) { let mut traversal = snapshot .traverse_from_path(true, true, false, "".as_ref()) .with_git_statuses(); @@ -3182,8 +3231,8 @@ fn check_git_statuses(snapshot: &Snapshot, expected_statuses: &[(&Path, Option>(); assert_eq!(found_statuses, expected_statuses); @@ -3330,14 +3379,21 @@ fn init_test(cx: &mut gpui::TestAppContext) { fn assert_entry_git_state( tree: &Worktree, path: &str, - git_status: Option, + worktree_status: Option, is_ignored: bool, ) { let entry = tree.entry_for_path(path).expect("entry {path} not found"); + let status = tree.status_for_file(Path::new(path)); + let expected = worktree_status.map(|worktree_status| { + TrackedStatus { + worktree_status, + index_status: StatusCode::Unmodified, + } + .into() + }); assert_eq!( - tree.status_for_file(Path::new(path)), - git_status, - "expected {path} to have git status: {git_status:?}" + status, expected, + "expected {path} to have git status: {expected:?}" ); assert_eq!( entry.is_ignored, is_ignored, diff --git a/script/zed-local b/script/zed-local index 9ec9b24af7509d..2d14a45ba5f48a 100755 --- a/script/zed-local +++ b/script/zed-local @@ -21,6 +21,7 @@ OPTIONS -2, -3, -4, ... Spawn multiple Zed instances, with their windows tiled. --top Arrange the Zed windows so they take up the top half of the screen. --stable Use stable Zed release installed on local machine for all instances (except for the first one). + --preview Like --stable, but uses the locally-installed preview release instead. `.trim(); const { spawn, execSync, execFileSync } = require("child_process"); @@ -48,6 +49,7 @@ let instanceCount = 1; let isReleaseMode = false; let isTop = false; let othersOnStable = false; +let othersOnPreview = false; let isStateful = false; const args = process.argv.slice(2); @@ -68,6 +70,8 @@ while (args.length > 0) { process.exit(0); } else if (arg === "--stable") { othersOnStable = true; + } else if (arg === "--preview") { + othersOnPreview = true; } else { break; } @@ -172,6 +176,8 @@ setTimeout(() => { let binaryPath = zedBinary; if (i != 0 && othersOnStable) { binaryPath = "/Applications/Zed.app/Contents/MacOS/zed"; + } else if (i != 0 && othersOnPreview) { + binaryPath = "/Applications/Zed Preview.app/Contents/MacOS/zed"; } spawn(binaryPath, i == 0 ? args : [], { stdio: "inherit",