Fix some syncing issues with git statuses (#25535)

Like the real app, this one infinite loops if you have a diff in an
UnsharedFile.

Release Notes:

- N/A *or* Added/Fixed/Improved ...

---------

Co-authored-by: Max Brunsfeld <maxbrunsfeld@gmail.com>
This commit is contained in:
Conrad Irwin 2025-02-25 22:09:02 -07:00 committed by GitHub
parent 88baf171c3
commit 08539b32d0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 283 additions and 6 deletions

1
Cargo.lock generated
View file

@ -2833,6 +2833,7 @@ dependencies = [
"futures 0.3.31", "futures 0.3.31",
"git", "git",
"git_hosting_providers", "git_hosting_providers",
"git_ui",
"google_ai", "google_ai",
"gpui", "gpui",
"hex", "hex",

View file

@ -97,6 +97,7 @@ extension.workspace = true
file_finder.workspace = true file_finder.workspace = true
fs = { workspace = true, features = ["test-support"] } fs = { workspace = true, features = ["test-support"] }
git = { workspace = true, features = ["test-support"] } git = { workspace = true, features = ["test-support"] }
git_ui = { workspace = true, features = ["test-support"] }
git_hosting_providers.workspace = true git_hosting_providers.workspace = true
gpui = { workspace = true, features = ["test-support"] } gpui = { workspace = true, features = ["test-support"] }
hyper.workspace = true hyper.workspace = true

View file

@ -13,6 +13,7 @@ mod channel_message_tests;
mod channel_tests; mod channel_tests;
mod editor_tests; mod editor_tests;
mod following_tests; mod following_tests;
mod git_tests;
mod integration_tests; mod integration_tests;
mod notification_tests; mod notification_tests;
mod random_channel_buffer_tests; mod random_channel_buffer_tests;

View file

@ -0,0 +1,130 @@
use std::{
path::{Path, PathBuf},
sync::Arc,
};
use call::ActiveCall;
use git::status::{FileStatus, StatusCode, TrackedStatus};
use git_ui::project_diff::ProjectDiff;
use gpui::{TestAppContext, VisualTestContext};
use project::ProjectPath;
use serde_json::json;
use workspace::Workspace;
//
use crate::tests::TestServer;
#[gpui::test]
async fn test_project_diff(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) {
let mut server = TestServer::start(cx_a.background_executor.clone()).await;
let client_a = server.create_client(cx_a, "user_a").await;
let client_b = server.create_client(cx_b, "user_b").await;
cx_a.set_name("cx_a");
cx_b.set_name("cx_b");
server
.create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)])
.await;
client_a
.fs()
.insert_tree(
"/a",
json!({
".git": {},
"changed.txt": "after\n",
"unchanged.txt": "unchanged\n",
"created.txt": "created\n",
"secret.pem": "secret-changed\n",
}),
)
.await;
client_a.fs().set_git_content_for_repo(
Path::new("/a/.git"),
&[
("changed.txt".into(), "before\n".to_string(), None),
("unchanged.txt".into(), "unchanged\n".to_string(), None),
("deleted.txt".into(), "deleted\n".to_string(), None),
("secret.pem".into(), "shh\n".to_string(), None),
],
);
let (project_a, worktree_id) = client_a.build_local_project("/a", cx_a).await;
let active_call_a = cx_a.read(ActiveCall::global);
let project_id = active_call_a
.update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
.await
.unwrap();
cx_b.update(editor::init);
cx_b.update(git_ui::init);
let project_b = client_b.join_remote_project(project_id, cx_b).await;
let workspace_b = cx_b.add_window(|window, cx| {
Workspace::new(
None,
project_b.clone(),
client_b.app_state.clone(),
window,
cx,
)
});
let cx_b = &mut VisualTestContext::from_window(*workspace_b, cx_b);
let workspace_b = workspace_b.root(cx_b).unwrap();
cx_b.update(|window, cx| {
window
.focused(cx)
.unwrap()
.dispatch_action(&git_ui::project_diff::Diff, window, cx)
});
let diff = workspace_b.update(cx_b, |workspace, cx| {
workspace.active_item(cx).unwrap().act_as::<ProjectDiff>(cx)
});
let diff = diff.unwrap();
cx_b.run_until_parked();
diff.update(cx_b, |diff, cx| {
assert_eq!(
diff.excerpt_paths(cx),
vec!["changed.txt", "deleted.txt", "created.txt"]
);
});
client_a
.fs()
.insert_tree(
"/a",
json!({
".git": {},
"changed.txt": "before\n",
"unchanged.txt": "changed\n",
"created.txt": "created\n",
"secret.pem": "secret-changed\n",
}),
)
.await;
client_a.fs().recalculate_git_status(Path::new("/a/.git"));
cx_b.run_until_parked();
project_b.update(cx_b, |project, cx| {
let project_path = ProjectPath {
worktree_id,
path: Arc::from(PathBuf::from("unchanged.txt")),
};
let status = project.project_path_git_status(&project_path, cx);
assert_eq!(
status.unwrap(),
FileStatus::Tracked(TrackedStatus {
worktree_status: StatusCode::Modified,
index_status: StatusCode::Unmodified,
})
);
});
diff.update(cx_b, |diff, cx| {
assert_eq!(
diff.excerpt_paths(cx),
vec!["deleted.txt", "unchanged.txt", "created.txt"]
);
});
}

View file

@ -5,12 +5,20 @@ mod mac_watcher;
pub mod fs_watcher; pub mod fs_watcher;
use anyhow::{anyhow, Context as _, Result}; 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;
use git::GitHostingProviderRegistry; use git::GitHostingProviderRegistry;
#[cfg(any(test, feature = "test-support"))] #[cfg(any(test, feature = "test-support"))]
use git::{repository::RepoPath, status::FileStatus}; use git::{repository::RepoPath, status::FileStatus};
#[cfg(any(target_os = "linux", target_os = "freebsd"))] #[cfg(any(target_os = "linux", target_os = "freebsd"))]
use ashpd::desktop::trash; use ashpd::desktop::trash;
#[cfg(any(test, feature = "test-support"))]
use std::collections::HashSet;
#[cfg(unix)] #[cfg(unix)]
use std::os::fd::AsFd; use std::os::fd::AsFd;
#[cfg(unix)] #[cfg(unix)]
@ -1292,6 +1300,105 @@ impl FakeFs {
}); });
} }
pub fn set_git_content_for_repo(
&self,
dot_git: &Path,
head_state: &[(RepoPath, String, Option<String>)],
) {
self.with_git_state(dot_git, true, |state| {
state.head_contents.clear();
state.head_contents.extend(
head_state
.iter()
.map(|(path, head_content, _)| (path.clone(), head_content.clone())),
);
state.index_contents.clear();
state.index_contents.extend(head_state.iter().map(
|(path, head_content, index_content)| {
(
path.clone(),
index_content.as_ref().unwrap_or(head_content).clone(),
)
},
));
});
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_blame_for_repo(&self, dot_git: &Path, blames: Vec<(RepoPath, git::blame::Blame)>) { pub fn set_blame_for_repo(&self, dot_git: &Path, blames: Vec<(RepoPath, git::blame::Blame)>) {
self.with_git_state(dot_git, true, |state| { self.with_git_state(dot_git, true, |state| {
state.blames.clear(); state.blames.clear();

View file

@ -49,3 +49,4 @@ windows.workspace = true
[features] [features]
default = [] default = []
test-support = ["multi_buffer/test-support"]

View file

@ -33,7 +33,7 @@ use crate::git_panel::{GitPanel, GitPanelAddon, GitStatusEntry};
actions!(git, [Diff]); actions!(git, [Diff]);
pub(crate) struct ProjectDiff { pub struct ProjectDiff {
multibuffer: Entity<MultiBuffer>, multibuffer: Entity<MultiBuffer>,
editor: Entity<Editor>, editor: Entity<Editor>,
project: Entity<Project>, project: Entity<Project>,
@ -438,6 +438,15 @@ impl ProjectDiff {
Ok(()) Ok(())
} }
#[cfg(any(test, feature = "test-support"))]
pub fn excerpt_paths(&self, cx: &App) -> Vec<String> {
self.multibuffer
.read(cx)
.excerpt_paths()
.map(|key| key.path().to_string_lossy().to_string())
.collect()
}
} }
impl EventEmitter<EditorEvent> for ProjectDiff {} impl EventEmitter<EditorEvent> for ProjectDiff {}

View file

@ -165,6 +165,10 @@ impl PathKey {
pub fn namespaced(namespace: &'static str, path: Arc<Path>) -> Self { pub fn namespaced(namespace: &'static str, path: Arc<Path>) -> Self {
Self { namespace, path } Self { namespace, path }
} }
pub fn path(&self) -> &Arc<Path> {
&self.path
}
} }
pub type MultiBufferPoint = Point; pub type MultiBufferPoint = Point;
@ -1453,6 +1457,11 @@ impl MultiBuffer {
excerpt.range.context.start, excerpt.range.context.start,
)) ))
} }
pub fn excerpt_paths(&self) -> impl Iterator<Item = &PathKey> {
self.buffers_by_path.keys()
}
/// Sets excerpts, returns `true` if at least one new excerpt was added. /// Sets excerpts, returns `true` if at least one new excerpt was added.
pub fn set_excerpts_for_path( pub fn set_excerpts_for_path(
&mut self, &mut self,

View file

@ -88,6 +88,7 @@ pub enum Message {
Fetch(GitRepo), Fetch(GitRepo),
} }
#[derive(Debug)]
pub enum GitEvent { pub enum GitEvent {
ActiveRepositoryChanged, ActiveRepositoryChanged,
FileSystemUpdated, FileSystemUpdated,

View file

@ -377,7 +377,7 @@ impl WorktreeStore {
match event { match event {
worktree::Event::UpdatedEntries(changes) => { worktree::Event::UpdatedEntries(changes) => {
cx.emit(WorktreeStoreEvent::WorktreeUpdatedEntries( cx.emit(WorktreeStoreEvent::WorktreeUpdatedEntries(
worktree.read(cx).id(), worktree_id,
changes.clone(), changes.clone(),
)); ));
} }

View file

@ -827,17 +827,35 @@ impl Worktree {
cx.spawn(|this, mut cx| async move { cx.spawn(|this, mut cx| async move {
while (snapshot_updated_rx.recv().await).is_some() { while (snapshot_updated_rx.recv().await).is_some() {
this.update(&mut cx, |this, cx| { this.update(&mut cx, |this, cx| {
let mut git_repos_changed = false;
let mut entries_changed = false;
let this = this.as_remote_mut().unwrap(); let this = this.as_remote_mut().unwrap();
{ {
let mut lock = this.background_snapshot.lock(); let mut lock = this.background_snapshot.lock();
this.snapshot = lock.0.clone(); this.snapshot = lock.0.clone();
if let Some(tx) = &this.update_observer { for update in lock.1.drain(..) {
for update in lock.1.drain(..) { if !update.updated_entries.is_empty()
|| !update.removed_entries.is_empty()
{
entries_changed = true;
}
if !update.updated_repositories.is_empty()
|| !update.removed_repositories.is_empty()
{
git_repos_changed = true;
}
if let Some(tx) = &this.update_observer {
tx.unbounded_send(update).ok(); tx.unbounded_send(update).ok();
} }
} }
}; };
cx.emit(Event::UpdatedEntries(Arc::default()));
if entries_changed {
cx.emit(Event::UpdatedEntries(Arc::default()));
}
if git_repos_changed {
cx.emit(Event::UpdatedGitRepositories(Arc::default()));
}
cx.notify(); cx.notify();
while let Some((scan_id, _)) = this.snapshot_subscriptions.front() { while let Some((scan_id, _)) = this.snapshot_subscriptions.front() {
if this.observed_snapshot(*scan_id) { if this.observed_snapshot(*scan_id) {
@ -5451,7 +5469,6 @@ impl BackgroundScanner {
else { else {
return; return;
}; };
log::trace!( log::trace!(
"computed git statuses for repo {repository_name} in {:?}", "computed git statuses for repo {repository_name} in {:?}",
t0.elapsed() t0.elapsed()