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 <mikayla@zed.dev>
Co-authored-by: Conrad <conrad@zed.dev>
This commit is contained in:
Cole Miller 2025-01-15 19:01:38 -05:00 committed by GitHub
parent 224f3d4746
commit a41d72ee81
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 1015 additions and 552 deletions

View file

@ -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<Path>) -> Option<GitFileStatus> {
pub fn status_for_file(&self, path: impl AsRef<Path>) -> Option<FileStatus> {
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<Path>, 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<GitFileStatus> {
self.status.index_status
}
pub fn worktree_status(&self) -> Option<GitFileStatus> {
self.status.worktree_status
}
pub fn is_staged(&self) -> Option<bool> {
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<proto::StatusEntry> for StatusEntry {
type Error = anyhow::Error;
fn try_from(value: proto::StatusEntry) -> Result<Self, Self::Error> {
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<GitStatuses>;
type Summary = PathSummary<GitSummary>;
fn summary(&self, _: &<Self::Summary as Summary>::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<GitFileStatus> {
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<Self> 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<GitStatuses>> for GitStatuses {
impl<'a> sum_tree::Dimension<'a, PathSummary<GitSummary>> for GitSummary {
fn zero(_cx: &()) -> Self {
Default::default()
}
fn add_summary(&mut self, summary: &'a PathSummary<GitStatuses>, _: &()) {
fn add_summary(&mut self, summary: &'a PathSummary<GitSummary>, _: &()) {
*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<GitFileStatus>,
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<Entry> for GitEntryRef<'a> {
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GitEntry {
pub entry: Entry,
pub git_status: Option<GitFileStatus>,
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<Entry> for GitEntry {
/// Walks the worktree entries and their associated git statuses.
pub struct GitTraversal<'a> {
traversal: Traversal<'a>,
current_entry_status: Option<GitFileStatus>,
current_entry_summary: Option<GitSummary>,
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<GitEntryRef<'a>> {
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<S>, TraversalProgress<'a>> f
}
}
impl<'a, 'b> SeekTarget<'a, PathSummary<GitStatuses>, (TraversalProgress<'a>, GitStatuses)>
impl<'a, 'b> SeekTarget<'a, PathSummary<GitSummary>, (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<GitStatusPair> {
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<proto::GitFileStatus>,
) -> anyhow::Result<FileStatus> {
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);
};
Some(GitStatusPair {
index_status: None,
worktree_status: Some(worktree_status),
})
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_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
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),
}),
};
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 _,
}
}