Files
oak-gpui/crates/git/src/status.rs
T
6924720b35 Move repository state RPC handlers to the GitStore (#27391)
This is another in the series of PRs to make the GitStore own all
repository state and enable better concurrency control for git
repository scans.

After this PR, the `RepositoryEntry`s stored in worktree snapshots are
used only as a staging ground for local GitStores to pull from after
git-related events; non-local worktrees don't store them at all,
although this is not reflected in the types. GitTraversal and other
places that need information about repositories get it from the
GitStore. The GitStore also takes over handling of the new
UpdateRepository and RemoveRepository messages. However, repositories
are still discovered and scanned on a per-worktree basis, and we're
still identifying them by the (worktree-specific) project entry ID of
their working directory.

- [x] Remove WorkDirectory from RepositoryEntry
- [x] Remove worktree IDs from repository-related RPC messages
- [x] Handle UpdateRepository and RemoveRepository RPCs from the
GitStore

Release Notes:

- N/A

---------

Co-authored-by: Max <max@zed.dev>
Co-authored-by: Max Brunsfeld <maxbrunsfeld@gmail.com>
2025-03-26 18:23:44 -04:00

539 lines
15 KiB
Rust

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, 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<UnmergedStatus> for FileStatus {
fn from(value: UnmergedStatus) -> Self {
FileStatus::Unmerged(value)
}
}
impl From<TrackedStatus> for FileStatus {
fn from(value: TrackedStatus) -> Self {
FileStatus::Tracked(value)
}
}
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
pub enum StageStatus {
Staged,
Unstaged,
PartiallyStaged,
}
impl StageStatus {
pub fn is_fully_staged(&self) -> bool {
matches!(self, StageStatus::Staged)
}
pub fn is_fully_unstaged(&self) -> bool {
matches!(self, StageStatus::Unstaged)
}
pub fn has_staged(&self) -> bool {
matches!(self, StageStatus::Staged | StageStatus::PartiallyStaged)
}
pub fn has_unstaged(&self) -> bool {
matches!(self, StageStatus::Unstaged | StageStatus::PartiallyStaged)
}
pub fn as_bool(self) -> Option<bool> {
match self {
StageStatus::Staged => Some(true),
StageStatus::Unstaged => Some(false),
StageStatus::PartiallyStaged => None,
}
}
}
impl FileStatus {
pub const fn worktree(worktree_status: StatusCode) -> Self {
FileStatus::Tracked(TrackedStatus {
index_status: StatusCode::Unmodified,
worktree_status,
})
}
pub const fn index(index_status: StatusCode) -> Self {
FileStatus::Tracked(TrackedStatus {
worktree_status: StatusCode::Unmodified,
index_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<Self> {
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 staging(self) -> StageStatus {
match self {
FileStatus::Untracked | FileStatus::Ignored | FileStatus::Unmerged { .. } => {
StageStatus::Unstaged
}
FileStatus::Tracked(tracked) => match (tracked.index_status, tracked.worktree_status) {
(StatusCode::Unmodified, _) => StageStatus::Unstaged,
(_, StatusCode::Unmodified) => StageStatus::Staged,
_ => StageStatus::PartiallyStaged,
},
}
}
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 has_changes(&self) -> bool {
self.is_modified()
|| self.is_created()
|| self.is_deleted()
|| self.is_untracked()
|| self.is_conflicted()
}
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,
},
FileStatus::Untracked => true,
_ => 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,
}) => GitSummary {
index: index_status.to_summary(),
worktree: worktree_status.to_summary(),
conflict: 0,
untracked: 0,
count: 1,
},
}
}
}
impl StatusCode {
fn from_byte(byte: u8) -> anyhow::Result<Self> {
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 to_summary(self) -> TrackedSummary {
match self {
StatusCode::Modified | StatusCode::TypeChanged => TrackedSummary {
modified: 1,
..TrackedSummary::UNCHANGED
},
StatusCode::Added => TrackedSummary {
added: 1,
..TrackedSummary::UNCHANGED
},
StatusCode::Deleted => TrackedSummary {
deleted: 1,
..TrackedSummary::UNCHANGED
},
StatusCode::Renamed | StatusCode::Copied | StatusCode::Unmodified => {
TrackedSummary::UNCHANGED
}
}
}
pub fn index(self) -> FileStatus {
FileStatus::Tracked(TrackedStatus {
index_status: self,
worktree_status: StatusCode::Unmodified,
})
}
pub fn worktree(self) -> FileStatus {
FileStatus::Tracked(TrackedStatus {
index_status: StatusCode::Unmodified,
worktree_status: self,
})
}
}
impl UnmergedStatusCode {
fn from_byte(byte: u8) -> anyhow::Result<Self> {
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 TrackedSummary {
pub added: usize,
pub modified: usize,
pub deleted: usize,
}
impl TrackedSummary {
pub const UNCHANGED: Self = Self {
added: 0,
modified: 0,
deleted: 0,
};
pub const ADDED: Self = Self {
added: 1,
modified: 0,
deleted: 0,
};
pub const MODIFIED: Self = Self {
added: 0,
modified: 1,
deleted: 0,
};
pub const DELETED: Self = Self {
added: 0,
modified: 0,
deleted: 1,
};
}
impl std::ops::AddAssign for TrackedSummary {
fn add_assign(&mut self, rhs: Self) {
self.added += rhs.added;
self.modified += rhs.modified;
self.deleted += rhs.deleted;
}
}
impl std::ops::Add for TrackedSummary {
type Output = Self;
fn add(self, rhs: Self) -> Self::Output {
TrackedSummary {
added: self.added + rhs.added,
modified: self.modified + rhs.modified,
deleted: self.deleted + rhs.deleted,
}
}
}
impl std::ops::Sub for TrackedSummary {
type Output = Self;
fn sub(self, rhs: Self) -> Self::Output {
TrackedSummary {
added: self.added - rhs.added,
modified: self.modified - rhs.modified,
deleted: self.deleted - rhs.deleted,
}
}
}
#[derive(Clone, Debug, Default, Copy, PartialEq, Eq)]
pub struct GitSummary {
pub index: TrackedSummary,
pub worktree: TrackedSummary,
pub conflict: usize,
pub untracked: usize,
pub count: usize,
}
impl GitSummary {
pub const CONFLICT: Self = Self {
conflict: 1,
count: 1,
..Self::UNCHANGED
};
pub const UNTRACKED: Self = Self {
untracked: 1,
count: 1,
..Self::UNCHANGED
};
pub const UNCHANGED: Self = Self {
index: TrackedSummary::UNCHANGED,
worktree: TrackedSummary::UNCHANGED,
conflict: 0,
untracked: 0,
count: 0,
};
}
impl From<FileStatus> 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<Self> 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.index += rhs.index;
self.worktree += rhs.worktree;
self.conflict += rhs.conflict;
self.untracked += rhs.untracked;
self.count += rhs.count;
}
}
impl std::ops::Sub for GitSummary {
type Output = GitSummary;
fn sub(self, rhs: Self) -> Self::Output {
GitSummary {
index: self.index - rhs.index,
worktree: self.worktree - rhs.worktree,
conflict: self.conflict - rhs.conflict,
untracked: self.untracked - rhs.untracked,
count: self.count - rhs.count,
}
}
}
#[derive(Clone, Debug)]
pub struct GitStatus {
pub entries: Arc<[(RepoPath, FileStatus)]>,
}
impl GitStatus {
pub(crate) fn new(
git_binary: &Path,
working_directory: &Path,
path_prefixes: &[RepoPath],
) -> Result<Self> {
let child = util::command::new_std_command(git_binary)
.current_dir(working_directory)
.args([
"--no-optional-locks",
"status",
"--porcelain=v1",
"--untracked-files=all",
"--no-renames",
"-z",
])
.args(path_prefixes.iter().map(|path_prefix| {
if path_prefix.0.as_ref() == Path::new("") {
Path::new(".")
} else {
path_prefix
}
}))
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| anyhow!("Failed to start git status process: {e}"))?;
let output = child
.wait_with_output()
.map_err(|e| anyhow!("Failed to read git status output: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(anyhow!("git status process failed: {stderr}"));
}
let stdout = String::from_utf8_lossy(&output.stdout);
let mut entries = stdout
.split('\0')
.filter_map(|entry| {
let sep = entry.get(2..3)?;
if sep != " " {
return None;
};
let path = &entry[3..];
// The git status output includes untracked directories as well as untracked files.
// We do our own processing to compute the "summary" status of each directory,
// so just skip any directories in the output, since they'll otherwise interfere
// with our handling of nested repositories.
if path.ends_with('/') {
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, status))
})
.collect::<Vec<_>>();
entries.sort_unstable_by(|(a, _), (b, _)| a.cmp(&b));
// When a file exists in HEAD, is deleted in the index, and exists again in the working copy,
// git produces two lines for it, one reading `D ` (deleted in index, unmodified in working copy)
// and the other reading `??` (untracked). Merge these two into the equivalent of `DA`.
entries.dedup_by(|(a, a_status), (b, b_status)| {
const INDEX_DELETED: FileStatus = FileStatus::index(StatusCode::Deleted);
if a.ne(&b) {
return false;
}
match (*a_status, *b_status) {
(INDEX_DELETED, FileStatus::Untracked) | (FileStatus::Untracked, INDEX_DELETED) => {
*b_status = TrackedStatus {
index_status: StatusCode::Deleted,
worktree_status: StatusCode::Added,
}
.into();
}
_ => panic!("Unexpected duplicated status entries: {a_status:?} and {b_status:?}"),
}
true
});
Ok(Self {
entries: entries.into(),
})
}
}
impl Default for GitStatus {
fn default() -> Self {
Self {
entries: Arc::new([]),
}
}
}