use crate::commit::parse_git_diff_name_status; use crate::stash::GitStash; use crate::status::{DiffTreeType, GitStatus, StatusCode, TreeDiff}; use crate::{Oid, RunHook, SHORT_SHA_LENGTH}; use anyhow::{Context as _, Result, anyhow, bail}; use collections::HashMap; 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; use schemars::JsonSchema; use serde::Deserialize; use smallvec::SmallVec; use smol::channel::Sender; use smol::io::{AsyncBufReadExt, AsyncReadExt, BufReader}; use text::LineEnding; use std::collections::HashSet; use std::ffi::{OsStr, OsString}; use std::sync::atomic::AtomicBool; use std::process::ExitStatus; use std::str::FromStr; use std::{ cmp::Ordering, future, path::{Path, PathBuf}, sync::Arc, }; use sum_tree::MapSeekTarget; use thiserror::Error; use util::command::{Stdio, new_command}; use util::paths::PathStyle; use util::rel_path::RelPath; use util::{ResultExt, normalize_path, paths}; use uuid::Uuid; pub use askpass::{AskPassDelegate, AskPassResult, AskPassSession}; pub const REMOTE_CANCELLED_BY_USER: &str = "Operation cancelled by user"; /// Format string used in graph log to get initial data for the git graph /// %H - Full commit hash /// %P - Parent hashes /// %D - Ref names /// %x00 - Null byte separator, used to split up commit data static GRAPH_COMMIT_FORMAT: &str = "--format=%H%x00%P%x00%D"; /// Number of commits to load per chunk for the git graph. pub const GRAPH_CHUNK_SIZE: usize = 1000; /// Default value for the `git.worktree_directory` setting. pub const DEFAULT_WORKTREE_DIRECTORY: &str = "../worktrees"; /// Given the git common directory (from `commondir()`), derive the original /// repository's working directory. /// /// For a standard checkout, `common_dir` is `/.git`, so the parent /// is the working directory. For a git worktree, `common_dir` is the **main** /// repo's `.git` directory, so the parent is the original repo's working directory. /// /// Falls back to returning `common_dir` itself if it doesn't end with `.git` /// (e.g. bare repos or unusual layouts). pub fn original_repo_path_from_common_dir(common_dir: &Path) -> PathBuf { if common_dir.file_name() == Some(OsStr::new(".git")) { common_dir .parent() .map(|p| p.to_path_buf()) .unwrap_or_else(|| common_dir.to_path_buf()) } else { common_dir.to_path_buf() } } /// Resolves the configured worktree directory to an absolute path. /// /// `worktree_directory_setting` is the raw string from the user setting /// (e.g. `"../worktrees"`, `".git/zed-worktrees"`, `"my-worktrees/"`). /// Trailing slashes are stripped. The path is resolved relative to /// `working_directory` (the repository's working directory root). /// /// When the resolved directory falls outside the working directory /// (e.g. `"../worktrees"`), the repository's directory name is /// automatically appended so that sibling repos don't collide. /// For example, with working directory `~/code/zed` and setting /// `"../worktrees"`, this returns `~/code/worktrees/zed`. /// /// When the resolved directory is inside the working directory /// (e.g. `".git/zed-worktrees"`), no extra component is added /// because the path is already project-scoped. pub fn resolve_worktree_directory( working_directory: &Path, worktree_directory_setting: &str, ) -> PathBuf { let trimmed = worktree_directory_setting.trim_end_matches(['/', '\\']); let joined = working_directory.join(trimmed); let resolved = normalize_path(&joined); if resolved.starts_with(working_directory) { resolved } else if let Some(repo_dir_name) = working_directory.file_name() { resolved.join(repo_dir_name) } else { resolved } } /// Validates that the resolved worktree directory is acceptable: /// - The setting must not be an absolute path. /// - The resolved path must be either a subdirectory of the working /// directory or a subdirectory of its parent (i.e., a sibling). /// /// Returns `Ok(resolved_path)` or an error with a user-facing message. pub fn validate_worktree_directory( working_directory: &Path, worktree_directory_setting: &str, ) -> Result { // Check the original setting before trimming, since a path like "///" // is absolute but becomes "" after stripping trailing separators. // Also check for leading `/` or `\` explicitly, because on Windows // `Path::is_absolute()` requires a drive letter — so `/tmp/worktrees` // would slip through even though it's clearly not a relative path. if Path::new(worktree_directory_setting).is_absolute() || worktree_directory_setting.starts_with('/') || worktree_directory_setting.starts_with('\\') { anyhow::bail!( "git.worktree_directory must be a relative path, got: {worktree_directory_setting:?}" ); } if worktree_directory_setting.is_empty() { anyhow::bail!("git.worktree_directory must not be empty"); } let trimmed = worktree_directory_setting.trim_end_matches(['/', '\\']); if trimmed == ".." { anyhow::bail!("git.worktree_directory must not be \"..\" (use \"../some-name\" instead)"); } let resolved = resolve_worktree_directory(working_directory, worktree_directory_setting); let parent = working_directory.parent().unwrap_or(working_directory); if !resolved.starts_with(parent) { anyhow::bail!( "git.worktree_directory resolved to {resolved:?}, which is outside \ the project root and its parent directory. It must resolve to a \ subdirectory of {working_directory:?} or a sibling of it." ); } Ok(resolved) } /// Returns the full absolute path for a specific branch's worktree /// given the resolved worktree directory. pub fn worktree_path_for_branch( working_directory: &Path, worktree_directory_setting: &str, branch: &str, ) -> PathBuf { resolve_worktree_directory(working_directory, worktree_directory_setting).join(branch) } /// Commit data needed for the git graph visualization. #[derive(Debug, Clone)] pub struct GraphCommitData { pub sha: Oid, /// Most commits have a single parent, so we use a SmallVec to avoid allocations. pub parents: SmallVec<[Oid; 1]>, pub author_name: SharedString, pub author_email: SharedString, pub commit_timestamp: i64, pub subject: SharedString, } #[derive(Debug)] pub struct InitialGraphCommitData { pub sha: Oid, pub parents: SmallVec<[Oid; 1]>, pub ref_names: Vec, } struct CommitDataRequest { sha: Oid, response_tx: oneshot::Sender>, } pub struct CommitDataReader { request_tx: smol::channel::Sender, _task: Task<()>, } impl CommitDataReader { pub async fn read(&self, sha: Oid) -> Result { let (response_tx, response_rx) = oneshot::channel(); self.request_tx .send(CommitDataRequest { sha, response_tx }) .await .map_err(|_| anyhow!("commit data reader task closed"))?; response_rx .await .map_err(|_| anyhow!("commit data reader task dropped response"))? } } fn parse_cat_file_commit(sha: Oid, content: &str) -> Option { let mut parents = SmallVec::new(); let mut author_name = SharedString::default(); let mut author_email = SharedString::default(); let mut commit_timestamp = 0i64; let mut in_headers = true; let mut subject = None; for line in content.lines() { if in_headers { if line.is_empty() { in_headers = false; continue; } if let Some(parent_sha) = line.strip_prefix("parent ") { if let Ok(oid) = Oid::from_str(parent_sha.trim()) { parents.push(oid); } } else if let Some(author_line) = line.strip_prefix("author ") { if let Some((name_email, _timestamp_tz)) = author_line.rsplit_once(' ') { if let Some((name_email, timestamp_str)) = name_email.rsplit_once(' ') { if let Ok(ts) = timestamp_str.parse::() { commit_timestamp = ts; } if let Some((name, email)) = name_email.rsplit_once(" <") { author_name = SharedString::from(name.to_string()); author_email = SharedString::from(email.trim_end_matches('>').to_string()); } } } } } else if subject.is_none() { subject = Some(SharedString::from(line.to_string())); } } Some(GraphCommitData { sha, parents, author_name, author_email, commit_timestamp, subject: subject.unwrap_or_default(), }) } #[derive(Clone, Debug, Hash, PartialEq, Eq)] pub struct Branch { pub is_head: bool, pub ref_name: SharedString, pub upstream: Option, pub most_recent_commit: Option, } impl Branch { pub fn name(&self) -> &str { self.ref_name .as_ref() .strip_prefix("refs/heads/") .or_else(|| self.ref_name.as_ref().strip_prefix("refs/remotes/")) .unwrap_or(self.ref_name.as_ref()) } pub fn is_remote(&self) -> bool { self.ref_name.starts_with("refs/remotes/") } pub fn remote_name(&self) -> Option<&str> { self.ref_name .strip_prefix("refs/remotes/") .and_then(|stripped| stripped.split("/").next()) } pub fn tracking_status(&self) -> Option { self.upstream .as_ref() .and_then(|upstream| upstream.tracking.status()) } pub fn priority_key(&self) -> (bool, Option) { ( self.is_head, self.most_recent_commit .as_ref() .map(|commit| commit.commit_timestamp), ) } } #[derive(Clone, Debug, Hash, PartialEq, Eq)] pub struct Worktree { pub path: PathBuf, pub ref_name: SharedString, // todo(git_worktree) This type should be a Oid pub sha: SharedString, } impl Worktree { pub fn branch(&self) -> &str { self.ref_name .as_ref() .strip_prefix("refs/heads/") .or_else(|| self.ref_name.as_ref().strip_prefix("refs/remotes/")) .unwrap_or(self.ref_name.as_ref()) } } pub fn parse_worktrees_from_str>(raw_worktrees: T) -> Vec { let mut worktrees = Vec::new(); let normalized = raw_worktrees.as_ref().replace("\r\n", "\n"); let entries = normalized.split("\n\n"); for entry in entries { let mut path = None; let mut sha = None; let mut ref_name = None; for line in entry.lines() { let line = line.trim(); if line.is_empty() { continue; } if let Some(rest) = line.strip_prefix("worktree ") { path = Some(rest.to_string()); } else if let Some(rest) = line.strip_prefix("HEAD ") { sha = Some(rest.to_string()); } else if let Some(rest) = line.strip_prefix("branch ") { ref_name = Some(rest.to_string()); } // Ignore other lines: detached, bare, locked, prunable, etc. } // todo(git_worktree) We should add a test for detach head state // a detach head will have ref_name as none so we would skip it if let (Some(path), Some(sha), Some(ref_name)) = (path, sha, ref_name) { worktrees.push(Worktree { path: PathBuf::from(path), ref_name: ref_name.into(), sha: sha.into(), }) } } worktrees } #[derive(Clone, Debug, Hash, PartialEq, Eq)] pub struct Upstream { pub ref_name: SharedString, pub tracking: UpstreamTracking, } impl Upstream { pub fn is_remote(&self) -> bool { self.remote_name().is_some() } pub fn remote_name(&self) -> Option<&str> { self.ref_name .strip_prefix("refs/remotes/") .and_then(|stripped| stripped.split("/").next()) } pub fn stripped_ref_name(&self) -> Option<&str> { self.ref_name.strip_prefix("refs/remotes/") } pub fn branch_name(&self) -> Option<&str> { self.ref_name .strip_prefix("refs/remotes/") .and_then(|stripped| stripped.split_once('/').map(|(_, name)| name)) } } #[derive(Clone, Copy, Default)] pub struct CommitOptions { pub amend: bool, pub signoff: bool, } #[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)] pub enum UpstreamTracking { /// Remote ref not present in local repository. Gone, /// Remote ref present in local repository (fetched from remote). Tracked(UpstreamTrackingStatus), } impl From for UpstreamTracking { fn from(status: UpstreamTrackingStatus) -> Self { UpstreamTracking::Tracked(status) } } impl UpstreamTracking { pub fn is_gone(&self) -> bool { matches!(self, UpstreamTracking::Gone) } pub fn status(&self) -> Option { match self { UpstreamTracking::Gone => None, UpstreamTracking::Tracked(status) => Some(*status), } } } #[derive(Debug, Clone)] pub struct RemoteCommandOutput { pub stdout: String, pub stderr: String, } impl RemoteCommandOutput { pub fn is_empty(&self) -> bool { self.stdout.is_empty() && self.stderr.is_empty() } } #[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)] pub struct UpstreamTrackingStatus { pub ahead: u32, pub behind: u32, } #[derive(Clone, Debug, Hash, PartialEq, Eq)] pub struct CommitSummary { pub sha: SharedString, pub subject: SharedString, /// This is a unix timestamp pub commit_timestamp: i64, pub author_name: SharedString, pub has_parent: bool, } #[derive(Clone, Debug, Default, Hash, PartialEq, Eq)] pub struct CommitDetails { pub sha: SharedString, pub message: SharedString, pub commit_timestamp: i64, pub author_email: SharedString, pub author_name: SharedString, } #[derive(Clone, Debug, Hash, PartialEq, Eq)] pub struct FileHistoryEntry { pub sha: SharedString, pub subject: SharedString, pub message: SharedString, pub commit_timestamp: i64, pub author_name: SharedString, pub author_email: SharedString, } #[derive(Debug, Clone)] pub struct FileHistory { pub entries: Vec, pub path: RepoPath, } #[derive(Debug)] pub struct CommitDiff { pub files: Vec, } #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub enum CommitFileStatus { Added, Modified, Deleted, } #[derive(Debug)] pub struct CommitFile { pub path: RepoPath, pub old_text: Option, pub new_text: Option, pub is_binary: bool, } impl CommitFile { pub fn status(&self) -> CommitFileStatus { match (&self.old_text, &self.new_text) { (None, Some(_)) => CommitFileStatus::Added, (Some(_), None) => CommitFileStatus::Deleted, _ => CommitFileStatus::Modified, } } } impl CommitDetails { pub fn short_sha(&self) -> SharedString { self.sha[..SHORT_SHA_LENGTH].to_string().into() } } /// Detects if content is binary by checking for NUL bytes in the first 8000 bytes. /// This matches git's binary detection heuristic. pub fn is_binary_content(content: &[u8]) -> bool { let check_len = content.len().min(8000); content[..check_len].contains(&0) } #[derive(Debug, Clone, Hash, PartialEq, Eq)] pub struct Remote { pub name: SharedString, } pub enum ResetMode { /// Reset the branch pointer, leave index and worktree unchanged (this will make it look like things that were /// committed are now staged). Soft, /// Reset the branch pointer and index, leave worktree unchanged (this makes it look as though things that were /// committed are now unstaged). Mixed, } #[derive(Debug, Clone, Hash, PartialEq, Eq)] pub enum FetchOptions { All, Remote(Remote), } impl FetchOptions { pub fn to_proto(&self) -> Option { match self { FetchOptions::All => None, FetchOptions::Remote(remote) => Some(remote.clone().name.into()), } } pub fn from_proto(remote_name: Option) -> Self { match remote_name { Some(name) => FetchOptions::Remote(Remote { name: name.into() }), None => FetchOptions::All, } } pub fn name(&self) -> SharedString { match self { Self::All => "Fetch all remotes".into(), Self::Remote(remote) => remote.name.clone(), } } } impl std::fmt::Display for FetchOptions { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { FetchOptions::All => write!(f, "--all"), FetchOptions::Remote(remote) => write!(f, "{}", remote.name), } } } /// Modifies .git/info/exclude temporarily pub struct GitExcludeOverride { git_exclude_path: PathBuf, original_excludes: Option, added_excludes: Option, } impl GitExcludeOverride { const START_BLOCK_MARKER: &str = "\n\n# ====== Auto-added by Zed: =======\n"; const END_BLOCK_MARKER: &str = "\n# ====== End of auto-added by Zed =======\n"; pub async fn new(git_exclude_path: PathBuf) -> Result { let original_excludes = smol::fs::read_to_string(&git_exclude_path) .await .ok() .map(|content| { // Auto-generated lines are normally cleaned up in // `restore_original()` or `drop()`, but may stuck in rare cases. // Make sure to remove them. Self::remove_auto_generated_block(&content) }); Ok(GitExcludeOverride { git_exclude_path, original_excludes, added_excludes: None, }) } pub async fn add_excludes(&mut self, excludes: &str) -> Result<()> { self.added_excludes = Some(if let Some(ref already_added) = self.added_excludes { format!("{already_added}\n{excludes}") } else { excludes.to_string() }); let mut content = self.original_excludes.clone().unwrap_or_default(); content.push_str(Self::START_BLOCK_MARKER); content.push_str(self.added_excludes.as_ref().unwrap()); content.push_str(Self::END_BLOCK_MARKER); smol::fs::write(&self.git_exclude_path, content).await?; Ok(()) } pub async fn restore_original(&mut self) -> Result<()> { if let Some(ref original) = self.original_excludes { smol::fs::write(&self.git_exclude_path, original).await?; } else if self.git_exclude_path.exists() { smol::fs::remove_file(&self.git_exclude_path).await?; } self.added_excludes = None; Ok(()) } fn remove_auto_generated_block(content: &str) -> String { let start_marker = Self::START_BLOCK_MARKER; let end_marker = Self::END_BLOCK_MARKER; let mut content = content.to_string(); let start_index = content.find(start_marker); let end_index = content.rfind(end_marker); if let (Some(start), Some(end)) = (start_index, end_index) { if end > start { content.replace_range(start..end + end_marker.len(), ""); } } // Older versions of Zed didn't have end-of-block markers, // so it's impossible to determine auto-generated lines. // Conservatively remove the standard list of excludes let standard_excludes = format!( "{}{}", Self::START_BLOCK_MARKER, include_str!("./checkpoint.gitignore") ); content = content.replace(&standard_excludes, ""); content } } impl Drop for GitExcludeOverride { fn drop(&mut self) { if self.added_excludes.is_some() { let git_exclude_path = self.git_exclude_path.clone(); let original_excludes = self.original_excludes.clone(); smol::spawn(async move { if let Some(original) = original_excludes { smol::fs::write(&git_exclude_path, original).await } else { smol::fs::remove_file(&git_exclude_path).await } }) .detach(); } } } #[derive(Clone, Debug, Default, PartialEq, Eq, Hash, Copy)] pub enum LogOrder { #[default] DateOrder, TopoOrder, AuthorDateOrder, ReverseChronological, } impl LogOrder { pub fn as_arg(&self) -> &'static str { match self { LogOrder::DateOrder => "--date-order", LogOrder::TopoOrder => "--topo-order", LogOrder::AuthorDateOrder => "--author-date-order", LogOrder::ReverseChronological => "--reverse", } } } #[derive(Clone, Debug, Default, PartialEq, Eq, Hash)] pub enum LogSource { #[default] All, Branch(SharedString), Sha(Oid), } impl LogSource { fn get_arg(&self) -> Result<&str> { match self { LogSource::All => Ok("--all"), LogSource::Branch(branch) => Ok(branch.as_str()), LogSource::Sha(oid) => { str::from_utf8(oid.as_bytes()).context("Failed to build str from sha") } } } } 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. fn load_index_text(&self, path: RepoPath) -> BoxFuture<'_, Option>; /// Returns the contents of an entry in the repository's HEAD, or None if HEAD does not exist or has no entry for the given path. /// /// Also returns `None` for symlinks. fn load_committed_text(&self, path: RepoPath) -> BoxFuture<'_, Option>; fn load_blob_content(&self, oid: Oid) -> BoxFuture<'_, Result>; fn set_index_text( &self, path: RepoPath, content: Option, env: Arc>, is_executable: bool, ) -> BoxFuture<'_, anyhow::Result<()>>; /// Returns the URL of the remote with the given name. fn remote_url(&self, name: &str) -> BoxFuture<'_, Option>; /// Resolve a list of refs to SHAs. fn revparse_batch(&self, revs: Vec) -> BoxFuture<'_, Result>>>; fn head_sha(&self) -> BoxFuture<'_, Option> { async move { self.revparse_batch(vec!["HEAD".into()]) .await .unwrap_or_default() .into_iter() .next() .flatten() } .boxed() } fn merge_message(&self) -> BoxFuture<'_, Option>; fn status(&self, path_prefixes: &[RepoPath]) -> Task>; fn diff_tree(&self, request: DiffTreeType) -> BoxFuture<'_, Result>; fn stash_entries(&self) -> BoxFuture<'_, Result>; fn branches(&self) -> BoxFuture<'_, Result>>; fn change_branch(&self, name: String) -> BoxFuture<'_, Result<()>>; fn create_branch(&self, name: String, base_branch: Option) -> BoxFuture<'_, Result<()>>; fn rename_branch(&self, branch: String, new_name: String) -> BoxFuture<'_, Result<()>>; fn delete_branch(&self, name: String) -> BoxFuture<'_, Result<()>>; fn worktrees(&self) -> BoxFuture<'_, Result>>; fn create_worktree( &self, name: String, directory: PathBuf, from_commit: Option, ) -> BoxFuture<'_, Result<()>>; fn remove_worktree(&self, path: PathBuf, force: bool) -> BoxFuture<'_, Result<()>>; fn rename_worktree(&self, old_path: PathBuf, new_path: PathBuf) -> BoxFuture<'_, Result<()>>; fn reset( &self, commit: String, mode: ResetMode, env: Arc>, ) -> BoxFuture<'_, Result<()>>; fn checkout_files( &self, commit: String, paths: Vec, env: Arc>, ) -> BoxFuture<'_, Result<()>>; fn show(&self, commit: String) -> BoxFuture<'_, Result>; fn load_commit(&self, commit: String, cx: AsyncApp) -> BoxFuture<'_, Result>; fn blame( &self, path: RepoPath, content: Rope, line_ending: LineEnding, ) -> BoxFuture<'_, Result>; fn file_history(&self, path: RepoPath) -> BoxFuture<'_, Result>; fn file_history_paginated( &self, path: RepoPath, skip: usize, limit: Option, ) -> BoxFuture<'_, Result>; /// Returns the absolute path to the repository. For worktrees, this will be the path to the /// worktree's gitdir within the main repository (typically `.git/worktrees/`). fn path(&self) -> PathBuf; fn main_repository_path(&self) -> PathBuf; /// Updates the index to match the worktree at the given paths. /// /// If any of the paths have been deleted from the worktree, they will be removed from the index if found there. fn stage_paths( &self, paths: Vec, env: Arc>, ) -> BoxFuture<'_, Result<()>>; /// Updates the index to match HEAD at the given paths. /// /// If any of the paths were previously staged but do not exist in HEAD, they will be removed from the index. fn unstage_paths( &self, paths: Vec, env: Arc>, ) -> BoxFuture<'_, Result<()>>; fn run_hook( &self, hook: RunHook, env: Arc>, ) -> BoxFuture<'_, Result<()>>; fn commit( &self, message: SharedString, name_and_email: Option<(SharedString, SharedString)>, options: CommitOptions, askpass: AskPassDelegate, env: Arc>, ) -> BoxFuture<'_, Result<()>>; fn stash_paths( &self, paths: Vec, env: Arc>, ) -> BoxFuture<'_, Result<()>>; fn stash_pop( &self, index: Option, env: Arc>, ) -> BoxFuture<'_, Result<()>>; fn stash_apply( &self, index: Option, env: Arc>, ) -> BoxFuture<'_, Result<()>>; fn stash_drop( &self, index: Option, env: Arc>, ) -> BoxFuture<'_, Result<()>>; fn push( &self, branch_name: String, remote_branch_name: String, upstream_name: String, options: Option, askpass: AskPassDelegate, env: Arc>, // This method takes an AsyncApp to ensure it's invoked on the main thread, // otherwise git-credentials-manager won't work. cx: AsyncApp, ) -> BoxFuture<'_, Result>; fn pull( &self, branch_name: Option, upstream_name: String, rebase: bool, askpass: AskPassDelegate, env: Arc>, // This method takes an AsyncApp to ensure it's invoked on the main thread, // otherwise git-credentials-manager won't work. cx: AsyncApp, ) -> BoxFuture<'_, Result>; fn fetch( &self, fetch_options: FetchOptions, askpass: AskPassDelegate, env: Arc>, // This method takes an AsyncApp to ensure it's invoked on the main thread, // otherwise git-credentials-manager won't work. cx: AsyncApp, ) -> BoxFuture<'_, Result>; fn get_push_remote(&self, branch: String) -> BoxFuture<'_, Result>>; fn get_branch_remote(&self, branch: String) -> BoxFuture<'_, Result>>; fn get_all_remotes(&self) -> BoxFuture<'_, Result>>; fn remove_remote(&self, name: String) -> BoxFuture<'_, Result<()>>; fn create_remote(&self, name: String, url: String) -> BoxFuture<'_, Result<()>>; /// returns a list of remote branches that contain HEAD fn check_for_pushed_commit(&self) -> BoxFuture<'_, Result>>; /// Run git diff fn diff(&self, diff: DiffType) -> BoxFuture<'_, Result>; fn diff_stat( &self, path_prefixes: &[RepoPath], ) -> BoxFuture<'_, Result>; /// Creates a checkpoint for the repository. fn checkpoint(&self) -> BoxFuture<'static, Result>; /// Resets to a previously-created checkpoint. fn restore_checkpoint(&self, checkpoint: GitRepositoryCheckpoint) -> BoxFuture<'_, Result<()>>; /// Compares two checkpoints, returning true if they are equal fn compare_checkpoints( &self, left: GitRepositoryCheckpoint, right: GitRepositoryCheckpoint, ) -> BoxFuture<'_, Result>; /// Computes a diff between two checkpoints. fn diff_checkpoints( &self, base_checkpoint: GitRepositoryCheckpoint, target_checkpoint: GitRepositoryCheckpoint, ) -> BoxFuture<'_, Result>; fn default_branch( &self, include_remote_name: bool, ) -> BoxFuture<'_, Result>>; /// Runs `git rev-list --parents` to get the commit graph structure. /// Returns commit SHAs and their parent SHAs for building the graph visualization. fn initial_graph_data( &self, log_source: LogSource, log_order: LogOrder, request_tx: Sender>>, ) -> BoxFuture<'_, Result<()>>; fn commit_data_reader(&self) -> Result; fn set_trusted(&self, trusted: bool); fn is_trusted(&self) -> bool; } pub enum DiffType { HeadToIndex, HeadToWorktree, MergeBase { base_ref: SharedString }, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, JsonSchema)] pub enum PushOptions { SetUpstream, Force, } impl std::fmt::Debug for dyn GitRepository { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("dyn GitRepository<...>").finish() } } pub struct RealGitRepository { pub repository: Arc>, pub system_git_binary_path: Option, pub any_git_binary_path: PathBuf, any_git_binary_help_output: Arc>>, executor: BackgroundExecutor, is_trusted: Arc, } impl RealGitRepository { pub fn new( dotgit_path: &Path, bundled_git_binary_path: Option, system_git_binary_path: Option, executor: BackgroundExecutor, ) -> Option { let any_git_binary_path = system_git_binary_path.clone().or(bundled_git_binary_path)?; let workdir_root = dotgit_path.parent()?; let repository = git2::Repository::open(workdir_root).log_err()?; Some(Self { repository: Arc::new(Mutex::new(repository)), system_git_binary_path, any_git_binary_path, executor, any_git_binary_help_output: Arc::new(Mutex::new(None)), is_trusted: Arc::new(AtomicBool::new(false)), }) } fn working_directory(&self) -> Result { self.repository .lock() .workdir() .context("failed to read git work directory") .map(Path::to_path_buf) } fn git_binary(&self) -> Result { Ok(GitBinary::new( self.any_git_binary_path.clone(), self.working_directory() .with_context(|| "Can't run git commands without a working directory")?, self.executor.clone(), self.is_trusted(), )) } async fn any_git_binary_help_output(&self) -> SharedString { if let Some(output) = self.any_git_binary_help_output.lock().clone() { return output; } let git_binary = self.git_binary(); let output: SharedString = self .executor .spawn(async move { git_binary?.run(["help", "-a"]).await }) .await .unwrap_or_default() .into(); *self.any_git_binary_help_output.lock() = Some(output.clone()); output } } #[derive(Clone, Debug)] pub struct GitRepositoryCheckpoint { pub commit_sha: Oid, } #[derive(Debug)] pub struct GitCommitter { pub name: Option, pub email: Option, } pub async fn get_git_committer(cx: &AsyncApp) -> GitCommitter { if cfg!(any(feature = "test-support", test)) { return GitCommitter { name: None, email: None, }; } let git_binary_path = if cfg!(target_os = "macos") && option_env!("ZED_BUNDLE").as_deref() == Some("true") { cx.update(|cx| { cx.path_for_auxiliary_executable("git") .context("could not find git binary path") .log_err() }) } else { None }; let git = GitBinary::new( git_binary_path.unwrap_or(PathBuf::from("git")), paths::home_dir().clone(), cx.background_executor().clone(), true, ); cx.background_spawn(async move { let name = git.run(["config", "--global", "user.name"]).await.log_err(); let email = git .run(["config", "--global", "user.email"]) .await .log_err(); GitCommitter { name, email } }) .await } 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() } fn main_repository_path(&self) -> PathBuf { let repo = self.repository.lock(); repo.commondir().into() } fn show(&self, commit: String) -> BoxFuture<'_, Result> { let git_binary = self.git_binary(); self.executor .spawn(async move { let git = git_binary?; let output = git .build_command([ "--no-optional-locks", "show", "--no-patch", "--format=%H%x00%B%x00%at%x00%ae%x00%an%x00", &commit, ]) .output() .await?; let output = std::str::from_utf8(&output.stdout)?; let fields = output.split('\0').collect::>(); if fields.len() != 6 { bail!("unexpected git-show output for {commit:?}: {output:?}") } let sha = fields[0].to_string().into(); let message = fields[1].to_string().into(); let commit_timestamp = fields[2].parse()?; let author_email = fields[3].to_string().into(); let author_name = fields[4].to_string().into(); Ok(CommitDetails { sha, message, commit_timestamp, author_email, author_name, }) }) .boxed() } 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?; let show_output = git .build_command([ "--no-optional-locks", "show", "--format=", "-z", "--no-renames", "--name-status", "--first-parent", ]) .arg(&commit) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .output() .await .context("starting git show process")?; let show_stdout = String::from_utf8_lossy(&show_output.stdout); let changes = parse_git_diff_name_status(&show_stdout); let parent_sha = format!("{}^", commit); let mut cat_file_process = git .build_command(["--no-optional-locks", "cat-file", "--batch=%(objectsize)"]) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .context("starting git cat-file process")?; let mut files = Vec::::new(); let mut stdin = BufWriter::with_capacity(512, cat_file_process.stdin.take().unwrap()); let mut stdout = BufReader::new(cat_file_process.stdout.take().unwrap()); let mut info_line = String::new(); let mut newline = [b'\0']; for (path, status_code) in changes { // git-show outputs `/`-delimited paths even on Windows. let Some(rel_path) = RelPath::unix(path).log_err() else { continue; }; match status_code { StatusCode::Modified => { stdin.write_all(commit.as_bytes()).await?; stdin.write_all(b":").await?; stdin.write_all(path.as_bytes()).await?; stdin.write_all(b"\n").await?; stdin.write_all(parent_sha.as_bytes()).await?; stdin.write_all(b":").await?; stdin.write_all(path.as_bytes()).await?; stdin.write_all(b"\n").await?; } StatusCode::Added => { stdin.write_all(commit.as_bytes()).await?; stdin.write_all(b":").await?; stdin.write_all(path.as_bytes()).await?; stdin.write_all(b"\n").await?; } StatusCode::Deleted => { stdin.write_all(parent_sha.as_bytes()).await?; stdin.write_all(b":").await?; stdin.write_all(path.as_bytes()).await?; stdin.write_all(b"\n").await?; } _ => continue, } stdin.flush().await?; info_line.clear(); stdout.read_line(&mut info_line).await?; let len = info_line.trim_end().parse().with_context(|| { format!("invalid object size output from cat-file {info_line}") })?; let mut text_bytes = vec![0; len]; stdout.read_exact(&mut text_bytes).await?; stdout.read_exact(&mut newline).await?; let mut old_text = None; let mut new_text = None; let mut is_binary = is_binary_content(&text_bytes); let text = if is_binary { String::new() } else { String::from_utf8_lossy(&text_bytes).to_string() }; match status_code { StatusCode::Modified => { info_line.clear(); stdout.read_line(&mut info_line).await?; let len = info_line.trim_end().parse().with_context(|| { format!("invalid object size output from cat-file {}", info_line) })?; let mut parent_bytes = vec![0; len]; stdout.read_exact(&mut parent_bytes).await?; stdout.read_exact(&mut newline).await?; is_binary = is_binary || is_binary_content(&parent_bytes); if is_binary { old_text = Some(String::new()); new_text = Some(String::new()); } else { old_text = Some(String::from_utf8_lossy(&parent_bytes).to_string()); new_text = Some(text); } } StatusCode::Added => new_text = Some(text), StatusCode::Deleted => old_text = Some(text), _ => continue, } files.push(CommitFile { path: RepoPath(Arc::from(rel_path)), old_text, new_text, is_binary, }) } Ok(CommitDiff { files }) }) .boxed() } fn reset( &self, commit: String, mode: ResetMode, env: Arc>, ) -> BoxFuture<'_, Result<()>> { let git_binary = self.git_binary(); 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()) .output() .await?; anyhow::ensure!( output.status.success(), "Failed to reset:\n{}", String::from_utf8_lossy(&output.stderr), ); Ok(()) } .boxed() } fn checkout_files( &self, commit: String, paths: Vec, env: Arc>, ) -> BoxFuture<'_, Result<()>> { let git_binary = self.git_binary(); async move { if paths.is_empty() { return Ok(()); } let git = git_binary?; let output = git .build_command(["checkout", &commit, "--"]) .envs(env.iter()) .args(paths.iter().map(|path| path.as_unix_str())) .output() .await?; anyhow::ensure!( output.status.success(), "Failed to checkout files:\n{}", String::from_utf8_lossy(&output.stderr), ); Ok(()) } .boxed() } 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(); 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()) } logic(&repo.lock(), &path) .context("loading index text") .log_err() .flatten() }) .boxed() } fn load_committed_text(&self, path: RepoPath) -> BoxFuture<'_, Option> { let repo = self.repository.clone(); 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()) } logic(&repo.lock(), &path) .context("loading committed text") .log_err() .flatten() }) .boxed() } fn load_blob_content(&self, oid: Oid) -> BoxFuture<'_, Result> { let repo = self.repository.clone(); self.executor .spawn(async move { let repo = repo.lock(); let content = repo.find_blob(oid.0)?.content().to_owned(); Ok(String::from_utf8(content)?) }) .boxed() } fn set_index_text( &self, path: RepoPath, content: Option, env: Arc>, is_executable: bool, ) -> BoxFuture<'_, anyhow::Result<()>> { let git_binary = 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 { let mut child = git .build_command(["hash-object", "-w", "--stdin"]) .envs(env.iter()) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .spawn()?; let mut stdin = child.stdin.take().unwrap(); stdin.write_all(content.as_bytes()).await?; stdin.flush().await?; drop(stdin); let output = child.output().await?.stdout; let sha = str::from_utf8(&output)?.trim(); log::debug!("indexing SHA: {sha}, path {path:?}"); let output = git .build_command(["update-index", "--add", "--cacheinfo", mode, sha]) .envs(env.iter()) .arg(path.as_unix_str()) .output() .await?; anyhow::ensure!( output.status.success(), "Failed to stage:\n{}", String::from_utf8_lossy(&output.stderr) ); } else { log::debug!("removing path {path:?} from the index"); let output = git .build_command(["update-index", "--force-remove"]) .envs(env.iter()) .arg(path.as_unix_str()) .output() .await?; anyhow::ensure!( output.status.success(), "Failed to unstage:\n{}", String::from_utf8_lossy(&output.stderr) ); } Ok(()) }) .boxed() } fn remote_url(&self, name: &str) -> BoxFuture<'_, Option> { let repo = self.repository.clone(); 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()) }) .boxed() } fn revparse_batch(&self, revs: Vec) -> BoxFuture<'_, Result>>> { let git_binary = self.git_binary(); self.executor .spawn(async move { let git = git_binary?; let mut process = git .build_command([ "--no-optional-locks", "cat-file", "--batch-check=%(objectname)", ]) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn()?; let stdin = process .stdin .take() .context("no stdin for git cat-file subprocess")?; let mut stdin = BufWriter::new(stdin); for rev in &revs { stdin.write_all(rev.as_bytes()).await?; stdin.write_all(b"\n").await?; } stdin.flush().await?; drop(stdin); let output = process.output().await?; let output = std::str::from_utf8(&output.stdout)?; let shas = output .lines() .map(|line| { if line.ends_with("missing") { None } else { Some(line.to_string()) } }) .collect::>(); if shas.len() != revs.len() { // In an octopus merge, git cat-file still only outputs the first sha from MERGE_HEAD. bail!("unexpected number of shas") } Ok(shas) }) .boxed() } fn merge_message(&self) -> BoxFuture<'_, Option> { let path = self.path().join("MERGE_MSG"); self.executor .spawn(async move { std::fs::read_to_string(&path).ok() }) .boxed() } fn status(&self, path_prefixes: &[RepoPath]) -> Task> { let git = match self.git_binary() { Ok(git) => git, Err(e) => return Task::ready(Err(e)), }; let args = git_status_args(path_prefixes); log::debug!("Checking for git status in {path_prefixes:?}"); self.executor.spawn(async move { let output = git.build_command(args).output().await?; if output.status.success() { let stdout = String::from_utf8_lossy(&output.stdout); stdout.parse() } else { let stderr = String::from_utf8_lossy(&output.stderr); anyhow::bail!("git status failed: {stderr}"); } }) } fn diff_tree(&self, request: DiffTreeType) -> BoxFuture<'_, Result> { let git = match self.git_binary() { Ok(git) => git, Err(e) => return Task::ready(Err(e)).boxed(), }; let mut args = vec![ OsString::from("--no-optional-locks"), OsString::from("diff-tree"), OsString::from("-r"), OsString::from("-z"), OsString::from("--no-renames"), ]; match request { DiffTreeType::MergeBase { base, head } => { args.push("--merge-base".into()); args.push(OsString::from(base.as_str())); args.push(OsString::from(head.as_str())); } DiffTreeType::Since { base, head } => { args.push(OsString::from(base.as_str())); args.push(OsString::from(head.as_str())); } } self.executor .spawn(async move { let output = git.build_command(args).output().await?; if output.status.success() { let stdout = String::from_utf8_lossy(&output.stdout); stdout.parse() } else { let stderr = String::from_utf8_lossy(&output.stderr); anyhow::bail!("git status failed: {stderr}"); } }) .boxed() } fn stash_entries(&self) -> BoxFuture<'_, Result> { let git_binary = self.git_binary(); 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() .await?; if output.status.success() { let stdout = String::from_utf8_lossy(&output.stdout); stdout.parse() } else { let stderr = String::from_utf8_lossy(&output.stderr); anyhow::bail!("git status failed: {stderr}"); } }) .boxed() } fn branches(&self) -> BoxFuture<'_, Result>> { let git_binary = self.git_binary(); self.executor .spawn(async move { let fields = [ "%(HEAD)", "%(objectname)", "%(parent)", "%(refname)", "%(upstream)", "%(upstream:track)", "%(committerdate:unix)", "%(authorname)", "%(contents:subject)", ] .join("%00"); let args = vec![ "for-each-ref", "refs/heads/**/*", "refs/remotes/**/*", "--format", &fields, ]; let git = git_binary?; let output = git.build_command(args).output().await?; anyhow::ensure!( output.status.success(), "Failed to git git branches:\n{}", String::from_utf8_lossy(&output.stderr) ); let input = String::from_utf8_lossy(&output.stdout); let mut branches = parse_branch_input(&input)?; if branches.is_empty() { let args = vec!["symbolic-ref", "--quiet", "HEAD"]; let output = git.build_command(args).output().await?; // git symbolic-ref returns a non-0 exit code if HEAD points // to something other than a branch if output.status.success() { let name = String::from_utf8_lossy(&output.stdout).trim().to_string(); branches.push(Branch { ref_name: name.into(), is_head: true, upstream: None, most_recent_commit: None, }); } } Ok(branches) }) .boxed() } fn worktrees(&self) -> BoxFuture<'_, Result>> { let git_binary = self.git_binary(); self.executor .spawn(async move { let git = git_binary?; let output = git .build_command(&["--no-optional-locks", "worktree", "list", "--porcelain"]) .output() .await?; if output.status.success() { let stdout = String::from_utf8_lossy(&output.stdout); Ok(parse_worktrees_from_str(&stdout)) } else { let stderr = String::from_utf8_lossy(&output.stderr); anyhow::bail!("git worktree list failed: {stderr}"); } }) .boxed() } fn create_worktree( &self, name: String, directory: PathBuf, from_commit: Option, ) -> BoxFuture<'_, Result<()>> { let git_binary = self.git_binary(); let final_path = directory.join(&name); let mut args = vec![ OsString::from("--no-optional-locks"), OsString::from("worktree"), OsString::from("add"), OsString::from("-b"), OsString::from(name.as_str()), OsString::from("--"), OsString::from(final_path.as_os_str()), ]; if let Some(from_commit) = from_commit { args.push(OsString::from(from_commit)); } else { args.push(OsString::from("HEAD")); } self.executor .spawn(async move { std::fs::create_dir_all(final_path.parent().unwrap_or(&final_path))?; let git = git_binary?; let output = git.build_command(args).output().await?; if output.status.success() { Ok(()) } else { let stderr = String::from_utf8_lossy(&output.stderr); anyhow::bail!("git worktree add failed: {stderr}"); } }) .boxed() } fn remove_worktree(&self, path: PathBuf, force: bool) -> BoxFuture<'_, Result<()>> { let git_binary = self.git_binary(); self.executor .spawn(async move { let mut args: Vec = vec![ "--no-optional-locks".into(), "worktree".into(), "remove".into(), ]; if force { args.push("--force".into()); } args.push("--".into()); args.push(path.as_os_str().into()); git_binary?.run(args).await?; anyhow::Ok(()) }) .boxed() } fn rename_worktree(&self, old_path: PathBuf, new_path: PathBuf) -> BoxFuture<'_, Result<()>> { let git_binary = self.git_binary(); self.executor .spawn(async move { let args: Vec = vec![ "--no-optional-locks".into(), "worktree".into(), "move".into(), "--".into(), old_path.as_os_str().into(), new_path.as_os_str().into(), ]; git_binary?.run(args).await?; anyhow::Ok(()) }) .boxed() } 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?; anyhow::Ok(()) }) .boxed() } fn create_branch( &self, name: String, base_branch: Option, ) -> BoxFuture<'_, Result<()>> { let git_binary = self.git_binary(); self.executor .spawn(async move { let mut args = vec!["switch", "-c", &name]; let base_branch_str; if let Some(ref base) = base_branch { base_branch_str = base.clone(); args.push(&base_branch_str); } git_binary?.run(&args).await?; anyhow::Ok(()) }) .boxed() } fn rename_branch(&self, branch: String, new_name: String) -> BoxFuture<'_, Result<()>> { let git_binary = self.git_binary(); self.executor .spawn(async move { git_binary? .run(&["branch", "-m", &branch, &new_name]) .await?; anyhow::Ok(()) }) .boxed() } fn delete_branch(&self, name: String) -> BoxFuture<'_, Result<()>> { let git_binary = self.git_binary(); self.executor .spawn(async move { git_binary?.run(&["branch", "-d", &name]).await?; anyhow::Ok(()) }) .boxed() } fn blame( &self, path: RepoPath, content: Rope, line_ending: LineEnding, ) -> BoxFuture<'_, Result> { let git = self.git_binary(); self.executor .spawn(async move { crate::blame::Blame::for_path(&git?, &path, &content, line_ending).await }) .boxed() } fn file_history(&self, path: RepoPath) -> BoxFuture<'_, Result> { self.file_history_paginated(path, 0, None) } fn file_history_paginated( &self, path: RepoPath, skip: usize, limit: Option, ) -> BoxFuture<'_, Result> { let git_binary = self.git_binary(); self.executor .spawn(async move { let git = git_binary?; // Use a unique delimiter with a hardcoded UUID to separate commits // This essentially eliminates any chance of encountering the delimiter in actual commit data let commit_delimiter = concat!("<>",); let format_string = format!( "--pretty=format:%H%x00%s%x00%B%x00%at%x00%an%x00%ae{}", commit_delimiter ); let mut args = vec!["--no-optional-locks", "log", "--follow", &format_string]; let skip_str; let limit_str; if skip > 0 { skip_str = skip.to_string(); args.push("--skip"); args.push(&skip_str); } if let Some(n) = limit { limit_str = n.to_string(); args.push("-n"); args.push(&limit_str); } args.push("--"); let output = git .build_command(&args) .arg(path.as_unix_str()) .output() .await?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); bail!("git log failed: {stderr}"); } let stdout = std::str::from_utf8(&output.stdout)?; let mut entries = Vec::new(); for commit_block in stdout.split(commit_delimiter) { let commit_block = commit_block.trim(); if commit_block.is_empty() { continue; } let fields: Vec<&str> = commit_block.split('\0').collect(); if fields.len() >= 6 { let sha = fields[0].trim().to_string().into(); let subject = fields[1].trim().to_string().into(); let message = fields[2].trim().to_string().into(); let commit_timestamp = fields[3].trim().parse().unwrap_or(0); let author_name = fields[4].trim().to_string().into(); let author_email = fields[5].trim().to_string().into(); entries.push(FileHistoryEntry { sha, subject, message, commit_timestamp, author_name, author_email, }); } } Ok(FileHistory { entries, path }) }) .boxed() } fn diff(&self, diff: DiffType) -> BoxFuture<'_, Result> { let git_binary = self.git_binary(); self.executor .spawn(async move { let git = git_binary?; let output = match diff { DiffType::HeadToIndex => { git.build_command(["diff", "--staged"]).output().await? } DiffType::HeadToWorktree => git.build_command(["diff"]).output().await?, DiffType::MergeBase { base_ref } => { git.build_command(["diff", "--merge-base", base_ref.as_ref()]) .output() .await? } }; anyhow::ensure!( output.status.success(), "Failed to run git diff:\n{}", String::from_utf8_lossy(&output.stderr) ); Ok(String::from_utf8_lossy(&output.stdout).to_string()) }) .boxed() } fn diff_stat( &self, path_prefixes: &[RepoPath], ) -> BoxFuture<'_, Result> { let path_prefixes = path_prefixes.to_vec(); let git_binary = self.git_binary(); self.executor .spawn(async move { let git_binary = git_binary?; let mut args: Vec = vec![ "diff".into(), "--numstat".into(), "--no-renames".into(), "HEAD".into(), ]; if !path_prefixes.is_empty() { args.push("--".into()); args.extend( path_prefixes .iter() .map(|p| p.as_std_path().to_string_lossy().into_owned()), ); } let output = git_binary.run(&args).await?; Ok(crate::status::parse_numstat(&output)) }) .boxed() } fn stage_paths( &self, paths: Vec, env: Arc>, ) -> BoxFuture<'_, Result<()>> { let git_binary = self.git_binary(); 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()) .args(paths.iter().map(|p| p.as_unix_str())) .output() .await?; anyhow::ensure!( output.status.success(), "Failed to stage paths:\n{}", String::from_utf8_lossy(&output.stderr), ); } Ok(()) }) .boxed() } fn unstage_paths( &self, paths: Vec, env: Arc>, ) -> BoxFuture<'_, Result<()>> { let git_binary = self.git_binary(); self.executor .spawn(async move { if !paths.is_empty() { let git = git_binary?; let output = git .build_command(["reset", "--quiet", "--"]) .envs(env.iter()) .args(paths.iter().map(|p| p.as_std_path())) .output() .await?; anyhow::ensure!( output.status.success(), "Failed to unstage:\n{}", String::from_utf8_lossy(&output.stderr), ); } Ok(()) }) .boxed() } fn stash_paths( &self, paths: Vec, env: Arc>, ) -> BoxFuture<'_, Result<()>> { let git_binary = self.git_binary(); self.executor .spawn(async move { let git = git_binary?; let output = git .build_command(["stash", "push", "--quiet", "--include-untracked"]) .envs(env.iter()) .args(paths.iter().map(|p| p.as_unix_str())) .output() .await?; anyhow::ensure!( output.status.success(), "Failed to stash:\n{}", String::from_utf8_lossy(&output.stderr) ); Ok(()) }) .boxed() } fn stash_pop( &self, index: Option, env: Arc>, ) -> BoxFuture<'_, Result<()>> { let git_binary = self.git_binary(); 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)); } let output = git.build_command(&args).envs(env.iter()).output().await?; anyhow::ensure!( output.status.success(), "Failed to stash pop:\n{}", String::from_utf8_lossy(&output.stderr) ); Ok(()) }) .boxed() } fn stash_apply( &self, index: Option, env: Arc>, ) -> BoxFuture<'_, Result<()>> { let git_binary = self.git_binary(); 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)); } let output = git.build_command(&args).envs(env.iter()).output().await?; anyhow::ensure!( output.status.success(), "Failed to apply stash:\n{}", String::from_utf8_lossy(&output.stderr) ); Ok(()) }) .boxed() } fn stash_drop( &self, index: Option, env: Arc>, ) -> BoxFuture<'_, Result<()>> { let git_binary = self.git_binary(); 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)); } let output = git.build_command(&args).envs(env.iter()).output().await?; anyhow::ensure!( output.status.success(), "Failed to stash drop:\n{}", String::from_utf8_lossy(&output.stderr) ); Ok(()) }) .boxed() } fn commit( &self, message: SharedString, name_and_email: Option<(SharedString, SharedString)>, options: CommitOptions, ask_pass: AskPassDelegate, env: Arc>, ) -> BoxFuture<'_, Result<()>> { let git_binary = self.git_binary(); 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()) .arg("--cleanup=strip") .arg("--no-verify") .stdout(Stdio::piped()) .stderr(Stdio::piped()); if options.amend { cmd.arg("--amend"); } if options.signoff { cmd.arg("--signoff"); } if let Some((name, email)) = name_and_email { cmd.arg("--author").arg(&format!("{name} <{email}>")); } run_git_command(env, ask_pass, cmd, executor).await?; Ok(()) } .boxed() } fn push( &self, branch_name: String, remote_branch_name: String, remote_name: String, options: Option, ask_pass: AskPassDelegate, env: Arc>, cx: AsyncApp, ) -> BoxFuture<'_, Result> { let working_directory = self.working_directory(); let executor = cx.background_executor().clone(); let git_binary_path = self.system_git_binary_path.clone(); let is_trusted = self.is_trusted(); // 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_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, executor.clone(), is_trusted, ); let mut command = git.build_command(["push"]); command .envs(env.iter()) .args(options.map(|option| match option { PushOptions::SetUpstream => "--set-upstream", PushOptions::Force => "--force-with-lease", })) .arg(remote_name) .arg(format!("{}:{}", branch_name, remote_branch_name)) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()); run_git_command(env, ask_pass, command, executor).await } .boxed() } fn pull( &self, branch_name: Option, remote_name: String, rebase: bool, ask_pass: AskPassDelegate, env: Arc>, cx: AsyncApp, ) -> BoxFuture<'_, Result> { let working_directory = self.working_directory(); let executor = cx.background_executor().clone(); let git_binary_path = self.system_git_binary_path.clone(); let is_trusted = self.is_trusted(); // 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_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, executor.clone(), is_trusted, ); let mut command = git.build_command(["pull"]); command.envs(env.iter()); if rebase { command.arg("--rebase"); } command .arg(remote_name) .args(branch_name) .stdout(Stdio::piped()) .stderr(Stdio::piped()); run_git_command(env, ask_pass, command, executor).await } .boxed() } fn fetch( &self, fetch_options: FetchOptions, ask_pass: AskPassDelegate, env: Arc>, cx: AsyncApp, ) -> BoxFuture<'_, Result> { let working_directory = self.working_directory(); let remote_name = format!("{}", fetch_options); let git_binary_path = self.system_git_binary_path.clone(); let executor = cx.background_executor().clone(); let is_trusted = self.is_trusted(); // 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_binary_path = git_binary_path.context("git not found on $PATH, can't fetch")?; let working_directory = working_directory?; let git = GitBinary::new( git_binary_path, working_directory, executor.clone(), is_trusted, ); let mut command = git.build_command(["fetch", &remote_name]); command .envs(env.iter()) .stdout(Stdio::piped()) .stderr(Stdio::piped()); run_git_command(env, ask_pass, command, executor).await } .boxed() } fn get_push_remote(&self, branch: String) -> BoxFuture<'_, Result>> { let git_binary = 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}}")) .output() .await?; if !output.status.success() { return Ok(None); } let remote_name = String::from_utf8_lossy(&output.stdout) .split('/') .next() .map(|name| Remote { name: name.trim().to_string().into(), }); Ok(remote_name) }) .boxed() } fn get_branch_remote(&self, branch: String) -> BoxFuture<'_, Result>> { let git_binary = self.git_binary(); self.executor .spawn(async move { let git = git_binary?; let output = git .build_command(["config", "--get"]) .arg(format!("branch.{branch}.remote")) .output() .await?; if !output.status.success() { return Ok(None); } let remote_name = String::from_utf8_lossy(&output.stdout); return Ok(Some(Remote { name: remote_name.trim().to_string().into(), })); }) .boxed() } fn get_all_remotes(&self) -> BoxFuture<'_, Result>> { let git_binary = self.git_binary(); self.executor .spawn(async move { let git = git_binary?; let output = git.build_command(["remote", "-v"]).output().await?; anyhow::ensure!( output.status.success(), "Failed to get all remotes:\n{}", String::from_utf8_lossy(&output.stderr) ); let remote_names: HashSet = String::from_utf8_lossy(&output.stdout) .lines() .filter(|line| !line.is_empty()) .filter_map(|line| { let mut split_line = line.split_whitespace(); let remote_name = split_line.next()?; Some(Remote { name: remote_name.trim().to_string().into(), }) }) .collect(); Ok(remote_names.into_iter().collect()) }) .boxed() } fn remove_remote(&self, name: String) -> BoxFuture<'_, Result<()>> { let repo = self.repository.clone(); self.executor .spawn(async move { let repo = repo.lock(); repo.remote_delete(&name)?; Ok(()) }) .boxed() } fn create_remote(&self, name: String, url: String) -> BoxFuture<'_, Result<()>> { let repo = self.repository.clone(); self.executor .spawn(async move { let repo = repo.lock(); repo.remote(&name, url.as_ref())?; Ok(()) }) .boxed() } fn check_for_pushed_commit(&self) -> BoxFuture<'_, Result>> { let git_binary = self.git_binary(); 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!( output.status.success(), String::from_utf8_lossy(&output.stderr).to_string() ); Ok(String::from_utf8(output.stdout)?) }; let head = git_cmd(&["rev-parse", "HEAD"]) .await .context("Failed to get HEAD")? .trim() .to_owned(); let mut remote_branches = vec![]; let mut add_if_matching = async |remote_head: &str| { if let Ok(merge_base) = git_cmd(&["merge-base", &head, remote_head]).await && merge_base.trim() == head && let Some(s) = remote_head.strip_prefix("refs/remotes/") { remote_branches.push(s.to_owned().into()); } }; // check the main branch of each remote let remotes = git_cmd(&["remote"]) .await .context("Failed to get remotes")?; for remote in remotes.lines() { if let Ok(remote_head) = git_cmd(&["symbolic-ref", &format!("refs/remotes/{remote}/HEAD")]).await { add_if_matching(remote_head.trim()).await; } } // ... and the remote branch that the checked-out one is tracking if let Ok(remote_head) = git_cmd(&["rev-parse", "--symbolic-full-name", "@{u}"]).await { add_if_matching(remote_head.trim()).await; } Ok(remote_branches) }) .boxed() } fn checkpoint(&self) -> BoxFuture<'static, Result> { let git_binary = self.git_binary(); 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?; git.run(&["add", "--all"]).await?; let tree = git.run(&["write-tree"]).await?; let checkpoint_sha = if let Some(head_sha) = head_sha.as_deref() { git.run(&["commit-tree", &tree, "-p", head_sha, "-m", "Checkpoint"]) .await? } else { git.run(&["commit-tree", &tree, "-m", "Checkpoint"]).await? }; excludes.restore_original().await?; Ok(GitRepositoryCheckpoint { commit_sha: checkpoint_sha.parse()?, }) }) .await }) .boxed() } fn restore_checkpoint(&self, checkpoint: GitRepositoryCheckpoint) -> BoxFuture<'_, Result<()>> { let git_binary = self.git_binary(); self.executor .spawn(async move { let git = git_binary?; git.run(&[ "restore", "--source", &checkpoint.commit_sha.to_string(), "--worktree", ".", ]) .await?; // TODO: We don't track binary and large files anymore, // so the following call would delete them. // Implement an alternative way to track files added by agent. // // git.with_temp_index(async move |git| { // git.run(&["read-tree", &checkpoint.commit_sha.to_string()]) // .await?; // git.run(&["clean", "-d", "--force"]).await // }) // .await?; Ok(()) }) .boxed() } fn compare_checkpoints( &self, left: GitRepositoryCheckpoint, right: GitRepositoryCheckpoint, ) -> BoxFuture<'_, Result> { let git_binary = self.git_binary(); self.executor .spawn(async move { let git = git_binary?; let result = git .run(&[ "diff-tree", "--quiet", &left.commit_sha.to_string(), &right.commit_sha.to_string(), ]) .await; match result { Ok(_) => Ok(true), Err(error) => { if let Some(GitBinaryCommandError { status, .. }) = error.downcast_ref::() && status.code() == Some(1) { return Ok(false); } Err(error) } } }) .boxed() } fn diff_checkpoints( &self, base_checkpoint: GitRepositoryCheckpoint, target_checkpoint: GitRepositoryCheckpoint, ) -> BoxFuture<'_, Result> { let git_binary = self.git_binary(); self.executor .spawn(async move { let git = git_binary?; git.run(&[ "diff", "--find-renames", "--patch", &base_checkpoint.commit_sha.to_string(), &target_checkpoint.commit_sha.to_string(), ]) .await }) .boxed() } fn default_branch( &self, include_remote_name: bool, ) -> BoxFuture<'_, Result>> { let git_binary = self.git_binary(); self.executor .spawn(async move { let git = git_binary?; let strip_prefix = if include_remote_name { "refs/remotes/" } else { "refs/remotes/upstream/" }; if let Ok(output) = git .run(&["symbolic-ref", "refs/remotes/upstream/HEAD"]) .await { let output = output .strip_prefix(strip_prefix) .map(|s| SharedString::from(s.to_owned())); return Ok(output); } let strip_prefix = if include_remote_name { "refs/remotes/" } else { "refs/remotes/origin/" }; if let Ok(output) = git.run(&["symbolic-ref", "refs/remotes/origin/HEAD"]).await { return Ok(output .strip_prefix(strip_prefix) .map(|s| SharedString::from(s.to_owned()))); } if let Ok(default_branch) = git.run(&["config", "init.defaultBranch"]).await { if git.run(&["rev-parse", &default_branch]).await.is_ok() { return Ok(Some(default_branch.into())); } } if git.run(&["rev-parse", "master"]).await.is_ok() { return Ok(Some("master".into())); } Ok(None) }) .boxed() } fn run_hook( &self, hook: RunHook, env: Arc>, ) -> BoxFuture<'_, Result<()>> { let git_binary = self.git_binary(); let repository = self.repository.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. async move { let git_binary = git_binary?; let working_directory = git_binary.working_directory.clone(); if !help_output .await .lines() .any(|line| line.trim().starts_with("hook ")) { let hook_abs_path = repository.lock().path().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) .envs(env.iter()) .current_dir(&working_directory) .output() .await?; if !output.status.success() { return Err(GitBinaryCommandError { stdout: String::from_utf8_lossy(&output.stdout).into_owned(), stderr: String::from_utf8_lossy(&output.stderr).into_owned(), status: output.status, } .into()); } } return Ok(()); } if git_binary.is_trusted { let git_binary = git_binary.envs(HashMap::clone(&env)); git_binary .run(&["hook", "run", "--ignore-missing", hook.as_str()]) .await?; } Ok(()) } .boxed() } fn initial_graph_data( &self, log_source: LogSource, log_order: LogOrder, request_tx: Sender>>, ) -> BoxFuture<'_, Result<()>> { let git_binary = self.git_binary(); async move { let git = git_binary?; let mut command = git.build_command([ "log", GRAPH_COMMIT_FORMAT, log_order.as_arg(), log_source.get_arg()?, ]); command.stdout(Stdio::piped()); command.stderr(Stdio::null()); let mut child = command.spawn()?; let stdout = child.stdout.take().context("failed to get stdout")?; let mut reader = BufReader::new(stdout); let mut line_buffer = String::new(); let mut lines: Vec = Vec::with_capacity(GRAPH_CHUNK_SIZE); loop { line_buffer.clear(); let bytes_read = reader.read_line(&mut line_buffer).await?; if bytes_read == 0 { if !lines.is_empty() { let commits = parse_initial_graph_output(lines.iter().map(|s| s.as_str())); if request_tx.send(commits).await.is_err() { log::warn!( "initial_graph_data: receiver dropped while sending commits" ); } } break; } let line = line_buffer.trim_end_matches('\n').to_string(); lines.push(line); if lines.len() >= GRAPH_CHUNK_SIZE { let commits = parse_initial_graph_output(lines.iter().map(|s| s.as_str())); if request_tx.send(commits).await.is_err() { log::warn!("initial_graph_data: receiver dropped while streaming commits"); break; } lines.clear(); } } child.status().await?; Ok(()) } .boxed() } fn commit_data_reader(&self) -> Result { let git_binary = self.git_binary()?; let (request_tx, request_rx) = smol::channel::bounded::(64); let task = self.executor.spawn(async move { if let Err(error) = run_commit_data_reader(git_binary, request_rx).await { log::error!("commit data reader failed: {error:?}"); } }); Ok(CommitDataReader { request_tx, _task: task, }) } fn set_trusted(&self, trusted: bool) { self.is_trusted .store(trusted, std::sync::atomic::Ordering::Release); } fn is_trusted(&self) -> bool { self.is_trusted.load(std::sync::atomic::Ordering::Acquire) } } async fn run_commit_data_reader( git: GitBinary, request_rx: smol::channel::Receiver, ) -> Result<()> { let mut process = git .build_command(["--no-optional-locks", "cat-file", "--batch"]) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .context("starting git cat-file --batch process")?; let mut stdin = BufWriter::new(process.stdin.take().context("no stdin")?); let mut stdout = BufReader::new(process.stdout.take().context("no stdout")?); const MAX_BATCH_SIZE: usize = 64; while let Ok(first_request) = request_rx.recv().await { let mut pending_requests = vec![first_request]; while pending_requests.len() < MAX_BATCH_SIZE { match request_rx.try_recv() { Ok(request) => pending_requests.push(request), Err(_) => break, } } for request in &pending_requests { stdin.write_all(request.sha.to_string().as_bytes()).await?; stdin.write_all(b"\n").await?; } stdin.flush().await?; for request in pending_requests { let result = read_single_commit_response(&mut stdout, &request.sha).await; request.response_tx.send(result).ok(); } } drop(stdin); process.kill().ok(); Ok(()) } async fn read_single_commit_response( stdout: &mut R, sha: &Oid, ) -> Result { let mut header_bytes = Vec::new(); stdout.read_until(b'\n', &mut header_bytes).await?; let header_line = String::from_utf8_lossy(&header_bytes); let parts: Vec<&str> = header_line.trim().split(' ').collect(); if parts.len() < 3 { bail!("invalid cat-file header: {header_line}"); } let object_type = parts[1]; if object_type == "missing" { bail!("object not found: {}", sha); } if object_type != "commit" { bail!("expected commit object, got {object_type}"); } let size: usize = parts[2] .parse() .with_context(|| format!("invalid object size: {}", parts[2]))?; let mut content = vec![0u8; size]; stdout.read_exact(&mut content).await?; let mut newline = [0u8; 1]; stdout.read_exact(&mut newline).await?; let content_str = String::from_utf8_lossy(&content); parse_cat_file_commit(*sha, &content_str) .ok_or_else(|| anyhow!("failed to parse commit {}", sha)) } fn parse_initial_graph_output<'a>( lines: impl Iterator, ) -> Vec> { lines .filter(|line| !line.is_empty()) .filter_map(|line| { // Format: "SHA\x00PARENT1 PARENT2...\x00REF1, REF2, ..." let mut parts = line.split('\x00'); let sha = Oid::from_str(parts.next()?).ok()?; let parents_str = parts.next()?; let parents = parents_str .split_whitespace() .filter_map(|p| Oid::from_str(p).ok()) .collect(); let ref_names_str = parts.next().unwrap_or(""); let ref_names = if ref_names_str.is_empty() { Vec::new() } else { ref_names_str .split(", ") .map(|s| SharedString::from(s.to_string())) .collect() }; Some(Arc::new(InitialGraphCommitData { sha, parents, ref_names, })) }) .collect() } fn git_status_args(path_prefixes: &[RepoPath]) -> Vec { let mut args = vec![ OsString::from("--no-optional-locks"), OsString::from("status"), OsString::from("--porcelain=v1"), OsString::from("--untracked-files=all"), OsString::from("--no-renames"), OsString::from("-z"), ]; args.extend(path_prefixes.iter().map(|path_prefix| { if path_prefix.is_empty() { Path::new(".").into() } else { path_prefix.as_std_path().into() } })); args } /// Temporarily git-ignore commonly ignored files and files over 2MB async fn exclude_files(git: &GitBinary) -> Result { const MAX_SIZE: u64 = 2 * 1024 * 1024; // 2 MB let mut excludes = git.with_exclude_overrides().await?; excludes .add_excludes(include_str!("./checkpoint.gitignore")) .await?; let working_directory = git.working_directory.clone(); let untracked_files = git.list_untracked_files().await?; let excluded_paths = untracked_files.into_iter().map(|path| { let working_directory = working_directory.clone(); smol::spawn(async move { let full_path = working_directory.join(path.clone()); match smol::fs::metadata(&full_path).await { Ok(metadata) if metadata.is_file() && metadata.len() >= MAX_SIZE => { Some(PathBuf::from("/").join(path.clone())) } _ => None, } }) }); let excluded_paths = futures::future::join_all(excluded_paths).await; let excluded_paths = excluded_paths.into_iter().flatten().collect::>(); if !excluded_paths.is_empty() { let exclude_patterns = excluded_paths .into_iter() .map(|path| path.to_string_lossy().into_owned()) .collect::>() .join("\n"); excludes.add_excludes(&exclude_patterns).await?; } Ok(excludes) } pub(crate) struct GitBinary { git_binary_path: PathBuf, working_directory: PathBuf, executor: BackgroundExecutor, index_file_path: Option, envs: HashMap, is_trusted: bool, } impl GitBinary { pub(crate) fn new( git_binary_path: PathBuf, working_directory: PathBuf, executor: BackgroundExecutor, is_trusted: bool, ) -> Self { Self { git_binary_path, working_directory, executor, index_file_path: None, envs: HashMap::default(), is_trusted, } } async fn list_untracked_files(&self) -> Result> { let status_output = self .run(&["status", "--porcelain=v1", "--untracked-files=all", "-z"]) .await?; let paths = status_output .split('\0') .filter(|entry| entry.len() >= 3 && entry.starts_with("?? ")) .map(|entry| PathBuf::from(&entry[3..])) .collect::>(); Ok(paths) } fn envs(mut self, envs: HashMap) -> Self { self.envs = envs; self } pub async fn with_temp_index( &mut self, f: impl AsyncFnOnce(&Self) -> Result, ) -> Result { let index_file_path = self.path_for_index_id(Uuid::new_v4()); let delete_temp_index = util::defer({ let index_file_path = index_file_path.clone(); let executor = self.executor.clone(); move || { executor .spawn(async move { smol::fs::remove_file(index_file_path).await.log_err(); }) .detach(); } }); // Copy the default index file so that Git doesn't have to rebuild the // whole index from scratch. This might fail if this is an empty repository. smol::fs::copy( self.working_directory.join(".git").join("index"), &index_file_path, ) .await .ok(); self.index_file_path = Some(index_file_path.clone()); let result = f(self).await; self.index_file_path = None; let result = result?; smol::fs::remove_file(index_file_path).await.ok(); delete_temp_index.abort(); Ok(result) } pub async fn with_exclude_overrides(&self) -> Result { let path = self .working_directory .join(".git") .join("info") .join("exclude"); GitExcludeOverride::new(path).await } fn path_for_index_id(&self, id: Uuid) -> PathBuf { self.working_directory .join(".git") .join(format!("index-{}.tmp", id)) } pub async fn run(&self, args: impl IntoIterator) -> Result where S: AsRef, { let mut stdout = self.run_raw(args).await?; if stdout.chars().last() == Some('\n') { stdout.pop(); } Ok(stdout) } /// Returns the result of the command without trimming the trailing newline. pub async fn run_raw(&self, args: impl IntoIterator) -> Result where S: AsRef, { let mut command = self.build_command(args); let output = command.output().await?; anyhow::ensure!( output.status.success(), GitBinaryCommandError { stdout: String::from_utf8_lossy(&output.stdout).to_string(), stderr: String::from_utf8_lossy(&output.stderr).to_string(), status: output.status, } ); Ok(String::from_utf8(output.stdout)?) } #[allow(clippy::disallowed_methods)] pub(crate) fn build_command( &self, args: impl IntoIterator, ) -> util::command::Command where S: AsRef, { let mut command = new_command(&self.git_binary_path); command.current_dir(&self.working_directory); command.args(["-c", "core.fsmonitor=false"]); command.arg("--no-pager"); if !self.is_trusted { command.args(["-c", "core.hooksPath=/dev/null"]); command.args(["-c", "core.sshCommand=ssh"]); command.args(["-c", "credential.helper="]); command.args(["-c", "protocol.ext.allow=never"]); command.args(["-c", "diff.external="]); } command.args(args); if let Some(index_file_path) = self.index_file_path.as_ref() { command.env("GIT_INDEX_FILE", index_file_path); } command.envs(&self.envs); command } } #[derive(Error, Debug)] #[error("Git command failed:\n{stdout}{stderr}\n")] struct GitBinaryCommandError { stdout: String, stderr: String, status: ExitStatus, } async fn run_git_command( env: Arc>, ask_pass: AskPassDelegate, mut command: util::command::Command, executor: BackgroundExecutor, ) -> Result { if env.contains_key("GIT_ASKPASS") { let git_process = command.spawn()?; let output = git_process.output().await?; anyhow::ensure!( output.status.success(), "{}", String::from_utf8_lossy(&output.stderr) ); Ok(RemoteCommandOutput { stdout: String::from_utf8_lossy(&output.stdout).to_string(), stderr: String::from_utf8_lossy(&output.stderr).to_string(), }) } else { let ask_pass = AskPassSession::new(executor, ask_pass).await?; command .env("GIT_ASKPASS", ask_pass.script_path()) .env("SSH_ASKPASS", ask_pass.script_path()) .env("SSH_ASKPASS_REQUIRE", "force"); let git_process = command.spawn()?; run_askpass_command(ask_pass, git_process).await } } async fn run_askpass_command( mut ask_pass: AskPassSession, git_process: util::command::Child, ) -> anyhow::Result { select_biased! { result = ask_pass.run().fuse() => { match result { AskPassResult::CancelledByUser => { Err(anyhow!(REMOTE_CANCELLED_BY_USER))? } AskPassResult::Timedout => { Err(anyhow!("Connecting to host timed out"))? } } } output = git_process.output().fuse() => { let output = output?; anyhow::ensure!( output.status.success(), "{}", String::from_utf8_lossy(&output.stderr) ); Ok(RemoteCommandOutput { stdout: String::from_utf8_lossy(&output.stdout).to_string(), stderr: String::from_utf8_lossy(&output.stderr).to_string(), }) } } } #[derive(Clone, Ord, Hash, PartialOrd, Eq, PartialEq)] pub struct RepoPath(Arc); impl std::fmt::Debug for RepoPath { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { self.0.fmt(f) } } impl RepoPath { pub fn new + ?Sized>(s: &S) -> Result { let rel_path = RelPath::unix(s.as_ref())?; Ok(Self::from_rel_path(rel_path)) } pub fn from_std_path(path: &Path, path_style: PathStyle) -> Result { let rel_path = RelPath::new(path, path_style)?; Ok(Self::from_rel_path(&rel_path)) } pub fn from_proto(proto: &str) -> Result { let rel_path = RelPath::from_proto(proto)?; Ok(Self(rel_path)) } pub fn from_rel_path(path: &RelPath) -> RepoPath { Self(Arc::from(path)) } 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 { self.0.as_std_path() } } } #[cfg(any(test, feature = "test-support"))] pub fn repo_path + ?Sized>(s: &S) -> RepoPath { RepoPath(RelPath::unix(s.as_ref()).unwrap().into()) } impl AsRef> for RepoPath { fn as_ref(&self) -> &Arc { &self.0 } } impl std::ops::Deref for RepoPath { type Target = RelPath; fn deref(&self) -> &Self::Target { &self.0 } } #[derive(Debug)] pub struct RepoPathDescendants<'a>(pub &'a RepoPath); impl MapSeekTarget for RepoPathDescendants<'_> { fn cmp_cursor(&self, key: &RepoPath) -> Ordering { if key.starts_with(self.0) { Ordering::Greater } else { self.0.cmp(key) } } } fn parse_branch_input(input: &str) -> Result> { let mut branches = Vec::new(); for line in input.split('\n') { if line.is_empty() { continue; } let mut fields = line.split('\x00'); let Some(head) = fields.next() else { continue; }; let Some(head_sha) = fields.next().map(|f| f.to_string().into()) else { continue; }; let Some(parent_sha) = fields.next().map(|f| f.to_string()) else { continue; }; let Some(ref_name) = fields.next().map(|f| f.to_string().into()) else { continue; }; let Some(upstream_name) = fields.next().map(|f| f.to_string()) else { continue; }; let Some(upstream_tracking) = fields.next().and_then(|f| parse_upstream_track(f).ok()) else { continue; }; let Some(commiterdate) = fields.next().and_then(|f| f.parse::().ok()) else { continue; }; let Some(author_name) = fields.next().map(|f| f.to_string().into()) else { continue; }; let Some(subject) = fields.next().map(|f| f.to_string().into()) else { continue; }; branches.push(Branch { is_head: head == "*", ref_name, most_recent_commit: Some(CommitSummary { sha: head_sha, subject, commit_timestamp: commiterdate, author_name: author_name, has_parent: !parent_sha.is_empty(), }), upstream: if upstream_name.is_empty() { None } else { Some(Upstream { ref_name: upstream_name.into(), tracking: upstream_tracking, }) }, }) } Ok(branches) } fn parse_upstream_track(upstream_track: &str) -> Result { if upstream_track.is_empty() { return Ok(UpstreamTracking::Tracked(UpstreamTrackingStatus { ahead: 0, behind: 0, })); } let upstream_track = upstream_track.strip_prefix("[").context("missing [")?; let upstream_track = upstream_track.strip_suffix("]").context("missing [")?; let mut ahead: u32 = 0; let mut behind: u32 = 0; for component in upstream_track.split(", ") { if component == "gone" { return Ok(UpstreamTracking::Gone); } if let Some(ahead_num) = component.strip_prefix("ahead ") { ahead = ahead_num.parse::()?; } if let Some(behind_num) = component.strip_prefix("behind ") { behind = behind_num.parse::()?; } } Ok(UpstreamTracking::Tracked(UpstreamTrackingStatus { ahead, behind, })) } fn checkpoint_author_envs() -> HashMap { HashMap::from_iter([ ("GIT_AUTHOR_NAME".to_string(), "Zed".to_string()), ("GIT_AUTHOR_EMAIL".to_string(), "hi@zed.dev".to_string()), ("GIT_COMMITTER_NAME".to_string(), "Zed".to_string()), ("GIT_COMMITTER_EMAIL".to_string(), "hi@zed.dev".to_string()), ]) } #[cfg(test)] mod tests { use super::*; use gpui::TestAppContext; fn disable_git_global_config() { unsafe { std::env::set_var("GIT_CONFIG_GLOBAL", ""); std::env::set_var("GIT_CONFIG_SYSTEM", ""); } } #[gpui::test] async fn test_build_command_untrusted_includes_both_safety_args(cx: &mut TestAppContext) { cx.executor().allow_parking(); let dir = tempfile::tempdir().unwrap(); let git = GitBinary::new( PathBuf::from("git"), dir.path().to_path_buf(), cx.executor(), false, ); let output = git .build_command(["version"]) .output() .await .expect("git version should succeed"); assert!(output.status.success()); let git = GitBinary::new( PathBuf::from("git"), dir.path().to_path_buf(), cx.executor(), false, ); let output = git .build_command(["config", "--get", "core.fsmonitor"]) .output() .await .expect("git config should run"); let stdout = String::from_utf8_lossy(&output.stdout); assert_eq!( stdout.trim(), "false", "fsmonitor should be disabled for untrusted repos" ); git2::Repository::init(dir.path()).unwrap(); let git = GitBinary::new( PathBuf::from("git"), dir.path().to_path_buf(), cx.executor(), false, ); let output = git .build_command(["config", "--get", "core.hooksPath"]) .output() .await .expect("git config should run"); let stdout = String::from_utf8_lossy(&output.stdout); assert_eq!( stdout.trim(), "/dev/null", "hooksPath should be /dev/null for untrusted repos" ); } #[gpui::test] 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(); let git = GitBinary::new( PathBuf::from("git"), dir.path().to_path_buf(), cx.executor(), true, ); let output = git .build_command(["config", "--get", "core.fsmonitor"]) .output() .await .expect("git config should run"); let stdout = String::from_utf8_lossy(&output.stdout); assert_eq!( stdout.trim(), "false", "fsmonitor should be disabled even for trusted repos" ); let git = GitBinary::new( PathBuf::from("git"), dir.path().to_path_buf(), cx.executor(), true, ); let output = git .build_command(["config", "--get", "core.hooksPath"]) .output() .await .expect("git config should run"); assert!( !output.status.success(), "hooksPath should NOT be overridden for trusted repos" ); } #[gpui::test] async fn test_checkpoint_basic(cx: &mut TestAppContext) { disable_git_global_config(); cx.executor().allow_parking(); let repo_dir = tempfile::tempdir().unwrap(); git2::Repository::init(repo_dir.path()).unwrap(); let file_path = repo_dir.path().join("file"); smol::fs::write(&file_path, "initial").await.unwrap(); let repo = RealGitRepository::new( &repo_dir.path().join(".git"), None, Some("git".into()), cx.executor(), ) .unwrap(); repo.stage_paths(vec![repo_path("file")], Arc::new(HashMap::default())) .await .unwrap(); repo.commit( "Initial commit".into(), None, CommitOptions::default(), AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}), Arc::new(checkpoint_author_envs()), ) .await .unwrap(); smol::fs::write(&file_path, "modified before checkpoint") .await .unwrap(); smol::fs::write(repo_dir.path().join("new_file_before_checkpoint"), "1") .await .unwrap(); let checkpoint = repo.checkpoint().await.unwrap(); // Ensure the user can't see any branches after creating a checkpoint. assert_eq!(repo.branches().await.unwrap().len(), 1); smol::fs::write(&file_path, "modified after checkpoint") .await .unwrap(); repo.stage_paths(vec![repo_path("file")], Arc::new(HashMap::default())) .await .unwrap(); repo.commit( "Commit after checkpoint".into(), None, CommitOptions::default(), AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}), Arc::new(checkpoint_author_envs()), ) .await .unwrap(); smol::fs::remove_file(repo_dir.path().join("new_file_before_checkpoint")) .await .unwrap(); smol::fs::write(repo_dir.path().join("new_file_after_checkpoint"), "2") .await .unwrap(); // Ensure checkpoint stays alive even after a Git GC. repo.gc().await.unwrap(); repo.restore_checkpoint(checkpoint.clone()).await.unwrap(); assert_eq!( smol::fs::read_to_string(&file_path).await.unwrap(), "modified before checkpoint" ); assert_eq!( smol::fs::read_to_string(repo_dir.path().join("new_file_before_checkpoint")) .await .unwrap(), "1" ); // See TODO above // assert_eq!( // smol::fs::read_to_string(repo_dir.path().join("new_file_after_checkpoint")) // .await // .ok(), // None // ); } #[gpui::test] async fn test_checkpoint_empty_repo(cx: &mut TestAppContext) { disable_git_global_config(); cx.executor().allow_parking(); let repo_dir = tempfile::tempdir().unwrap(); git2::Repository::init(repo_dir.path()).unwrap(); let repo = RealGitRepository::new( &repo_dir.path().join(".git"), None, Some("git".into()), cx.executor(), ) .unwrap(); smol::fs::write(repo_dir.path().join("foo"), "foo") .await .unwrap(); let checkpoint_sha = repo.checkpoint().await.unwrap(); // Ensure the user can't see any branches after creating a checkpoint. assert_eq!(repo.branches().await.unwrap().len(), 1); smol::fs::write(repo_dir.path().join("foo"), "bar") .await .unwrap(); smol::fs::write(repo_dir.path().join("baz"), "qux") .await .unwrap(); repo.restore_checkpoint(checkpoint_sha).await.unwrap(); assert_eq!( smol::fs::read_to_string(repo_dir.path().join("foo")) .await .unwrap(), "foo" ); // See TODOs above // assert_eq!( // smol::fs::read_to_string(repo_dir.path().join("baz")) // .await // .ok(), // None // ); } #[gpui::test] async fn test_compare_checkpoints(cx: &mut TestAppContext) { disable_git_global_config(); cx.executor().allow_parking(); let repo_dir = tempfile::tempdir().unwrap(); git2::Repository::init(repo_dir.path()).unwrap(); let repo = RealGitRepository::new( &repo_dir.path().join(".git"), None, Some("git".into()), cx.executor(), ) .unwrap(); smol::fs::write(repo_dir.path().join("file1"), "content1") .await .unwrap(); let checkpoint1 = repo.checkpoint().await.unwrap(); smol::fs::write(repo_dir.path().join("file2"), "content2") .await .unwrap(); let checkpoint2 = repo.checkpoint().await.unwrap(); assert!( !repo .compare_checkpoints(checkpoint1, checkpoint2.clone()) .await .unwrap() ); let checkpoint3 = repo.checkpoint().await.unwrap(); assert!( repo.compare_checkpoints(checkpoint2, checkpoint3) .await .unwrap() ); } #[gpui::test] async fn test_checkpoint_exclude_binary_files(cx: &mut TestAppContext) { disable_git_global_config(); cx.executor().allow_parking(); let repo_dir = tempfile::tempdir().unwrap(); 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(); smol::fs::write(&text_path, "fn main() {}").await.unwrap(); smol::fs::write(&bin_path, "some binary file here") .await .unwrap(); let repo = RealGitRepository::new( &repo_dir.path().join(".git"), None, Some("git".into()), cx.executor(), ) .unwrap(); // initial commit repo.stage_paths(vec![repo_path("main.rs")], Arc::new(HashMap::default())) .await .unwrap(); repo.commit( "Initial commit".into(), None, CommitOptions::default(), AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}), Arc::new(checkpoint_author_envs()), ) .await .unwrap(); let checkpoint = repo.checkpoint().await.unwrap(); smol::fs::write(&text_path, "fn main() { println!(\"Modified\"); }") .await .unwrap(); smol::fs::write(&bin_path, "Modified binary file") .await .unwrap(); repo.restore_checkpoint(checkpoint).await.unwrap(); // Text files should be restored to checkpoint state, // but binaries should not (they aren't tracked) assert_eq!( smol::fs::read_to_string(&text_path).await.unwrap(), "fn main() {}" ); assert_eq!( smol::fs::read_to_string(&bin_path).await.unwrap(), "Modified binary file" ); } #[test] fn test_branches_parsing() { // suppress "help: octal escapes are not supported, `\0` is always null" #[allow(clippy::octal_escapes)] let input = "*\0060964da10574cd9bf06463a53bf6e0769c5c45e\0\0refs/heads/zed-patches\0refs/remotes/origin/zed-patches\0\01733187470\0John Doe\0generated protobuf\n"; assert_eq!( parse_branch_input(input).unwrap(), vec![Branch { is_head: true, ref_name: "refs/heads/zed-patches".into(), upstream: Some(Upstream { ref_name: "refs/remotes/origin/zed-patches".into(), tracking: UpstreamTracking::Tracked(UpstreamTrackingStatus { ahead: 0, behind: 0 }) }), most_recent_commit: Some(CommitSummary { sha: "060964da10574cd9bf06463a53bf6e0769c5c45e".into(), subject: "generated protobuf".into(), commit_timestamp: 1733187470, author_name: SharedString::new_static("John Doe"), has_parent: false, }) }] ) } #[test] fn test_branches_parsing_containing_refs_with_missing_fields() { #[allow(clippy::octal_escapes)] let input = " \090012116c03db04344ab10d50348553aa94f1ea0\0refs/heads/broken\n \0eb0cae33272689bd11030822939dd2701c52f81e\0895951d681e5561478c0acdd6905e8aacdfd2249\0refs/heads/dev\0\0\01762948725\0Zed\0Add feature\n*\0895951d681e5561478c0acdd6905e8aacdfd2249\0\0refs/heads/main\0\0\01762948695\0Zed\0Initial commit\n"; let branches = parse_branch_input(input).unwrap(); assert_eq!(branches.len(), 2); assert_eq!( branches, vec![ Branch { is_head: false, ref_name: "refs/heads/dev".into(), upstream: None, most_recent_commit: Some(CommitSummary { sha: "eb0cae33272689bd11030822939dd2701c52f81e".into(), subject: "Add feature".into(), commit_timestamp: 1762948725, author_name: SharedString::new_static("Zed"), has_parent: true, }) }, Branch { is_head: true, ref_name: "refs/heads/main".into(), upstream: None, most_recent_commit: Some(CommitSummary { sha: "895951d681e5561478c0acdd6905e8aacdfd2249".into(), subject: "Initial commit".into(), commit_timestamp: 1762948695, author_name: SharedString::new_static("Zed"), has_parent: false, }) } ] ) } #[test] fn test_upstream_branch_name() { let upstream = Upstream { ref_name: "refs/remotes/origin/feature/branch".into(), tracking: UpstreamTracking::Tracked(UpstreamTrackingStatus { ahead: 0, behind: 0, }), }; assert_eq!(upstream.branch_name(), Some("feature/branch")); let upstream = Upstream { ref_name: "refs/remotes/upstream/main".into(), tracking: UpstreamTracking::Tracked(UpstreamTrackingStatus { ahead: 0, behind: 0, }), }; assert_eq!(upstream.branch_name(), Some("main")); let upstream = Upstream { ref_name: "refs/heads/local".into(), tracking: UpstreamTracking::Tracked(UpstreamTrackingStatus { ahead: 0, behind: 0, }), }; assert_eq!(upstream.branch_name(), None); // Test case where upstream branch name differs from what might be the local branch name let upstream = Upstream { ref_name: "refs/remotes/origin/feature/git-pull-request".into(), tracking: UpstreamTracking::Tracked(UpstreamTrackingStatus { ahead: 0, behind: 0, }), }; assert_eq!(upstream.branch_name(), Some("feature/git-pull-request")); } #[test] fn test_parse_worktrees_from_str() { // Empty input let result = parse_worktrees_from_str(""); assert!(result.is_empty()); // Single worktree (main) let input = "worktree /home/user/project\nHEAD abc123def\nbranch refs/heads/main\n\n"; let result = parse_worktrees_from_str(input); assert_eq!(result.len(), 1); assert_eq!(result[0].path, PathBuf::from("/home/user/project")); assert_eq!(result[0].sha.as_ref(), "abc123def"); assert_eq!(result[0].ref_name.as_ref(), "refs/heads/main"); // Multiple worktrees let input = "worktree /home/user/project\nHEAD abc123\nbranch refs/heads/main\n\n\ worktree /home/user/project-wt\nHEAD def456\nbranch refs/heads/feature\n\n"; let result = parse_worktrees_from_str(input); assert_eq!(result.len(), 2); assert_eq!(result[0].path, PathBuf::from("/home/user/project")); assert_eq!(result[0].ref_name.as_ref(), "refs/heads/main"); assert_eq!(result[1].path, PathBuf::from("/home/user/project-wt")); assert_eq!(result[1].ref_name.as_ref(), "refs/heads/feature"); // Detached HEAD entry (should be skipped since ref_name won't parse) let input = "worktree /home/user/project\nHEAD abc123\nbranch refs/heads/main\n\n\ worktree /home/user/detached\nHEAD def456\ndetached\n\n"; let result = parse_worktrees_from_str(input); assert_eq!(result.len(), 1); assert_eq!(result[0].path, PathBuf::from("/home/user/project")); // Bare repo entry (should be skipped) let input = "worktree /home/user/bare.git\nHEAD abc123\nbare\n\n\ worktree /home/user/project\nHEAD def456\nbranch refs/heads/main\n\n"; let result = parse_worktrees_from_str(input); assert_eq!(result.len(), 1); assert_eq!(result[0].path, PathBuf::from("/home/user/project")); // Extra porcelain lines (locked, prunable) should be ignored let input = "worktree /home/user/project\nHEAD abc123\nbranch refs/heads/main\n\n\ worktree /home/user/locked-wt\nHEAD def456\nbranch refs/heads/locked-branch\nlocked\n\n\ worktree /home/user/prunable-wt\nHEAD 789aaa\nbranch refs/heads/prunable-branch\nprunable\n\n"; let result = parse_worktrees_from_str(input); assert_eq!(result.len(), 3); assert_eq!(result[0].path, PathBuf::from("/home/user/project")); assert_eq!(result[0].ref_name.as_ref(), "refs/heads/main"); assert_eq!(result[1].path, PathBuf::from("/home/user/locked-wt")); assert_eq!(result[1].ref_name.as_ref(), "refs/heads/locked-branch"); assert_eq!(result[2].path, PathBuf::from("/home/user/prunable-wt")); assert_eq!(result[2].ref_name.as_ref(), "refs/heads/prunable-branch"); // Leading/trailing whitespace on lines should be tolerated let input = " worktree /home/user/project \n HEAD abc123 \n branch refs/heads/main \n\n"; let result = parse_worktrees_from_str(input); assert_eq!(result.len(), 1); assert_eq!(result[0].path, PathBuf::from("/home/user/project")); assert_eq!(result[0].sha.as_ref(), "abc123"); assert_eq!(result[0].ref_name.as_ref(), "refs/heads/main"); // Windows-style line endings should be handled let input = "worktree /home/user/project\r\nHEAD abc123\r\nbranch refs/heads/main\r\n\r\n"; let result = parse_worktrees_from_str(input); assert_eq!(result.len(), 1); assert_eq!(result[0].path, PathBuf::from("/home/user/project")); assert_eq!(result[0].sha.as_ref(), "abc123"); assert_eq!(result[0].ref_name.as_ref(), "refs/heads/main"); } const TEST_WORKTREE_DIRECTORIES: &[&str] = &["../worktrees", ".git/zed-worktrees", "my-worktrees/"]; #[gpui::test] async fn test_create_and_list_worktrees(cx: &mut TestAppContext) { disable_git_global_config(); cx.executor().allow_parking(); for worktree_dir_setting in TEST_WORKTREE_DIRECTORIES { let repo_dir = tempfile::tempdir().unwrap(); git2::Repository::init(repo_dir.path()).unwrap(); let repo = RealGitRepository::new( &repo_dir.path().join(".git"), None, Some("git".into()), cx.executor(), ) .unwrap(); // Create an initial commit (required for worktrees) smol::fs::write(repo_dir.path().join("file.txt"), "content") .await .unwrap(); repo.stage_paths(vec![repo_path("file.txt")], Arc::new(HashMap::default())) .await .unwrap(); repo.commit( "Initial commit".into(), None, CommitOptions::default(), AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}), Arc::new(checkpoint_author_envs()), ) .await .unwrap(); // List worktrees — should have just the main one let worktrees = repo.worktrees().await.unwrap(); assert_eq!(worktrees.len(), 1); assert_eq!( worktrees[0].path.canonicalize().unwrap(), repo_dir.path().canonicalize().unwrap() ); // Create a new worktree repo.create_worktree( "test-branch".to_string(), resolve_worktree_directory(repo_dir.path(), worktree_dir_setting), Some("HEAD".to_string()), ) .await .unwrap(); // List worktrees — should have two let worktrees = repo.worktrees().await.unwrap(); assert_eq!(worktrees.len(), 2); let expected_path = worktree_path_for_branch(repo_dir.path(), worktree_dir_setting, "test-branch"); let new_worktree = worktrees .iter() .find(|w| w.branch() == "test-branch") .expect("should find worktree with test-branch"); assert_eq!( new_worktree.path.canonicalize().unwrap(), expected_path.canonicalize().unwrap(), "failed for worktree_directory setting: {worktree_dir_setting:?}" ); // Clean up so the next iteration starts fresh repo.remove_worktree(expected_path, true).await.unwrap(); // Clean up the worktree base directory if it was created outside repo_dir // (e.g. for the "../worktrees" setting, it won't be inside the TempDir) let resolved_dir = resolve_worktree_directory(repo_dir.path(), worktree_dir_setting); if !resolved_dir.starts_with(repo_dir.path()) { let _ = std::fs::remove_dir_all(&resolved_dir); } } } #[gpui::test] async fn test_remove_worktree(cx: &mut TestAppContext) { disable_git_global_config(); cx.executor().allow_parking(); for worktree_dir_setting in TEST_WORKTREE_DIRECTORIES { let repo_dir = tempfile::tempdir().unwrap(); git2::Repository::init(repo_dir.path()).unwrap(); let repo = RealGitRepository::new( &repo_dir.path().join(".git"), None, Some("git".into()), cx.executor(), ) .unwrap(); // Create an initial commit smol::fs::write(repo_dir.path().join("file.txt"), "content") .await .unwrap(); repo.stage_paths(vec![repo_path("file.txt")], Arc::new(HashMap::default())) .await .unwrap(); repo.commit( "Initial commit".into(), None, CommitOptions::default(), AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}), Arc::new(checkpoint_author_envs()), ) .await .unwrap(); // Create a worktree repo.create_worktree( "to-remove".to_string(), resolve_worktree_directory(repo_dir.path(), worktree_dir_setting), Some("HEAD".to_string()), ) .await .unwrap(); let worktree_path = worktree_path_for_branch(repo_dir.path(), worktree_dir_setting, "to-remove"); assert!(worktree_path.exists()); // Remove the worktree repo.remove_worktree(worktree_path.clone(), false) .await .unwrap(); // Verify it's gone from the list let worktrees = repo.worktrees().await.unwrap(); assert_eq!(worktrees.len(), 1); assert!( worktrees.iter().all(|w| w.branch() != "to-remove"), "removed worktree should not appear in list" ); // Verify the directory is removed assert!(!worktree_path.exists()); // Clean up the worktree base directory if it was created outside repo_dir // (e.g. for the "../worktrees" setting, it won't be inside the TempDir) let resolved_dir = resolve_worktree_directory(repo_dir.path(), worktree_dir_setting); if !resolved_dir.starts_with(repo_dir.path()) { let _ = std::fs::remove_dir_all(&resolved_dir); } } } #[gpui::test] async fn test_remove_worktree_force(cx: &mut TestAppContext) { disable_git_global_config(); cx.executor().allow_parking(); for worktree_dir_setting in TEST_WORKTREE_DIRECTORIES { let repo_dir = tempfile::tempdir().unwrap(); git2::Repository::init(repo_dir.path()).unwrap(); let repo = RealGitRepository::new( &repo_dir.path().join(".git"), None, Some("git".into()), cx.executor(), ) .unwrap(); // Create an initial commit smol::fs::write(repo_dir.path().join("file.txt"), "content") .await .unwrap(); repo.stage_paths(vec![repo_path("file.txt")], Arc::new(HashMap::default())) .await .unwrap(); repo.commit( "Initial commit".into(), None, CommitOptions::default(), AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}), Arc::new(checkpoint_author_envs()), ) .await .unwrap(); // Create a worktree repo.create_worktree( "dirty-wt".to_string(), resolve_worktree_directory(repo_dir.path(), worktree_dir_setting), Some("HEAD".to_string()), ) .await .unwrap(); let worktree_path = worktree_path_for_branch(repo_dir.path(), worktree_dir_setting, "dirty-wt"); // Add uncommitted changes in the worktree smol::fs::write(worktree_path.join("dirty-file.txt"), "uncommitted") .await .unwrap(); // Non-force removal should fail with dirty worktree let result = repo.remove_worktree(worktree_path.clone(), false).await; assert!( result.is_err(), "non-force removal of dirty worktree should fail" ); // Force removal should succeed repo.remove_worktree(worktree_path.clone(), true) .await .unwrap(); let worktrees = repo.worktrees().await.unwrap(); assert_eq!(worktrees.len(), 1); assert!(!worktree_path.exists()); // Clean up the worktree base directory if it was created outside repo_dir // (e.g. for the "../worktrees" setting, it won't be inside the TempDir) let resolved_dir = resolve_worktree_directory(repo_dir.path(), worktree_dir_setting); if !resolved_dir.starts_with(repo_dir.path()) { let _ = std::fs::remove_dir_all(&resolved_dir); } } } #[gpui::test] async fn test_rename_worktree(cx: &mut TestAppContext) { disable_git_global_config(); cx.executor().allow_parking(); for worktree_dir_setting in TEST_WORKTREE_DIRECTORIES { let repo_dir = tempfile::tempdir().unwrap(); git2::Repository::init(repo_dir.path()).unwrap(); let repo = RealGitRepository::new( &repo_dir.path().join(".git"), None, Some("git".into()), cx.executor(), ) .unwrap(); // Create an initial commit smol::fs::write(repo_dir.path().join("file.txt"), "content") .await .unwrap(); repo.stage_paths(vec![repo_path("file.txt")], Arc::new(HashMap::default())) .await .unwrap(); repo.commit( "Initial commit".into(), None, CommitOptions::default(), AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}), Arc::new(checkpoint_author_envs()), ) .await .unwrap(); // Create a worktree repo.create_worktree( "old-name".to_string(), resolve_worktree_directory(repo_dir.path(), worktree_dir_setting), Some("HEAD".to_string()), ) .await .unwrap(); let old_path = worktree_path_for_branch(repo_dir.path(), worktree_dir_setting, "old-name"); assert!(old_path.exists()); // Move the worktree to a new path let new_path = resolve_worktree_directory(repo_dir.path(), worktree_dir_setting).join("new-name"); repo.rename_worktree(old_path.clone(), new_path.clone()) .await .unwrap(); // Verify the old path is gone and new path exists assert!(!old_path.exists()); assert!(new_path.exists()); // Verify it shows up in worktree list at the new path let worktrees = repo.worktrees().await.unwrap(); assert_eq!(worktrees.len(), 2); let moved_worktree = worktrees .iter() .find(|w| w.branch() == "old-name") .expect("should find worktree by branch name"); assert_eq!( moved_worktree.path.canonicalize().unwrap(), new_path.canonicalize().unwrap() ); // Clean up so the next iteration starts fresh repo.remove_worktree(new_path, true).await.unwrap(); // Clean up the worktree base directory if it was created outside repo_dir // (e.g. for the "../worktrees" setting, it won't be inside the TempDir) let resolved_dir = resolve_worktree_directory(repo_dir.path(), worktree_dir_setting); if !resolved_dir.starts_with(repo_dir.path()) { let _ = std::fs::remove_dir_all(&resolved_dir); } } } #[test] fn test_resolve_worktree_directory() { let work_dir = Path::new("/code/my-project"); // Sibling directory — outside project, so repo dir name is appended assert_eq!( resolve_worktree_directory(work_dir, "../worktrees"), PathBuf::from("/code/worktrees/my-project") ); // Git subdir — inside project, no repo name appended assert_eq!( resolve_worktree_directory(work_dir, ".git/zed-worktrees"), PathBuf::from("/code/my-project/.git/zed-worktrees") ); // Simple subdir — inside project, no repo name appended assert_eq!( resolve_worktree_directory(work_dir, "my-worktrees"), PathBuf::from("/code/my-project/my-worktrees") ); // Trailing slash is stripped assert_eq!( resolve_worktree_directory(work_dir, "../worktrees/"), PathBuf::from("/code/worktrees/my-project") ); assert_eq!( resolve_worktree_directory(work_dir, "my-worktrees/"), PathBuf::from("/code/my-project/my-worktrees") ); // Multiple trailing slashes assert_eq!( resolve_worktree_directory(work_dir, "foo///"), PathBuf::from("/code/my-project/foo") ); // Trailing backslashes (Windows-style) assert_eq!( resolve_worktree_directory(work_dir, "my-worktrees\\"), PathBuf::from("/code/my-project/my-worktrees") ); assert_eq!( resolve_worktree_directory(work_dir, "foo\\/\\"), PathBuf::from("/code/my-project/foo") ); // Empty string resolves to the working directory itself (inside) assert_eq!( resolve_worktree_directory(work_dir, ""), PathBuf::from("/code/my-project") ); // Just ".." — outside project, repo dir name appended assert_eq!( resolve_worktree_directory(work_dir, ".."), PathBuf::from("/code/my-project") ); } #[test] fn test_original_repo_path_from_common_dir() { // Normal repo: common_dir is /.git assert_eq!( original_repo_path_from_common_dir(Path::new("/code/zed5/.git")), PathBuf::from("/code/zed5") ); // Worktree: common_dir is the main repo's .git // (same result — that's the point, it always traces back to the original) assert_eq!( original_repo_path_from_common_dir(Path::new("/code/zed5/.git")), PathBuf::from("/code/zed5") ); // Bare repo: no .git suffix, returns as-is assert_eq!( original_repo_path_from_common_dir(Path::new("/code/zed5.git")), PathBuf::from("/code/zed5.git") ); // Root-level .git directory assert_eq!( original_repo_path_from_common_dir(Path::new("/.git")), PathBuf::from("/") ); } #[test] fn test_validate_worktree_directory() { let work_dir = Path::new("/code/my-project"); // Valid: sibling assert!(validate_worktree_directory(work_dir, "../worktrees").is_ok()); // Valid: subdirectory assert!(validate_worktree_directory(work_dir, ".git/zed-worktrees").is_ok()); assert!(validate_worktree_directory(work_dir, "my-worktrees").is_ok()); // Invalid: just ".." would resolve back to the working directory itself let err = validate_worktree_directory(work_dir, "..").unwrap_err(); assert!(err.to_string().contains("must not be \"..\"")); // Invalid: ".." with trailing separators let err = validate_worktree_directory(work_dir, "..\\").unwrap_err(); assert!(err.to_string().contains("must not be \"..\"")); let err = validate_worktree_directory(work_dir, "../").unwrap_err(); assert!(err.to_string().contains("must not be \"..\"")); // Invalid: empty string would resolve to the working directory itself let err = validate_worktree_directory(work_dir, "").unwrap_err(); assert!(err.to_string().contains("must not be empty")); // Invalid: absolute path let err = validate_worktree_directory(work_dir, "/tmp/worktrees").unwrap_err(); assert!(err.to_string().contains("relative path")); // Invalid: "/" is absolute on Unix let err = validate_worktree_directory(work_dir, "/").unwrap_err(); assert!(err.to_string().contains("relative path")); // Invalid: "///" is absolute let err = validate_worktree_directory(work_dir, "///").unwrap_err(); assert!(err.to_string().contains("relative path")); // Invalid: escapes too far up let err = validate_worktree_directory(work_dir, "../../other-project/wt").unwrap_err(); assert!(err.to_string().contains("outside")); } #[test] fn test_worktree_path_for_branch() { let work_dir = Path::new("/code/my-project"); // Outside project — repo dir name is part of the resolved directory assert_eq!( worktree_path_for_branch(work_dir, "../worktrees", "feature/foo"), PathBuf::from("/code/worktrees/my-project/feature/foo") ); // Inside project — no repo dir name inserted assert_eq!( worktree_path_for_branch(work_dir, ".git/zed-worktrees", "my-branch"), PathBuf::from("/code/my-project/.git/zed-worktrees/my-branch") ); // Trailing slash on setting (inside project) assert_eq!( worktree_path_for_branch(work_dir, "my-worktrees/", "branch"), PathBuf::from("/code/my-project/my-worktrees/branch") ); } impl RealGitRepository { /// Force a Git garbage collection on the repository. fn gc(&self) -> BoxFuture<'_, Result<()>> { let working_directory = self.working_directory(); let git_binary_path = self.any_git_binary_path.clone(); let executor = self.executor.clone(); 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, executor, true); git.run(&["gc", "--prune"]).await?; Ok(()) }) .boxed() } } }