git: Add trusted worktree support to git integrations (#50649)

This PR cleans up the git command spawning by wrapping everything in
GitBinary instead to follow a builder/factory pattern. It also extends
trusted workspace support to git commands.

I also added a `clippy.toml` configuration to our git crate that warns
against using `Command` struct to spawn git commands instead of going
through `GitBinary`. This should help us maintain the factory pattern in
the future

Before you mark this PR as ready for review, make sure that you have:
- [x] Added solid test coverage and/or screenshots from doing manual
testing
- [x] Done a self-review taking into account security and performance
aspects

Release Notes:

- git: Add trusted workspace support for Zed's git integration
This commit is contained in:
Anthony Eid 2026-03-04 13:19:13 +01:00 committed by GitHub
parent 0b58d34936
commit d5137d76c1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 644 additions and 365 deletions

View file

@ -20,7 +20,7 @@ use ignore::gitignore::GitignoreBuilder;
use parking_lot::Mutex;
use rope::Rope;
use smol::{channel::Sender, future::FutureExt as _};
use std::{path::PathBuf, sync::Arc};
use std::{path::PathBuf, sync::Arc, sync::atomic::AtomicBool};
use text::LineEnding;
use util::{paths::PathStyle, rel_path::RelPath};
@ -32,6 +32,7 @@ pub struct FakeGitRepository {
pub(crate) dot_git_path: PathBuf,
pub(crate) repository_dir_path: PathBuf,
pub(crate) common_dir_path: PathBuf,
pub(crate) is_trusted: Arc<AtomicBool>,
}
#[derive(Debug, Clone)]
@ -1035,4 +1036,13 @@ impl GitRepository for FakeGitRepository {
fn commit_data_reader(&self) -> Result<CommitDataReader> {
anyhow::bail!("commit_data_reader not supported for FakeGitRepository")
}
fn set_trusted(&self, trusted: bool) {
self.is_trusted
.store(trusted, std::sync::atomic::Ordering::Release);
}
fn is_trusted(&self) -> bool {
self.is_trusted.load(std::sync::atomic::Ordering::Acquire)
}
}

View file

@ -2776,6 +2776,7 @@ impl Fs for FakeFs {
repository_dir_path: repository_dir_path.to_owned(),
common_dir_path: common_dir_path.to_owned(),
checkpoints: Arc::default(),
is_trusted: Arc::default(),
}) as _
},
)

28
crates/git/clippy.toml Normal file
View file

@ -0,0 +1,28 @@
allow-private-module-inception = true
avoid-breaking-exported-api = false
ignore-interior-mutability = [
# Suppresses clippy::mutable_key_type, which is a false positive as the Eq
# and Hash impls do not use fields with interior mutability.
"agent_ui::context::AgentContextKey"
]
disallowed-methods = [
{ path = "std::process::Command::spawn", reason = "Spawning `std::process::Command` can block the current thread for an unknown duration", replacement = "smol::process::Command::spawn" },
{ path = "std::process::Command::output", reason = "Spawning `std::process::Command` can block the current thread for an unknown duration", replacement = "smol::process::Command::output" },
{ path = "std::process::Command::status", reason = "Spawning `std::process::Command` can block the current thread for an unknown duration", replacement = "smol::process::Command::status" },
{ path = "std::process::Command::stdin", reason = "`smol::process::Command::from()` does not preserve stdio configuration", replacement = "smol::process::Command::stdin" },
{ path = "std::process::Command::stdout", reason = "`smol::process::Command::from()` does not preserve stdio configuration", replacement = "smol::process::Command::stdout" },
{ path = "std::process::Command::stderr", reason = "`smol::process::Command::from()` does not preserve stdio configuration", replacement = "smol::process::Command::stderr" },
{ path = "smol::Timer::after", reason = "smol::Timer introduces non-determinism in tests", replacement = "gpui::BackgroundExecutor::timer" },
{ path = "serde_json::from_reader", reason = "Parsing from a buffer is much slower than first reading the buffer into a Vec/String, see https://github.com/serde-rs/json/issues/160#issuecomment-253446892. Use `serde_json::from_slice` instead." },
{ path = "serde_json_lenient::from_reader", reason = "Parsing from a buffer is much slower than first reading the buffer into a Vec/String, see https://github.com/serde-rs/json/issues/160#issuecomment-253446892, Use `serde_json_lenient::from_slice` instead." },
{ path = "cocoa::foundation::NSString::alloc", reason = "NSString must be autoreleased to avoid memory leaks. Use `ns_string()` helper instead." },
{ path = "smol::process::Command::new", reason = "Git commands must go through `GitBinary::build_command` to ensure security flags like `-c core.fsmonitor=false` are always applied.", replacement = "GitBinary::build_command" },
{ path = "util::command::new_command", reason = "Git commands must go through `GitBinary::build_command` to ensure security flags like `-c core.fsmonitor=false` are always applied.", replacement = "GitBinary::build_command" },
{ path = "util::command::Command::new", reason = "Git commands must go through `GitBinary::build_command` to ensure security flags like `-c core.fsmonitor=false` are always applied.", replacement = "GitBinary::build_command" },
]
disallowed-types = [
# { path = "std::collections::HashMap", replacement = "collections::HashMap" },
# { path = "std::collections::HashSet", replacement = "collections::HashSet" },
# { path = "indexmap::IndexSet", replacement = "collections::IndexSet" },
# { path = "indexmap::IndexMap", replacement = "collections::IndexMap" },
]

View file

@ -1,11 +1,11 @@
use crate::Oid;
use crate::commit::get_messages;
use crate::repository::RepoPath;
use crate::repository::{GitBinary, RepoPath};
use anyhow::{Context as _, Result};
use collections::{HashMap, HashSet};
use futures::AsyncWriteExt;
use serde::{Deserialize, Serialize};
use std::{ops::Range, path::Path};
use std::ops::Range;
use text::{LineEnding, Rope};
use time::OffsetDateTime;
use time::UtcOffset;
@ -21,15 +21,13 @@ pub struct Blame {
}
impl Blame {
pub async fn for_path(
git_binary: &Path,
working_directory: &Path,
pub(crate) async fn for_path(
git: &GitBinary,
path: &RepoPath,
content: &Rope,
line_ending: LineEnding,
) -> Result<Self> {
let output =
run_git_blame(git_binary, working_directory, path, content, line_ending).await?;
let output = run_git_blame(git, path, content, line_ending).await?;
let mut entries = parse_git_blame(&output)?;
entries.sort_unstable_by(|a, b| a.range.start.cmp(&b.range.start));
@ -40,7 +38,7 @@ impl Blame {
}
let shas = unique_shas.into_iter().collect::<Vec<_>>();
let messages = get_messages(working_directory, &shas)
let messages = get_messages(git, &shas)
.await
.context("failed to get commit messages")?;
@ -52,8 +50,7 @@ const GIT_BLAME_NO_COMMIT_ERROR: &str = "fatal: no such ref: HEAD";
const GIT_BLAME_NO_PATH: &str = "fatal: no such path";
async fn run_git_blame(
git_binary: &Path,
working_directory: &Path,
git: &GitBinary,
path: &RepoPath,
contents: &Rope,
line_ending: LineEnding,
@ -61,12 +58,7 @@ async fn run_git_blame(
let mut child = {
let span = ztracing::debug_span!("spawning git-blame command", path = path.as_unix_str());
let _enter = span.enter();
util::command::new_command(git_binary)
.current_dir(working_directory)
.arg("blame")
.arg("--incremental")
.arg("--contents")
.arg("-")
git.build_command(["blame", "--incremental", "--contents", "-"])
.arg(path.as_unix_str())
.stdin(Stdio::piped())
.stdout(Stdio::piped())

View file

@ -1,11 +1,11 @@
use crate::{
BuildCommitPermalinkParams, GitHostingProviderRegistry, GitRemote, Oid, parse_git_remote_url,
status::StatusCode,
repository::GitBinary, status::StatusCode,
};
use anyhow::{Context as _, Result};
use collections::HashMap;
use gpui::SharedString;
use std::{path::Path, sync::Arc};
use std::sync::Arc;
#[derive(Clone, Debug, Default)]
pub struct ParsedCommitMessage {
@ -48,7 +48,7 @@ impl ParsedCommitMessage {
}
}
pub async fn get_messages(working_directory: &Path, shas: &[Oid]) -> Result<HashMap<Oid, String>> {
pub(crate) async fn get_messages(git: &GitBinary, shas: &[Oid]) -> Result<HashMap<Oid, String>> {
if shas.is_empty() {
return Ok(HashMap::default());
}
@ -63,12 +63,12 @@ pub async fn get_messages(working_directory: &Path, shas: &[Oid]) -> Result<Hash
let mut result = vec![];
for shas in shas.chunks(MAX_ENTRIES_PER_INVOCATION) {
let partial = get_messages_impl(working_directory, shas).await?;
let partial = get_messages_impl(git, shas).await?;
result.extend(partial);
}
result
} else {
get_messages_impl(working_directory, shas).await?
get_messages_impl(git, shas).await?
};
Ok(shas
@ -78,11 +78,10 @@ pub async fn get_messages(working_directory: &Path, shas: &[Oid]) -> Result<Hash
.collect::<HashMap<Oid, String>>())
}
async fn get_messages_impl(working_directory: &Path, shas: &[Oid]) -> Result<Vec<String>> {
async fn get_messages_impl(git: &GitBinary, shas: &[Oid]) -> Result<Vec<String>> {
const MARKER: &str = "<MARKER>";
let output = util::command::new_command("git")
.current_dir(working_directory)
.arg("show")
let output = git
.build_command(["show"])
.arg("-s")
.arg(format!("--format=%B{}", MARKER))
.args(shas.iter().map(ToString::to_string))

File diff suppressed because it is too large Load diff

View file

@ -6,6 +6,9 @@ pub mod pending_op;
use crate::{
ProjectEnvironment, ProjectItem, ProjectPath,
buffer_store::{BufferStore, BufferStoreEvent},
trusted_worktrees::{
PathTrust, TrustedWorktrees, TrustedWorktreesEvent, TrustedWorktreesStore,
},
worktree_store::{WorktreeStore, WorktreeStoreEvent},
};
use anyhow::{Context as _, Result, anyhow, bail};
@ -354,6 +357,7 @@ impl LocalRepositoryState {
dot_git_abs_path: Arc<Path>,
project_environment: WeakEntity<ProjectEnvironment>,
fs: Arc<dyn Fs>,
is_trusted: bool,
cx: &mut AsyncApp,
) -> anyhow::Result<Self> {
let environment = project_environment
@ -381,6 +385,7 @@ impl LocalRepositoryState {
}
})
.await?;
backend.set_trusted(is_trusted);
Ok(LocalRepositoryState {
backend,
environment: Arc::new(environment),
@ -495,11 +500,15 @@ impl GitStore {
state: GitStoreState,
cx: &mut Context<Self>,
) -> Self {
let _subscriptions = vec![
let mut _subscriptions = vec![
cx.subscribe(&worktree_store, Self::on_worktree_store_event),
cx.subscribe(&buffer_store, Self::on_buffer_store_event),
];
if let Some(trusted_worktrees) = TrustedWorktrees::try_get_global(cx) {
_subscriptions.push(cx.subscribe(&trusted_worktrees, Self::on_trusted_worktrees_event));
}
GitStore {
state,
buffer_store,
@ -1517,6 +1526,13 @@ impl GitStore {
let original_repo_abs_path: Arc<Path> =
git::repository::original_repo_path_from_common_dir(common_dir_abs_path).into();
let id = RepositoryId(next_repository_id.fetch_add(1, atomic::Ordering::Release));
let is_trusted = TrustedWorktrees::try_get_global(cx)
.map(|trusted_worktrees| {
trusted_worktrees.update(cx, |trusted_worktrees, cx| {
trusted_worktrees.can_trust(&self.worktree_store, worktree_id, cx)
})
})
.unwrap_or(false);
let git_store = cx.weak_entity();
let repo = cx.new(|cx| {
let mut repo = Repository::local(
@ -1526,6 +1542,7 @@ impl GitStore {
dot_git_abs_path.clone(),
project_environment.downgrade(),
fs.clone(),
is_trusted,
git_store,
cx,
);
@ -1566,6 +1583,39 @@ impl GitStore {
}
}
fn on_trusted_worktrees_event(
&mut self,
_: Entity<TrustedWorktreesStore>,
event: &TrustedWorktreesEvent,
cx: &mut Context<Self>,
) {
if !matches!(self.state, GitStoreState::Local { .. }) {
return;
}
let (is_trusted, event_paths) = match event {
TrustedWorktreesEvent::Trusted(_, trusted_paths) => (true, trusted_paths),
TrustedWorktreesEvent::Restricted(_, restricted_paths) => (false, restricted_paths),
};
for (repo_id, worktree_ids) in &self.worktree_ids {
if worktree_ids
.iter()
.any(|worktree_id| event_paths.contains(&PathTrust::Worktree(*worktree_id)))
{
if let Some(repo) = self.repositories.get(repo_id) {
let repository_state = repo.read(cx).repository_state.clone();
cx.background_spawn(async move {
if let Ok(RepositoryState::Local(state)) = repository_state.await {
state.backend.set_trusted(is_trusted);
}
})
.detach();
}
}
}
}
fn on_buffer_store_event(
&mut self,
_: Entity<BufferStore>,
@ -3763,6 +3813,13 @@ impl MergeDetails {
}
impl Repository {
pub fn is_trusted(&self) -> bool {
match self.repository_state.peek() {
Some(Ok(RepositoryState::Local(state))) => state.backend.is_trusted(),
_ => false,
}
}
pub fn snapshot(&self) -> RepositorySnapshot {
self.snapshot.clone()
}
@ -3788,6 +3845,7 @@ impl Repository {
dot_git_abs_path: Arc<Path>,
project_environment: WeakEntity<ProjectEnvironment>,
fs: Arc<dyn Fs>,
is_trusted: bool,
git_store: WeakEntity<GitStore>,
cx: &mut Context<Self>,
) -> Self {
@ -3804,6 +3862,7 @@ impl Repository {
dot_git_abs_path,
project_environment,
fs,
is_trusted,
cx,
)
.await

View file

@ -1293,3 +1293,208 @@ mod git_worktrees {
use crate::Project;
}
mod trust_tests {
use collections::HashSet;
use fs::FakeFs;
use gpui::TestAppContext;
use project::trusted_worktrees::*;
use serde_json::json;
use settings::SettingsStore;
use util::path;
use crate::Project;
fn init_test(cx: &mut TestAppContext) {
zlog::init_test();
cx.update(|cx| {
let settings_store = SettingsStore::test(cx);
cx.set_global(settings_store);
});
}
#[gpui::test]
async fn test_repository_defaults_to_untrusted_without_trust_system(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.background_executor.clone());
fs.insert_tree(
path!("/project"),
json!({
".git": {},
"a.txt": "hello",
}),
)
.await;
// Create project without trust system — repos should default to untrusted.
let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
cx.executor().run_until_parked();
let repository = project.read_with(cx, |project, cx| {
project.repositories(cx).values().next().unwrap().clone()
});
repository.read_with(cx, |repo, _| {
assert!(
!repo.is_trusted(),
"repository should default to untrusted when no trust system is initialized"
);
});
}
#[gpui::test]
async fn test_multiple_repos_trust_with_single_worktree(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.background_executor.clone());
fs.insert_tree(
path!("/project"),
json!({
".git": {},
"a.txt": "hello",
"sub": {
".git": {},
"b.txt": "world",
},
}),
)
.await;
cx.update(|cx| {
init(DbTrustedPaths::default(), cx);
});
let project =
Project::test_with_worktree_trust(fs.clone(), [path!("/project").as_ref()], cx).await;
cx.executor().run_until_parked();
let worktree_store = project.read_with(cx, |project, _| project.worktree_store());
let worktree_id = worktree_store.read_with(cx, |store, cx| {
store.worktrees().next().unwrap().read(cx).id()
});
let repos = project.read_with(cx, |project, cx| {
project
.repositories(cx)
.values()
.cloned()
.collect::<Vec<_>>()
});
assert_eq!(repos.len(), 2, "should have two repositories");
for repo in &repos {
repo.read_with(cx, |repo, _| {
assert!(
!repo.is_trusted(),
"all repos should be untrusted initially"
);
});
}
let trusted_worktrees = cx
.update(|cx| TrustedWorktrees::try_get_global(cx).expect("trust global should be set"));
trusted_worktrees.update(cx, |store, cx| {
store.trust(
&worktree_store,
HashSet::from_iter([PathTrust::Worktree(worktree_id)]),
cx,
);
});
cx.executor().run_until_parked();
for repo in &repos {
repo.read_with(cx, |repo, _| {
assert!(
repo.is_trusted(),
"all repos should be trusted after worktree is trusted"
);
});
}
}
#[gpui::test]
async fn test_repository_trust_restrict_trust_cycle(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.background_executor.clone());
fs.insert_tree(
path!("/project"),
json!({
".git": {},
"a.txt": "hello",
}),
)
.await;
cx.update(|cx| {
project::trusted_worktrees::init(DbTrustedPaths::default(), cx);
});
let project =
Project::test_with_worktree_trust(fs.clone(), [path!("/project").as_ref()], cx).await;
cx.executor().run_until_parked();
let worktree_store = project.read_with(cx, |project, _| project.worktree_store());
let worktree_id = worktree_store.read_with(cx, |store, cx| {
store.worktrees().next().unwrap().read(cx).id()
});
let repository = project.read_with(cx, |project, cx| {
project.repositories(cx).values().next().unwrap().clone()
});
repository.read_with(cx, |repo, _| {
assert!(!repo.is_trusted(), "repository should start untrusted");
});
let trusted_worktrees = cx
.update(|cx| TrustedWorktrees::try_get_global(cx).expect("trust global should be set"));
trusted_worktrees.update(cx, |store, cx| {
store.trust(
&worktree_store,
HashSet::from_iter([PathTrust::Worktree(worktree_id)]),
cx,
);
});
cx.executor().run_until_parked();
repository.read_with(cx, |repo, _| {
assert!(
repo.is_trusted(),
"repository should be trusted after worktree is trusted"
);
});
trusted_worktrees.update(cx, |store, cx| {
store.restrict(
worktree_store.downgrade(),
HashSet::from_iter([PathTrust::Worktree(worktree_id)]),
cx,
);
});
cx.executor().run_until_parked();
repository.read_with(cx, |repo, _| {
assert!(
!repo.is_trusted(),
"repository should be untrusted after worktree is restricted"
);
});
trusted_worktrees.update(cx, |store, cx| {
store.trust(
&worktree_store,
HashSet::from_iter([PathTrust::Worktree(worktree_id)]),
cx,
);
});
cx.executor().run_until_parked();
repository.read_with(cx, |repo, _| {
assert!(
repo.is_trusted(),
"repository should be trusted again after second trust"
);
});
}
}