From 6b02e5761f4b7a3b9a297614a596555aeffcc078 Mon Sep 17 00:00:00 2001 From: Kieran Freitag Date: Wed, 8 Apr 2026 13:35:20 -0700 Subject: [PATCH 01/12] Remove git2 (libgit2) dependency Replace all libgit2 usage with the git CLI and lightweight Rust crates: - buffer_diff: Replace git2::Patch with imara-diff for line diffing - git: Replace git2::Oid with a simple [u8; 20] backed by the hex crate - git: Replace git2::Repository with stored paths (git_dir, common_dir, working_directory) and git CLI calls for index/blob/remote operations - project/util: Replace git2::Repository::init in tests with git CLI - Remove reload_index (no-op without in-memory state) This removes the libgit2-sys, libz-sys, and git2 C dependencies, eliminating ~30k lines of vendored C from the build. --- Cargo.lock | 44 +-- Cargo.toml | 1 - crates/buffer_diff/Cargo.toml | 2 +- crates/buffer_diff/src/buffer_diff.rs | 260 ++++++++---------- crates/fs/src/fake_git_repo.rs | 2 - crates/git/Cargo.toml | 2 +- crates/git/src/blame.rs | 2 - crates/git/src/git.rs | 70 +++-- crates/git/src/repository.rs | 259 +++++++++-------- crates/project/Cargo.toml | 1 - .../tests/integration/project_tests.rs | 205 ++++++++------ crates/remote_server/Cargo.toml | 3 +- crates/util/Cargo.toml | 4 +- crates/util/src/test.rs | 10 +- 14 files changed, 403 insertions(+), 462 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c6225699f1c882..066b5572585010 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2170,8 +2170,8 @@ dependencies = [ "clock", "ctor", "futures 0.3.32", - "git2", "gpui", + "imara-diff", "language", "log", "pretty_assertions", @@ -7162,8 +7162,8 @@ dependencies = [ "collections", "derive_more", "futures 0.3.32", - "git2", "gpui", + "hex", "http_client", "itertools 0.14.0", "log", @@ -7189,19 +7189,6 @@ dependencies = [ "ztracing", ] -[[package]] -name = "git2" -version = "0.20.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b88256088d75a56f8ecfa070513a775dd9107f6530ef14919dac831af9cfe2b" -dependencies = [ - "bitflags 2.10.0", - "libc", - "libgit2-sys", - "log", - "url", -] - [[package]] name = "git_graph" version = "0.1.0" @@ -9736,18 +9723,6 @@ dependencies = [ "cc", ] -[[package]] -name = "libgit2-sys" -version = "0.18.3+1.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9b3acc4b91781bb0b3386669d325163746af5f6e4f73e6d2d630e09a35f3487" -dependencies = [ - "cc", - "libc", - "libz-sys", - "pkg-config", -] - [[package]] name = "libloading" version = "0.8.9" @@ -9820,18 +9795,6 @@ dependencies = [ "webrtc-sys", ] -[[package]] -name = "libz-sys" -version = "1.1.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b70e7a7df205e92a1a4cd9aaae7898dac0aa555503cc0a649494d0d60e7651d" -dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", -] - [[package]] name = "line_ending_selector" version = "0.1.0" @@ -13292,7 +13255,6 @@ dependencies = [ "fuzzy", "fuzzy_nucleo", "git", - "git2", "git_hosting_providers", "globset", "gpui", @@ -14466,7 +14428,6 @@ dependencies = [ "fs", "futures 0.3.32", "git", - "git2", "git_hosting_providers", "gpui", "gpui_platform", @@ -19152,7 +19113,6 @@ dependencies = [ "dunce", "futures 0.3.32", "futures-lite 1.13.0", - "git2", "globset", "gpui_util", "itertools 0.14.0", diff --git a/Cargo.toml b/Cargo.toml index 925c2d32215a7b..c5cac32a62af06 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -578,7 +578,6 @@ futures = "0.3" futures-concurrency = "7.7.1" futures-lite = "1.13" gh-workflow = { git = "https://github.com/zed-industries/gh-workflow", rev = "37f3c0575d379c218a9c455ee67585184e40d43f" } -git2 = { version = "0.20.1", default-features = false, features = ["vendored-libgit2"] } globset = "0.4" heapless = "0.9.2" handlebars = "4.3" diff --git a/crates/buffer_diff/Cargo.toml b/crates/buffer_diff/Cargo.toml index da18728ed4da5c..d574b0dd3cfdb0 100644 --- a/crates/buffer_diff/Cargo.toml +++ b/crates/buffer_diff/Cargo.toml @@ -17,7 +17,7 @@ test-support = ["settings"] [dependencies] clock.workspace = true futures.workspace = true -git2.workspace = true +imara-diff.workspace = true gpui.workspace = true language.workspace = true log.workspace = true diff --git a/crates/buffer_diff/src/buffer_diff.rs b/crates/buffer_diff/src/buffer_diff.rs index c168bd2956e068..767714843b55e0 100644 --- a/crates/buffer_diff/src/buffer_diff.rs +++ b/crates/buffer_diff/src/buffer_diff.rs @@ -1,5 +1,7 @@ use futures::channel::oneshot; -use git2::{DiffLineType as GitDiffLineType, DiffOptions as GitOptions, Patch as GitPatch}; +use imara_diff::{ + Algorithm, Sink, diff as imara_diff, intern::InternedInput, sources::lines_with_terminator, +}; use gpui::{App, AppContext as _, Context, Entity, EventEmitter, Task}; use language::{ Capability, Diff, DiffOptions, Language, LanguageName, LanguageRegistry, @@ -1128,17 +1130,6 @@ fn compute_hunks( if let Some((diff_base, diff_base_rope)) = diff_base { let buffer_text = buffer.as_rope().to_string(); - let mut options = GitOptions::default(); - options.context_lines(0); - let patch = GitPatch::from_buffers( - diff_base.as_bytes(), - None, - buffer_text.as_bytes(), - None, - Some(&mut options), - ) - .log_err(); - // A common case in Zed is that the empty buffer is represented as just a newline, // but if we just compute a naive diff you get a "preserved" line in the middle, // which is a bit odd. @@ -1157,19 +1148,14 @@ fn compute_hunks( return tree; } - if let Some(patch) = patch { - let mut divergence = 0; - for hunk_index in 0..patch.num_hunks() { - let hunk = process_patch_hunk( - &patch, - hunk_index, - &diff_base_rope, - buffer, - &mut divergence, - diff_options.as_ref(), - ); - tree.push(hunk, buffer); - } + let input = InternedInput::new( + lines_with_terminator(diff_base.as_ref()), + lines_with_terminator(buffer_text.as_str()), + ); + let sink = HunkSink::new(&diff_base, &diff_base_rope, buffer, diff_options.as_ref()); + let hunks = imara_diff(Algorithm::Histogram, &input, sink); + for hunk in hunks { + tree.push(hunk, buffer); } } else { tree.push( @@ -1187,6 +1173,111 @@ fn compute_hunks( tree } +struct HunkSink<'a> { + diff_base_rope: &'a Rope, + buffer: &'a text::BufferSnapshot, + diff_options: Option<&'a DiffOptions>, + old_line_offsets: Vec, + hunks: Vec, +} + +impl<'a> HunkSink<'a> { + fn new( + diff_base: &'a str, + diff_base_rope: &'a Rope, + buffer: &'a text::BufferSnapshot, + diff_options: Option<&'a DiffOptions>, + ) -> Self { + let old_line_offsets = Self::compute_line_offsets(diff_base); + Self { + diff_base_rope, + buffer, + diff_options, + old_line_offsets, + hunks: Vec::new(), + } + } + + fn compute_line_offsets(text: &str) -> Vec { + let mut offsets = vec![0]; + let mut offset = 0; + for line in lines_with_terminator(text) { + offset += line.len(); + offsets.push(offset); + } + offsets + } +} + +impl Sink for HunkSink<'_> { + type Out = Vec; + + fn process_change(&mut self, before: Range, after: Range) { + let old_start = before.start as usize; + let old_end = before.end as usize; + let new_start = after.start as usize; + let new_end = after.end as usize; + + let diff_base_byte_range = self.old_line_offsets[old_start]..self.old_line_offsets[old_end]; + + let buffer_row_range = (new_start as u32)..(new_end as u32); + + let start = Point::new(buffer_row_range.start, 0); + let end = Point::new(buffer_row_range.end, 0); + let buffer_range = self.buffer.anchor_before(start)..self.buffer.anchor_before(end); + + let base_line_count = old_end - old_start; + let buffer_line_count = new_end - new_start; + + let (base_word_diffs, buffer_word_diffs) = if let Some(diff_options) = self.diff_options + && !buffer_row_range.is_empty() + && base_line_count == buffer_line_count + && diff_options.max_word_diff_line_count >= base_line_count + { + let base_text: String = self.diff_base_rope + .chunks_in_range(diff_base_byte_range.clone()) + .collect(); + let buffer_text: String = self.buffer.text_for_range(buffer_range.clone()).collect(); + + let (base_word_diffs, buffer_word_diffs_relative) = word_diff_ranges( + &base_text, + &buffer_text, + DiffOptions { + language_scope: diff_options.language_scope.clone(), + ..*diff_options + }, + ); + + let buffer_start_offset = buffer_range.start.to_offset(self.buffer); + let buffer_word_diffs = buffer_word_diffs_relative + .into_iter() + .map(|range| { + let start = self.buffer.anchor_after(buffer_start_offset + range.start); + let end = self.buffer.anchor_after(buffer_start_offset + range.end); + start..end + }) + .collect(); + + (base_word_diffs, buffer_word_diffs) + } else { + (Vec::default(), Vec::default()) + }; + + self.hunks.push(InternalDiffHunk { + buffer_range, + diff_base_byte_range: diff_base_byte_range.clone(), + diff_base_point_range: self.diff_base_rope.offset_to_point(diff_base_byte_range.start) + ..self.diff_base_rope.offset_to_point(diff_base_byte_range.end), + base_word_diffs, + buffer_word_diffs, + }); + } + + fn finish(self) -> Self::Out { + self.hunks + } +} + fn compare_hunks( new_hunks: &SumTree, old_hunks: &SumTree, @@ -1379,125 +1470,6 @@ fn compare_hunks( } } -fn process_patch_hunk( - patch: &GitPatch<'_>, - hunk_index: usize, - diff_base: &Rope, - buffer: &text::BufferSnapshot, - buffer_row_divergence: &mut i64, - diff_options: Option<&DiffOptions>, -) -> InternalDiffHunk { - let line_item_count = patch.num_lines_in_hunk(hunk_index).unwrap(); - assert!(line_item_count > 0); - - let mut first_deletion_buffer_row: Option = None; - let mut buffer_row_range: Option> = None; - let mut diff_base_byte_range: Option> = None; - let mut first_addition_old_row: Option = None; - - for line_index in 0..line_item_count { - let line = patch.line_in_hunk(hunk_index, line_index).unwrap(); - let kind = line.origin_value(); - let content_offset = line.content_offset() as isize; - let content_len = line.content().len() as isize; - match kind { - GitDiffLineType::Addition => { - if first_addition_old_row.is_none() { - first_addition_old_row = Some( - (line.new_lineno().unwrap() as i64 - *buffer_row_divergence - 1) as u32, - ); - } - *buffer_row_divergence += 1; - let row = line.new_lineno().unwrap().saturating_sub(1); - - match &mut buffer_row_range { - Some(Range { end, .. }) => *end = row + 1, - None => buffer_row_range = Some(row..row + 1), - } - } - GitDiffLineType::Deletion => { - let end = content_offset + content_len; - - match &mut diff_base_byte_range { - Some(head_byte_range) => head_byte_range.end = end as usize, - None => diff_base_byte_range = Some(content_offset as usize..end as usize), - } - - if first_deletion_buffer_row.is_none() { - let old_row = line.old_lineno().unwrap().saturating_sub(1); - let row = old_row as i64 + *buffer_row_divergence; - first_deletion_buffer_row = Some(row as u32); - } - - *buffer_row_divergence -= 1; - } - _ => {} - } - } - - let buffer_row_range = buffer_row_range.unwrap_or_else(|| { - // Pure deletion hunk without addition. - let row = first_deletion_buffer_row.unwrap(); - row..row - }); - let diff_base_byte_range = diff_base_byte_range.unwrap_or_else(|| { - // Pure addition hunk without deletion. - let row = first_addition_old_row.unwrap(); - let offset = diff_base.point_to_offset(Point::new(row, 0)); - offset..offset - }); - - let start = Point::new(buffer_row_range.start, 0); - let end = Point::new(buffer_row_range.end, 0); - let buffer_range = buffer.anchor_before(start)..buffer.anchor_before(end); - - let base_line_count = line_item_count.saturating_sub(buffer_row_range.len()); - - let (base_word_diffs, buffer_word_diffs) = if let Some(diff_options) = diff_options - && !buffer_row_range.is_empty() - && base_line_count == buffer_row_range.len() - && diff_options.max_word_diff_line_count >= base_line_count - { - let base_text: String = diff_base - .chunks_in_range(diff_base_byte_range.clone()) - .collect(); - - let buffer_text: String = buffer.text_for_range(buffer_range.clone()).collect(); - - let (base_word_diffs, buffer_word_diffs_relative) = word_diff_ranges( - &base_text, - &buffer_text, - DiffOptions { - language_scope: diff_options.language_scope.clone(), - ..*diff_options - }, - ); - - let buffer_start_offset = buffer_range.start.to_offset(buffer); - let buffer_word_diffs = buffer_word_diffs_relative - .into_iter() - .map(|range| { - let start = buffer.anchor_after(buffer_start_offset + range.start); - let end = buffer.anchor_after(buffer_start_offset + range.end); - start..end - }) - .collect(); - - (base_word_diffs, buffer_word_diffs) - } else { - (Vec::default(), Vec::default()) - }; - - InternalDiffHunk { - buffer_range, - diff_base_byte_range: diff_base_byte_range.clone(), - diff_base_point_range: diff_base.offset_to_point(diff_base_byte_range.start) - ..diff_base.offset_to_point(diff_base_byte_range.end), - base_word_diffs, - buffer_word_diffs, - } -} - impl std::fmt::Debug for BufferDiff { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("BufferChangeSet") diff --git a/crates/fs/src/fake_git_repo.rs b/crates/fs/src/fake_git_repo.rs index 7b89a0751f17ef..de8c7e16ff07c0 100644 --- a/crates/fs/src/fake_git_repo.rs +++ b/crates/fs/src/fake_git_repo.rs @@ -109,8 +109,6 @@ impl FakeGitRepository { } impl GitRepository for FakeGitRepository { - fn reload_index(&self) {} - fn load_index_text(&self, path: RepoPath) -> BoxFuture<'_, Option> { let fut = self.with_state_async(false, move |state| { state diff --git a/crates/git/Cargo.toml b/crates/git/Cargo.toml index 23a937bf1fa174..3d210c0d6f9052 100644 --- a/crates/git/Cargo.toml +++ b/crates/git/Cargo.toml @@ -20,8 +20,8 @@ askpass.workspace = true async-trait.workspace = true collections.workspace = true derive_more.workspace = true -git2.workspace = true gpui.workspace = true +hex.workspace = true http_client.workspace = true itertools.workspace = true log.workspace = true diff --git a/crates/git/src/blame.rs b/crates/git/src/blame.rs index 76e622fd6d7ae4..3b871ffc1d8633 100644 --- a/crates/git/src/blame.rs +++ b/crates/git/src/blame.rs @@ -12,8 +12,6 @@ use time::UtcOffset; use time::macros::format_description; use util::command::Stdio; -pub use git2 as libgit; - #[derive(Debug, Clone, Default)] pub struct Blame { pub entries: Vec, diff --git a/crates/git/src/git.rs b/crates/git/src/git.rs index 766378bf2e514d..11ea06e53f2b84 100644 --- a/crates/git/src/git.rs +++ b/crates/git/src/git.rs @@ -8,8 +8,7 @@ pub mod status; pub use crate::hosting_provider::*; pub use crate::remote::*; -use anyhow::{Context as _, Result}; -pub use git2 as libgit; +use anyhow::Result; use gpui::{Action, actions}; pub use repository::RemoteCommandOutput; use schemars::JsonSchema; @@ -131,40 +130,42 @@ pub struct RestoreFile { /// The length of a Git short SHA. pub const SHORT_SHA_LENGTH: usize = 7; -#[derive(Clone, Copy, Eq, Hash, PartialEq)] -pub struct Oid(libgit::Oid); +#[derive(Clone, Copy, Default, Eq, Hash, PartialEq)] +pub struct Oid([u8; 20]); impl Oid { pub fn from_bytes(bytes: &[u8]) -> Result { - let oid = libgit::Oid::from_bytes(bytes).context("failed to parse bytes into git oid")?; - Ok(Self(oid)) + let bytes: [u8; 20] = bytes + .try_into() + .map_err(|_| anyhow::anyhow!("expected 20 bytes for git oid, got {}", bytes.len()))?; + Ok(Self(bytes)) } #[cfg(any(test, feature = "test-support"))] pub fn random(rng: &mut impl rand::Rng) -> Self { - let mut bytes = [0; 20]; + let mut bytes = [0u8; 20]; rng.fill(&mut bytes); - Self::from_bytes(&bytes).unwrap() + Self(bytes) } pub fn as_bytes(&self) -> &[u8] { - self.0.as_bytes() + &self.0 } pub(crate) fn is_zero(&self) -> bool { - self.0.is_zero() + self.0 == [0u8; 20] } /// Returns this [`Oid`] as a short SHA. pub fn display_short(&self) -> String { - self.to_string().chars().take(SHORT_SHA_LENGTH).collect() + hex::encode(self.0)[..SHORT_SHA_LENGTH].to_string() } } impl TryFrom<&str> for Oid { type Error = anyhow::Error; - fn try_from(value: &str) -> std::prelude::v1::Result { + fn try_from(value: &str) -> std::result::Result { Oid::from_str(value) } } @@ -172,10 +173,17 @@ impl TryFrom<&str> for Oid { impl FromStr for Oid { type Err = anyhow::Error; - fn from_str(s: &str) -> std::prelude::v1::Result { - libgit::Oid::from_str(s) - .context("parsing git oid") - .map(Self) + fn from_str(s: &str) -> std::result::Result { + anyhow::ensure!( + !s.is_empty() && s.len() <= 40, + "invalid hex length {} for git oid", + s.len() + ); + let mut padded = [b'0'; 40]; + padded[..s.len()].copy_from_slice(s.as_bytes()); + let mut bytes = [0u8; 20]; + hex::decode_to_slice(&padded, &mut bytes)?; + Ok(Self(bytes)) } } @@ -187,21 +195,21 @@ impl fmt::Debug for Oid { impl fmt::Display for Oid { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - self.0.fmt(f) + f.write_str(&hex::encode(self.0)) } } impl Serialize for Oid { - fn serialize(&self, serializer: S) -> std::prelude::v1::Result + fn serialize(&self, serializer: S) -> std::result::Result where S: serde::Serializer, { - serializer.serialize_str(&self.0.to_string()) + serializer.serialize_str(&hex::encode(self.0)) } } impl<'de> Deserialize<'de> for Oid { - fn deserialize(deserializer: D) -> Result + fn deserialize(deserializer: D) -> std::result::Result where D: serde::Deserializer<'de>, { @@ -210,32 +218,18 @@ impl<'de> Deserialize<'de> for Oid { } } -impl Default for Oid { - fn default() -> Self { - Self(libgit::Oid::zero()) - } -} - impl From for u32 { fn from(oid: Oid) -> Self { - let bytes = oid.0.as_bytes(); - debug_assert!(bytes.len() > 4); - - let mut u32_bytes: [u8; 4] = [0; 4]; - u32_bytes.copy_from_slice(&bytes[..4]); - + let mut u32_bytes = [0u8; 4]; + u32_bytes.copy_from_slice(&oid.0[..4]); u32::from_ne_bytes(u32_bytes) } } impl From for usize { fn from(oid: Oid) -> Self { - let bytes = oid.0.as_bytes(); - debug_assert!(bytes.len() > 8); - - let mut u64_bytes: [u8; 8] = [0; 8]; - u64_bytes.copy_from_slice(&bytes[..8]); - + let mut u64_bytes = [0u8; 8]; + u64_bytes.copy_from_slice(&oid.0[..8]); u64::from_ne_bytes(u64_bytes) as usize } } diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs index d7049c0a50cb94..ade27e5805a00d 100644 --- a/crates/git/src/repository.rs +++ b/crates/git/src/repository.rs @@ -8,7 +8,6 @@ use futures::channel::oneshot; use futures::future::BoxFuture; use futures::io::BufWriter; use futures::{AsyncWriteExt, FutureExt as _, select_biased}; -use git2::{BranchType, ErrorCode}; use gpui::{AppContext as _, AsyncApp, BackgroundExecutor, SharedString, Task}; use parking_lot::Mutex; use rope::Rope; @@ -27,7 +26,6 @@ use std::process::ExitStatus; use std::str::FromStr; use std::{ cmp::Ordering, - future, path::{Path, PathBuf}, sync::Arc, }; @@ -694,8 +692,6 @@ pub struct SearchCommitArgs { } pub trait GitRepository: Send + Sync { - fn reload_index(&self); - /// Returns the contents of an entry in the repository's index, or None if there is no entry for the given path. /// /// Also returns `None` for symlinks. @@ -982,7 +978,9 @@ impl std::fmt::Debug for dyn GitRepository { } pub struct RealGitRepository { - pub repository: Arc>, + pub git_dir: PathBuf, + pub common_dir: PathBuf, + pub working_directory: PathBuf, pub system_git_binary_path: Option, pub any_git_binary_path: PathBuf, any_git_binary_help_output: Arc>>, @@ -1004,11 +1002,50 @@ impl RealGitRepository { log::info!( "opening git repository at {dotgit_path:?} using git binary {any_git_binary_path:?}" ); - let workdir_root = dotgit_path.parent().context(".git has no parent")?; - let repository = - git2::Repository::open(workdir_root).context("creating libgit2 repository")?; + let working_directory = dotgit_path + .parent() + .context(".git has no parent")? + .to_path_buf(); + + let git_dir = if dotgit_path.is_file() { + // Worktree gitdir file: contains `gitdir: ` + let content = + std::fs::read_to_string(dotgit_path).context("reading .git worktree file")?; + let path_str = content + .strip_prefix("gitdir: ") + .context("expected .git file to start with 'gitdir: '")? + .trim(); + let resolved = PathBuf::from(path_str); + if resolved.is_absolute() { + resolved + } else { + working_directory.join(resolved) + } + } else { + dotgit_path.to_path_buf() + }; + + let common_dir = { + let commondir_file = git_dir.join("commondir"); + if commondir_file.is_file() { + let content = + std::fs::read_to_string(&commondir_file).context("reading commondir file")?; + let path_str = content.trim(); + let resolved = PathBuf::from(path_str); + if resolved.is_absolute() { + resolved + } else { + git_dir.join(resolved) + } + } else { + git_dir.clone() + } + }; + Ok(Self { - repository: Arc::new(Mutex::new(repository)), + git_dir, + common_dir, + working_directory, system_git_binary_path, any_git_binary_path, executor, @@ -1018,11 +1055,7 @@ impl RealGitRepository { } fn working_directory(&self) -> Result { - self.repository - .lock() - .workdir() - .context("failed to read git work directory") - .map(Path::to_path_buf) + Ok(self.working_directory.clone()) } fn git_binary(&self) -> Result { @@ -1105,20 +1138,12 @@ pub async fn get_git_committer(cx: &AsyncApp) -> GitCommitter { } impl GitRepository for RealGitRepository { - fn reload_index(&self) { - if let Ok(mut index) = self.repository.lock().index() { - _ = index.read(false); - } - } - fn path(&self) -> PathBuf { - let repo = self.repository.lock(); - repo.path().into() + self.git_dir.clone() } fn main_repository_path(&self) -> PathBuf { - let repo = self.repository.lock(); - repo.commondir().into() + self.common_dir.clone() } fn show(&self, commit: String) -> BoxFuture<'_, Result> { @@ -1157,9 +1182,6 @@ impl GitRepository for RealGitRepository { } fn load_commit(&self, commit: String, cx: AsyncApp) -> BoxFuture<'_, Result> { - if self.repository.lock().workdir().is_none() { - return future::ready(Err(anyhow!("no working directory"))).boxed(); - } let git_binary = self.git_binary(); cx.background_spawn(async move { let git = git_binary?; @@ -1345,75 +1367,53 @@ impl GitRepository for RealGitRepository { } fn load_index_text(&self, path: RepoPath) -> BoxFuture<'_, Option> { - // https://git-scm.com/book/en/v2/Git-Internals-Git-Objects - const GIT_MODE_SYMLINK: u32 = 0o120000; - - let repo = self.repository.clone(); + let git_binary = self.git_binary(); + let path_str = format!(":{}", path.as_unix_str()); self.executor .spawn(async move { - fn logic(repo: &git2::Repository, path: &RepoPath) -> Result> { - let mut index = repo.index()?; - index.read(false)?; - - const STAGE_NORMAL: i32 = 0; - // git2 unwraps internally on empty paths or `.` - if path.is_empty() { - bail!("empty path has no index text"); - } - let Some(entry) = index.get_path(path.as_std_path(), STAGE_NORMAL) else { - return Ok(None); - }; - if entry.mode == GIT_MODE_SYMLINK { - return Ok(None); - } - - let content = repo.find_blob(entry.id)?.content().to_owned(); - Ok(String::from_utf8(content).ok()) + let git = git_binary.log_err()?; + let output = git + .build_command(&["show", &path_str]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .await + .log_err()?; + if !output.status.success() { + return None; } - - logic(&repo.lock(), &path) - .context("loading index text") - .log_err() - .flatten() + String::from_utf8(output.stdout).ok() }) .boxed() } fn load_committed_text(&self, path: RepoPath) -> BoxFuture<'_, Option> { - let repo = self.repository.clone(); + let git_binary = self.git_binary(); + let path_str = format!("HEAD:{}", path.as_unix_str()); self.executor .spawn(async move { - fn logic(repo: &git2::Repository, path: &RepoPath) -> Result> { - let head = repo.head()?.peel_to_tree()?; - // git2 unwraps internally on empty paths or `.` - if path.is_empty() { - return Err(anyhow!("empty path has no committed text")); - } - let Some(entry) = head.get_path(path.as_std_path()).ok() else { - return Ok(None); - }; - if entry.filemode() == i32::from(git2::FileMode::Link) { - return Ok(None); - } - let content = repo.find_blob(entry.id())?.content().to_owned(); - Ok(String::from_utf8(content).ok()) + let git = git_binary.log_err()?; + let output = git + .build_command(&["show", &path_str]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .await + .log_err()?; + if !output.status.success() { + return None; } - - logic(&repo.lock(), &path) - .context("loading committed text") - .log_err() - .flatten() + String::from_utf8(output.stdout).ok() }) .boxed() } fn load_blob_content(&self, oid: Oid) -> BoxFuture<'_, Result> { - let repo = self.repository.clone(); + let git_binary = self.git_binary(); + let oid_str = oid.to_string(); self.executor .spawn(async move { - let repo = repo.lock(); - let content = repo.find_blob(oid.0)?.content().to_owned(); - Ok(String::from_utf8(content)?) + git_binary?.run_raw(&["cat-file", "blob", &oid_str]).await }) .boxed() } @@ -1480,13 +1480,26 @@ impl GitRepository for RealGitRepository { } fn remote_url(&self, name: &str) -> BoxFuture<'_, Option> { - let repo = self.repository.clone(); + let git_binary = self.git_binary(); let name = name.to_owned(); self.executor .spawn(async move { - let repo = repo.lock(); - let remote = repo.find_remote(&name).ok()?; - remote.url().map(|url| url.to_string()) + let git = git_binary.log_err()?; + let output = git + .build_command(&["remote", "get-url", &name]) + .output() + .await + .log_err()?; + if !output.status.success() { + return None; + } + let url = String::from_utf8(output.stdout).ok()?; + let url = url.trim(); + if url.is_empty() { + None + } else { + Some(url.to_string()) + } }) .boxed() } @@ -1786,43 +1799,10 @@ impl GitRepository for RealGitRepository { } fn change_branch(&self, name: String) -> BoxFuture<'_, Result<()>> { - let repo = self.repository.clone(); let git_binary = self.git_binary(); - let branch = self.executor.spawn(async move { - let repo = repo.lock(); - let branch = if let Ok(branch) = repo.find_branch(&name, BranchType::Local) { - branch - } else if let Ok(revision) = repo.find_branch(&name, BranchType::Remote) { - let (_, branch_name) = name.split_once("/").context("Unexpected branch format")?; - - let revision = revision.get(); - let branch_commit = revision.peel_to_commit()?; - let mut branch = match repo.branch(&branch_name, &branch_commit, false) { - Ok(branch) => branch, - Err(err) if err.code() == ErrorCode::Exists => { - repo.find_branch(&branch_name, BranchType::Local)? - } - Err(err) => { - return Err(err.into()); - } - }; - - branch.set_upstream(Some(&name))?; - branch - } else { - anyhow::bail!("Branch '{}' not found", name); - }; - - Ok(branch - .name()? - .context("cannot checkout anonymous branch")? - .to_string()) - }); - self.executor .spawn(async move { - let branch = branch.await?; - git_binary?.run(&["checkout", &branch]).await?; + git_binary?.run(&["checkout", &name]).await?; anyhow::Ok(()) }) .boxed() @@ -2472,23 +2452,20 @@ impl GitRepository for RealGitRepository { } fn remove_remote(&self, name: String) -> BoxFuture<'_, Result<()>> { - let repo = self.repository.clone(); + let git_binary = self.git_binary(); self.executor .spawn(async move { - let repo = repo.lock(); - repo.remote_delete(&name)?; - + git_binary?.run(&["remote", "remove", &name]).await?; Ok(()) }) .boxed() } fn create_remote(&self, name: String, url: String) -> BoxFuture<'_, Result<()>> { - let repo = self.repository.clone(); + let git_binary = self.git_binary(); self.executor .spawn(async move { - let repo = repo.lock(); - repo.remote(&name, url.as_ref())?; + git_binary?.run(&["remote", "add", &name, &url]).await?; Ok(()) }) .boxed() @@ -2720,7 +2697,7 @@ impl GitRepository for RealGitRepository { env: Arc>, ) -> BoxFuture<'_, Result<()>> { let git_binary = self.git_binary(); - let repository = self.repository.clone(); + let git_dir = self.git_dir.clone(); let help_output = self.any_git_binary_help_output(); // Note: Do not spawn these commands on the background thread, as this causes some git hooks to hang. @@ -2733,7 +2710,7 @@ impl GitRepository for RealGitRepository { .lines() .any(|line| line.trim().starts_with("hook ")) { - let hook_abs_path = repository.lock().path().join("hooks").join(hook.as_str()); + let hook_abs_path = git_dir.join("hooks").join(hook.as_str()); if hook_abs_path.is_file() && git_binary.is_trusted { #[allow(clippy::disallowed_methods)] let output = new_command(&hook_abs_path) @@ -3351,8 +3328,6 @@ impl RepoPath { } pub fn as_std_path(&self) -> &Path { - // git2 does not like empty paths and our RelPath infra turns `.` into `` - // so undo that here if self.is_empty() { Path::new(".") } else { @@ -3505,6 +3480,18 @@ mod tests { } } + #[allow(clippy::disallowed_methods)] + fn git_init_repo(path: &Path) { + fs::create_dir_all(path).expect("failed to create repo directory"); + std::process::Command::new("git") + .args(["init", "-b", "main"]) + .current_dir(path) + .env("GIT_CONFIG_GLOBAL", "") + .env("GIT_CONFIG_SYSTEM", "") + .output() + .expect("failed to init git repo"); + } + #[gpui::test] async fn test_build_command_untrusted_includes_both_safety_args(cx: &mut TestAppContext) { cx.executor().allow_parking(); @@ -3542,7 +3529,7 @@ mod tests { "fsmonitor should be disabled for untrusted repos" ); - git2::Repository::init(dir.path()).unwrap(); + git_init_repo(dir.path()); let git = GitBinary::new( PathBuf::from("git"), dir.path().to_path_buf(), @@ -3567,7 +3554,7 @@ mod tests { async fn test_build_command_trusted_only_disables_fsmonitor(cx: &mut TestAppContext) { cx.executor().allow_parking(); let dir = tempfile::tempdir().unwrap(); - git2::Repository::init(dir.path()).unwrap(); + git_init_repo(dir.path()); let git = GitBinary::new( PathBuf::from("git"), @@ -3635,7 +3622,7 @@ mod tests { let repo_dir = tempfile::tempdir().unwrap(); - git2::Repository::init(repo_dir.path()).unwrap(); + git_init_repo(repo_dir.path()); let file_path = repo_dir.path().join("file"); smol::fs::write(&file_path, "initial").await.unwrap(); @@ -3724,7 +3711,7 @@ mod tests { cx.executor().allow_parking(); let repo_dir = tempfile::tempdir().unwrap(); - git2::Repository::init(repo_dir.path()).unwrap(); + git_init_repo(repo_dir.path()); let repo = RealGitRepository::new( &repo_dir.path().join(".git"), None, @@ -3770,7 +3757,7 @@ mod tests { cx.executor().allow_parking(); let repo_dir = tempfile::tempdir().unwrap(); - git2::Repository::init(repo_dir.path()).unwrap(); + git_init_repo(repo_dir.path()); let repo = RealGitRepository::new( &repo_dir.path().join(".git"), None, @@ -3814,7 +3801,7 @@ mod tests { let text_path = repo_dir.path().join("main.rs"); let bin_path = repo_dir.path().join("binary.o"); - git2::Repository::init(repo_dir.path()).unwrap(); + git_init_repo(repo_dir.path()); smol::fs::write(&text_path, "fn main() {}").await.unwrap(); @@ -4077,7 +4064,7 @@ mod tests { fs::create_dir_all(&repo_dir).unwrap(); fs::create_dir_all(&worktrees_dir).unwrap(); - git2::Repository::init(&repo_dir).unwrap(); + git_init_repo(&repo_dir); let repo = RealGitRepository::new( &repo_dir.join(".git"), @@ -4147,7 +4134,7 @@ mod tests { let temp_dir = tempfile::tempdir().unwrap(); let repo_dir = temp_dir.path().join("repo"); let worktrees_dir = temp_dir.path().join("worktrees"); - git2::Repository::init(&repo_dir).unwrap(); + git_init_repo(&repo_dir); let repo = RealGitRepository::new( &repo_dir.join(".git"), @@ -4245,7 +4232,7 @@ mod tests { let repo_dir = temp_dir.path().join("repo"); let worktrees_dir = temp_dir.path().join("worktrees"); - git2::Repository::init(&repo_dir).unwrap(); + git_init_repo(&repo_dir); let repo = RealGitRepository::new( &repo_dir.join(".git"), diff --git a/crates/project/Cargo.toml b/crates/project/Cargo.toml index 628e979aab939a..0e3ab8b94f2f71 100644 --- a/crates/project/Cargo.toml +++ b/crates/project/Cargo.toml @@ -113,7 +113,6 @@ context_server = { workspace = true, features = ["test-support"] } buffer_diff = { workspace = true, features = ["test-support"] } dap = { workspace = true, features = ["test-support"] } fs = { workspace = true, features = ["test-support"] } -git2.workspace = true gpui = { workspace = true, features = ["test-support"] } language = { workspace = true, features = ["test-support"] } lsp = { workspace = true, features = ["test-support"] } diff --git a/crates/project/tests/integration/project_tests.rs b/crates/project/tests/integration/project_tests.rs index f680ccee78e997..c4a502b9cdb53e 100644 --- a/crates/project/tests/integration/project_tests.rs +++ b/crates/project/tests/integration/project_tests.rs @@ -33,7 +33,7 @@ use git::{ repository::{RepoPath, repo_path}, status::{DiffStat, FileStatus, StatusCode, TrackedStatus}, }; -use git2::RepositoryInitOptions; +use std::process::Command; use gpui::{ App, AppContext, BackgroundExecutor, BorrowAppContext, Entity, FutureExt, SharedString, Task, TestAppContext, UpdateGlobal, @@ -10473,7 +10473,8 @@ async fn test_conflicted_cherry_pick(cx: &mut gpui::TestAppContext) { })); let root_path = root.path(); - let repo = git_init(&root_path.join("project")); + let work_dir = &root_path.join("project"); + let repo = git_init(work_dir); git_add("a.txt", &repo); git_commit("init", &repo); @@ -10491,16 +10492,12 @@ async fn test_conflicted_cherry_pick(cx: &mut gpui::TestAppContext) { }); git_branch("other-branch", &repo); - git_checkout("refs/heads/other-branch", &repo); + git_checkout("other-branch", &repo); std::fs::write(root_path.join("project/a.txt"), "A").unwrap(); git_add("a.txt", &repo); git_commit("capitalize", &repo); - let commit = repo - .head() - .expect("Failed to get HEAD") - .peel_to_commit() - .expect("HEAD is not a commit"); - git_checkout("refs/heads/main", &repo); + let commit = git_rev_parse("HEAD", &repo); + git_checkout("main", &repo); std::fs::write(root_path.join("project/a.txt"), "b").unwrap(); git_add("a.txt", &repo); git_commit("improve letter", &repo); @@ -10509,7 +10506,7 @@ async fn test_conflicted_cherry_pick(cx: &mut gpui::TestAppContext) { .expect("No CHERRY_PICK_HEAD"); pretty_assertions::assert_eq!( git_status(&repo), - collections::HashMap::from_iter([("a.txt".to_owned(), git2::Status::CONFLICTED)]) + collections::HashMap::from_iter([("a.txt".to_owned(), "UU".to_owned())]) ); tree.flush_fs_events(cx).await; project @@ -10732,8 +10729,7 @@ async fn test_file_status(cx: &mut gpui::TestAppContext) { // Set up git repository before creating the worktree. let work_dir = root.path().join("project"); - let mut repo = git_init(work_dir.as_path()); - repo.add_ignore_rule(IGNORE_RULE).unwrap(); + let repo = git_init(work_dir.as_path()); git_add(A_TXT, &repo); git_add(E_TXT, &repo); git_add(DOTGITIGNORE, &repo); @@ -10820,7 +10816,7 @@ async fn test_file_status(cx: &mut gpui::TestAppContext) { // Modify files in the working copy and perform git operations on other files. git_reset(0, &repo); git_remove_index(Path::new(B_TXT), &repo); - git_stash(&mut repo); + git_stash(&repo); std::fs::write(work_dir.join(E_TXT), "eeee").unwrap(); std::fs::write(work_dir.join(BUILD_FILE), "this should be ignored").unwrap(); tree.flush_fs_events(cx).await; @@ -10944,7 +10940,6 @@ async fn test_ignored_dirs_events(cx: &mut gpui::TestAppContext) { // Set up git repository before creating the worktree. let work_dir = root.path().join("project"); let repo = git_init(work_dir.as_path()); - repo.add_ignore_rule(IGNORE_RULE).unwrap(); git_add("src/main.rs", &repo); git_add(".gitignore", &repo); git_commit("Initial commit", &repo); @@ -12107,111 +12102,147 @@ fn assert_entry_git_state( ); } +#[allow(clippy::disallowed_methods)] +fn git_cmd(work_dir: &Path) -> Command { + let mut cmd = Command::new("git"); + cmd.current_dir(work_dir) + .env("GIT_CONFIG_GLOBAL", "") + .env("GIT_CONFIG_SYSTEM", "") + .env("GIT_AUTHOR_NAME", "test") + .env("GIT_AUTHOR_EMAIL", "test@zed.dev") + .env("GIT_COMMITTER_NAME", "test") + .env("GIT_COMMITTER_EMAIL", "test@zed.dev"); + cmd +} + +#[allow(clippy::disallowed_methods)] #[track_caller] -fn git_init(path: &Path) -> git2::Repository { - let mut init_opts = RepositoryInitOptions::new(); - init_opts.initial_head("main"); - git2::Repository::init_opts(path, &init_opts).expect("Failed to initialize git repository") +fn git_init(path: &Path) -> PathBuf { + let output = git_cmd(path) + .args(["init", "-b", "main"]) + .output() + .expect("Failed to run git init"); + assert!(output.status.success(), "git init failed: {}", String::from_utf8_lossy(&output.stderr)); + path.to_path_buf() } +#[allow(clippy::disallowed_methods)] #[track_caller] -fn git_add>(path: P, repo: &git2::Repository) { - let path = path.as_ref(); - let mut index = repo.index().expect("Failed to get index"); - index.add_path(path).expect("Failed to add file"); - index.write().expect("Failed to write index"); +fn git_add>(path: P, work_dir: &Path) { + let output = git_cmd(work_dir) + .args(["add"]) + .arg(path.as_ref()) + .output() + .expect("Failed to run git add"); + assert!(output.status.success(), "git add failed: {}", String::from_utf8_lossy(&output.stderr)); } +#[allow(clippy::disallowed_methods)] #[track_caller] -fn git_remove_index(path: &Path, repo: &git2::Repository) { - let mut index = repo.index().expect("Failed to get index"); - index.remove_path(path).expect("Failed to add file"); - index.write().expect("Failed to write index"); +fn git_remove_index(path: &Path, work_dir: &Path) { + let output = git_cmd(work_dir) + .args(["rm", "--cached"]) + .arg(path) + .output() + .expect("Failed to run git rm"); + assert!(output.status.success(), "git rm --cached failed: {}", String::from_utf8_lossy(&output.stderr)); } +#[allow(clippy::disallowed_methods)] #[track_caller] -fn git_commit(msg: &'static str, repo: &git2::Repository) { - use git2::Signature; - - let signature = Signature::now("test", "test@zed.dev").unwrap(); - let oid = repo.index().unwrap().write_tree().unwrap(); - let tree = repo.find_tree(oid).unwrap(); - if let Ok(head) = repo.head() { - let parent_obj = head.peel(git2::ObjectType::Commit).unwrap(); - - let parent_commit = parent_obj.as_commit().unwrap(); - - repo.commit( - Some("HEAD"), - &signature, - &signature, - msg, - &tree, - &[parent_commit], - ) - .expect("Failed to commit with parent"); - } else { - repo.commit(Some("HEAD"), &signature, &signature, msg, &tree, &[]) - .expect("Failed to commit"); - } +fn git_commit(msg: &str, work_dir: &Path) { + let output = git_cmd(work_dir) + .args(["commit", "-m", msg]) + .output() + .expect("Failed to run git commit"); + assert!(output.status.success(), "git commit failed: {}", String::from_utf8_lossy(&output.stderr)); } -#[cfg(any())] +#[allow(clippy::disallowed_methods)] #[track_caller] -fn git_cherry_pick(commit: &git2::Commit<'_>, repo: &git2::Repository) { - repo.cherrypick(commit, None).expect("Failed to cherrypick"); +fn git_stash(work_dir: &Path) { + let output = git_cmd(work_dir) + .args(["stash"]) + .output() + .expect("Failed to run git stash"); + assert!(output.status.success(), "git stash failed: {}", String::from_utf8_lossy(&output.stderr)); } +#[allow(clippy::disallowed_methods)] #[track_caller] -fn git_stash(repo: &mut git2::Repository) { - use git2::Signature; +fn git_reset(offset: usize, work_dir: &Path) { + let target = format!("HEAD~{}", offset + 1); + let output = git_cmd(work_dir) + .args(["reset", "--soft", &target]) + .output() + .expect("Failed to run git reset"); + assert!(output.status.success(), "git reset failed: {}", String::from_utf8_lossy(&output.stderr)); +} - let signature = Signature::now("test", "test@zed.dev").unwrap(); - repo.stash_save(&signature, "N/A", None) - .expect("Failed to stash"); +#[cfg(any())] +#[allow(clippy::disallowed_methods)] +#[track_caller] +fn git_branch(name: &str, work_dir: &Path) { + let output = git_cmd(work_dir) + .args(["branch", name]) + .output() + .expect("Failed to run git branch"); + assert!(output.status.success(), "git branch failed: {}", String::from_utf8_lossy(&output.stderr)); } +#[cfg(any())] +#[allow(clippy::disallowed_methods)] #[track_caller] -fn git_reset(offset: usize, repo: &git2::Repository) { - let head = repo.head().expect("Couldn't get repo head"); - let object = head.peel(git2::ObjectType::Commit).unwrap(); - let commit = object.as_commit().unwrap(); - let new_head = commit - .parents() - .inspect(|parnet| { - parnet.message(); - }) - .nth(offset) - .expect("Not enough history"); - repo.reset(new_head.as_object(), git2::ResetType::Soft, None) - .expect("Could not reset"); +fn git_checkout(name: &str, work_dir: &Path) { + let output = git_cmd(work_dir) + .args(["checkout", name]) + .output() + .expect("Failed to run git checkout"); + assert!(output.status.success(), "git checkout failed: {}", String::from_utf8_lossy(&output.stderr)); } #[cfg(any())] +#[allow(clippy::disallowed_methods)] #[track_caller] -fn git_branch(name: &str, repo: &git2::Repository) { - let head = repo - .head() - .expect("Couldn't get repo head") - .peel_to_commit() - .expect("HEAD is not a commit"); - repo.branch(name, &head, false).expect("Failed to commit"); +fn git_rev_parse(rev: &str, work_dir: &Path) -> String { + let output = git_cmd(work_dir) + .args(["rev-parse", rev]) + .output() + .expect("Failed to run git rev-parse"); + assert!(output.status.success(), "git rev-parse failed: {}", String::from_utf8_lossy(&output.stderr)); + String::from_utf8(output.stdout).unwrap().trim().to_string() } #[cfg(any())] +#[allow(clippy::disallowed_methods)] #[track_caller] -fn git_checkout(name: &str, repo: &git2::Repository) { - repo.set_head(name).expect("Failed to set head"); - repo.checkout_head(None).expect("Failed to check out head"); +fn git_cherry_pick(commit: &str, work_dir: &Path) { + let output = git_cmd(work_dir) + .args(["cherry-pick", "--no-commit", commit]) + .output() + .expect("Failed to run git cherry-pick"); + // cherry-pick with conflicts exits non-zero, so don't assert success + let _ = output; } #[cfg(any())] +#[allow(clippy::disallowed_methods)] #[track_caller] -fn git_status(repo: &git2::Repository) -> collections::HashMap { - repo.statuses(None) - .unwrap() - .iter() - .map(|status| (status.path().unwrap().to_string(), status.status())) +fn git_status(work_dir: &Path) -> collections::HashMap { + let output = git_cmd(work_dir) + .args(["status", "--porcelain=v1"]) + .output() + .expect("Failed to run git status"); + assert!(output.status.success(), "git status failed: {}", String::from_utf8_lossy(&output.stderr)); + let stdout = String::from_utf8(output.stdout).unwrap(); + stdout + .lines() + .filter(|line| !line.is_empty()) + .map(|line| { + let status = line[..2].to_string(); + let path = line[3..].to_string(); + (path, status) + }) .collect() } diff --git a/crates/remote_server/Cargo.toml b/crates/remote_server/Cargo.toml index c6ce45ba1ce283..c85656864b30ad 100644 --- a/crates/remote_server/Cargo.toml +++ b/crates/remote_server/Cargo.toml @@ -37,7 +37,6 @@ fs.workspace = true futures.workspace = true git.workspace = true git_hosting_providers.workspace = true -git2 = { workspace = true, features = ["vendored-libgit2"] } gpui.workspace = true gpui_platform.workspace = true gpui_tokio.workspace = true @@ -111,4 +110,4 @@ cargo_toml.workspace = true toml.workspace = true [package.metadata.cargo-machete] -ignored = ["git2", "rust-embed", "paths"] +ignored = ["rust-embed", "paths"] diff --git a/crates/util/Cargo.toml b/crates/util/Cargo.toml index 4f317e79e0cfc9..13f40811225561 100644 --- a/crates/util/Cargo.toml +++ b/crates/util/Cargo.toml @@ -15,7 +15,7 @@ path = "src/util.rs" doctest = true [features] -test-support = ["git2", "rand", "util_macros"] +test-support = ["rand", "util_macros"] [dependencies] anyhow.workspace = true @@ -46,7 +46,6 @@ gpui_util.workspace = true [target.'cfg(not(target_family = "wasm"))'.dependencies] smol.workspace = true which.workspace = true -git2 = { workspace = true, optional = true } async-fs.workspace = true walkdir.workspace = true dirs.workspace = true @@ -63,7 +62,6 @@ mach2.workspace = true tendril = "0.4.3" [dev-dependencies] -git2.workspace = true rand.workspace = true util_macros.workspace = true pretty_assertions.workspace = true diff --git a/crates/util/src/test.rs b/crates/util/src/test.rs index 717754e33375a5..264061e22d4158 100644 --- a/crates/util/src/test.rs +++ b/crates/util/src/test.rs @@ -4,7 +4,6 @@ mod marked_text; pub use assertions::*; pub use marked_text::*; -use git2; use std::ffi::OsStr; use std::path::{Path, PathBuf}; use tempfile::TempDir; @@ -44,8 +43,15 @@ fn write_tree(path: &Path, tree: serde_json::Value) { fs::create_dir(&path).unwrap(); #[cfg(not(target_family = "wasm"))] + #[allow(clippy::disallowed_methods)] if path.file_name() == Some(OsStr::new(".git")) { - git2::Repository::init(path.parent().unwrap()).unwrap(); + std::process::Command::new("git") + .args(["init", "-b", "main"]) + .current_dir(path.parent().unwrap()) + .env("GIT_CONFIG_GLOBAL", "") + .env("GIT_CONFIG_SYSTEM", "") + .output() + .expect("failed to init git repo"); } write_tree(&path, contents); From c0fe3a8daa144e1f6ddecab10dea2c822edc5ffc Mon Sep 17 00:00:00 2001 From: Kieran Freitag Date: Wed, 8 Apr 2026 13:35:28 -0700 Subject: [PATCH 02/12] worktree: Fix linked worktree git dir event handling When a linked worktree receives fs events for its .git directory, the event path resolves to the per-worktree git dir (e.g. main_repo/.git/worktrees/), not the .git directory itself or the common dir. The existing match only checked common_dir_abs_path and repository_dir_abs_path, causing the repository entry to be missed and then removed as stale. Add dot_git_abs_path to the match, and fix the staleness metadata check to use dot_git_abs_path (the actual .git entry) rather than common_dir_abs_path (which may be outside the worktree root). --- crates/worktree/src/worktree.rs | 4 +- crates/worktree/tests/integration/main.rs | 66 +++++++++++++++++++++++ 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/crates/worktree/src/worktree.rs b/crates/worktree/src/worktree.rs index 1494ba5fce2e46..d875ba5a497be3 100644 --- a/crates/worktree/src/worktree.rs +++ b/crates/worktree/src/worktree.rs @@ -5235,6 +5235,8 @@ impl BackgroundScanner { if SanitizedPath::new(repo.common_dir_abs_path.as_ref()) == dot_git_dir || SanitizedPath::new(repo.repository_dir_abs_path.as_ref()) == dot_git_dir + || SanitizedPath::new(repo.dot_git_abs_path.as_ref()) + == dot_git_dir { Some(repo.clone()) } else { @@ -5287,7 +5289,7 @@ impl BackgroundScanner { if exists_in_snapshot || matches!( - self.fs.metadata(&entry.common_dir_abs_path).await, + self.fs.metadata(&entry.dot_git_abs_path).await, Ok(Some(_)) ) { diff --git a/crates/worktree/tests/integration/main.rs b/crates/worktree/tests/integration/main.rs index b8d1994b1dc3f8..ffca315c9d5cde 100644 --- a/crates/worktree/tests/integration/main.rs +++ b/crates/worktree/tests/integration/main.rs @@ -2827,6 +2827,72 @@ async fn test_root_repo_common_dir(executor: BackgroundExecutor, cx: &mut TestAp ); } +#[gpui::test] +async fn test_linked_worktree_git_dir_events_do_not_panic( + executor: BackgroundExecutor, + cx: &mut TestAppContext, +) { + init_test(cx); + + use git::repository::Worktree as GitWorktree; + + let fs = FakeFs::new(executor); + + fs.insert_tree( + path!("/main_repo"), + json!({ + ".git": {}, + "file.txt": "content", + }), + ) + .await; + fs.add_linked_worktree_for_repo( + Path::new(path!("/main_repo/.git")), + false, + GitWorktree { + path: PathBuf::from(path!("/linked_worktree")), + ref_name: Some("refs/heads/feature".into()), + sha: "abc123".into(), + is_main: false, + }, + ) + .await; + fs.write( + path!("/linked_worktree/file.txt").as_ref(), + "content".as_bytes(), + ) + .await + .unwrap(); + + let tree = Worktree::local( + path!("/linked_worktree").as_ref(), + true, + fs.clone(), + Arc::default(), + true, + WorktreeId::from_proto(0), + &mut cx.to_async(), + ) + .await + .unwrap(); + tree.update(cx, |tree, _| tree.as_local().unwrap().scan_complete()) + .await; + cx.run_until_parked(); + + // Trigger an fs event in the common git dir (main repo's .git), + // which is outside the linked worktree root. The watcher monitors + // this directory, so events from it flow through process_events. + // This must not panic when the .git path is outside the worktree root. + tree.flush_fs_events_in_root_git_repository(cx).await; + + tree.read_with(cx, |tree, _| { + assert!( + tree.snapshot().root_repo_common_dir().is_some(), + "linked worktree should still have its git repository after git dir events" + ); + }); +} + fn init_test(cx: &mut gpui::TestAppContext) { zlog::init_test(); From 7cd710ff75a13dabc2bd5745bacc361875a7c698 Mon Sep 17 00:00:00 2001 From: Kieran Freitag Date: Tue, 14 Apr 2026 13:09:26 -0700 Subject: [PATCH 03/12] worktree: Fix linked worktree regression test to actually exercise the bug --- crates/worktree/tests/integration/main.rs | 41 +++++++++-------------- 1 file changed, 16 insertions(+), 25 deletions(-) diff --git a/crates/worktree/tests/integration/main.rs b/crates/worktree/tests/integration/main.rs index ffca315c9d5cde..2e53d710faec08 100644 --- a/crates/worktree/tests/integration/main.rs +++ b/crates/worktree/tests/integration/main.rs @@ -2828,24 +2828,16 @@ async fn test_root_repo_common_dir(executor: BackgroundExecutor, cx: &mut TestAp } #[gpui::test] -async fn test_linked_worktree_git_dir_events_do_not_panic( +async fn test_linked_worktree_gitfile_event_preserves_repo( executor: BackgroundExecutor, cx: &mut TestAppContext, ) { init_test(cx); - use git::repository::Worktree as GitWorktree; let fs = FakeFs::new(executor); - - fs.insert_tree( - path!("/main_repo"), - json!({ - ".git": {}, - "file.txt": "content", - }), - ) - .await; + fs.insert_tree(path!("/main_repo"), json!({ ".git": {}, "file.txt": "" })) + .await; fs.add_linked_worktree_for_repo( Path::new(path!("/main_repo/.git")), false, @@ -2857,12 +2849,9 @@ async fn test_linked_worktree_git_dir_events_do_not_panic( }, ) .await; - fs.write( - path!("/linked_worktree/file.txt").as_ref(), - "content".as_bytes(), - ) - .await - .unwrap(); + fs.write(path!("/linked_worktree/file.txt").as_ref(), b"content") + .await + .unwrap(); let tree = Worktree::local( path!("/linked_worktree").as_ref(), @@ -2879,16 +2868,18 @@ async fn test_linked_worktree_git_dir_events_do_not_panic( .await; cx.run_until_parked(); - // Trigger an fs event in the common git dir (main repo's .git), - // which is outside the linked worktree root. The watcher monitors - // this directory, so events from it flow through process_events. - // This must not panic when the .git path is outside the worktree root. - tree.flush_fs_events_in_root_git_repository(cx).await; + // Overwrite the .git gitfile with garbage to trigger an event for the + // gitfile path itself, which only matches `dot_git_abs_path`. + fs.write(path!("/linked_worktree/.git").as_ref(), b"garbage") + .await + .unwrap(); + tree.flush_fs_events(cx).await; tree.read_with(cx, |tree, _| { - assert!( - tree.snapshot().root_repo_common_dir().is_some(), - "linked worktree should still have its git repository after git dir events" + assert_eq!( + tree.snapshot().root_repo_common_dir().map(|p| p.as_ref()), + Some(Path::new(path!("/main_repo/.git"))), + "linked worktree repo should survive a gitfile change event" ); }); } From fc078a6fdb083a309070869db0e05d531f3b14bf Mon Sep 17 00:00:00 2001 From: Anthony Eid Date: Mon, 1 Jun 2026 20:21:09 -0400 Subject: [PATCH 04/12] Make infallable function return non result --- Cargo.lock | 1 - crates/git/Cargo.toml | 1 - crates/git/src/repository.rs | 163 ++++++++---------- .../tests/integration/project_tests.rs | 5 +- 4 files changed, 77 insertions(+), 93 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 34cae2c7431729..3b43b47d9b5667 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7621,7 +7621,6 @@ dependencies = [ "derive_more", "futures 0.3.32", "gpui", - "hex", "http_client", "itertools 0.14.0", "log", diff --git a/crates/git/Cargo.toml b/crates/git/Cargo.toml index 10d4683686eff4..6e5739081eea76 100644 --- a/crates/git/Cargo.toml +++ b/crates/git/Cargo.toml @@ -21,7 +21,6 @@ async-trait.workspace = true collections.workspace = true derive_more.workspace = true gpui.workspace = true -hex.workspace = true http_client.workspace = true itertools.workspace = true log.workspace = true diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs index 119d2bef28273e..d9f684bb2def96 100644 --- a/crates/git/src/repository.rs +++ b/crates/git/src/repository.rs @@ -1181,37 +1181,35 @@ impl RealGitRepository { }) } - fn working_directory(&self) -> Result { - Ok(self.working_directory.clone()) + fn working_directory(&self) -> PathBuf { + self.working_directory.clone() } - fn git_binary_in_worktree(&self) -> Result { - Ok(GitBinary::new( + fn git_binary_in_worktree(&self) -> GitBinary { + GitBinary::new( self.any_git_binary_path.clone(), - self.working_directory() - .with_context(|| "Can't run git commands without a working directory")?, + self.working_directory(), self.path(), self.executor.clone(), self.is_trusted(), - )) + ) } - fn git_binary(&self) -> Result { - Ok(GitBinary::new( + fn git_binary(&self) -> GitBinary { + GitBinary::new( self.any_git_binary_path.clone(), - self.working_directory() - .with_context(|| "Can't run git commands without a working directory")?, + self.working_directory(), self.path(), self.executor.clone(), self.is_trusted(), - )) + ) } fn edit_ref(&self, edit: RefEdit) -> BoxFuture<'_, Result<()>> { let git_binary = self.git_binary(); self.executor .spawn(async move { - let git = git_binary?; + let git = git_binary; let args = edit.into_args(); git.run(&args).await?; Ok(()) @@ -1227,7 +1225,7 @@ impl RealGitRepository { let output: SharedString = self .executor .spawn(async move { - let git = git_binary?; + let git = git_binary; git.run(&["help", "-a"]).await }) .await @@ -1308,7 +1306,7 @@ impl GitRepository for RealGitRepository { let git_binary = self.git_binary(); self.executor .spawn(async move { - let git = git_binary?; + let git = git_binary; let output = git .build_command(&[ "show", @@ -1342,7 +1340,7 @@ impl GitRepository for RealGitRepository { fn load_commit(&self, commit: String, cx: AsyncApp) -> BoxFuture<'_, Result> { let git_binary = self.git_binary(); cx.background_spawn(async move { - let git = git_binary?; + let git = git_binary; let show_output = git .build_command(&[ "show", @@ -1479,7 +1477,7 @@ impl GitRepository for RealGitRepository { ResetMode::Soft => "--soft", }; - let git = git_binary?; + let git = git_binary; let output = git .build_command(&["reset", mode_flag, &commit]) .envs(env.iter()) @@ -1507,7 +1505,7 @@ impl GitRepository for RealGitRepository { return Ok(()); } - let git = git_binary?; + let git = git_binary; let output = git .build_command(&["checkout", &commit, "--"]) .envs(env.iter()) @@ -1529,7 +1527,7 @@ impl GitRepository for RealGitRepository { let path_str = format!(":{}", path.as_unix_str()); self.executor .spawn(async move { - let git = git_binary.log_err()?; + let git = git_binary; let output = git .build_command(&["show", &path_str]) .stdout(Stdio::piped()) @@ -1550,7 +1548,7 @@ impl GitRepository for RealGitRepository { let path_str = format!("HEAD:{}", path.as_unix_str()); self.executor .spawn(async move { - let git = git_binary.log_err()?; + let git = git_binary; let output = git .build_command(&["show", &path_str]) .stdout(Stdio::piped()) @@ -1570,28 +1568,22 @@ impl GitRepository for RealGitRepository { let git_binary = self.git_binary(); let oid_str = oid.to_string(); self.executor - .spawn(async move { git_binary?.run_raw(&["cat-file", "blob", &oid_str]).await }) + .spawn(async move { git_binary.run_raw(&["cat-file", "blob", &oid_str]).await }) .boxed() } fn load_commit_template(&self) -> BoxFuture<'_, Result>> { - let working_directory_and_git_binary = self.working_directory().map(|working_directory| { - ( - working_directory.clone(), - GitBinary::new( - self.any_git_binary_path.clone(), - working_directory, - self.path(), - self.executor.clone(), - self.is_trusted(), - ), - ) - }); + let working_directory = self.working_directory(); + let git_binary = GitBinary::new( + self.any_git_binary_path.clone(), + working_directory.clone(), + self.path(), + self.executor.clone(), + self.is_trusted(), + ); self.executor .spawn(async move { - let (working_directory, git_binary) = working_directory_and_git_binary?; - let output = git_binary .build_command(&["config", "--get", "commit.template"]) .output() @@ -1636,7 +1628,7 @@ impl GitRepository for RealGitRepository { let git_binary = self.git_binary(); self.executor .spawn(async move { - let git = git_binary?; + let git = git_binary; let mode = if is_executable { "100755" } else { "100644" }; if let Some(content) = content { @@ -1692,7 +1684,7 @@ impl GitRepository for RealGitRepository { let name = name.to_owned(); self.executor .spawn(async move { - let git = git_binary.log_err()?; + let git = git_binary; let output = git .build_command(&["remote", "get-url", &name]) .output() @@ -1716,7 +1708,7 @@ impl GitRepository for RealGitRepository { let git_binary = self.git_binary(); self.executor .spawn(async move { - let git = git_binary?; + let git = git_binary; let mut process = git .build_command(&["cat-file", "--batch-check=%(objectname)"]) .stdin(Stdio::piped()) @@ -1767,10 +1759,7 @@ impl GitRepository for RealGitRepository { } fn status(&self, path_prefixes: &[RepoPath]) -> Task> { - let git = match self.git_binary_in_worktree() { - Ok(git) => git, - Err(e) => return Task::ready(Err(e)), - }; + let git = self.git_binary_in_worktree(); let args = git_status_args(path_prefixes); log::debug!("Checking for git status in {path_prefixes:?}"); self.executor.spawn(async move { @@ -1786,10 +1775,7 @@ impl GitRepository for RealGitRepository { } fn diff_tree(&self, request: DiffTreeType) -> BoxFuture<'_, Result> { - let git = match self.git_binary_in_worktree() { - Ok(git) => git, - Err(e) => return Task::ready(Err(e)).boxed(), - }; + let git = self.git_binary_in_worktree(); let mut args = vec![ OsString::from("diff-tree"), @@ -1827,7 +1813,7 @@ impl GitRepository for RealGitRepository { let git_binary = self.git_binary_in_worktree(); self.executor .spawn(async move { - let git = git_binary?; + let git = git_binary; let output = git .build_command(&["stash", "list", "--pretty=format:%gd%x00%H%x00%ct%x00%s"]) .output() @@ -1847,7 +1833,7 @@ impl GitRepository for RealGitRepository { let git_binary = self.git_binary(); self.executor .spawn(async move { - let git = git_binary?; + let git = git_binary; let fields = [ "%(HEAD)", "%(objectname)", @@ -1908,7 +1894,7 @@ impl GitRepository for RealGitRepository { let main_worktree_path = original_repo_path_from_common_dir(&self.common_dir); self.executor .spawn(async move { - let git = git_binary?; + let git = git_binary; let output = git .build_command(&["worktree", "list", "--porcelain"]) .output() @@ -1963,7 +1949,7 @@ impl GitRepository for RealGitRepository { self.executor .spawn(async move { - let git = git_binary?; + let git = git_binary; std::fs::create_dir_all(path.parent().unwrap_or(&path))?; let output = git.build_command(&args).output().await?; if output.status.success() { @@ -1981,7 +1967,7 @@ impl GitRepository for RealGitRepository { self.executor .spawn(async move { - let git = git_binary?; + let git = git_binary; let mut args: Vec = vec!["worktree".into(), "remove".into()]; if force { args.push("--force".into()); @@ -1999,7 +1985,7 @@ impl GitRepository for RealGitRepository { self.executor .spawn(async move { - let git = git_binary?; + let git = git_binary; let args: Vec = vec![ "worktree".into(), "move".into(), @@ -2043,7 +2029,7 @@ impl GitRepository for RealGitRepository { let git_binary = self.git_binary(); self.executor .spawn(async move { - git_binary?.run(&["checkout", &name]).await?; + git_binary.run(&["checkout", &name]).await?; anyhow::Ok(()) }) .boxed() @@ -2065,7 +2051,7 @@ impl GitRepository for RealGitRepository { args.push(&base_branch_str); } - git_binary?.run(&args).await?; + git_binary.run(&args).await?; anyhow::Ok(()) }) .boxed() @@ -2076,7 +2062,7 @@ impl GitRepository for RealGitRepository { self.executor .spawn(async move { - git_binary? + git_binary .run(&["branch", "-m", &branch, &new_name]) .await?; anyhow::Ok(()) @@ -2095,7 +2081,7 @@ impl GitRepository for RealGitRepository { self.executor .spawn(async move { let flag = delete_branch_flag(is_remote, force); - git_binary?.run(&["branch", flag, &name]).await?; + git_binary.run(&["branch", flag, &name]).await?; anyhow::Ok(()) }) .boxed() @@ -2111,7 +2097,7 @@ impl GitRepository for RealGitRepository { self.executor .spawn(async move { - crate::blame::Blame::for_path(&git?, &path, &content, line_ending).await + crate::blame::Blame::for_path(&git, &path, &content, line_ending).await }) .boxed() } @@ -2120,7 +2106,7 @@ impl GitRepository for RealGitRepository { let git_binary = self.git_binary_in_worktree(); self.executor .spawn(async move { - let git = git_binary?; + let git = git_binary; let output = match diff { DiffType::HeadToIndex => { git.build_command(&["diff", "--staged"]).output().await? @@ -2152,7 +2138,7 @@ impl GitRepository for RealGitRepository { self.executor .spawn(async move { - let git_binary = git_binary?; + let git_binary = git_binary; let mut args: Vec = vec![ "diff".into(), "--numstat".into(), @@ -2182,7 +2168,7 @@ impl GitRepository for RealGitRepository { self.executor .spawn(async move { if !paths.is_empty() { - let git = git_binary?; + let git = git_binary; let output = git .build_command(&["update-index", "--add", "--remove", "--"]) .envs(env.iter()) @@ -2210,7 +2196,7 @@ impl GitRepository for RealGitRepository { self.executor .spawn(async move { if !paths.is_empty() { - let git = git_binary?; + let git = git_binary; let output = git .build_command(&["reset", "--quiet", "--"]) .envs(env.iter()) @@ -2237,7 +2223,7 @@ impl GitRepository for RealGitRepository { let git_binary = self.git_binary_in_worktree(); self.executor .spawn(async move { - let git = git_binary?; + let git = git_binary; let output = git .build_command(&["stash", "push", "--quiet", "--include-untracked", "--"]) .envs(env.iter()) @@ -2263,7 +2249,7 @@ impl GitRepository for RealGitRepository { let git_binary = self.git_binary_in_worktree(); self.executor .spawn(async move { - let git = git_binary?; + let git = git_binary; let mut args = vec!["stash".to_string(), "pop".to_string()]; if let Some(index) = index { args.push(format!("stash@{{{}}}", index)); @@ -2288,7 +2274,7 @@ impl GitRepository for RealGitRepository { let git_binary = self.git_binary_in_worktree(); self.executor .spawn(async move { - let git = git_binary?; + let git = git_binary; let mut args = vec!["stash".to_string(), "apply".to_string()]; if let Some(index) = index { args.push(format!("stash@{{{}}}", index)); @@ -2313,7 +2299,7 @@ impl GitRepository for RealGitRepository { let git_binary = self.git_binary_in_worktree(); self.executor .spawn(async move { - let git = git_binary?; + let git = git_binary; let mut args = vec!["stash".to_string(), "drop".to_string()]; if let Some(index) = index { args.push(format!("stash@{{{}}}", index)); @@ -2343,7 +2329,7 @@ impl GitRepository for RealGitRepository { // Note: Do not spawn this command on the background thread, it might pop open the credential helper // which we want to block on. async move { - let git = git_binary?; + let git = git_binary; let mut cmd = git.build_command(&["commit", "--quiet", "-m"]); cmd.envs(env.iter()) .arg(&message.to_string()) @@ -2387,7 +2373,7 @@ impl GitRepository for RealGitRepository { let git_binary = self.git_binary(); self.executor .spawn(async move { - let git = git_binary?; + let git = git_binary; let args: Vec = vec!["worktree".into(), "repair".into()]; git.run(&args).await?; Ok(()) @@ -2414,7 +2400,6 @@ impl GitRepository for RealGitRepository { // which we want to block on. async move { let git_binary_path = git_binary_path.context("git not found on $PATH, can't push")?; - let working_directory = working_directory?; let git = GitBinary::new( git_binary_path, working_directory, @@ -2458,7 +2443,6 @@ impl GitRepository for RealGitRepository { // which we want to block on. async move { let git_binary_path = git_binary_path.context("git not found on $PATH, can't pull")?; - let working_directory = working_directory?; let git = GitBinary::new( git_binary_path, working_directory, @@ -2491,7 +2475,7 @@ impl GitRepository for RealGitRepository { env: Arc>, cx: AsyncApp, ) -> BoxFuture<'_, Result> { - let working_directory = self.working_directory().unwrap_or(self.path()); + let working_directory = self.working_directory(); let git_directory = self.path(); let remote_name = format!("{}", fetch_options); let git_binary_path = self.system_git_binary_path.clone(); @@ -2523,7 +2507,7 @@ impl GitRepository for RealGitRepository { let git_binary = self.git_binary(); self.executor .spawn(async move { - let git = git_binary?; + let git = git_binary; let output = git .build_command(&["rev-parse", "--abbrev-ref"]) .arg(format!("{branch}@{{push}}")) @@ -2548,7 +2532,7 @@ impl GitRepository for RealGitRepository { let git_binary = self.git_binary(); self.executor .spawn(async move { - let git = git_binary?; + let git = git_binary; let output = git .build_command(&["config", "--get"]) .arg(format!("branch.{branch}.remote")) @@ -2570,7 +2554,7 @@ impl GitRepository for RealGitRepository { let git_binary = self.git_binary(); self.executor .spawn(async move { - let git = git_binary?; + let git = git_binary; let output = git.build_command(&["remote", "-v"]).output().await?; anyhow::ensure!( @@ -2600,7 +2584,7 @@ impl GitRepository for RealGitRepository { let git_binary = self.git_binary(); self.executor .spawn(async move { - git_binary?.run(&["remote", "remove", &name]).await?; + git_binary.run(&["remote", "remove", &name]).await?; Ok(()) }) .boxed() @@ -2610,7 +2594,7 @@ impl GitRepository for RealGitRepository { let git_binary = self.git_binary(); self.executor .spawn(async move { - git_binary?.run(&["remote", "add", &name, &url]).await?; + git_binary.run(&["remote", "add", &name, &url]).await?; Ok(()) }) .boxed() @@ -2620,7 +2604,7 @@ impl GitRepository for RealGitRepository { let git_binary = self.git_binary_in_worktree(); self.executor .spawn(async move { - let git = git_binary?; + let git = git_binary; let git_cmd = async |args: &[&str]| -> Result { let output = git.build_command(args).output().await?; anyhow::ensure!( @@ -2674,7 +2658,7 @@ impl GitRepository for RealGitRepository { let git_binary = self.git_binary_in_worktree(); self.executor .spawn(async move { - let mut git = git_binary?.envs(checkpoint_author_envs()); + let mut git = git_binary.envs(checkpoint_author_envs()); git.with_temp_index(async |git| { let head_sha = git.run(&["rev-parse", "HEAD"]).await.ok(); let mut excludes = exclude_files(git).await?; @@ -2703,7 +2687,7 @@ impl GitRepository for RealGitRepository { let git_binary = self.git_binary_in_worktree(); self.executor .spawn(async move { - let git = git_binary?; + let git = git_binary; git.run(&[ "restore", "--source", @@ -2733,7 +2717,7 @@ impl GitRepository for RealGitRepository { let git_binary = self.git_binary_in_worktree(); self.executor .spawn(async move { - let mut git = git_binary?.envs(checkpoint_author_envs()); + let mut git = git_binary.envs(checkpoint_author_envs()); let head_sha = git .run(&["rev-parse", "HEAD"]) @@ -2791,7 +2775,7 @@ impl GitRepository for RealGitRepository { let git_binary = self.git_binary_in_worktree(); self.executor .spawn(async move { - let git = git_binary?; + let git = git_binary; // First, set the index AND working tree to match the unstaged // tree. --reset -u computes a tree-level diff between the @@ -2821,7 +2805,7 @@ impl GitRepository for RealGitRepository { let git_binary = self.git_binary_in_worktree(); self.executor .spawn(async move { - let git = git_binary?; + let git = git_binary; let result = git .run(&[ "diff-tree", @@ -2855,7 +2839,7 @@ impl GitRepository for RealGitRepository { let git_binary = self.git_binary_in_worktree(); self.executor .spawn(async move { - let git = git_binary?; + let git = git_binary; git.run(&[ "diff", "--find-renames", @@ -2875,7 +2859,7 @@ impl GitRepository for RealGitRepository { let git_binary = self.git_binary(); self.executor .spawn(async move { - let git = git_binary?; + let git = git_binary; let strip_prefix = if include_remote_name { "refs/remotes/" } else { @@ -2934,7 +2918,7 @@ impl GitRepository for RealGitRepository { // Note: Do not spawn these commands on the background thread, as this causes some git hooks to hang. async move { - let git_binary = git_binary?; + let git_binary = git_binary; let working_directory = git_binary.working_directory.clone(); if !help_output @@ -2984,7 +2968,7 @@ impl GitRepository for RealGitRepository { let git_binary = self.git_binary(); async move { - let git = git_binary?; + let git = git_binary; let mut git_log_command = vec!["log", GRAPH_COMMIT_FORMAT, log_order.as_arg()]; git_log_command.extend(log_source.get_args()?); let mut command = git.build_command(&git_log_command); @@ -3056,7 +3040,7 @@ impl GitRepository for RealGitRepository { let git_binary = self.git_binary(); async move { - let git = git_binary?; + let git = git_binary; let mut args = vec!["log", SEARCH_COMMIT_FORMAT]; args.push("--fixed-strings"); @@ -3110,7 +3094,7 @@ impl GitRepository for RealGitRepository { let git_binary = self.git_binary(); async move { - let git = git_binary?; + let git = git_binary; if paths.is_empty() { return Ok(Vec::new()); } @@ -3148,7 +3132,7 @@ impl GitRepository for RealGitRepository { } fn commit_data_reader(&self) -> Result { - let git_binary = self.git_binary()?; + let git_binary = self.git_binary(); let (request_tx, request_rx) = async_channel::bounded::(64); @@ -4805,7 +4789,7 @@ mod tests { cx.executor(), ) .unwrap(); - let git = repo.git_binary().unwrap(); + let git = repo.git_binary(); let graph_commits = async || { let (tx, rx) = smol::channel::unbounded(); @@ -4889,7 +4873,6 @@ mod tests { self.executor .spawn(async move { let git_binary_path = git_binary_path.clone(); - let working_directory = working_directory?; let git = GitBinary::new( git_binary_path, working_directory, diff --git a/crates/project/tests/integration/project_tests.rs b/crates/project/tests/integration/project_tests.rs index a27d140d62fdc3..9ad334ece6b0fd 100644 --- a/crates/project/tests/integration/project_tests.rs +++ b/crates/project/tests/integration/project_tests.rs @@ -11134,7 +11134,7 @@ async fn test_conflicted_cherry_pick(cx: &mut gpui::TestAppContext) { .expect("No CHERRY_PICK_HEAD"); pretty_assertions::assert_eq!( git_status(&repo), - collections::HashMap::from_iter([("a.txt".to_owned(), "UU".to_owned())]) + collections::HashMap::from_iter([("a.txt".to_owned(), GIT_STATUS_CONFLICTED.to_owned())]) ); tree.flush_fs_events(cx).await; project @@ -12735,6 +12735,9 @@ fn assert_entry_git_state( ); } +#[cfg(any())] +const GIT_STATUS_CONFLICTED: &str = "UU"; + #[allow(clippy::disallowed_methods)] fn git_cmd(work_dir: &Path) -> Command { let mut cmd = Command::new("git"); From 3fe1ee4e6be01f5f12b47a0b479cb11d0aaa9c00 Mon Sep 17 00:00:00 2001 From: Anthony Eid Date: Mon, 1 Jun 2026 20:27:20 -0400 Subject: [PATCH 05/12] More clean up and fixes --- crates/git/src/repository.rs | 26 +++++++++----------------- 1 file changed, 9 insertions(+), 17 deletions(-) diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs index d9f684bb2def96..1b9b7083c71f23 100644 --- a/crates/git/src/repository.rs +++ b/crates/git/src/repository.rs @@ -2370,10 +2370,9 @@ impl GitRepository for RealGitRepository { } fn repair_worktrees(&self) -> BoxFuture<'_, Result<()>> { - let git_binary = self.git_binary(); + let git = self.git_binary(); self.executor .spawn(async move { - let git = git_binary; let args: Vec = vec!["worktree".into(), "repair".into()]; git.run(&args).await?; Ok(()) @@ -2504,10 +2503,9 @@ impl GitRepository for RealGitRepository { } fn get_push_remote(&self, branch: String) -> BoxFuture<'_, Result>> { - let git_binary = self.git_binary(); + let git = self.git_binary(); self.executor .spawn(async move { - let git = git_binary; let output = git .build_command(&["rev-parse", "--abbrev-ref"]) .arg(format!("{branch}@{{push}}")) @@ -2529,10 +2527,9 @@ impl GitRepository for RealGitRepository { } fn get_branch_remote(&self, branch: String) -> BoxFuture<'_, Result>> { - let git_binary = self.git_binary(); + let git = self.git_binary(); self.executor .spawn(async move { - let git = git_binary; let output = git .build_command(&["config", "--get"]) .arg(format!("branch.{branch}.remote")) @@ -2551,10 +2548,9 @@ impl GitRepository for RealGitRepository { } fn get_all_remotes(&self) -> BoxFuture<'_, Result>> { - let git_binary = self.git_binary(); + let git = self.git_binary(); self.executor .spawn(async move { - let git = git_binary; let output = git.build_command(&["remote", "-v"]).output().await?; anyhow::ensure!( @@ -2856,10 +2852,9 @@ impl GitRepository for RealGitRepository { &self, include_remote_name: bool, ) -> BoxFuture<'_, Result>> { - let git_binary = self.git_binary(); + let git = self.git_binary(); self.executor .spawn(async move { - let git = git_binary; let strip_prefix = if include_remote_name { "refs/remotes/" } else { @@ -2912,7 +2907,7 @@ impl GitRepository for RealGitRepository { hook: RunHook, env: Arc>, ) -> BoxFuture<'_, Result<()>> { - let git_binary = self.git_binary(); + let git_binary = self.git_binary_in_worktree(); let git_dir = self.git_dir.clone(); let help_output = self.any_git_binary_help_output(); @@ -2965,10 +2960,9 @@ impl GitRepository for RealGitRepository { log_order: LogOrder, request_tx: Sender>>, ) -> BoxFuture<'_, Result<()>> { - let git_binary = self.git_binary(); + let git = self.git_binary(); async move { - let git = git_binary; let mut git_log_command = vec!["log", GRAPH_COMMIT_FORMAT, log_order.as_arg()]; git_log_command.extend(log_source.get_args()?); let mut command = git.build_command(&git_log_command); @@ -3037,10 +3031,9 @@ impl GitRepository for RealGitRepository { search_args: SearchCommitArgs, request_tx: Sender, ) -> BoxFuture<'_, Result<()>> { - let git_binary = self.git_binary(); + let git = self.git_binary(); async move { - let git = git_binary; let mut args = vec!["log", SEARCH_COMMIT_FORMAT]; args.push("--fixed-strings"); @@ -3091,10 +3084,9 @@ impl GitRepository for RealGitRepository { paths: Vec, commit_limit: usize, ) -> BoxFuture<'_, Result>> { - let git_binary = self.git_binary(); + let git = self.git_binary(); async move { - let git = git_binary; if paths.is_empty() { return Ok(Vec::new()); } From d8365e282a2af272ca639a7ba3871af876e35f20 Mon Sep 17 00:00:00 2001 From: Anthony Eid Date: Mon, 1 Jun 2026 21:06:48 -0400 Subject: [PATCH 06/12] more clean up --- crates/git/src/repository.rs | 99 +++++++++++------------------------- 1 file changed, 31 insertions(+), 68 deletions(-) diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs index 1b9b7083c71f23..7d26d8533f43ef 100644 --- a/crates/git/src/repository.rs +++ b/crates/git/src/repository.rs @@ -1221,13 +1221,10 @@ impl RealGitRepository { if let Some(output) = self.any_git_binary_help_output.lock().clone() { return output; } - let git_binary = self.git_binary(); + let git = self.git_binary(); let output: SharedString = self .executor - .spawn(async move { - let git = git_binary; - git.run(&["help", "-a"]).await - }) + .spawn(async move { git.run(&["help", "-a"]).await }) .await .unwrap_or_default() .into(); @@ -1303,10 +1300,9 @@ impl GitRepository for RealGitRepository { } fn show(&self, commit: String) -> BoxFuture<'_, Result> { - let git_binary = self.git_binary(); + let git = self.git_binary(); self.executor .spawn(async move { - let git = git_binary; let output = git .build_command(&[ "show", @@ -1338,9 +1334,8 @@ impl GitRepository for RealGitRepository { } fn load_commit(&self, commit: String, cx: AsyncApp) -> BoxFuture<'_, Result> { - let git_binary = self.git_binary(); + let git = self.git_binary(); cx.background_spawn(async move { - let git = git_binary; let show_output = git .build_command(&[ "show", @@ -1470,14 +1465,13 @@ impl GitRepository for RealGitRepository { mode: ResetMode, env: Arc>, ) -> BoxFuture<'_, Result<()>> { - let git_binary = self.git_binary_in_worktree(); + let git = self.git_binary_in_worktree(); async move { let mode_flag = match mode { ResetMode::Mixed => "--mixed", ResetMode::Soft => "--soft", }; - let git = git_binary; let output = git .build_command(&["reset", mode_flag, &commit]) .envs(env.iter()) @@ -1499,13 +1493,12 @@ impl GitRepository for RealGitRepository { paths: Vec, env: Arc>, ) -> BoxFuture<'_, Result<()>> { - let git_binary = self.git_binary_in_worktree(); + let git = self.git_binary_in_worktree(); async move { if paths.is_empty() { return Ok(()); } - let git = git_binary; let output = git .build_command(&["checkout", &commit, "--"]) .envs(env.iter()) @@ -1544,11 +1537,10 @@ impl GitRepository for RealGitRepository { } fn load_committed_text(&self, path: RepoPath) -> BoxFuture<'_, Option> { - let git_binary = self.git_binary(); + let git = self.git_binary(); let path_str = format!("HEAD:{}", path.as_unix_str()); self.executor .spawn(async move { - let git = git_binary; let output = git .build_command(&["show", &path_str]) .stdout(Stdio::piped()) @@ -1625,10 +1617,9 @@ impl GitRepository for RealGitRepository { env: Arc>, is_executable: bool, ) -> BoxFuture<'_, anyhow::Result<()>> { - let git_binary = self.git_binary(); + let git = self.git_binary(); self.executor .spawn(async move { - let git = git_binary; let mode = if is_executable { "100755" } else { "100644" }; if let Some(content) = content { @@ -1680,11 +1671,10 @@ impl GitRepository for RealGitRepository { } fn remote_url(&self, name: &str) -> BoxFuture<'_, Option> { - let git_binary = self.git_binary(); + let git = self.git_binary(); let name = name.to_owned(); self.executor .spawn(async move { - let git = git_binary; let output = git .build_command(&["remote", "get-url", &name]) .output() @@ -1705,10 +1695,9 @@ impl GitRepository for RealGitRepository { } fn revparse_batch(&self, revs: Vec) -> BoxFuture<'_, Result>>> { - let git_binary = self.git_binary(); + let git = self.git_binary(); self.executor .spawn(async move { - let git = git_binary; let mut process = git .build_command(&["cat-file", "--batch-check=%(objectname)"]) .stdin(Stdio::piped()) @@ -1810,10 +1799,9 @@ impl GitRepository for RealGitRepository { } fn stash_entries(&self) -> BoxFuture<'static, Result> { - let git_binary = self.git_binary_in_worktree(); + let git = self.git_binary_in_worktree(); self.executor .spawn(async move { - let git = git_binary; let output = git .build_command(&["stash", "list", "--pretty=format:%gd%x00%H%x00%ct%x00%s"]) .output() @@ -1830,10 +1818,9 @@ impl GitRepository for RealGitRepository { } fn branches(&self) -> BoxFuture<'_, Result> { - let git_binary = self.git_binary(); + let git = self.git_binary(); self.executor .spawn(async move { - let git = git_binary; let fields = [ "%(HEAD)", "%(objectname)", @@ -1890,11 +1877,10 @@ impl GitRepository for RealGitRepository { } fn worktrees(&self) -> BoxFuture<'_, Result>> { - let git_binary = self.git_binary(); + let git = self.git_binary(); let main_worktree_path = original_repo_path_from_common_dir(&self.common_dir); self.executor .spawn(async move { - let git = git_binary; let output = git .build_command(&["worktree", "list", "--porcelain"]) .output() @@ -1918,7 +1904,7 @@ impl GitRepository for RealGitRepository { target: CreateWorktreeTarget, path: PathBuf, ) -> BoxFuture<'_, Result<()>> { - let git_binary = self.git_binary(); + let git = self.git_binary(); let mut args = vec![OsString::from("worktree"), OsString::from("add")]; match &target { @@ -1949,7 +1935,6 @@ impl GitRepository for RealGitRepository { self.executor .spawn(async move { - let git = git_binary; std::fs::create_dir_all(path.parent().unwrap_or(&path))?; let output = git.build_command(&args).output().await?; if output.status.success() { @@ -1963,11 +1948,10 @@ impl GitRepository for RealGitRepository { } fn remove_worktree(&self, path: PathBuf, force: bool) -> BoxFuture<'_, Result<()>> { - let git_binary = self.git_binary(); + let git = self.git_binary(); self.executor .spawn(async move { - let git = git_binary; let mut args: Vec = vec!["worktree".into(), "remove".into()]; if force { args.push("--force".into()); @@ -1981,11 +1965,10 @@ impl GitRepository for RealGitRepository { } fn rename_worktree(&self, old_path: PathBuf, new_path: PathBuf) -> BoxFuture<'_, Result<()>> { - let git_binary = self.git_binary(); + let git = self.git_binary(); self.executor .spawn(async move { - let git = git_binary; let args: Vec = vec![ "worktree".into(), "move".into(), @@ -2103,10 +2086,9 @@ impl GitRepository for RealGitRepository { } fn diff(&self, diff: DiffType) -> BoxFuture<'_, Result> { - let git_binary = self.git_binary_in_worktree(); + let git = self.git_binary_in_worktree(); self.executor .spawn(async move { - let git = git_binary; let output = match diff { DiffType::HeadToIndex => { git.build_command(&["diff", "--staged"]).output().await? @@ -2138,7 +2120,6 @@ impl GitRepository for RealGitRepository { self.executor .spawn(async move { - let git_binary = git_binary; let mut args: Vec = vec![ "diff".into(), "--numstat".into(), @@ -2164,11 +2145,10 @@ impl GitRepository for RealGitRepository { paths: Vec, env: Arc>, ) -> BoxFuture<'_, Result<()>> { - let git_binary = self.git_binary_in_worktree(); + let git = self.git_binary_in_worktree(); self.executor .spawn(async move { if !paths.is_empty() { - let git = git_binary; let output = git .build_command(&["update-index", "--add", "--remove", "--"]) .envs(env.iter()) @@ -2191,12 +2171,11 @@ impl GitRepository for RealGitRepository { paths: Vec, env: Arc>, ) -> BoxFuture<'_, Result<()>> { - let git_binary = self.git_binary_in_worktree(); + let git = self.git_binary_in_worktree(); self.executor .spawn(async move { if !paths.is_empty() { - let git = git_binary; let output = git .build_command(&["reset", "--quiet", "--"]) .envs(env.iter()) @@ -2220,10 +2199,9 @@ impl GitRepository for RealGitRepository { paths: Vec, env: Arc>, ) -> BoxFuture<'_, Result<()>> { - let git_binary = self.git_binary_in_worktree(); + let git = self.git_binary_in_worktree(); self.executor .spawn(async move { - let git = git_binary; let output = git .build_command(&["stash", "push", "--quiet", "--include-untracked", "--"]) .envs(env.iter()) @@ -2246,10 +2224,9 @@ impl GitRepository for RealGitRepository { index: Option, env: Arc>, ) -> BoxFuture<'_, Result<()>> { - let git_binary = self.git_binary_in_worktree(); + let git = self.git_binary_in_worktree(); self.executor .spawn(async move { - let git = git_binary; let mut args = vec!["stash".to_string(), "pop".to_string()]; if let Some(index) = index { args.push(format!("stash@{{{}}}", index)); @@ -2271,10 +2248,9 @@ impl GitRepository for RealGitRepository { index: Option, env: Arc>, ) -> BoxFuture<'_, Result<()>> { - let git_binary = self.git_binary_in_worktree(); + let git = self.git_binary_in_worktree(); self.executor .spawn(async move { - let git = git_binary; let mut args = vec!["stash".to_string(), "apply".to_string()]; if let Some(index) = index { args.push(format!("stash@{{{}}}", index)); @@ -2296,10 +2272,9 @@ impl GitRepository for RealGitRepository { index: Option, env: Arc>, ) -> BoxFuture<'_, Result<()>> { - let git_binary = self.git_binary_in_worktree(); + let git = self.git_binary_in_worktree(); self.executor .spawn(async move { - let git = git_binary; let mut args = vec!["stash".to_string(), "drop".to_string()]; if let Some(index) = index { args.push(format!("stash@{{{}}}", index)); @@ -2324,12 +2299,11 @@ impl GitRepository for RealGitRepository { ask_pass: AskPassDelegate, env: Arc>, ) -> BoxFuture<'_, Result<()>> { - let git_binary = self.git_binary_in_worktree(); + let git = self.git_binary_in_worktree(); let executor = self.executor.clone(); // Note: Do not spawn this command on the background thread, it might pop open the credential helper // which we want to block on. async move { - let git = git_binary; let mut cmd = git.build_command(&["commit", "--quiet", "-m"]); cmd.envs(env.iter()) .arg(&message.to_string()) @@ -2597,10 +2571,9 @@ impl GitRepository for RealGitRepository { } fn check_for_pushed_commit(&self) -> BoxFuture<'_, Result>> { - let git_binary = self.git_binary_in_worktree(); + let git = self.git_binary_in_worktree(); self.executor .spawn(async move { - let git = git_binary; let git_cmd = async |args: &[&str]| -> Result { let output = git.build_command(args).output().await?; anyhow::ensure!( @@ -2651,10 +2624,9 @@ impl GitRepository for RealGitRepository { } fn checkpoint(&self) -> BoxFuture<'static, Result> { - let git_binary = self.git_binary_in_worktree(); + let mut git = self.git_binary_in_worktree().envs(checkpoint_author_envs()); self.executor .spawn(async move { - let mut git = git_binary.envs(checkpoint_author_envs()); git.with_temp_index(async |git| { let head_sha = git.run(&["rev-parse", "HEAD"]).await.ok(); let mut excludes = exclude_files(git).await?; @@ -2680,10 +2652,9 @@ impl GitRepository for RealGitRepository { } fn restore_checkpoint(&self, checkpoint: GitRepositoryCheckpoint) -> BoxFuture<'_, Result<()>> { - let git_binary = self.git_binary_in_worktree(); + let git = self.git_binary_in_worktree(); self.executor .spawn(async move { - let git = git_binary; git.run(&[ "restore", "--source", @@ -2710,11 +2681,9 @@ impl GitRepository for RealGitRepository { } fn create_archive_checkpoint(&self) -> BoxFuture<'_, Result<(String, String)>> { - let git_binary = self.git_binary_in_worktree(); + let mut git = self.git_binary_in_worktree().envs(checkpoint_author_envs()); self.executor .spawn(async move { - let mut git = git_binary.envs(checkpoint_author_envs()); - let head_sha = git .run(&["rev-parse", "HEAD"]) .await @@ -2768,11 +2737,9 @@ impl GitRepository for RealGitRepository { staged_sha: String, unstaged_sha: String, ) -> BoxFuture<'_, Result<()>> { - let git_binary = self.git_binary_in_worktree(); + let git = self.git_binary_in_worktree(); self.executor .spawn(async move { - let git = git_binary; - // First, set the index AND working tree to match the unstaged // tree. --reset -u computes a tree-level diff between the // current index and unstaged_sha's tree and applies additions, @@ -2798,10 +2765,9 @@ impl GitRepository for RealGitRepository { left: GitRepositoryCheckpoint, right: GitRepositoryCheckpoint, ) -> BoxFuture<'_, Result> { - let git_binary = self.git_binary_in_worktree(); + let git = self.git_binary_in_worktree(); self.executor .spawn(async move { - let git = git_binary; let result = git .run(&[ "diff-tree", @@ -2832,10 +2798,9 @@ impl GitRepository for RealGitRepository { base_checkpoint: GitRepositoryCheckpoint, target_checkpoint: GitRepositoryCheckpoint, ) -> BoxFuture<'_, Result> { - let git_binary = self.git_binary_in_worktree(); + let git = self.git_binary_in_worktree(); self.executor .spawn(async move { - let git = git_binary; git.run(&[ "diff", "--find-renames", @@ -2913,8 +2878,6 @@ impl GitRepository for RealGitRepository { // Note: Do not spawn these commands on the background thread, as this causes some git hooks to hang. async move { - let git_binary = git_binary; - let working_directory = git_binary.working_directory.clone(); if !help_output .await From 6c85a23d4a068518b4d6dbc32dcf3bc117756a48 Mon Sep 17 00:00:00 2001 From: Anthony Eid Date: Mon, 1 Jun 2026 21:25:48 -0400 Subject: [PATCH 07/12] Add test cases for bare repo, linked worktree, and path stuff --- crates/git/src/repository.rs | 133 +++++++++++++++++++++++++++++++++-- 1 file changed, 126 insertions(+), 7 deletions(-) diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs index 7d26d8533f43ef..0f97fbe4ff8d6e 100644 --- a/crates/git/src/repository.rs +++ b/crates/git/src/repository.rs @@ -3729,7 +3729,10 @@ fn checkpoint_author_envs() -> HashMap { #[cfg(test)] mod tests { - use std::fs; + use std::{ + ffi::{OsStr, OsString}, + fs, + }; use super::*; use gpui::TestAppContext; @@ -3742,15 +3745,131 @@ mod tests { } #[allow(clippy::disallowed_methods)] - fn git_init_repo(path: &Path) { - fs::create_dir_all(path).expect("failed to create repo directory"); - std::process::Command::new("git") - .args(["init", "-b", "main"]) - .current_dir(path) + #[track_caller] + fn git_command(working_directory: &Path, arguments: I) + where + I: IntoIterator, + S: AsRef, + { + let output = std::process::Command::new("git") + .args(arguments) + .current_dir(working_directory) .env("GIT_CONFIG_GLOBAL", "") .env("GIT_CONFIG_SYSTEM", "") + .env("GIT_AUTHOR_NAME", "test") + .env("GIT_AUTHOR_EMAIL", "test@zed.dev") + .env("GIT_COMMITTER_NAME", "test") + .env("GIT_COMMITTER_EMAIL", "test@zed.dev") .output() - .expect("failed to init git repo"); + .expect("failed to run git command"); + assert!( + output.status.success(), + "git command failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + fn git_init_repo(path: &Path) { + fs::create_dir_all(path).expect("failed to create repo directory"); + git_command(path, ["init", "-b", "main"]); + } + + #[gpui::test] + async fn test_real_git_repository_new_resolves_linked_worktree_paths(cx: &mut TestAppContext) { + disable_git_global_config(); + cx.executor().allow_parking(); + + let temp_dir = tempfile::tempdir().unwrap(); + let repo_dir = temp_dir.path().join("repo"); + let worktree_dir = temp_dir.path().join("worktree"); + git_init_repo(&repo_dir); + fs::write(repo_dir.join("file.txt"), "initial").unwrap(); + git_command(&repo_dir, ["add", "file.txt"]); + git_command(&repo_dir, ["commit", "-m", "initial"]); + git_command( + &repo_dir, + [ + OsString::from("worktree"), + OsString::from("add"), + OsString::from("-b"), + OsString::from("feature"), + worktree_dir.as_os_str().into(), + ], + ); + + let repository = RealGitRepository::new( + &worktree_dir.join(".git"), + None, + Some("git".into()), + cx.executor(), + ) + .unwrap(); + + assert_eq!(repository.working_directory, worktree_dir); + assert_eq!(repository.common_dir, repo_dir.join(".git")); + assert_eq!( + original_repo_path_from_common_dir(&repository.common_dir), + Some(repo_dir) + ); + } + + #[gpui::test] + async fn test_real_git_repository_new_supports_bare_repositories(cx: &mut TestAppContext) { + disable_git_global_config(); + cx.executor().allow_parking(); + + let temp_dir = tempfile::tempdir().unwrap(); + let repo_dir = temp_dir.path().join("repo.git"); + git_command( + temp_dir.path(), + [ + OsString::from("init"), + OsString::from("--bare"), + repo_dir.as_os_str().into(), + ], + ); + + let repository = + RealGitRepository::new(&repo_dir, None, Some("git".into()), cx.executor()).unwrap(); + + assert_eq!(repository.git_dir, repo_dir); + assert_eq!(repository.common_dir, repo_dir); + assert_eq!(repository.main_repository_path(), repo_dir); + assert_eq!( + repository + .git_binary() + .run(&["rev-parse", "--is-bare-repository"]) + .await + .unwrap(), + "true" + ); + } + + #[gpui::test] + fn test_real_git_repository_new_rejects_malformed_git_file(cx: &mut TestAppContext) { + disable_git_global_config(); + + let temp_dir = tempfile::tempdir().unwrap(); + let worktree_dir = temp_dir.path().join("worktree"); + fs::create_dir_all(&worktree_dir).unwrap(); + fs::write(worktree_dir.join(".git"), "not a gitdir file\n").unwrap(); + + let error = match RealGitRepository::new( + &worktree_dir.join(".git"), + None, + Some("git".into()), + cx.executor(), + ) { + Ok(_) => panic!("malformed .git file should be rejected"), + Err(error) => error, + }; + + assert!( + error + .to_string() + .contains("expected .git file to start with 'gitdir: '"), + "unexpected error: {error:#}" + ); } #[test] From 21bd23403b0f96f7b22f1c21d88afe0cfc960dd3 Mon Sep 17 00:00:00 2001 From: Anthony Eid Date: Mon, 1 Jun 2026 21:28:45 -0400 Subject: [PATCH 08/12] Get tests passing --- crates/git/src/repository.rs | 32 +++++++++++++++++++++----------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs index 0f97fbe4ff8d6e..56fbeb04947786 100644 --- a/crates/git/src/repository.rs +++ b/crates/git/src/repository.rs @@ -75,6 +75,11 @@ pub fn original_repo_path_from_common_dir(common_dir: &Path) -> Option } } +fn normalize_git_metadata_path(path: PathBuf) -> Result { + paths::normalize_lexically(&path) + .map_err(|_| anyhow!("git metadata path escapes its filesystem root: {path:?}")) +} + /// Commit data needed for the git graph visualization. #[derive(Debug, Clone)] pub struct CommitData { @@ -1129,13 +1134,16 @@ impl RealGitRepository { log::info!( "opening git repository at {dotgit_path:?} using git binary {any_git_binary_path:?}" ); - let working_directory = dotgit_path - .parent() - .context(".git has no parent")? - .to_path_buf(); + let dotgit_parent = dotgit_path.parent().context(".git has no parent")?; + let has_working_directory = + dotgit_path.is_file() || dotgit_path.file_name() == Some(OsStr::new(".git")); + let working_directory = if has_working_directory { + normalize_git_metadata_path(dotgit_parent.to_path_buf())? + } else { + normalize_git_metadata_path(dotgit_path.to_path_buf())? + }; let git_dir = if dotgit_path.is_file() { - // Worktree gitdir file: contains `gitdir: ` let content = std::fs::read_to_string(dotgit_path).context("reading .git worktree file")?; let path_str = content @@ -1143,13 +1151,14 @@ impl RealGitRepository { .context("expected .git file to start with 'gitdir: '")? .trim(); let resolved = PathBuf::from(path_str); - if resolved.is_absolute() { + let resolved = if resolved.is_absolute() { resolved } else { - working_directory.join(resolved) - } + dotgit_parent.join(resolved) + }; + normalize_git_metadata_path(resolved)? } else { - dotgit_path.to_path_buf() + normalize_git_metadata_path(dotgit_path.to_path_buf())? }; let common_dir = { @@ -1159,11 +1168,12 @@ impl RealGitRepository { std::fs::read_to_string(&commondir_file).context("reading commondir file")?; let path_str = content.trim(); let resolved = PathBuf::from(path_str); - if resolved.is_absolute() { + let resolved = if resolved.is_absolute() { resolved } else { git_dir.join(resolved) - } + }; + normalize_git_metadata_path(resolved)? } else { git_dir.clone() } From e6cf5d568e51c252b898676fbec31cd6ff64c630 Mon Sep 17 00:00:00 2001 From: Anthony Eid Date: Mon, 1 Jun 2026 23:36:26 -0400 Subject: [PATCH 09/12] set askpass to false --- crates/git/src/repository.rs | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs index 56fbeb04947786..fa99fcd49b444d 100644 --- a/crates/git/src/repository.rs +++ b/crates/git/src/repository.rs @@ -3784,6 +3784,12 @@ mod tests { git_command(path, ["init", "-b", "main"]); } + fn test_commit_envs() -> HashMap { + let mut env = checkpoint_author_envs(); + env.insert("GIT_ASKPASS".to_string(), "false".to_string()); + env + } + #[gpui::test] async fn test_real_git_repository_new_resolves_linked_worktree_paths(cx: &mut TestAppContext) { disable_git_global_config(); @@ -4140,7 +4146,7 @@ mod tests { None, CommitOptions::default(), AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}), - Arc::new(checkpoint_author_envs()), + Arc::new(test_commit_envs()), ) .await .unwrap(); @@ -4167,7 +4173,7 @@ mod tests { None, CommitOptions::default(), AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}), - Arc::new(checkpoint_author_envs()), + Arc::new(test_commit_envs()), ) .await .unwrap(); @@ -4277,7 +4283,7 @@ mod tests { None, CommitOptions::default(), AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}), - Arc::new(checkpoint_author_envs()), + Arc::new(test_commit_envs()), ) .await .unwrap(); @@ -4382,7 +4388,7 @@ mod tests { None, CommitOptions::default(), AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}), - Arc::new(checkpoint_author_envs()), + Arc::new(test_commit_envs()), ) .await .unwrap(); @@ -4648,7 +4654,7 @@ mod tests { None, CommitOptions::default(), AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}), - Arc::new(checkpoint_author_envs()), + Arc::new(test_commit_envs()), ) .await .unwrap(); @@ -4718,7 +4724,7 @@ mod tests { None, CommitOptions::default(), AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}), - Arc::new(checkpoint_author_envs()), + Arc::new(test_commit_envs()), ) .await .unwrap(); @@ -4816,7 +4822,7 @@ mod tests { None, CommitOptions::default(), AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}), - Arc::new(checkpoint_author_envs()), + Arc::new(test_commit_envs()), ) .await .unwrap(); From ef1c8cc52d6f58bbd6a8d8d3c0b3b2f4f05fd2d5 Mon Sep 17 00:00:00 2001 From: Anthony Eid Date: Mon, 1 Jun 2026 23:49:40 -0400 Subject: [PATCH 10/12] Improve support for bare worktrees --- crates/git/src/repository.rs | 80 +++++++++++++++++++++++++----------- 1 file changed, 56 insertions(+), 24 deletions(-) diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs index fa99fcd49b444d..e1e3a8216698c0 100644 --- a/crates/git/src/repository.rs +++ b/crates/git/src/repository.rs @@ -1093,7 +1093,8 @@ impl std::fmt::Debug for dyn GitRepository { pub struct RealGitRepository { pub git_dir: PathBuf, pub common_dir: PathBuf, - pub working_directory: PathBuf, + /// `None` only for bare repositories, which do not have a working directory. + pub working_directory: Option, pub system_git_binary_path: Option, pub any_git_binary_path: PathBuf, any_git_binary_help_output: Arc>>, @@ -1138,9 +1139,9 @@ impl RealGitRepository { let has_working_directory = dotgit_path.is_file() || dotgit_path.file_name() == Some(OsStr::new(".git")); let working_directory = if has_working_directory { - normalize_git_metadata_path(dotgit_parent.to_path_buf())? + Some(normalize_git_metadata_path(dotgit_parent.to_path_buf())?) } else { - normalize_git_metadata_path(dotgit_path.to_path_buf())? + None }; let git_dir = if dotgit_path.is_file() { @@ -1191,24 +1192,32 @@ impl RealGitRepository { }) } - fn working_directory(&self) -> PathBuf { - self.working_directory.clone() + fn working_directory(&self) -> Result { + self.working_directory + .clone() + .context("bare repositories do not have a working directory") } - fn git_binary_in_worktree(&self) -> GitBinary { - GitBinary::new( + fn command_directory(&self) -> PathBuf { + self.working_directory + .clone() + .unwrap_or_else(|| self.git_dir.clone()) + } + + fn git_binary_in_worktree(&self) -> Result { + Ok(GitBinary::new( self.any_git_binary_path.clone(), - self.working_directory(), + self.working_directory()?, self.path(), self.executor.clone(), self.is_trusted(), - ) + )) } fn git_binary(&self) -> GitBinary { GitBinary::new( self.any_git_binary_path.clone(), - self.working_directory(), + self.command_directory(), self.path(), self.executor.clone(), self.is_trusted(), @@ -1477,6 +1486,7 @@ impl GitRepository for RealGitRepository { ) -> BoxFuture<'_, Result<()>> { let git = self.git_binary_in_worktree(); async move { + let git = git?; let mode_flag = match mode { ResetMode::Mixed => "--mixed", ResetMode::Soft => "--soft", @@ -1505,6 +1515,7 @@ impl GitRepository for RealGitRepository { ) -> BoxFuture<'_, Result<()>> { let git = self.git_binary_in_worktree(); async move { + let git = git?; if paths.is_empty() { return Ok(()); } @@ -1576,16 +1587,12 @@ impl GitRepository for RealGitRepository { fn load_commit_template(&self) -> BoxFuture<'_, Result>> { let working_directory = self.working_directory(); - let git_binary = GitBinary::new( - self.any_git_binary_path.clone(), - working_directory.clone(), - self.path(), - self.executor.clone(), - self.is_trusted(), - ); + let git_binary = self.git_binary_in_worktree(); self.executor .spawn(async move { + let working_directory = working_directory?; + let git_binary = git_binary?; let output = git_binary .build_command(&["config", "--get", "commit.template"]) .output() @@ -1762,6 +1769,7 @@ impl GitRepository for RealGitRepository { let args = git_status_args(path_prefixes); log::debug!("Checking for git status in {path_prefixes:?}"); self.executor.spawn(async move { + let git = git?; let output = git.build_command(&args).output().await?; if output.status.success() { let stdout = String::from_utf8_lossy(&output.stdout); @@ -1796,6 +1804,7 @@ impl GitRepository for RealGitRepository { self.executor .spawn(async move { + let git = git?; let output = git.build_command(&args).output().await?; if output.status.success() { let stdout = String::from_utf8_lossy(&output.stdout); @@ -1812,6 +1821,7 @@ impl GitRepository for RealGitRepository { let git = self.git_binary_in_worktree(); self.executor .spawn(async move { + let git = git?; let output = git .build_command(&["stash", "list", "--pretty=format:%gd%x00%H%x00%ct%x00%s"]) .output() @@ -2037,6 +2047,7 @@ impl GitRepository for RealGitRepository { self.executor .spawn(async move { + let git_binary = git_binary?; let mut args = vec!["switch", "-c", &name]; let base_branch_str; if let Some(ref base) = base_branch { @@ -2055,6 +2066,7 @@ impl GitRepository for RealGitRepository { self.executor .spawn(async move { + let git_binary = git_binary?; git_binary .run(&["branch", "-m", &branch, &new_name]) .await?; @@ -2073,6 +2085,7 @@ impl GitRepository for RealGitRepository { self.executor .spawn(async move { + let git_binary = git_binary?; let flag = delete_branch_flag(is_remote, force); git_binary.run(&["branch", flag, &name]).await?; anyhow::Ok(()) @@ -2090,6 +2103,7 @@ impl GitRepository for RealGitRepository { self.executor .spawn(async move { + let git = git?; crate::blame::Blame::for_path(&git, &path, &content, line_ending).await }) .boxed() @@ -2099,6 +2113,7 @@ impl GitRepository for RealGitRepository { let git = self.git_binary_in_worktree(); self.executor .spawn(async move { + let git = git?; let output = match diff { DiffType::HeadToIndex => { git.build_command(&["diff", "--staged"]).output().await? @@ -2130,6 +2145,7 @@ impl GitRepository for RealGitRepository { self.executor .spawn(async move { + let git_binary = git_binary?; let mut args: Vec = vec![ "diff".into(), "--numstat".into(), @@ -2158,6 +2174,7 @@ impl GitRepository for RealGitRepository { let git = self.git_binary_in_worktree(); self.executor .spawn(async move { + let git = git?; if !paths.is_empty() { let output = git .build_command(&["update-index", "--add", "--remove", "--"]) @@ -2185,6 +2202,7 @@ impl GitRepository for RealGitRepository { self.executor .spawn(async move { + let git = git?; if !paths.is_empty() { let output = git .build_command(&["reset", "--quiet", "--"]) @@ -2212,6 +2230,7 @@ impl GitRepository for RealGitRepository { let git = self.git_binary_in_worktree(); self.executor .spawn(async move { + let git = git?; let output = git .build_command(&["stash", "push", "--quiet", "--include-untracked", "--"]) .envs(env.iter()) @@ -2237,6 +2256,7 @@ impl GitRepository for RealGitRepository { let git = self.git_binary_in_worktree(); self.executor .spawn(async move { + let git = git?; let mut args = vec!["stash".to_string(), "pop".to_string()]; if let Some(index) = index { args.push(format!("stash@{{{}}}", index)); @@ -2261,6 +2281,7 @@ impl GitRepository for RealGitRepository { let git = self.git_binary_in_worktree(); self.executor .spawn(async move { + let git = git?; let mut args = vec!["stash".to_string(), "apply".to_string()]; if let Some(index) = index { args.push(format!("stash@{{{}}}", index)); @@ -2285,6 +2306,7 @@ impl GitRepository for RealGitRepository { let git = self.git_binary_in_worktree(); self.executor .spawn(async move { + let git = git?; let mut args = vec!["stash".to_string(), "drop".to_string()]; if let Some(index) = index { args.push(format!("stash@{{{}}}", index)); @@ -2314,6 +2336,7 @@ impl GitRepository for RealGitRepository { // Note: Do not spawn this command on the background thread, it might pop open the credential helper // which we want to block on. async move { + let git = git?; let mut cmd = git.build_command(&["commit", "--quiet", "-m"]); cmd.envs(env.iter()) .arg(&message.to_string()) @@ -2374,7 +2397,7 @@ impl GitRepository for RealGitRepository { env: Arc>, cx: AsyncApp, ) -> BoxFuture<'_, Result> { - let working_directory = self.working_directory(); + let working_directory = self.command_directory(); let git_directory = self.path(); let executor = cx.background_executor().clone(); let git_binary_path = self.system_git_binary_path.clone(); @@ -2417,7 +2440,7 @@ impl GitRepository for RealGitRepository { env: Arc>, cx: AsyncApp, ) -> BoxFuture<'_, Result> { - let working_directory = self.working_directory(); + let working_directory = self.command_directory(); let git_directory = self.path(); let executor = cx.background_executor().clone(); let git_binary_path = self.system_git_binary_path.clone(); @@ -2458,7 +2481,7 @@ impl GitRepository for RealGitRepository { env: Arc>, cx: AsyncApp, ) -> BoxFuture<'_, Result> { - let working_directory = self.working_directory(); + let working_directory = self.command_directory(); let git_directory = self.path(); let remote_name = format!("{}", fetch_options); let git_binary_path = self.system_git_binary_path.clone(); @@ -2584,6 +2607,7 @@ impl GitRepository for RealGitRepository { let git = self.git_binary_in_worktree(); self.executor .spawn(async move { + let git = git?; let git_cmd = async |args: &[&str]| -> Result { let output = git.build_command(args).output().await?; anyhow::ensure!( @@ -2634,9 +2658,10 @@ impl GitRepository for RealGitRepository { } fn checkpoint(&self) -> BoxFuture<'static, Result> { - let mut git = self.git_binary_in_worktree().envs(checkpoint_author_envs()); + let git = self.git_binary_in_worktree(); self.executor .spawn(async move { + let mut git = git?.envs(checkpoint_author_envs()); git.with_temp_index(async |git| { let head_sha = git.run(&["rev-parse", "HEAD"]).await.ok(); let mut excludes = exclude_files(git).await?; @@ -2665,6 +2690,7 @@ impl GitRepository for RealGitRepository { let git = self.git_binary_in_worktree(); self.executor .spawn(async move { + let git = git?; git.run(&[ "restore", "--source", @@ -2691,9 +2717,10 @@ impl GitRepository for RealGitRepository { } fn create_archive_checkpoint(&self) -> BoxFuture<'_, Result<(String, String)>> { - let mut git = self.git_binary_in_worktree().envs(checkpoint_author_envs()); + let git = self.git_binary_in_worktree(); self.executor .spawn(async move { + let mut git = git?.envs(checkpoint_author_envs()); let head_sha = git .run(&["rev-parse", "HEAD"]) .await @@ -2750,6 +2777,7 @@ impl GitRepository for RealGitRepository { let git = self.git_binary_in_worktree(); self.executor .spawn(async move { + let git = git?; // First, set the index AND working tree to match the unstaged // tree. --reset -u computes a tree-level diff between the // current index and unstaged_sha's tree and applies additions, @@ -2778,6 +2806,7 @@ impl GitRepository for RealGitRepository { let git = self.git_binary_in_worktree(); self.executor .spawn(async move { + let git = git?; let result = git .run(&[ "diff-tree", @@ -2811,6 +2840,7 @@ impl GitRepository for RealGitRepository { let git = self.git_binary_in_worktree(); self.executor .spawn(async move { + let git = git?; git.run(&[ "diff", "--find-renames", @@ -2888,6 +2918,7 @@ impl GitRepository for RealGitRepository { // Note: Do not spawn these commands on the background thread, as this causes some git hooks to hang. async move { + let git_binary = git_binary?; let working_directory = git_binary.working_directory.clone(); if !help_output .await @@ -3821,7 +3852,7 @@ mod tests { ) .unwrap(); - assert_eq!(repository.working_directory, worktree_dir); + assert_eq!(repository.working_directory, Some(worktree_dir)); assert_eq!(repository.common_dir, repo_dir.join(".git")); assert_eq!( original_repo_path_from_common_dir(&repository.common_dir), @@ -3850,6 +3881,7 @@ mod tests { assert_eq!(repository.git_dir, repo_dir); assert_eq!(repository.common_dir, repo_dir); + assert_eq!(repository.working_directory, None); assert_eq!(repository.main_repository_path(), repo_dir); assert_eq!( repository @@ -4956,7 +4988,7 @@ mod tests { impl RealGitRepository { /// Force a Git garbage collection on the repository. fn gc(&self) -> BoxFuture<'_, Result<()>> { - let working_directory = self.working_directory(); + let working_directory = self.command_directory(); let git_directory = self.path(); let git_binary_path = self.any_git_binary_path.clone(); let executor = self.executor.clone(); From 74f06df444e2d93c13f232105f432e5f080a324f Mon Sep 17 00:00:00 2001 From: Anthony Eid Date: Mon, 1 Jun 2026 23:53:14 -0400 Subject: [PATCH 11/12] add last test --- crates/git/src/repository.rs | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs index e1e3a8216698c0..3b15fa7aa1f1a3 100644 --- a/crates/git/src/repository.rs +++ b/crates/git/src/repository.rs @@ -3821,6 +3821,36 @@ mod tests { env } + #[gpui::test] + async fn test_real_git_repository_new_resolves_normal_repository_paths( + cx: &mut TestAppContext, + ) { + disable_git_global_config(); + cx.executor().allow_parking(); + + let repo_dir = tempfile::tempdir().unwrap(); + git_init_repo(repo_dir.path()); + + let repository = RealGitRepository::new( + &repo_dir.path().join(".git"), + None, + Some("git".into()), + cx.executor(), + ) + .unwrap(); + + assert_eq!(repository.git_dir, repo_dir.path().join(".git")); + assert_eq!(repository.common_dir, repo_dir.path().join(".git")); + assert_eq!( + repository.working_directory, + Some(repo_dir.path().to_path_buf()) + ); + assert_eq!( + original_repo_path_from_common_dir(&repository.common_dir), + Some(repo_dir.path().to_path_buf()) + ); + } + #[gpui::test] async fn test_real_git_repository_new_resolves_linked_worktree_paths(cx: &mut TestAppContext) { disable_git_global_config(); From 2cf205ffff4bdc917659a2f72f9e960211ca9417 Mon Sep 17 00:00:00 2001 From: Anthony Eid Date: Tue, 2 Jun 2026 01:25:00 -0400 Subject: [PATCH 12/12] fix tests --- crates/git/src/repository.rs | 43 ++++++++++++++++++++++-------------- 1 file changed, 27 insertions(+), 16 deletions(-) diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs index 3b15fa7aa1f1a3..6746133a5bf80e 100644 --- a/crates/git/src/repository.rs +++ b/crates/git/src/repository.rs @@ -3821,6 +3821,14 @@ mod tests { env } + #[track_caller] + fn assert_same_path(left: impl AsRef, right: impl AsRef) { + assert_eq!( + fs::canonicalize(left.as_ref()).unwrap(), + fs::canonicalize(right.as_ref()).unwrap() + ); + } + #[gpui::test] async fn test_real_git_repository_new_resolves_normal_repository_paths( cx: &mut TestAppContext, @@ -3839,15 +3847,15 @@ mod tests { ) .unwrap(); - assert_eq!(repository.git_dir, repo_dir.path().join(".git")); - assert_eq!(repository.common_dir, repo_dir.path().join(".git")); - assert_eq!( - repository.working_directory, - Some(repo_dir.path().to_path_buf()) + assert_same_path(&repository.git_dir, repo_dir.path().join(".git")); + assert_same_path(&repository.common_dir, repo_dir.path().join(".git")); + assert_same_path( + repository.working_directory.as_ref().unwrap(), + repo_dir.path(), ); - assert_eq!( - original_repo_path_from_common_dir(&repository.common_dir), - Some(repo_dir.path().to_path_buf()) + assert_same_path( + original_repo_path_from_common_dir(&repository.common_dir).unwrap(), + repo_dir.path(), ); } @@ -3882,11 +3890,14 @@ mod tests { ) .unwrap(); - assert_eq!(repository.working_directory, Some(worktree_dir)); - assert_eq!(repository.common_dir, repo_dir.join(".git")); - assert_eq!( - original_repo_path_from_common_dir(&repository.common_dir), - Some(repo_dir) + assert_same_path( + repository.working_directory.as_ref().unwrap(), + &worktree_dir, + ); + assert_same_path(&repository.common_dir, repo_dir.join(".git")); + assert_same_path( + original_repo_path_from_common_dir(&repository.common_dir).unwrap(), + repo_dir, ); } @@ -3909,10 +3920,10 @@ mod tests { let repository = RealGitRepository::new(&repo_dir, None, Some("git".into()), cx.executor()).unwrap(); - assert_eq!(repository.git_dir, repo_dir); - assert_eq!(repository.common_dir, repo_dir); + assert_same_path(&repository.git_dir, &repo_dir); + assert_same_path(&repository.common_dir, &repo_dir); assert_eq!(repository.working_directory, None); - assert_eq!(repository.main_repository_path(), repo_dir); + assert_same_path(repository.main_repository_path(), &repo_dir); assert_eq!( repository .git_binary()