Compare commits

...

6 Commits

Author SHA1 Message Date
Cole Miller
4d9de293f9 Merge origin/main 2025-01-27 11:49:23 -05:00
Cole Miller
caade6af7a WIP 2025-01-22 23:42:47 -05:00
Cole Miller
984eb9b5c3 Work 2025-01-22 23:38:46 -05:00
Cole Miller
00dea4ccc9 Fix 2025-01-22 15:02:10 -05:00
Cole Miller
abb295ea22 Make the untracked part of the test actually test something 2025-01-22 14:36:51 -05:00
Cole Miller
33fa0724cf git: Handle git status output for deleted-in-index state 2025-01-22 14:28:56 -05:00
3 changed files with 351 additions and 102 deletions

View File

@@ -5,9 +5,9 @@ mod mac_watcher;
pub mod fs_watcher;
use anyhow::{anyhow, Context as _, Result};
#[cfg(any(test, feature = "test-support"))]
use git::status::FileStatus;
use git::GitHostingProviderRegistry;
#[cfg(any(test, feature = "test-support"))]
use git::{repository::RepoPath, status::FileStatus};
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
use ashpd::desktop::trash;
@@ -892,58 +892,7 @@ impl FakeFsState {
target: &Path,
follow_symlink: bool,
) -> Option<(Arc<Mutex<FakeFsEntry>>, PathBuf)> {
let mut path = target.to_path_buf();
let mut canonical_path = PathBuf::new();
let mut entry_stack = Vec::new();
'outer: loop {
let mut path_components = path.components().peekable();
let mut prefix = None;
while let Some(component) = path_components.next() {
match component {
Component::Prefix(prefix_component) => prefix = Some(prefix_component),
Component::RootDir => {
entry_stack.clear();
entry_stack.push(self.root.clone());
canonical_path.clear();
match prefix {
Some(prefix_component) => {
canonical_path = PathBuf::from(prefix_component.as_os_str());
// Prefixes like `C:\\` are represented without their trailing slash, so we have to re-add it.
canonical_path.push(std::path::MAIN_SEPARATOR_STR);
}
None => canonical_path = PathBuf::from(std::path::MAIN_SEPARATOR_STR),
}
}
Component::CurDir => {}
Component::ParentDir => {
entry_stack.pop()?;
canonical_path.pop();
}
Component::Normal(name) => {
let current_entry = entry_stack.last().cloned()?;
let current_entry = current_entry.lock();
if let FakeFsEntry::Dir { entries, .. } = &*current_entry {
let entry = entries.get(name.to_str().unwrap()).cloned()?;
if path_components.peek().is_some() || follow_symlink {
let entry = entry.lock();
if let FakeFsEntry::Symlink { target, .. } = &*entry {
let mut target = target.clone();
target.extend(path_components);
path = target;
continue 'outer;
}
}
entry_stack.push(entry.clone());
canonical_path = canonical_path.join(name);
} else {
return None;
}
}
}
}
break;
}
Some((entry_stack.pop()?, canonical_path))
FakeFsEntry::try_read_path(&self.root, target, follow_symlink)
}
fn write_path<Fn, T>(&self, path: &Path, callback: Fn) -> Result<T>
@@ -1227,16 +1176,23 @@ impl FakeFs {
F: FnOnce(&mut FakeGitRepositoryState),
{
let mut state = self.state.lock();
let entry = state.read_path(dot_git).unwrap();
let mut entry = entry.lock();
let dot_git_entry = state.read_path(dot_git).unwrap();
let mut dot_git_entry = dot_git_entry.lock();
let parent = dot_git.parent().expect(".git has no parent path");
let parent_entry = state.read_path(parent).unwrap();
if let FakeFsEntry::Dir { git_repo_state, .. } = &mut *entry {
let repo_state = git_repo_state.get_or_insert_with(|| {
Arc::new(Mutex::new(FakeGitRepositoryState::new(
dot_git.to_path_buf(),
state.git_event_tx.clone(),
)))
});
if let FakeFsEntry::Dir { git_repo_state, .. } = &mut *dot_git_entry {
let git_fs = FakeGitRepositoryFs {
dot_git_dir: dot_git.to_owned(),
entry: parent_entry,
event_emitter: state.git_event_tx.clone(),
};
let repo_state = git_repo_state
.get_or_insert_with(|| {
Arc::new(Mutex::new(FakeGitRepositoryState::new(Arc::new(git_fs))))
})
.clone();
drop(dot_git_entry);
let mut repo_state = repo_state.lock();
f(&mut repo_state);
@@ -1276,7 +1232,7 @@ impl FakeFs {
state.index_contents.extend(
head_state
.iter()
.map(|(path, content)| (path.to_path_buf(), content.clone())),
.map(|(path, content)| ((*path).into(), content.clone())),
);
});
}
@@ -1284,11 +1240,9 @@ impl FakeFs {
pub fn set_blame_for_repo(&self, dot_git: &Path, blames: Vec<(&Path, git::blame::Blame)>) {
self.with_git_state(dot_git, true, |state| {
state.blames.clear();
state.blames.extend(
blames
.into_iter()
.map(|(path, blame)| (path.to_path_buf(), blame)),
);
state
.blames
.extend(blames.into_iter().map(|(path, blame)| (path.into(), blame)));
});
}
@@ -1431,6 +1385,65 @@ impl FakeFsEntry {
Err(anyhow!("not a directory: {}", path.display()))
}
}
fn try_read_path(
this: &Arc<Mutex<Self>>,
target: &Path,
follow_symlink: bool,
) -> Option<(Arc<Mutex<FakeFsEntry>>, PathBuf)> {
let mut path = target.to_path_buf();
let mut canonical_path = PathBuf::new();
let mut entry_stack = Vec::new();
'outer: loop {
let mut path_components = path.components().peekable();
let mut prefix = None;
while let Some(component) = path_components.next() {
match component {
Component::Prefix(prefix_component) => prefix = Some(prefix_component),
Component::RootDir => {
entry_stack.clear();
entry_stack.push(this.clone());
canonical_path.clear();
match prefix {
Some(prefix_component) => {
canonical_path = PathBuf::from(prefix_component.as_os_str());
// Prefixes like `C:\\` are represented without their trailing slash, so we have to re-add it.
canonical_path.push(std::path::MAIN_SEPARATOR_STR);
}
None => canonical_path = PathBuf::from(std::path::MAIN_SEPARATOR_STR),
}
}
Component::CurDir => {}
Component::ParentDir => {
entry_stack.pop()?;
canonical_path.pop();
}
Component::Normal(name) => {
let current_entry = entry_stack.last().cloned()?;
let current_entry = current_entry.lock();
if let FakeFsEntry::Dir { entries, .. } = &*current_entry {
let entry = entries.get(name.to_str().unwrap()).cloned()?;
if path_components.peek().is_some() || follow_symlink {
let entry = entry.lock();
if let FakeFsEntry::Symlink { target, .. } = &*entry {
let mut target = target.clone();
target.extend(path_components);
path = target;
continue 'outer;
}
}
entry_stack.push(entry.clone());
canonical_path = canonical_path.join(name);
} else {
return None;
}
}
}
}
break;
}
Some((entry_stack.pop()?, canonical_path))
}
}
#[cfg(any(test, feature = "test-support"))]
@@ -1927,15 +1940,20 @@ impl Fs for FakeFs {
fn open_repo(&self, abs_dot_git: &Path) -> Option<Arc<dyn GitRepository>> {
let state = self.state.lock();
let entry = state.read_path(abs_dot_git).unwrap();
let mut entry = entry.lock();
if let FakeFsEntry::Dir { git_repo_state, .. } = &mut *entry {
let dot_git_entry = state.read_path(abs_dot_git).unwrap();
let mut dot_git_entry = dot_git_entry.lock();
let parent = abs_dot_git.parent().expect(".git has no parent path");
let parent_entry = state.read_path(parent).unwrap();
if let FakeFsEntry::Dir { git_repo_state, .. } = &mut *dot_git_entry {
let git_fs = FakeGitRepositoryFs {
dot_git_dir: abs_dot_git.to_owned(),
entry: parent_entry,
event_emitter: state.git_event_tx.clone(),
};
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(),
)))
Arc::new(Mutex::new(FakeGitRepositoryState::new(Arc::new(git_fs))))
})
.clone();
Some(git::repository::FakeGitRepository::open(state))
@@ -1958,6 +1976,77 @@ impl Fs for FakeFs {
}
}
#[cfg(any(test, feature = "test-support"))]
struct FakeGitRepositoryFs {
dot_git_dir: PathBuf,
entry: Arc<Mutex<FakeFsEntry>>,
event_emitter: smol::channel::Sender<PathBuf>,
}
impl git::repository::FakeGitRepositoryFs for FakeGitRepositoryFs {
fn dot_git_dir(&self) -> PathBuf {
self.dot_git_dir.clone()
}
fn read_path(&self, path: &RepoPath) -> Option<String> {
let (entry, _) = FakeFsEntry::try_read_path(&self.entry, path, false)?;
let content = entry.lock().file_content(path).ok()?.clone();
let content = String::from_utf8(content).expect("Non-UTF-8 content in FakeFs entry");
Some(content)
}
fn all_paths(&self) -> Box<dyn Iterator<Item = (RepoPath, String)>> {
let mut stack = vec![];
let mut start = "".to_owned();
let mut entry = self.entry.clone();
let mut path: PathBuf = "".into();
let mut result = Vec::new();
'outer: loop {
let cur = entry.clone();
for (segment, child) in cur.lock().dir_entries(&path).unwrap().range(start..) {
let guard = child.lock();
match &*guard {
FakeFsEntry::File { content, .. } => {
let content = String::from_utf8(content.clone())
.expect("Non-UTF-8 content in FakeFs entry");
result.push((path.join(segment).into(), content));
}
FakeFsEntry::Dir {
git_repo_state,
entries,
..
} => {
if git_repo_state.is_some()
|| entries.keys().any(|segment| segment == ".git")
{
continue;
}
stack.push((entry.clone(), segment.clone()));
entry = child.clone();
start = "".to_owned();
path = path.join(segment);
continue 'outer;
}
FakeFsEntry::Symlink { .. } => {}
}
}
let Some((parent, parent_start)) = stack.pop() else {
break;
};
entry = parent;
start = parent_start;
path.pop();
}
Box::new(result.into_iter())
}
fn repo_changed(&self) {
self.event_emitter
.try_send(self.dot_git_dir.clone())
.expect("Dropped repo change notification")
}
}
fn chunks(rope: &Rope, line_ending: LineEnding) -> impl Iterator<Item = &str> {
rope.chunks().flat_map(move |chunk| {
let mut newline = false;

View File

@@ -1,4 +1,4 @@
use crate::status::FileStatus;
use crate::status::{FileStatus, StatusCode, TrackedStatus};
use crate::GitHostingProviderRegistry;
use crate::{blame::Blame, status::GitStatus};
use anyhow::{anyhow, Context as _, Result};
@@ -31,7 +31,7 @@ pub trait GitRepository: Send + Sync {
/// Loads a git repository entry's contents.
/// Note that for symlink entries, this will return the contents of the symlink, not the target.
fn load_index_text(&self, relative_file_path: &Path) -> Option<String>;
fn load_index_text(&self, relative_file_path: &RepoPath) -> Option<String>;
/// Returns the URL of the remote with the given name.
fn remote_url(&self, name: &str) -> Option<String>;
@@ -106,7 +106,7 @@ impl GitRepository for RealGitRepository {
repo.path().into()
}
fn load_index_text(&self, relative_file_path: &Path) -> Option<String> {
fn load_index_text(&self, relative_file_path: &RepoPath) -> Option<String> {
fn logic(repo: &git2::Repository, relative_file_path: &Path) -> Result<Option<String>> {
const STAGE_NORMAL: i32 = 0;
let index = repo.index()?;
@@ -307,17 +307,38 @@ pub struct FakeGitRepository {
state: Arc<Mutex<FakeGitRepositoryState>>,
}
#[derive(Debug, Clone)]
/// Stub for the Fs trait to break what would otherwise be a circular dependency between fs and git.
pub trait FakeGitRepositoryFs: Send + Sync {
fn dot_git_dir(&self) -> PathBuf;
fn read_path(&self, path: &RepoPath) -> Option<String>;
fn all_paths(&self) -> Box<dyn Iterator<Item = (RepoPath, String)>>;
fn repo_changed(&self);
}
#[derive(Clone)]
pub struct FakeGitRepositoryState {
pub dot_git_dir: PathBuf,
pub event_emitter: smol::channel::Sender<PathBuf>,
pub index_contents: HashMap<PathBuf, String>,
pub blames: HashMap<PathBuf, Blame>,
pub fake_fs: Arc<dyn FakeGitRepositoryFs>,
pub head_contents: HashMap<RepoPath, String>,
pub index_contents: HashMap<RepoPath, String>,
pub blames: HashMap<RepoPath, Blame>,
pub statuses: HashMap<RepoPath, FileStatus>,
pub current_branch_name: Option<String>,
pub branches: HashSet<String>,
}
impl std::fmt::Debug for FakeGitRepositoryState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("FakeGitRepositoryState")
.field("head_contents", &self.head_contents)
.field("index_contents", &self.index_contents)
.field("blames", &self.blames)
.field("statuses", &self.statuses)
.field("current_branch_name", &self.current_branch_name)
.field("branches", &self.branches)
.finish()
}
}
impl FakeGitRepository {
pub fn open(state: Arc<Mutex<FakeGitRepositoryState>>) -> Arc<dyn GitRepository> {
Arc::new(FakeGitRepository { state })
@@ -325,10 +346,10 @@ impl FakeGitRepository {
}
impl FakeGitRepositoryState {
pub fn new(dot_git_dir: PathBuf, event_emitter: smol::channel::Sender<PathBuf>) -> Self {
pub fn new(fake_fs: Arc<dyn FakeGitRepositoryFs>) -> Self {
FakeGitRepositoryState {
dot_git_dir,
event_emitter,
fake_fs,
head_contents: Default::default(),
index_contents: Default::default(),
blames: Default::default(),
statuses: Default::default(),
@@ -336,12 +357,79 @@ impl FakeGitRepositoryState {
branches: Default::default(),
}
}
fn set_statuses(&mut self) {
let mut paths = self
.head_contents
.iter()
.map(|(path, contents)| (path.clone(), Some(contents.clone()), None, None))
.chain(
self.index_contents
.iter()
.map(|(path, contents)| (path.clone(), None, Some(contents.clone()), None)),
)
.chain(
self.fake_fs
.all_paths()
.map(|(path, contents)| (path, None, None, Some(contents))),
)
.collect::<Vec<_>>();
paths.sort_by(|(a, _, _, _), (b, _, _, _)| a.cmp(&b));
paths.dedup_by(
|(a, head_a, index_a, worktree_a), (b, head_b, index_b, worktree_b)| {
if a != b {
return false;
}
*head_b = head_b.take().or(head_a.take());
*index_b = index_b.take().or(index_a.take());
*worktree_b = worktree_b.take().or(worktree_a.take());
true
},
);
self.statuses.clear();
self.statuses.extend(
paths
.into_iter()
.filter_map(|(path, head, index, worktree)| {
fn status_code(a: &Option<String>, b: &Option<String>) -> StatusCode {
match (a, b) {
(None, None) => StatusCode::Unmodified,
(Some(a), Some(b)) => {
if a == b {
StatusCode::Modified
} else {
StatusCode::Unmodified
}
}
(None, Some(_)) => StatusCode::Added,
(Some(_), None) => StatusCode::Deleted,
}
}
let status = match (head, index, worktree) {
(None, None, None) => return None,
(Some(head), Some(index), Some(worktree))
if head == index && index == worktree =>
{
return None
}
(None, None, Some(_)) => FileStatus::Untracked,
(head, index, worktree) => TrackedStatus {
index_status: status_code(&head, &index),
worktree_status: status_code(&index, &worktree),
}
.into(),
};
Some((path, status))
}),
)
}
}
impl GitRepository for FakeGitRepository {
fn reload_index(&self) {}
fn load_index_text(&self, path: &Path) -> Option<String> {
fn load_index_text(&self, path: &RepoPath) -> Option<String> {
let state = self.state.lock();
state.index_contents.get(path).cloned()
}
@@ -360,8 +448,7 @@ impl GitRepository for FakeGitRepository {
}
fn dot_git_dir(&self) -> PathBuf {
let state = self.state.lock();
state.dot_git_dir.clone()
self.state.lock().fake_fs.dot_git_dir()
}
fn status(&self, path_prefixes: &[RepoPath]) -> Result<GitStatus> {
@@ -410,20 +497,14 @@ impl GitRepository for FakeGitRepository {
fn change_branch(&self, name: &str) -> Result<()> {
let mut state = self.state.lock();
state.current_branch_name = Some(name.to_owned());
state
.event_emitter
.try_send(state.dot_git_dir.clone())
.expect("Dropped repo change event");
state.fake_fs.repo_changed();
Ok(())
}
fn create_branch(&self, name: &str) -> Result<()> {
let mut state = self.state.lock();
state.branches.insert(name.to_owned());
state
.event_emitter
.try_send(state.dot_git_dir.clone())
.expect("Dropped repo change event");
state.fake_fs.repo_changed();
Ok(())
}
@@ -436,12 +517,38 @@ impl GitRepository for FakeGitRepository {
.cloned()
}
fn stage_paths(&self, _paths: &[RepoPath]) -> Result<()> {
unimplemented!()
fn stage_paths(&self, paths: &[RepoPath]) -> Result<()> {
let mut state = self.state.lock();
for path in paths {
match state.fake_fs.read_path(path) {
Some(content) => {
state.index_contents.insert(path.clone(), content);
}
None => {
state.index_contents.remove(path);
}
}
}
state.fake_fs.repo_changed();
Ok(())
}
fn unstage_paths(&self, _paths: &[RepoPath]) -> Result<()> {
unimplemented!()
fn unstage_paths(&self, paths: &[RepoPath]) -> Result<()> {
let mut state = self.state.lock();
for path in paths {
let content = state.head_contents.get(path).cloned();
match content {
Some(content) => {
state.index_contents.insert(path.clone(), content);
}
None => {
state.index_contents.remove(path);
}
}
}
state.set_statuses();
state.fake_fs.repo_changed();
Ok(())
}
fn commit(&self, _message: &str) -> Result<()> {

View File

@@ -3213,6 +3213,59 @@ async fn test_propagate_statuses_for_nested_repos(cx: &mut TestAppContext) {
);
}
#[gpui::test]
async fn test_fake_git_repository_fs(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.background_executor.clone());
fs.insert_tree(
"/root",
json!({
"x": {
".git": {},
"x1.txt": "foo",
"x2.txt": "bar",
"y": {
".git": {},
"y1.txt": "baz",
"y2.txt": "qux"
},
"z.txt": "sneaky..."
},
}),
)
.await;
fs.with_git_state("/root/x/.git".as_ref(), false, |repo_state| {
let all_paths = repo_state.fake_fs.all_paths().collect::<Vec<_>>();
assert_eq!(
all_paths,
&[
("x1.txt".into(), "foo".to_owned()),
("x2.txt".into(), "bar".to_owned()),
("z.txt".into(), "sneaky...".to_owned())
]
);
});
}
//#[gpui::test]
//async fn test_git_status_correspondence(mut rng: StdRng, cx: &mut TestAppContext) {
// // operations
// // - take something that's staged and unstage it
// // - take something that's unstaged and stage it
// // - take something that's modified from the index and revert it
// // - take something that's unmodified from the index and modify it
// init_test(cx);
// let fake = FakeFs::new(cx.background_executor.clone());
// let initial = json!({
// "a.txt": "a",
// "b.txt": "b",
// "c.txt": "c",
// });
// fake.insert_tree("/project", initial.clone()).await;
// let real = temp_tree(json!({"project": initial}));
//}
#[gpui::test]
async fn test_private_single_file_worktree(cx: &mut TestAppContext) {
init_test(cx);