WIP: Track live entry status in repository

Mikayla Maki and petros created

co-authored-by: petros <petros@zed.dev>

Change summary

crates/fs/src/repository.rs     | 152 ++++++++++++++++++++--------------
crates/project/src/worktree.rs  |  95 ++++++++++++++++-----
crates/sum_tree/src/tree_map.rs |   4 
3 files changed, 165 insertions(+), 86 deletions(-)

Detailed changes

crates/fs/src/repository.rs 🔗

@@ -1,11 +1,14 @@
 use anyhow::Result;
 use collections::HashMap;
+use git2::Status;
 use parking_lot::Mutex;
-use sum_tree::TreeMap;
 use std::{
+    ffi::OsStr,
+    os::unix::prelude::OsStrExt,
     path::{Component, Path, PathBuf},
-    sync::Arc, ffi::OsStr, os::unix::prelude::OsStrExt,
+    sync::Arc,
 };
+use sum_tree::TreeMap;
 use util::ResultExt;
 
 pub use git2::Repository as LibGitRepository;
@@ -19,6 +22,8 @@ pub trait GitRepository: Send {
     fn branch_name(&self) -> Option<String>;
 
     fn statuses(&self) -> Option<TreeMap<RepoPath, GitStatus>>;
+
+    fn file_status(&self, path: &RepoPath) -> Option<GitStatus>;
 }
 
 impl std::fmt::Debug for dyn GitRepository {
@@ -70,72 +75,22 @@ impl GitRepository for LibGitRepository {
 
         let mut map = TreeMap::default();
 
-        for status in statuses.iter() {
+        for status in statuses
+            .iter()
+            .filter(|status| !status.status().contains(git2::Status::IGNORED))
+        {
             let path = RepoPath(PathBuf::from(OsStr::from_bytes(status.path_bytes())));
 
-            let status_data = status.status();
-
-            let status = if status_data.contains(git2::Status::CONFLICTED) {
-                GitStatus::Conflict
-            } else if status_data.intersects(git2::Status::INDEX_MODIFIED
-                | git2::Status::WT_MODIFIED
-                | git2::Status::INDEX_RENAMED
-                | git2::Status::WT_RENAMED) {
-                GitStatus::Modified
-            } else if status_data.intersects(git2::Status::INDEX_NEW | git2::Status::WT_NEW) {
-                GitStatus::Added
-            } else {
-                GitStatus::Untracked
-            };
-
-            map.insert(path, status)
+            map.insert(path, status.status().into())
         }
 
         Some(map)
     }
-}
-
-#[derive(Debug, Clone, Default)]
-pub enum GitStatus {
-    Added,
-    Modified,
-    Conflict,
-    #[default]
-    Untracked,
-}
 
-#[derive(Clone, Debug, Ord, Hash, PartialOrd, Eq, PartialEq)]
-pub struct RepoPath(PathBuf);
+    fn file_status(&self, path: &RepoPath) -> Option<GitStatus> {
+        let status = self.status_file(path).log_err()?;
 
-impl From<&Path> for RepoPath {
-    fn from(value: &Path) -> Self {
-        RepoPath(value.to_path_buf())
-    }
-}
-
-impl From<PathBuf> for RepoPath {
-    fn from(value: PathBuf) -> Self {
-        RepoPath(value)
-    }
-}
-
-impl Default for RepoPath {
-    fn default() -> Self {
-        RepoPath(PathBuf::new())
-    }
-}
-
-impl AsRef<Path> for RepoPath {
-    fn as_ref(&self) -> &Path {
-        self.0.as_ref()
-    }
-}
-
-impl std::ops::Deref for RepoPath {
-    type Target = PathBuf;
-
-    fn deref(&self) -> &Self::Target {
-        &self.0
+        Some(status.into())
     }
 }
 
@@ -170,7 +125,11 @@ impl GitRepository for FakeGitRepository {
         state.branch_name.clone()
     }
 
-    fn statuses(&self) -> Option<TreeMap<RepoPath, GitStatus>>{
+    fn statuses(&self) -> Option<TreeMap<RepoPath, GitStatus>> {
+        todo!()
+    }
+
+    fn file_status(&self, _: &RepoPath) -> Option<GitStatus> {
         todo!()
     }
 }
@@ -203,3 +162,74 @@ fn check_path_to_repo_path_errors(relative_file_path: &Path) -> Result<()> {
         _ => Ok(()),
     }
 }
+
+#[derive(Debug, Clone, Default, PartialEq, Eq)]
+pub enum GitStatus {
+    Added,
+    Modified,
+    Conflict,
+    #[default]
+    Untracked,
+}
+
+impl From<Status> for GitStatus {
+    fn from(value: Status) -> Self {
+        if value.contains(git2::Status::CONFLICTED) {
+            GitStatus::Conflict
+        } else if value.intersects(
+            git2::Status::INDEX_MODIFIED
+                | git2::Status::WT_MODIFIED
+                | git2::Status::INDEX_RENAMED
+                | git2::Status::WT_RENAMED,
+        ) {
+            GitStatus::Modified
+        } else if value.intersects(git2::Status::INDEX_NEW | git2::Status::WT_NEW) {
+            GitStatus::Added
+        } else {
+            GitStatus::Untracked
+        }
+    }
+}
+
+#[derive(Clone, Debug, Ord, Hash, PartialOrd, Eq, PartialEq)]
+pub struct RepoPath(PathBuf);
+
+impl RepoPath {
+    fn new(path: PathBuf) -> Self {
+        debug_assert!(path.is_relative(), "Repo paths must be relative");
+
+        RepoPath(path)
+    }
+}
+
+impl From<&Path> for RepoPath {
+    fn from(value: &Path) -> Self {
+        RepoPath::new(value.to_path_buf())
+    }
+}
+
+impl From<PathBuf> for RepoPath {
+    fn from(value: PathBuf) -> Self {
+        RepoPath::new(value)
+    }
+}
+
+impl Default for RepoPath {
+    fn default() -> Self {
+        RepoPath(PathBuf::new())
+    }
+}
+
+impl AsRef<Path> for RepoPath {
+    fn as_ref(&self) -> &Path {
+        self.0.as_ref()
+    }
+}
+
+impl std::ops::Deref for RepoPath {
+    type Target = PathBuf;
+
+    fn deref(&self) -> &Self::Target {
+        &self.0
+    }
+}

crates/project/src/worktree.rs 🔗

@@ -6,7 +6,10 @@ use anyhow::{anyhow, Context, Result};
 use client::{proto, Client};
 use clock::ReplicaId;
 use collections::{HashMap, VecDeque};
-use fs::{repository::{GitRepository, RepoPath, GitStatus}, Fs, LineEnding};
+use fs::{
+    repository::{GitRepository, GitStatus, RepoPath},
+    Fs, LineEnding,
+};
 use futures::{
     channel::{
         mpsc::{self, UnboundedSender},
@@ -121,7 +124,7 @@ pub struct Snapshot {
 pub struct RepositoryEntry {
     pub(crate) work_directory: WorkDirectoryEntry,
     pub(crate) branch: Option<Arc<str>>,
-    // pub(crate) statuses: TreeMap<RepoPath, GitStatus>
+    pub(crate) statuses: TreeMap<RepoPath, GitStatus>,
 }
 
 impl RepositoryEntry {
@@ -169,7 +172,6 @@ impl AsRef<Path> for RepositoryWorkDirectory {
     }
 }
 
-
 #[derive(Clone, Debug, Ord, PartialOrd, Eq, PartialEq)]
 pub struct WorkDirectoryEntry(ProjectEntryId);
 
@@ -219,6 +221,7 @@ pub struct LocalSnapshot {
 #[derive(Debug, Clone)]
 pub struct LocalRepositoryEntry {
     pub(crate) scan_id: usize,
+    pub(crate) full_scan_id: usize,
     pub(crate) repo_ptr: Arc<Mutex<dyn GitRepository>>,
     /// Path to the actual .git folder.
     /// Note: if .git is a file, this points to the folder indicated by the .git file
@@ -1412,6 +1415,8 @@ impl Snapshot {
             let repository = RepositoryEntry {
                 work_directory: ProjectEntryId::from_proto(repository.work_directory_id).into(),
                 branch: repository.branch.map(Into::into),
+                // TODO: status
+                statuses: Default::default(),
             };
             if let Some(entry) = self.entry_for_id(repository.work_directory_id()) {
                 self.repository_entries
@@ -1572,6 +1577,10 @@ impl LocalSnapshot {
         current_candidate.map(|entry| entry.to_owned())
     }
 
+    pub(crate) fn get_local_repo(&self, repo: &RepositoryEntry) -> Option<&LocalRepositoryEntry> {
+        self.git_repositories.get(&repo.work_directory.0)
+    }
+
     pub(crate) fn repo_for_metadata(
         &self,
         path: &Path,
@@ -1842,6 +1851,7 @@ impl LocalSnapshot {
                 RepositoryEntry {
                     work_directory: work_dir_id.into(),
                     branch: repo_lock.branch_name().map(Into::into),
+                    statuses: repo_lock.statuses().unwrap_or_default(),
                 },
             );
             drop(repo_lock);
@@ -1850,6 +1860,7 @@ impl LocalSnapshot {
                 work_dir_id,
                 LocalRepositoryEntry {
                     scan_id,
+                    full_scan_id: scan_id,
                     repo_ptr: repo,
                     git_dir_path: parent_path.clone(),
                 },
@@ -2825,26 +2836,7 @@ impl BackgroundScanner {
                     fs_entry.is_ignored = ignore_stack.is_all();
                     snapshot.insert_entry(fs_entry, self.fs.as_ref());
 
-                    let scan_id = snapshot.scan_id;
-
-                    let repo_with_path_in_dotgit = snapshot.repo_for_metadata(&path);
-                    if let Some((entry_id, repo)) = repo_with_path_in_dotgit {
-                        let work_dir = snapshot
-                            .entry_for_id(entry_id)
-                            .map(|entry| RepositoryWorkDirectory(entry.path.clone()))?;
-
-                        let repo = repo.lock();
-                        repo.reload_index();
-                        let branch = repo.branch_name();
-
-                        snapshot.git_repositories.update(&entry_id, |entry| {
-                            entry.scan_id = scan_id;
-                        });
-
-                        snapshot
-                            .repository_entries
-                            .update(&work_dir, |entry| entry.branch = branch.map(Into::into));
-                    }
+                    self.reload_repo_for_path(&path, &mut snapshot);
 
                     if let Some(scan_queue_tx) = &scan_queue_tx {
                         let mut ancestor_inodes = snapshot.ancestor_inodes_for_path(&path);
@@ -2872,6 +2864,63 @@ impl BackgroundScanner {
         Some(event_paths)
     }
 
+    fn reload_repo_for_path(&self, path: &Path, snapshot: &mut LocalSnapshot) -> Option<()> {
+        let scan_id = snapshot.scan_id;
+
+        if path
+            .components()
+            .any(|component| component.as_os_str() == *DOT_GIT)
+        {
+            let (entry_id, repo) = snapshot.repo_for_metadata(&path)?;
+
+            let work_dir = snapshot
+                .entry_for_id(entry_id)
+                .map(|entry| RepositoryWorkDirectory(entry.path.clone()))?;
+
+            let repo = repo.lock();
+            repo.reload_index();
+            let branch = repo.branch_name();
+            let statuses = repo.statuses().unwrap_or_default();
+
+            snapshot.git_repositories.update(&entry_id, |entry| {
+                entry.scan_id = scan_id;
+                entry.full_scan_id = scan_id;
+            });
+
+            snapshot.repository_entries.update(&work_dir, |entry| {
+                entry.branch = branch.map(Into::into);
+                entry.statuses = statuses;
+            });
+        } else if let Some(repo) = snapshot.repo_for(&path) {
+            let status = {
+                let local_repo = snapshot.get_local_repo(&repo)?;
+                // Short circuit if we've already scanned everything
+                if local_repo.full_scan_id == scan_id {
+                    return None;
+                }
+
+                let repo_path = repo.work_directory.relativize(&snapshot, &path)?;
+                let git_ptr = local_repo.repo_ptr.lock();
+                git_ptr.file_status(&repo_path)?
+            };
+
+            if status != GitStatus::Untracked {
+                let work_dir = repo.work_directory(snapshot)?;
+                let work_dir_id = repo.work_directory;
+
+                snapshot
+                    .git_repositories
+                    .update(&work_dir_id, |entry| entry.scan_id = scan_id);
+
+                snapshot
+                    .repository_entries
+                    .update(&work_dir, |entry| entry.statuses.insert(repo_path, status));
+            }
+        }
+
+        Some(())
+    }
+
     async fn update_ignore_statuses(&self) {
         use futures::FutureExt as _;
 

crates/sum_tree/src/tree_map.rs 🔗

@@ -2,13 +2,13 @@ use std::{cmp::Ordering, fmt::Debug};
 
 use crate::{Bias, Dimension, Item, KeyedItem, SeekTarget, SumTree, Summary};
 
-#[derive(Clone, Debug)]
+#[derive(Clone, Debug, PartialEq, Eq)]
 pub struct TreeMap<K, V>(SumTree<MapEntry<K, V>>)
 where
     K: Clone + Debug + Default + Ord,
     V: Clone + Debug;
 
-#[derive(Clone, Debug)]
+#[derive(Clone, Debug, PartialEq, Eq)]
 pub struct MapEntry<K, V> {
     key: K,
     value: V,