@@ -1,8 +1,8 @@
-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};
@@ -10,10 +10,9 @@ 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;
@@ -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<()>;
@@ -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>> {
@@ -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();
@@ -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>>,
@@ -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>> {
@@ -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([]),
+ }
+ }
+}
@@ -21,6 +21,7 @@ use futures::{
FutureExt as _, Stream, StreamExt,
};
use fuzzy::CharBag;
+use git::status::GitStatus;
use git::{
repository::{GitFileStatus, GitRepository, RepoPath},
DOT_GIT, GITIGNORE,
@@ -77,7 +78,7 @@ pub const FS_WATCH_LATENCY: Duration = Duration::from_millis(100);
#[cfg(not(feature = "test-support"))]
pub const FS_WATCH_LATENCY: Duration = Duration::from_millis(100);
-const GIT_STATUS_UPDATE_BATCH_SIZE: usize = 100;
+const GIT_STATUS_UPDATE_BATCH_SIZE: usize = 1024;
#[derive(Copy, Clone, PartialEq, Eq, Debug, Hash, PartialOrd, Ord)]
pub struct WorktreeId(usize);
@@ -2019,18 +2020,13 @@ impl Snapshot {
include_ignored: bool,
path: &Path,
) -> Traversal {
- let mut cursor = self.entries_by_path.cursor();
- cursor.seek(&TraversalTarget::Path(path), Bias::Left, &());
- let mut traversal = Traversal {
- cursor,
+ Traversal::new(
+ &self.entries_by_path,
include_files,
include_dirs,
include_ignored,
- };
- if traversal.end_offset() == traversal.start_offset() {
- traversal.next();
- }
- traversal
+ path,
+ )
}
pub fn files(&self, include_ignored: bool, start: usize) -> Traversal {
@@ -2558,8 +2554,12 @@ impl BackgroundScannerState {
if let Ok(repo_path) = repo_entry.relativize(&self.snapshot, &path) {
containing_repository = Some(ScanJobContainingRepository {
work_directory: workdir_path,
- repository: repo.repo_ptr.clone(),
- staged_statuses: repo.repo_ptr.lock().staged_statuses(&repo_path),
+ statuses: repo
+ .repo_ptr
+ .lock()
+ .statuses(&repo_path)
+ .log_err()
+ .unwrap_or_default(),
});
}
}
@@ -3832,11 +3832,14 @@ impl BackgroundScanner {
.lock()
.build_git_repository(child_path.clone(), self.fs.as_ref())
{
- let staged_statuses = repository.lock().staged_statuses(Path::new(""));
+ let statuses = repository
+ .lock()
+ .statuses(Path::new(""))
+ .log_err()
+ .unwrap_or_default();
containing_repository = Some(ScanJobContainingRepository {
work_directory,
- repository,
- staged_statuses,
+ statuses,
});
}
} else if child_name == *GITIGNORE {
@@ -3946,13 +3949,8 @@ impl BackgroundScanner {
if !child_entry.is_ignored {
if let Some(repo) = &containing_repository {
if let Ok(repo_path) = child_entry.path.strip_prefix(&repo.work_directory) {
- if let Some(mtime) = child_entry.mtime {
- let repo_path = RepoPath(repo_path.into());
- child_entry.git_status = combine_git_statuses(
- repo.staged_statuses.get(&repo_path).copied(),
- repo.repository.lock().unstaged_status(&repo_path, mtime),
- );
- }
+ let repo_path = RepoPath(repo_path.into());
+ child_entry.git_status = repo.statuses.get(&repo_path);
}
}
}
@@ -4079,10 +4077,7 @@ impl BackgroundScanner {
if !is_dir && !fs_entry.is_ignored && !fs_entry.is_external {
if let Some((repo_entry, repo)) = state.snapshot.repo_for_path(path) {
if let Ok(repo_path) = repo_entry.relativize(&state.snapshot, path) {
- if let Some(mtime) = fs_entry.mtime {
- let repo = repo.repo_ptr.lock();
- fs_entry.git_status = repo.status(&repo_path, mtime);
- }
+ fs_entry.git_status = repo.repo_ptr.lock().status(&repo_path);
}
}
}
@@ -4267,11 +4262,8 @@ impl BackgroundScanner {
path_entry.is_ignored = entry.is_ignored;
if !entry.is_dir() && !entry.is_ignored && !entry.is_external {
if let Some((ref repo_entry, local_repo)) = repo {
- if let Some(mtime) = &entry.mtime {
- if let Ok(repo_path) = repo_entry.relativize(&snapshot, &entry.path) {
- let repo = local_repo.repo_ptr.lock();
- entry.git_status = repo.status(&repo_path, *mtime);
- }
+ if let Ok(repo_path) = repo_entry.relativize(&snapshot, &entry.path) {
+ entry.git_status = local_repo.repo_ptr.lock().status(&repo_path);
}
}
}
@@ -4351,7 +4343,17 @@ impl BackgroundScanner {
}
};
- let staged_statuses = repository.lock().staged_statuses(Path::new(""));
+ let statuses = repository
+ .lock()
+ .statuses(Path::new(""))
+ .log_err()
+ .unwrap_or_default();
+ let entries = state.snapshot.entries_by_path.clone();
+ let location_in_repo = state
+ .snapshot
+ .repository_entries
+ .get(&work_dir)
+ .and_then(|repo| repo.location_in_repo.clone());
let mut files =
state
.snapshot
@@ -4363,10 +4365,11 @@ impl BackgroundScanner {
smol::block_on(update_job_tx.send(UpdateGitStatusesJob {
start_path: start_path.clone(),
end_path: end_path.clone(),
+ entries: entries.clone(),
+ location_in_repo: location_in_repo.clone(),
containing_repository: ScanJobContainingRepository {
work_directory: work_dir.clone(),
- repository: repository.clone(),
- staged_statuses: staged_statuses.clone(),
+ statuses: statuses.clone(),
},
}))
.unwrap();
@@ -4414,16 +4417,12 @@ impl BackgroundScanner {
self.executor
.scoped(|scope| {
- // Git status updates are currently not very parallelizable,
- // because they need to lock the git repository. Limit the number
- // of workers so that
- for _ in 0..self.executor.num_cpus().min(3) {
+ for _ in 0..self.executor.num_cpus() {
scope.spawn(async {
- let mut entries = Vec::with_capacity(GIT_STATUS_UPDATE_BATCH_SIZE);
loop {
select_biased! {
// Process any path refresh requests before moving on to process
- // the queue of ignore statuses.
+ // the queue of git statuses.
request = self.scan_requests_rx.recv().fuse() => {
let Ok(request) = request else { break };
if !self.process_scan_request(request, true).await {
@@ -4434,7 +4433,7 @@ impl BackgroundScanner {
// Process git status updates in batches.
job = update_job_rx.recv().fuse() => {
let Ok(job) = job else { break };
- self.update_git_statuses(job, &mut entries);
+ self.update_git_statuses(job);
}
}
}
@@ -4445,61 +4444,30 @@ impl BackgroundScanner {
}
/// Update the git statuses for a given batch of entries.
- fn update_git_statuses(&self, job: UpdateGitStatusesJob, entries: &mut Vec<Entry>) {
+ fn update_git_statuses(&self, job: UpdateGitStatusesJob) {
+ // Determine which entries in this batch have changed their git status.
let t0 = Instant::now();
- let repo_work_dir = &job.containing_repository.work_directory;
- let state = self.state.lock();
- let Some(repo_entry) = state
- .snapshot
- .repository_entries
- .get(&repo_work_dir)
- .cloned()
- else {
- return;
- };
-
- // Retrieve a batch of entries for this job, and then release the state lock.
- entries.clear();
- for entry in state
- .snapshot
- .traverse_from_path(true, false, false, &job.start_path)
- {
+ let mut edits = Vec::new();
+ for entry in Traversal::new(&job.entries, true, false, false, &job.start_path) {
if job
.end_path
.as_ref()
.map_or(false, |end| &entry.path >= end)
- || !entry.path.starts_with(&repo_work_dir)
{
break;
}
- entries.push(entry.clone());
- }
- drop(state);
-
- // Determine which entries in this batch have changed their git status.
- let mut edits = vec![];
- for entry in entries.iter() {
- let Ok(repo_path) = entry.path.strip_prefix(&repo_work_dir) else {
- continue;
- };
- let Some(mtime) = entry.mtime else {
+ let Ok(repo_path) = entry
+ .path
+ .strip_prefix(&job.containing_repository.work_directory)
+ else {
continue;
};
- let repo_path = RepoPath(if let Some(location) = &repo_entry.location_in_repo {
+ let repo_path = RepoPath(if let Some(location) = &job.location_in_repo {
location.join(repo_path)
} else {
repo_path.to_path_buf()
});
- let git_status = combine_git_statuses(
- job.containing_repository
- .staged_statuses
- .get(&repo_path)
- .copied(),
- job.containing_repository
- .repository
- .lock()
- .unstaged_status(&repo_path, mtime),
- );
+ let git_status = job.containing_repository.statuses.get(&repo_path);
if entry.git_status != git_status {
let mut entry = entry.clone();
entry.git_status = git_status;
@@ -4508,20 +4476,22 @@ impl BackgroundScanner {
}
// Apply the git status changes.
- let mut state = self.state.lock();
- let path_changes = edits.iter().map(|edit| {
- if let Edit::Insert(entry) = edit {
- entry.path.clone()
- } else {
- unreachable!()
- }
- });
- util::extend_sorted(&mut state.changed_paths, path_changes, usize::MAX, Ord::cmp);
- state.snapshot.entries_by_path.edit(edits, &());
+ if edits.len() > 0 {
+ let mut state = self.state.lock();
+ let path_changes = edits.iter().map(|edit| {
+ if let Edit::Insert(entry) = edit {
+ entry.path.clone()
+ } else {
+ unreachable!()
+ }
+ });
+ util::extend_sorted(&mut state.changed_paths, path_changes, usize::MAX, Ord::cmp);
+ state.snapshot.entries_by_path.edit(edits, &());
+ }
log::trace!(
- "refreshed git status of {} entries starting with {} in {:?}",
- entries.len(),
+ "refreshed git status of entries starting with {} in {:?}",
+ // entries.len(),
job.start_path.display(),
t0.elapsed()
);
@@ -4682,8 +4652,7 @@ struct ScanJob {
#[derive(Clone)]
struct ScanJobContainingRepository {
work_directory: RepositoryWorkDirectory,
- repository: Arc<Mutex<dyn GitRepository>>,
- staged_statuses: TreeMap<RepoPath, GitFileStatus>,
+ statuses: GitStatus,
}
struct UpdateIgnoreStatusJob {
@@ -4694,9 +4663,11 @@ struct UpdateIgnoreStatusJob {
}
struct UpdateGitStatusesJob {
+ entries: SumTree<Entry>,
start_path: Arc<Path>,
end_path: Option<Arc<Path>>,
containing_repository: ScanJobContainingRepository,
+ location_in_repo: Option<Arc<Path>>,
}
pub trait WorktreeModelHandle {
@@ -4906,6 +4877,26 @@ pub struct Traversal<'a> {
}
impl<'a> Traversal<'a> {
+ fn new(
+ entries: &'a SumTree<Entry>,
+ include_files: bool,
+ include_dirs: bool,
+ include_ignored: bool,
+ start_path: &Path,
+ ) -> Self {
+ let mut cursor = entries.cursor();
+ cursor.seek(&TraversalTarget::Path(start_path), Bias::Left, &());
+ let mut traversal = Self {
+ cursor,
+ include_files,
+ include_dirs,
+ include_ignored,
+ };
+ if traversal.end_offset() == traversal.start_offset() {
+ traversal.next();
+ }
+ traversal
+ }
pub fn advance(&mut self) -> bool {
self.advance_by(1)
}
@@ -5079,25 +5070,6 @@ impl<'a> TryFrom<(&'a CharBag, proto::Entry)> for Entry {
}
}
-fn combine_git_statuses(
- staged: Option<GitFileStatus>,
- unstaged: Option<GitFileStatus>,
-) -> Option<GitFileStatus> {
- if let Some(staged) = staged {
- if let Some(unstaged) = unstaged {
- if unstaged == staged {
- Some(staged)
- } else {
- Some(GitFileStatus::Modified)
- }
- } else {
- Some(staged)
- }
- } else {
- unstaged
- }
-}
-
fn git_status_from_proto(git_status: Option<i32>) -> Option<GitFileStatus> {
git_status.and_then(|status| {
proto::GitStatus::from_i32(status).map(|status| match status {