Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Compute git statuses using the bundled git executable, not libgit2 #12444

Merged
merged 2 commits into from
May 29, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 4 additions & 5 deletions crates/git/src/blame.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,11 @@ use std::process::{Command, Stdio};
use std::sync::Arc;
use std::{ops::Range, path::Path};
use text::Rope;
use time;
use time::macros::format_description;
use time::OffsetDateTime;
use time::UtcOffset;
use url::Url;

#[cfg(windows)]
use std::os::windows::process::CommandExt;

pub use git2 as libgit;

#[derive(Debug, Clone, Default)]
Expand Down Expand Up @@ -98,7 +94,10 @@ fn run_git_blame(
.stderr(Stdio::piped());

#[cfg(windows)]
child.creation_flags(windows::Win32::System::Threading::CREATE_NO_WINDOW.0);
{
use std::os::windows::process::CommandExt;
child.creation_flags(windows::Win32::System::Threading::CREATE_NO_WINDOW.0);
}

let child = child
.spawn()
Expand Down
1 change: 1 addition & 0 deletions crates/git/src/git.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ pub mod blame;
pub mod commit;
pub mod diff;
pub mod repository;
pub mod status;

lazy_static! {
pub static ref DOT_GIT: &'static OsStr = OsStr::new(".git");
Expand Down
160 changes: 31 additions & 129 deletions crates/git/src/repository.rs
Original file line number Diff line number Diff line change
@@ -1,19 +1,18 @@
use crate::blame::Blame;
use crate::GitHostingProviderRegistry;
use crate::{blame::Blame, status::GitStatus};
use anyhow::{Context, Result};
use collections::HashMap;
use git2::{BranchType, StatusShow};
use git2::BranchType;
use parking_lot::Mutex;
use rope::Rope;
use serde::{Deserialize, Serialize};
use std::{
cmp::Ordering,
path::{Component, Path, PathBuf},
sync::Arc,
time::SystemTime,
};
use sum_tree::{MapSeekTarget, TreeMap};
use util::{paths::PathExt, ResultExt};
use sum_tree::MapSeekTarget;
use util::ResultExt;

pub use git2::Repository as LibGitRepository;

Expand All @@ -39,23 +38,11 @@ pub trait GitRepository: Send {
/// Returns the SHA of the current HEAD.
fn head_sha(&self) -> Option<String>;

/// Get the statuses of all of the files in the index that start with the given
/// path and have changes with respect to the HEAD commit. This is fast because
/// the index stores hashes of trees, so that unchanged directories can be skipped.
fn staged_statuses(&self, path_prefix: &Path) -> TreeMap<RepoPath, GitFileStatus>;
fn statuses(&self, path_prefix: &Path) -> Result<GitStatus>;

/// Get the status of a given file in the working directory with respect to
/// the index. In the common case, when there are no changes, this only requires
/// an index lookup. The index stores the mtime of each file when it was added,
/// so there's no work to do if the mtime matches.
fn unstaged_status(&self, path: &RepoPath, mtime: SystemTime) -> Option<GitFileStatus>;

/// Get the status of a given file in the working directory with respect to
/// the HEAD commit. In the common case, when there are no changes, this only
/// requires an index lookup and blob comparison between the index and the HEAD
/// commit. The index stores the mtime of each file when it was added, so there's
/// no need to consider the working directory file if the mtime matches.
fn status(&self, path: &RepoPath, mtime: SystemTime) -> Option<GitFileStatus>;
fn status(&self, path: &Path) -> Option<GitFileStatus> {
Some(self.statuses(path).ok()?.entries.first()?.1)
}

fn branches(&self) -> Result<Vec<Branch>>;
fn change_branch(&self, _: &str) -> Result<()>;
Expand Down Expand Up @@ -137,65 +124,12 @@ impl GitRepository for RealGitRepository {
head.target().map(|oid| oid.to_string())
}

fn staged_statuses(&self, path_prefix: &Path) -> TreeMap<RepoPath, GitFileStatus> {
let mut map = TreeMap::default();

let mut options = git2::StatusOptions::new();
options.pathspec(path_prefix);
options.show(StatusShow::Index);

if let Some(statuses) = self.repository.statuses(Some(&mut options)).log_err() {
for status in statuses.iter() {
let path = RepoPath(PathBuf::try_from_bytes(status.path_bytes()).unwrap());
let status = status.status();
if !status.contains(git2::Status::IGNORED) {
if let Some(status) = read_status(status) {
map.insert(path, status)
}
}
}
}
map
}

fn unstaged_status(&self, path: &RepoPath, mtime: SystemTime) -> Option<GitFileStatus> {
// If the file has not changed since it was added to the index, then
// there can't be any changes.
if matches_index(&self.repository, path, mtime) {
return None;
}

let mut options = git2::StatusOptions::new();
options.pathspec(&path.0);
options.disable_pathspec_match(true);
options.include_untracked(true);
options.recurse_untracked_dirs(true);
options.include_unmodified(true);
options.show(StatusShow::Workdir);

let statuses = self.repository.statuses(Some(&mut options)).log_err()?;
let status = statuses.get(0).and_then(|s| read_status(s.status()));
status
}

fn status(&self, path: &RepoPath, mtime: SystemTime) -> Option<GitFileStatus> {
let mut options = git2::StatusOptions::new();
options.pathspec(&path.0);
options.disable_pathspec_match(true);
options.include_untracked(true);
options.recurse_untracked_dirs(true);
options.include_unmodified(true);

// If the file has not changed since it was added to the index, then
// there's no need to examine the working directory file: just compare
// the blob in the index to the one in the HEAD commit.
if matches_index(&self.repository, path, mtime) {
options.show(StatusShow::Index);
}

let statuses = self.repository.statuses(Some(&mut options)).log_err()?;
let status = statuses.get(0).and_then(|s| read_status(s.status()));
status
fn statuses(&self, path_prefix: &Path) -> Result<GitStatus> {
let working_directory = self
.repository
.workdir()
.context("failed to read git work directory")?;
GitStatus::new(&self.git_binary_path, working_directory, path_prefix)
}

fn branches(&self) -> Result<Vec<Branch>> {
Expand All @@ -222,6 +156,7 @@ impl GitRepository for RealGitRepository {
.collect();
Ok(valid_branches)
}

fn change_branch(&self, name: &str) -> Result<()> {
let revision = self.repository.find_branch(name, BranchType::Local)?;
let revision = revision.get();
Expand Down Expand Up @@ -261,38 +196,6 @@ impl GitRepository for RealGitRepository {
}
}

fn matches_index(repo: &LibGitRepository, path: &RepoPath, mtime: SystemTime) -> bool {
if let Some(index) = repo.index().log_err() {
if let Some(entry) = index.get_path(path, 0) {
if let Some(mtime) = mtime.duration_since(SystemTime::UNIX_EPOCH).log_err() {
if entry.mtime.seconds() == mtime.as_secs() as i32
&& entry.mtime.nanoseconds() == mtime.subsec_nanos()
{
return true;
}
}
}
}
false
}

fn read_status(status: git2::Status) -> Option<GitFileStatus> {
if status.contains(git2::Status::CONFLICTED) {
Some(GitFileStatus::Conflict)
} else if status.intersects(
git2::Status::WT_MODIFIED
| git2::Status::WT_RENAMED
| git2::Status::INDEX_MODIFIED
| git2::Status::INDEX_RENAMED,
) {
Some(GitFileStatus::Modified)
} else if status.intersects(git2::Status::WT_NEW | git2::Status::INDEX_NEW) {
Some(GitFileStatus::Added)
} else {
None
}
}

#[derive(Debug, Clone, Default)]
pub struct FakeGitRepository {
state: Arc<Mutex<FakeGitRepositoryState>>,
Expand Down Expand Up @@ -333,24 +236,23 @@ impl GitRepository for FakeGitRepository {
None
}

fn staged_statuses(&self, path_prefix: &Path) -> TreeMap<RepoPath, GitFileStatus> {
let mut map = TreeMap::default();
let state = self.state.lock();
for (repo_path, status) in state.worktree_statuses.iter() {
if repo_path.0.starts_with(path_prefix) {
map.insert(repo_path.to_owned(), status.to_owned());
}
}
map
}

fn unstaged_status(&self, _path: &RepoPath, _mtime: SystemTime) -> Option<GitFileStatus> {
None
}

fn status(&self, path: &RepoPath, _mtime: SystemTime) -> Option<GitFileStatus> {
fn statuses(&self, path_prefix: &Path) -> Result<GitStatus> {
let state = self.state.lock();
state.worktree_statuses.get(path).cloned()
let mut entries = state
.worktree_statuses
.iter()
.filter_map(|(repo_path, status)| {
if repo_path.0.starts_with(path_prefix) {
Some((repo_path.to_owned(), *status))
} else {
None
}
})
.collect::<Vec<_>>();
entries.sort_unstable_by(|a, b| a.0.cmp(&b.0));
Ok(GitStatus {
entries: entries.into(),
})
}

fn branches(&self) -> Result<Vec<Branch>> {
Expand Down
99 changes: 99 additions & 0 deletions crates/git/src/status.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
use crate::repository::{GitFileStatus, RepoPath};
use anyhow::{anyhow, Result};
use std::{
path::{Path, PathBuf},
process::{Command, Stdio},
sync::Arc,
};

#[derive(Clone)]
pub struct GitStatus {
pub entries: Arc<[(RepoPath, GitFileStatus)]>,
}

impl GitStatus {
pub(crate) fn new(
git_binary: &Path,
working_directory: &Path,
mut path_prefix: &Path,
) -> Result<Self> {
let mut child = Command::new(git_binary);

if path_prefix == Path::new("") {
path_prefix = Path::new(".");
}

child
.current_dir(working_directory)
.args([
"--no-optional-locks",
"status",
"--porcelain=v1",
"--untracked-files=all",
"-z",
])
.arg(path_prefix)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());

#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
child.creation_flags(windows::Win32::System::Threading::CREATE_NO_WINDOW.0);
}

let child = child
.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 blame 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| {
if entry.is_char_boundary(3) {
let (status, path) = entry.split_at(3);
let status = status.trim();
Some((
RepoPath(PathBuf::from(path)),
match status {
"A" | "??" => GitFileStatus::Added,
"M" => GitFileStatus::Modified,
_ => return None,
},
))
} else {
None
}
})
.collect::<Vec<_>>();
entries.sort_unstable_by(|a, b| a.0.cmp(&b.0));
Ok(Self {
entries: entries.into(),
})
}

pub fn get(&self, path: &Path) -> Option<GitFileStatus> {
self.entries
.binary_search_by(|(repo_path, _)| repo_path.0.as_path().cmp(path))
.ok()
.map(|index| self.entries[index].1)
}
}

impl Default for GitStatus {
fn default() -> Self {
Self {
entries: Arc::new([]),
}
}
}
Loading
Loading