Make FakeGitRepository behave more like a real git repository (#26961)

This PR reworks the `FakeGitRepository` type that we use for testing git
interactions, to make it more realistic. In particular, the `status`
method now derives the Git status from the differences between HEAD, the
index, and the working copy. This way, if you modify a file in the
`FakeFs`, the Git repository's `status` method will reflect that
modification.

Release Notes:

- N/A

---------

Co-authored-by: Junkui Zhang <364772080@qq.com>
This commit is contained in:
Max Brunsfeld 2025-03-19 09:04:27 -07:00 committed by GitHub
parent 5f398071b2
commit 74a39c7263
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 790 additions and 679 deletions

View file

@ -5,36 +5,23 @@ mod mac_watcher;
pub mod fs_watcher;
use anyhow::{anyhow, Context as _, Result};
#[cfg(any(test, feature = "test-support"))]
use collections::HashMap;
#[cfg(any(test, feature = "test-support"))]
use git::status::StatusCode;
#[cfg(any(test, feature = "test-support"))]
use git::status::TrackedStatus;
#[cfg(any(test, feature = "test-support"))]
use git::{repository::RepoPath, status::FileStatus};
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
use ashpd::desktop::trash;
use gpui::App;
use gpui::Global;
use gpui::ReadGlobal as _;
use std::borrow::Cow;
#[cfg(any(test, feature = "test-support"))]
use std::collections::HashSet;
#[cfg(unix)]
use std::os::fd::AsFd;
#[cfg(unix)]
use std::os::fd::AsRawFd;
use util::command::new_std_command;
#[cfg(unix)]
use std::os::unix::fs::MetadataExt;
use std::os::fd::{AsFd, AsRawFd};
#[cfg(unix)]
use std::os::unix::fs::FileTypeExt;
use std::os::unix::fs::{FileTypeExt, MetadataExt};
use async_tar::Archive;
use futures::{future::BoxFuture, AsyncRead, Stream, StreamExt};
use git::repository::{GitRepository, RealGitRepository};
use gpui::{App, Global, ReadGlobal};
use rope::Rope;
use serde::{Deserialize, Serialize};
use smol::io::AsyncWriteExt;
@ -47,12 +34,18 @@ use std::{
};
use tempfile::{NamedTempFile, TempDir};
use text::LineEnding;
use util::ResultExt;
#[cfg(any(test, feature = "test-support"))]
mod fake_git_repo;
#[cfg(any(test, feature = "test-support"))]
use collections::{btree_map, BTreeMap};
#[cfg(any(test, feature = "test-support"))]
use git::FakeGitRepositoryState;
use fake_git_repo::FakeGitRepositoryState;
#[cfg(any(test, feature = "test-support"))]
use git::{
repository::RepoPath,
status::{FileStatus, StatusCode, TrackedStatus, UnmergedStatus},
};
#[cfg(any(test, feature = "test-support"))]
use parking_lot::Mutex;
#[cfg(any(test, feature = "test-support"))]
@ -708,7 +701,7 @@ impl Fs for RealFs {
Arc<dyn Watcher>,
) {
use parking_lot::Mutex;
use util::paths::SanitizedPath;
use util::{paths::SanitizedPath, ResultExt as _};
let (tx, rx) = smol::channel::unbounded();
let pending_paths: Arc<Mutex<Vec<PathEvent>>> = Default::default();
@ -758,14 +751,10 @@ impl Fs for RealFs {
}
fn open_repo(&self, dotgit_path: &Path) -> Option<Arc<dyn GitRepository>> {
// with libgit2, we can open git repo from an existing work dir
// https://libgit2.org/docs/reference/main/repository/git_repository_open.html
let workdir_root = dotgit_path.parent()?;
let repo = git2::Repository::open(workdir_root).log_err()?;
Some(Arc::new(RealGitRepository::new(
repo,
dotgit_path,
self.git_binary_path.clone(),
)))
)?))
}
fn git_init(&self, abs_work_directory_path: &Path, fallback_branch_name: String) -> Result<()> {
@ -885,7 +874,7 @@ enum FakeFsEntry {
mtime: MTime,
len: u64,
entries: BTreeMap<String, Arc<Mutex<FakeFsEntry>>>,
git_repo_state: Option<Arc<Mutex<git::FakeGitRepositoryState>>>,
git_repo_state: Option<Arc<Mutex<FakeGitRepositoryState>>>,
},
Symlink {
target: PathBuf,
@ -1254,9 +1243,9 @@ impl FakeFs {
.boxed()
}
pub fn with_git_state<F>(&self, dot_git: &Path, emit_git_event: bool, f: F)
pub fn with_git_state<T, F>(&self, dot_git: &Path, emit_git_event: bool, f: F) -> T
where
F: FnOnce(&mut FakeGitRepositoryState),
F: FnOnce(&mut FakeGitRepositoryState) -> T,
{
let mut state = self.state.lock();
let entry = state.read_path(dot_git).unwrap();
@ -1271,11 +1260,13 @@ impl FakeFs {
});
let mut repo_state = repo_state.lock();
f(&mut repo_state);
let result = f(&mut repo_state);
if emit_git_event {
state.emit_event([(dot_git, None)]);
}
result
} else {
panic!("not a directory");
}
@ -1302,6 +1293,21 @@ impl FakeFs {
})
}
pub fn set_unmerged_paths_for_repo(
&self,
dot_git: &Path,
unmerged_state: &[(RepoPath, UnmergedStatus)],
) {
self.with_git_state(dot_git, true, |state| {
state.unmerged_paths.clear();
state.unmerged_paths.extend(
unmerged_state
.iter()
.map(|(path, content)| (path.clone(), *content)),
);
});
}
pub fn set_index_for_repo(&self, dot_git: &Path, index_state: &[(RepoPath, String)]) {
self.with_git_state(dot_git, true, |state| {
state.index_contents.clear();
@ -1346,80 +1352,20 @@ impl FakeFs {
},
));
});
self.recalculate_git_status(dot_git);
}
pub fn recalculate_git_status(&self, dot_git: &Path) {
let git_files: HashMap<_, _> = self
.files()
.iter()
.filter_map(|path| {
let repo_path =
RepoPath::new(path.strip_prefix(dot_git.parent().unwrap()).ok()?.into());
let content = self
.read_file_sync(path)
.ok()
.map(|content| String::from_utf8(content).unwrap());
Some((repo_path, content?))
})
.collect();
self.with_git_state(dot_git, false, |state| {
state.statuses.clear();
let mut paths: HashSet<_> = state.head_contents.keys().collect();
paths.extend(state.index_contents.keys());
paths.extend(git_files.keys());
for path in paths {
let head = state.head_contents.get(path);
let index = state.index_contents.get(path);
let fs = git_files.get(path);
let status = match (head, index, fs) {
(Some(head), Some(index), Some(fs)) => FileStatus::Tracked(TrackedStatus {
index_status: if head == index {
StatusCode::Unmodified
} else {
StatusCode::Modified
},
worktree_status: if fs == index {
StatusCode::Unmodified
} else {
StatusCode::Modified
},
}),
(Some(head), Some(index), None) => FileStatus::Tracked(TrackedStatus {
index_status: if head == index {
StatusCode::Unmodified
} else {
StatusCode::Modified
},
worktree_status: StatusCode::Deleted,
}),
(Some(_), None, Some(_)) => FileStatus::Tracked(TrackedStatus {
index_status: StatusCode::Deleted,
worktree_status: StatusCode::Added,
}),
(Some(_), None, None) => FileStatus::Tracked(TrackedStatus {
index_status: StatusCode::Deleted,
worktree_status: StatusCode::Deleted,
}),
(None, Some(index), Some(fs)) => FileStatus::Tracked(TrackedStatus {
index_status: StatusCode::Added,
worktree_status: if fs == index {
StatusCode::Unmodified
} else {
StatusCode::Modified
},
}),
(None, Some(_), None) => FileStatus::Tracked(TrackedStatus {
index_status: StatusCode::Added,
worktree_status: StatusCode::Deleted,
}),
(None, None, Some(_)) => FileStatus::Untracked,
(None, None, None) => {
unreachable!();
}
};
state.statuses.insert(path.clone(), status);
}
pub fn set_head_and_index_for_repo(
&self,
dot_git: &Path,
contents_by_path: &[(RepoPath, String)],
) {
self.with_git_state(dot_git, true, |state| {
state.head_contents.clear();
state.index_contents.clear();
state.head_contents.extend(contents_by_path.iter().cloned());
state
.index_contents
.extend(contents_by_path.iter().cloned());
});
}
@ -1430,38 +1376,85 @@ impl FakeFs {
});
}
pub fn set_status_for_repo_via_working_copy_change(
&self,
dot_git: &Path,
statuses: &[(&Path, FileStatus)],
) {
self.with_git_state(dot_git, false, |state| {
state.statuses.clear();
state.statuses.extend(
statuses
.iter()
.map(|(path, content)| ((**path).into(), *content)),
);
});
self.state.lock().emit_event(
statuses
.iter()
.map(|(path, _)| (dot_git.parent().unwrap().join(path), None)),
);
}
pub fn set_status_for_repo_via_git_operation(
&self,
dot_git: &Path,
statuses: &[(&Path, FileStatus)],
) {
/// Put the given git repository into a state with the given status,
/// by mutating the head, index, and unmerged state.
pub fn set_status_for_repo(&self, dot_git: &Path, statuses: &[(&Path, FileStatus)]) {
let workdir_path = dot_git.parent().unwrap();
let workdir_contents = self.files_with_contents(&workdir_path);
self.with_git_state(dot_git, true, |state| {
state.statuses.clear();
state.statuses.extend(
statuses
state.index_contents.clear();
state.head_contents.clear();
state.unmerged_paths.clear();
for (path, content) in workdir_contents {
let repo_path: RepoPath = path.strip_prefix(&workdir_path).unwrap().into();
let status = statuses
.iter()
.map(|(path, content)| ((**path).into(), *content)),
);
.find_map(|(p, status)| (**p == *repo_path.0).then_some(status));
let mut content = String::from_utf8_lossy(&content).to_string();
let mut index_content = None;
let mut head_content = None;
match status {
None => {
index_content = Some(content.clone());
head_content = Some(content);
}
Some(FileStatus::Untracked | FileStatus::Ignored) => {}
Some(FileStatus::Unmerged(unmerged_status)) => {
state
.unmerged_paths
.insert(repo_path.clone(), *unmerged_status);
content.push_str(" (unmerged)");
index_content = Some(content.clone());
head_content = Some(content);
}
Some(FileStatus::Tracked(TrackedStatus {
index_status,
worktree_status,
})) => {
match worktree_status {
StatusCode::Modified => {
let mut content = content.clone();
content.push_str(" (modified in working copy)");
index_content = Some(content);
}
StatusCode::TypeChanged | StatusCode::Unmodified => {
index_content = Some(content.clone());
}
StatusCode::Added => {}
StatusCode::Deleted | StatusCode::Renamed | StatusCode::Copied => {
panic!("cannot create these statuses for an existing file");
}
};
match index_status {
StatusCode::Modified => {
let mut content = index_content.clone().expect(
"file cannot be both modified in index and created in working copy",
);
content.push_str(" (modified in index)");
head_content = Some(content);
}
StatusCode::TypeChanged | StatusCode::Unmodified => {
head_content = Some(index_content.clone().expect("file cannot be both unmodified in index and created in working copy"));
}
StatusCode::Added => {}
StatusCode::Deleted => {
head_content = Some("".into());
}
StatusCode::Renamed | StatusCode::Copied => {
panic!("cannot create these statuses for an existing file");
}
};
}
};
if let Some(content) = index_content {
state.index_contents.insert(repo_path.clone(), content);
}
if let Some(content) = head_content {
state.head_contents.insert(repo_path.clone(), content);
}
}
});
}
@ -1541,6 +1534,32 @@ impl FakeFs {
result
}
pub fn files_with_contents(&self, prefix: &Path) -> Vec<(PathBuf, Vec<u8>)> {
let mut result = Vec::new();
let mut queue = collections::VecDeque::new();
queue.push_back((
PathBuf::from(util::path!("/")),
self.state.lock().root.clone(),
));
while let Some((path, entry)) = queue.pop_front() {
let e = entry.lock();
match &*e {
FakeFsEntry::File { content, .. } => {
if path.starts_with(prefix) {
result.push((path, content.clone()));
}
}
FakeFsEntry::Dir { entries, .. } => {
for (name, entry) in entries {
queue.push_back((path.join(name), entry.clone()));
}
}
FakeFsEntry::Symlink { .. } => {}
}
}
result
}
/// How many `read_dir` calls have been issued.
pub fn read_dir_call_count(&self) -> usize {
self.state.lock().read_dir_call_count
@ -2087,15 +2106,17 @@ impl Fs for FakeFs {
let entry = state.read_path(abs_dot_git).unwrap();
let mut entry = entry.lock();
if let FakeFsEntry::Dir { git_repo_state, .. } = &mut *entry {
let state = git_repo_state
.get_or_insert_with(|| {
Arc::new(Mutex::new(FakeGitRepositoryState::new(
abs_dot_git.to_path_buf(),
state.git_event_tx.clone(),
)))
})
.clone();
Some(git::FakeGitRepository::open(state))
git_repo_state.get_or_insert_with(|| {
Arc::new(Mutex::new(FakeGitRepositoryState::new(
abs_dot_git.to_path_buf(),
state.git_event_tx.clone(),
)))
});
Some(Arc::new(fake_git_repo::FakeGitRepository {
fs: self.this.upgrade().unwrap(),
executor: self.executor.clone(),
dot_git_path: abs_dot_git.to_path_buf(),
}))
} else {
None
}