use crate::commit::{CommitDiffObject, CommitDiffObjectKind, parse_git_diff_raw}; use crate::stash::GitStash; use crate::status::{ DiffTreeType, FileStatus, GitStatus, StatusCode, TrackedStatus, TreeDiff, TreeDiffStatus, }; use crate::{Oid, RunHook, SHA256_HEX_LENGTH, SHORT_SHA_LENGTH}; use anyhow::{Context as _, Result, anyhow, bail}; use async_channel::Sender; use collections::HashMap; use file_content::{ByteContent, decode_byte_header}; use futures::channel::oneshot; use futures::future::BoxFuture; use futures::io::BufWriter; use futures::{AsyncWriteExt, FutureExt as _, select_biased}; 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::io::{AsyncBufReadExt, AsyncReadExt, BufReader}; use text::LineEnding; use std::borrow::Cow; use std::collections::HashSet; use std::ffi::{OsStr, OsString}; use std::sync::atomic::AtomicBool; use std::process::{ExitStatus, Output}; use std::str::FromStr; use std::time::SystemTime; use std::{ cmp::Ordering, 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, 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"; /// Used to get commits that match with a search /// %H - Full commit hash static SEARCH_COMMIT_FORMAT: &str = "--format=%H"; /// 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. /// /// Returns `None` if `common_dir` doesn't end with `.git` (e.g. bare repos), /// because there is no working-tree root to resolve to in that case. pub fn original_repo_path_from_common_dir(common_dir: &Path) -> Option { if common_dir.file_name() == Some(OsStr::new(".git")) { common_dir.parent().map(|p| p.to_path_buf()) } else { None } } fn linked_worktree_git_dir(worktree_path: &Path) -> Result { let dot_git_path = worktree_path.join(".git"); let git_file = std::fs::read_to_string(&dot_git_path) .with_context(|| format!("failed to read {}", dot_git_path.display()))?; let git_dir = git_file .strip_prefix("gitdir:") .context("worktree .git file missing gitdir pointer")? .trim(); Ok(worktree_path.join(git_dir)) } fn normalize_git_metadata_path(path: PathBuf) -> Result { paths::normalize_lexically(&path) .map_err(|_| anyhow!("git metadata path escapes its filesystem root: {path:?}")) } /// Commit data needed for the git graph visualization. #[derive(Debug, Clone)] pub struct CommitData { 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, pub message: SharedString, } #[derive(Debug)] pub struct InitialGraphCommitData { pub sha: Oid, pub parents: SmallVec<[Oid; 1]>, pub ref_names: Vec, } impl InitialGraphCommitData { pub fn tag_names(&self) -> Vec<&str> { self.ref_names .iter() .filter_map(|ref_name| { let tag_name = ref_name.strip_prefix("tag: ")?; if tag_name.is_empty() { return None; } Some(tag_name) }) .collect() } } struct CommitDataRequest { sha: Oid, response_tx: oneshot::Sender>, } pub struct CommitDataReader { request_tx: async_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"))? } #[cfg(any(test, feature = "test-support"))] pub fn for_test( executor: BackgroundExecutor, resolve: impl 'static + Send + Sync + Fn(Oid) -> Result, ) -> Self { let (request_tx, request_rx) = smol::channel::bounded::(64); let resolve = Arc::new(resolve); let delay_executor = executor.clone(); let task = executor.spawn(async move { while let Ok(CommitDataRequest { sha, response_tx }) = request_rx.recv().await { delay_executor.simulate_random_delay().await; response_tx.send(resolve(sha)).ok(); } }); Self { request_tx, _task: task, } } } 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; let mut message_lines = Vec::new(); 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())); } message_lines.push(line); } } Some(CommitData { sha, parents, author_name, author_email, commit_timestamp, subject: subject.unwrap_or_default(), message: SharedString::from(message_lines.join("\n")), }) } #[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, } #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct BranchesScanResult { pub branches: Vec, pub error: Option, } impl From> for BranchesScanResult { fn from(branches: Vec) -> Self { Self { branches, error: None, } } } 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: Option, // todo(git_worktree) This type should be a Oid pub sha: SharedString, pub is_main: bool, pub is_bare: bool, } /// Describes how a new worktree should choose or create its checked-out HEAD. #[derive(Clone, Debug, Hash, PartialEq, Eq)] pub enum CreateWorktreeTarget { /// Check out an existing local branch in the new worktree. ExistingBranch { /// The existing local branch to check out. branch_name: String, }, /// Create a new local branch for the new worktree. NewBranch { /// The new local branch to create and check out. branch_name: String, /// The commit or ref to create the branch from. Uses `HEAD` when `None`. base_sha: Option, }, /// Check out a commit or ref in detached HEAD state. Detached { /// The commit or ref to check out. Uses `HEAD` when `None`. base_sha: Option, }, } impl CreateWorktreeTarget { pub fn branch_name(&self) -> Option<&str> { match self { Self::ExistingBranch { branch_name } | Self::NewBranch { branch_name, .. } => { Some(branch_name) } Self::Detached { .. } => None, } } } impl Worktree { /// Returns the branch name if the worktree is attached to a branch. pub fn branch_name(&self) -> Option<&str> { self.ref_name.as_ref().map(|ref_name| { ref_name .strip_prefix("refs/heads/") .or_else(|| ref_name.strip_prefix("refs/remotes/")) .unwrap_or(ref_name) }) } /// Returns a display name for the worktree, suitable for use in the UI. /// /// If the worktree is attached to a branch, returns the branch name. /// Otherwise, returns the short SHA of the worktree's HEAD commit. pub fn display_name(&self) -> &str { self.branch_name() .unwrap_or(&self.sha[..self.sha.len().min(SHORT_SHA_LENGTH)]) } pub fn directory_name(&self, name_anchor_path: Option<&Path>, path_style: PathStyle) -> String { if self.is_main { return "main worktree".to_string(); } let dir_name = path_style .file_name(&self.path) .and_then(|name| name.to_str()) .unwrap_or(self.display_name()); if let Some(name_anchor_path) = name_anchor_path { let name_anchor_dir = path_style .file_name(name_anchor_path) .and_then(|name| name.to_str()); if name_anchor_dir == Some(dir_name) { if let Some(parent_name) = path_style .parent(&self.path) .and_then(|parent| path_style.file_name(parent)) .and_then(|name| name.to_str()) { return parent_name.to_string(); } } } dir_name.to_string() } } pub fn parse_worktrees_from_str>( raw_worktrees: T, main_worktree_path: Option<&Path>, ) -> 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; let mut is_bare = false; 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()); } else if line == "bare" { is_bare = true; } // Ignore other lines: detached, locked, prunable, etc. } if let (Some(path), Some(sha)) = (path, sha) { let path = PathBuf::from(path); let is_main = main_worktree_path.is_some_and(|main_worktree_path| path == main_worktree_path); worktrees.push(Worktree { path, ref_name: ref_name.map(Into::into), sha: sha.into(), is_main, is_bare, }); } } 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, pub allow_empty: bool, pub no_verify: 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(Debug)] pub struct CommitDiff { pub files: Vec, pub is_shallow_boundary: bool, } #[derive(Clone, Debug, Default, Eq, PartialEq)] pub struct FileHistoryChangedFileSets { pub file_sets: 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_content: Option>, pub new_content: Option>, pub is_binary: bool, } impl CommitFile { pub fn status(&self) -> CommitFileStatus { match (&self.old_content, &self.new_content) { (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() } } pub fn is_binary_content(content: &[u8]) -> bool { let check_len = content.len().min(8000); content[..check_len].contains(&0) } struct LoadedCommitObject { content: Vec, is_binary: bool, } async fn read_commit_blob( stdout: &mut R, info_line: &mut String, newline: &mut [u8; 1], ) -> Result { info_line.clear(); stdout.read_line(info_line).await?; let len = info_line .trim_end() .parse() .with_context(|| format!("invalid object size output from cat-file {info_line}"))?; let mut bytes = vec![0; len]; stdout.read_exact(&mut bytes).await?; stdout.read_exact(newline).await?; let is_binary = is_binary_content(&bytes) || decode_byte_header(&bytes).1 == ByteContent::Binary; Ok(LoadedCommitObject { content: bytes, is_binary, }) } async fn load_commit_object( object: Option>, stdout: &mut R, info_line: &mut String, newline: &mut [u8; 1], ) -> Result> { match object { Some(object) if object.kind == CommitDiffObjectKind::Gitlink => { Ok(Some(LoadedCommitObject { content: format!("Subproject commit {}\n", object.oid).into_bytes(), is_binary: false, })) } Some(_) => Ok(Some(read_commit_blob(stdout, info_line, newline).await?)), None => Ok(None), } } async fn read_shallow_file(shallow_file_path: &Path) -> Result> { match smol::fs::read_to_string(shallow_file_path).await { Ok(contents) => Ok(Some(contents)), Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), Err(error) => Err(error).context("reading shallow file"), } } async fn is_shallow_boundary_commit( git: &GitBinary, shallow_file_path: &Path, commit: &str, ) -> Result { let Some(shallow_contents) = read_shallow_file(shallow_file_path).await? else { return Ok(false); }; let oid = git .run(&["rev-parse", "--verify", &format!("{commit}^{{commit}}")]) .await .context("resolving commit for shallow boundary check")?; Ok(shallow_contents .lines() .any(|line| line.trim() == oid.trim())) } #[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, Unshallow, Remote(Remote), } impl FetchOptions { pub fn to_proto(&self) -> Option { match self { FetchOptions::All | FetchOptions::Unshallow => None, FetchOptions::Remote(remote) => Some(remote.clone().name.into()), } } pub fn from_proto(remote_name: Option, unshallow: bool) -> Self { if unshallow { return FetchOptions::Unshallow; } 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::Unshallow => "Fetch missing history".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::Unshallow => write!(f, "--unshallow"), FetchOptions::Remote(remote) => write!(f, "{}", remote.name), } } } #[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), Path(RepoPath), } impl LogSource { fn get_args(&self) -> Vec> { let mut args = match self { LogSource::All => vec![ Cow::Borrowed("--ignore-missing"), // needed in case of unborn HEAD Cow::Borrowed("--branches"), Cow::Borrowed("--remotes"), Cow::Borrowed("--tags"), Cow::Borrowed("HEAD"), ], LogSource::Branch(branch) => vec![Cow::Borrowed(branch.as_str())], LogSource::Sha(oid) => vec![Cow::Owned(oid.to_string())], LogSource::Path(path) => vec![ Cow::Borrowed("--follow"), Cow::Borrowed("--"), Cow::Borrowed(path.as_unix_str()), ], }; // Without a terminator git cannot tell a branch named `docs/rewrite` from a // `docs/rewrite` directory in the working tree, and refuses the argument as // ambiguous. `Path` states its own separator before the path it passes. if !matches!(self, LogSource::Path(_)) { args.push(Cow::Borrowed("--")); } args } } pub struct SearchCommitArgs { pub query: SharedString, pub case_sensitive: bool, } pub fn commit_hash_search_query(query: &str) -> Option<&str> { let query = query.trim(); (SHORT_SHA_LENGTH..=SHA256_HEX_LENGTH) .contains(&query.len()) .then_some(query) .filter(|query| query.bytes().all(|byte| byte.is_ascii_hexdigit())) } pub fn delete_branch_flag(is_remote_tracking_ref: bool, force: bool) -> &'static str { match (is_remote_tracking_ref, force) { (true, true) => "-Dr", (true, false) => "-dr", (false, true) => "-D", (false, false) => "-d", } } pub trait GitRepository: Send + Sync { /// 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>> { let future = self.load_revisions(vec![format!(":{}", path.as_unix_str())]); async move { future.await.ok()?.pop()? }.boxed() } /// 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>> { let future = self.load_revisions(vec![format!("HEAD:{}", path.as_unix_str())]); async move { future.await.ok()?.pop()? }.boxed() } 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> { let name = name.to_string(); let fut = self.remote_urls(); async move { fut.await.remove(&name) }.boxed() } /// Returns the URL of all remotes. fn remote_urls(&self) -> BoxFuture<'_, HashMap>; /// Resolve a list of refs to SHAs. fn revparse_batch(&self, revs: Vec) -> BoxFuture<'_, Result>>>; fn load_revisions(&self, revisions: 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<'static, Result>; fn check_access(&self) -> BoxFuture<'_, Result<()>> { async move { Ok(()) }.boxed() } 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, is_remote: bool, name: String, force: bool, ) -> BoxFuture<'_, Result<()>>; fn worktrees(&self) -> BoxFuture<'_, Result>>; /// Returns the creation time of a linked worktree's git metadata /// directory (`.git/worktrees//`), resolved via the worktree's /// `.git` file. /// /// The metadata directory is created by `git worktree add` and removed /// by `git worktree remove`, so its creation time identifies a /// particular incarnation of the worktree: if the worktree is removed /// and recreated at the same path, the creation time changes. /// /// Returns `Ok(None)` when the worktree directory does not exist at /// all, and an error when the directory exists but the time cannot be /// determined (e.g. on filesystems without birthtime support); callers /// should fail safe in the error case. fn worktree_created_at( &self, worktree_path: PathBuf, ) -> BoxFuture<'_, Result>>; fn create_worktree( &self, target: CreateWorktreeTarget, path: PathBuf, ) -> BoxFuture<'_, Result<()>>; fn checkout_branch_in_worktree( &self, branch_name: String, worktree_path: PathBuf, create: bool, ) -> 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<()>>; /// Restores `paths` in the working tree. /// /// With `Some(commit)`, both the index and the working tree are reset to the /// commit's version, discarding staged and unstaged changes. With `None`, only /// the working tree is restored from the index, so staged changes are kept. fn checkout_files( &self, commit: Option, paths: Vec, env: Arc>, ) -> BoxFuture<'_, Result<()>>; fn show(&self, commit: String) -> BoxFuture<'_, Result>; fn load_commit( &self, commit: String, ignore_shallow_boundary: bool, cx: AsyncApp, ) -> BoxFuture<'_, Result>; fn blame( &self, path: RepoPath, content: Rope, line_ending: LineEnding, ) -> BoxFuture<'_, Result>; fn blame_at_revision( &self, path: RepoPath, revision: Oid, ) -> 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<()>>; /// Only used to serve `proto::RunGitHook` requests from older remote clients; /// new code lets `git commit` run hooks itself. /// /// TODO: remove together with `proto::RunGitHook` (see the deprecation note in git.proto). 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, message: Option, env: Arc>, ) -> BoxFuture<'_, Result<()>>; fn stash_staged( &self, message: Option, 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: Option, 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, diff: DiffStatType, path_prefixes: &[RepoPath], ) -> BoxFuture<'static, Result>; /// Creates two detached commits capturing the current staged and unstaged /// state without moving any branch. Returns (staged_sha, unstaged_sha). fn create_archive_checkpoint(&self) -> BoxFuture<'_, Result<(String, String)>>; /// Restores the working directory and index from archive checkpoint SHAs. /// Assumes HEAD is already at the correct commit (original_commit_hash). /// Restores the index to match staged_sha's tree, and the working /// directory to match unstaged_sha's tree. fn restore_archive_checkpoint( &self, staged_sha: String, unstaged_sha: String, ) -> BoxFuture<'_, Result<()>>; fn load_commit_template(&self) -> 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 search_commits( &self, log_source: LogSource, search_args: SearchCommitArgs, request_tx: Sender, ) -> BoxFuture<'_, Result<()>>; fn file_history_changed_files( &self, paths: Vec, commit_limit: usize, ) -> BoxFuture<'_, Result>>; fn commit_data_reader(&self) -> Result; fn create_ref(&self, ref_name: String, commit: String) -> BoxFuture<'_, Result<()>>; fn update_ref(&self, ref_name: String, commit: String) -> BoxFuture<'_, Result<()>>; fn delete_ref(&self, ref_name: String) -> BoxFuture<'_, Result<()>>; fn repair_worktrees(&self) -> BoxFuture<'_, Result<()>>; fn set_trusted(&self, trusted: bool); fn is_trusted(&self) -> bool; } pub enum DiffType { HeadToIndex, HeadToWorktree, MergeBase { base_ref: SharedString }, } #[derive(Clone, Copy)] pub enum DiffStatType { HeadToIndex, HeadToWorktree, IndexToWorktree, } #[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 git_dir: PathBuf, pub common_dir: PathBuf, /// `None` only for bare repositories, which do not have a working directory. pub working_directory: Option, pub system_git_binary_path: Option, pub any_git_binary_path: PathBuf, any_git_binary_help_output: Arc>>, executor: BackgroundExecutor, is_trusted: Arc, } #[derive(Debug)] pub enum RefEdit { Create { ref_name: String, commit: String }, Update { ref_name: String, commit: String }, Delete { ref_name: String }, } impl RefEdit { fn into_args(self) -> Vec { match self { Self::Create { ref_name, commit } => { vec![ "update-ref".into(), ref_name.into(), commit.into(), "".into(), ] } Self::Update { ref_name, commit } => { vec!["update-ref".into(), ref_name.into(), commit.into()] } Self::Delete { ref_name } => { vec!["update-ref".into(), "-d".into(), ref_name.into()] } } } } impl RealGitRepository { pub fn new( dotgit_path: &Path, bundled_git_binary_path: Option, system_git_binary_path: Option, executor: BackgroundExecutor, ) -> Result { let any_git_binary_path = system_git_binary_path .clone() .or(bundled_git_binary_path) .context("no git binary available")?; log::info!( "opening git repository at {dotgit_path:?} using git binary {any_git_binary_path:?}" ); let dotgit_parent = dotgit_path.parent().context(".git has no parent")?; let has_working_directory = dotgit_path.is_file() || dotgit_path.file_name() == Some(OsStr::new(".git")); let working_directory = if has_working_directory { Some(normalize_git_metadata_path(dotgit_parent.to_path_buf())?) } else { None }; let git_dir = if dotgit_path.is_file() { let content = std::fs::read_to_string(dotgit_path).context("reading .git worktree file")?; let path_str = content .strip_prefix("gitdir: ") .context("expected .git file to start with 'gitdir: '")? .trim(); let resolved = PathBuf::from(path_str); let resolved = if resolved.is_absolute() { resolved } else { dotgit_parent.join(resolved) }; normalize_git_metadata_path(resolved)? } else { normalize_git_metadata_path(dotgit_path.to_path_buf())? }; let common_dir = { let commondir_file = git_dir.join("commondir"); if commondir_file.is_file() { let content = std::fs::read_to_string(&commondir_file).context("reading commondir file")?; let path_str = content.trim(); let resolved = PathBuf::from(path_str); let resolved = if resolved.is_absolute() { resolved } else { git_dir.join(resolved) }; normalize_git_metadata_path(resolved)? } else { git_dir.clone() } }; Ok(Self { git_dir, common_dir, working_directory, 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.working_directory .clone() .context("bare repositories do not have a working directory") } fn command_directory(&self) -> PathBuf { self.working_directory .clone() .unwrap_or_else(|| self.git_dir.clone()) } fn git_binary_in_worktree(&self) -> Result { Ok(GitBinary::new( self.any_git_binary_path.clone(), self.working_directory()?, self.path(), self.executor.clone(), self.is_trusted(), )) } fn git_binary(&self) -> GitBinary { GitBinary::new( self.any_git_binary_path.clone(), self.command_directory(), self.path(), self.executor.clone(), self.is_trusted(), ) } fn edit_ref(&self, edit: RefEdit) -> BoxFuture<'_, Result<()>> { let git_binary = self.git_binary(); self.executor .spawn(async move { let git = git_binary; let args = edit.into_args(); git.run(&args).await?; Ok(()) }) .boxed() } 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 = self.git_binary(); let output: SharedString = self .executor .spawn(async move { git.run(&["help", "-a"]).await }) .await .unwrap_or_default() .into(); *self.any_git_binary_help_output.lock() = Some(output.clone()); output } } #[derive(Debug)] pub struct GitCommitter { pub name: Option, pub email: Option, } #[derive(Clone, Debug)] pub struct GitCommitTemplate { pub template: String, } 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(), paths::home_dir().join(".git"), 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 } fn parse_remote_urls(stdout: &str) -> HashMap { let mut urls = HashMap::default(); for line in stdout.lines() { if let Some((line, suffix)) = line.rsplit_once(" (fetch)") && (suffix.is_empty() || suffix.starts_with(" [") && suffix.ends_with(']')) && let Some((name, url)) = line.split_once(char::is_whitespace) { urls.insert(name.to_string(), url.trim_start().to_string()); } } urls } impl GitRepository for RealGitRepository { fn path(&self) -> PathBuf { self.git_dir.clone() } fn main_repository_path(&self) -> PathBuf { self.common_dir.clone() } fn show(&self, commit: String) -> BoxFuture<'_, Result> { let git = self.git_binary(); self.executor .spawn(async move { let output = git .build_command(&[ "show", "--no-patch", "--format=%H%x00%B%x00%at%x00%ae%x00%an%x00", &commit, // `commit` reaches here as whatever the user typed, so it can name // a branch that also names a path in the working tree. "--", ]) .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, ignore_shallow_boundary: bool, cx: AsyncApp, ) -> BoxFuture<'_, Result> { let git = self.git_binary(); let shallow_file_path = self.common_dir.join("shallow"); cx.background_spawn(async move { if !ignore_shallow_boundary && is_shallow_boundary_commit(&git, &shallow_file_path, &commit).await? { return Ok(CommitDiff { files: Vec::new(), is_shallow_boundary: true, }); } let show_output = git .build_command(&[ "show", "--format=", "-z", "--no-renames", "--raw", "--no-abbrev", "--first-parent", ]) .arg(&commit) .arg("--") .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .output() .await .context("starting git show process")?; anyhow::ensure!( show_output.status.success(), "git show failed: {}", String::from_utf8_lossy(&show_output.stderr) ); let show_stdout = String::from_utf8_lossy(&show_output.stdout); let changes = parse_git_diff_raw(&show_stdout); let mut cat_file_process = git .build_command(&["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 stdin = cat_file_process .stdin .take() .context("git cat-file process has no stdin")?; let stdout = cat_file_process .stdout .take() .context("git cat-file process has no stdout")?; let mut stdin = BufWriter::with_capacity(512, stdin); let mut stdout = BufReader::new(stdout); let mut info_line = String::new(); let mut newline = [b'\0']; for change in changes { let change = change?; let path = change.path; // git-show outputs `/`-delimited paths even on Windows. let Some(rel_path) = RelPath::from_unix_str(path).log_err() else { continue; }; let objects = [change.new_object, change.old_object]; let mut has_blobs = false; for object in objects.iter().flatten() { if object.kind == CommitDiffObjectKind::Blob { stdin.write_all(object.oid.as_bytes()).await?; stdin.write_all(b"\n").await?; has_blobs = true; } } if has_blobs { stdin.flush().await?; } let [new_object, old_object] = objects; let new_object = load_commit_object(new_object, &mut stdout, &mut info_line, &mut newline) .await?; let old_object = load_commit_object(old_object, &mut stdout, &mut info_line, &mut newline) .await?; let is_binary = new_object.as_ref().is_some_and(|object| object.is_binary) || old_object.as_ref().is_some_and(|object| object.is_binary); let new_content = new_object.map(|object| object.content); let old_content = old_object.map(|object| object.content); files.push(CommitFile { path: RepoPath(Arc::from(rel_path)), old_content, new_content, is_binary, }) } Ok(CommitDiff { files, is_shallow_boundary: false, }) }) .boxed() } fn reset( &self, commit: String, mode: ResetMode, env: Arc>, ) -> BoxFuture<'_, Result<()>> { let git = self.git_binary_in_worktree(); async move { let git = git?; let mode_flag = match mode { ResetMode::Mixed => "--mixed", ResetMode::Soft => "--soft", }; 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: Option, paths: Vec, env: Arc>, ) -> BoxFuture<'_, Result<()>> { let git = self.git_binary_in_worktree(); async move { let git = git?; if paths.is_empty() { return Ok(()); } let mut args = vec!["checkout"]; args.extend(commit.as_deref()); args.push("--"); let output = git .build_command(&args) .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_blob_content(&self, oid: Oid) -> BoxFuture<'_, Result>> { let git_binary = self.git_binary(); let oid_str = oid.to_string(); self.executor .spawn(async move { let mut command = git_binary.build_command(&["cat-file", "blob", &oid_str]); 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(output.stdout) }) .boxed() } fn load_commit_template(&self) -> BoxFuture<'_, Result>> { let working_directory = self.working_directory(); let git_binary = self.git_binary_in_worktree(); self.executor .spawn(async move { let working_directory = working_directory?; let git_binary = git_binary?; let output = git_binary .build_command(&["config", "--get", "commit.template"]) .output() .await .context("failed to run git config --get commit.template")?; let raw_path = String::from_utf8_lossy(&output.stdout).trim().to_string(); if !output.status.success() || raw_path.is_empty() { return Ok(None); } let path = PathBuf::from(&raw_path); let path = if let Some(path) = raw_path.strip_prefix("~/") { paths::home_dir().join(path) } else if path.is_relative() { working_directory.join(path) } else { path }; let template = match std::fs::read_to_string(&path) { Ok(s) if !s.trim().is_empty() => Some(s), Err(err) => { log::warn!("failed to read commit template {}: {}", path.display(), err); None } _ => None, }; Ok(template.map(|template| GitCommitTemplate { template })) }) .boxed() } fn set_index_text( &self, path: RepoPath, content: Option>, env: Arc>, is_executable: bool, ) -> BoxFuture<'_, anyhow::Result<()>> { let git = self.git_binary(); self.executor .spawn(async move { let mode = if is_executable { "100755" } else { "100644" }; if let Some(content) = content { let mut child = git .build_command(&[ "hash-object", "-w", "--stdin", "--path", path.as_unix_str(), ]) .envs(env.iter()) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn()?; let mut stdin = child.stdin.take().context("hash-object has no stdin")?; stdin.write_all(&content).await?; stdin.flush().await?; drop(stdin); let output = child.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, } ); let sha = str::from_utf8(&output.stdout)?.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_urls(&self) -> BoxFuture<'_, HashMap> { let git = self.git_binary(); self.executor .spawn(async move { if let Ok(stdout) = git.run(&["remote", "-v"]).await { parse_remote_urls(&stdout) } else { HashMap::default() } }) .boxed() } fn revparse_batch(&self, revs: Vec) -> BoxFuture<'_, Result>>> { let git = self.git_binary(); self.executor .spawn(async move { let mut process = git .build_command(&["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 load_revisions( &self, revisions: Vec, ) -> BoxFuture<'_, Result>>>> { let git = self.git_binary(); self.executor .spawn(async move { if revisions.is_empty() { return Ok(Vec::new()); } if let Some(revision) = revisions.iter().find(|revision| revision.contains('\n')) { anyhow::bail!( "revision spec {revision:?} contains a newline and cannot be passed to git cat-file --batch" ); } let mut process = git .build_command(&["cat-file", "--batch"]) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .kill_on_drop(true) .spawn()?; let mut stdin = BufWriter::new(process.stdin.take().context("no stdin")?); let mut stdout = BufReader::new(process.stdout.take().context("no stdout")?); let mut newline = [0u8; 1]; let mut header_bytes = Vec::new(); let mut results = Vec::with_capacity(revisions.len()); for rev in &revisions { stdin.write_all(rev.as_bytes()).await?; stdin.write_all(b"\n").await?; stdin.flush().await?; header_bytes.clear(); 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(); match parts[..] { [.., "missing"] => { results.push(None); } [_, object_type, size_str] => { let size: usize = size_str .parse() .with_context(|| format!("invalid object size: {size_str}"))?; let mut content = vec![0u8; size]; stdout.read_exact(&mut content).await?; stdout.read_exact(&mut newline).await?; if object_type == "blob" { results.push(Some(content)); } else { results.push(None); } } _ => bail!("invalid cat-file header: {header_line}"), } } drop(stdin); process.output().await?; Ok(results) }) .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 = self.git_binary_in_worktree(); let args = git_status_args(path_prefixes); log::debug!("Checking for git status in {path_prefixes:?}"); self.executor.spawn(async move { let git = git?; 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 check_access(&self) -> BoxFuture<'_, Result<()>> { let git = self.git_binary_in_worktree(); self.executor .spawn(async move { git?.run(&["rev-parse"]).await?; Ok(()) }) .boxed() } fn diff_tree(&self, request: DiffTreeType) -> BoxFuture<'_, Result> { let git = self.git_binary_in_worktree(); let working_directory = self.working_directory.clone(); let merge_base_ref = match &request { DiffTreeType::MergeBaseWithWorktree { base } => Some(base.clone()), DiffTreeType::MergeBase { .. } | DiffTreeType::Since { .. } => None, }; let args = match request { DiffTreeType::MergeBase { base, head } => [ "diff-tree", "-r", "-z", "--abbrev=64", "--no-renames", "--merge-base", base.as_str(), head.as_str(), "--", ] .map(OsString::from) .to_vec(), DiffTreeType::MergeBaseWithWorktree { base } => [ "diff", "--raw", "-z", "--abbrev=64", "--no-renames", "--merge-base", base.as_str(), "--", ] .map(OsString::from) .to_vec(), DiffTreeType::Since { base, head } => [ "diff-tree", "-r", "-z", "--abbrev=64", "--no-renames", base.as_str(), head.as_str(), "--", ] .map(OsString::from) .to_vec(), }; self.executor .spawn(async move { let git = git?; let output = git.build_command(&args).output().await?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); anyhow::bail!("git diff-tree failed: {stderr}"); } let stdout = String::from_utf8_lossy(&output.stdout); let mut tree_diff = stdout.parse::()?; let Some(merge_base_ref) = merge_base_ref else { return Ok(tree_diff); }; let Some(working_directory) = working_directory else { return Ok(tree_diff); }; if !tree_diff .entries .values() .any(|status| matches!(status, TreeDiffStatus::Deleted { .. })) { return Ok(tree_diff); } let status_output = git.build_command(&git_status_args(&[])).output().await?; if !status_output.status.success() { let stderr = String::from_utf8_lossy(&status_output.stderr); anyhow::bail!("git status failed: {stderr}"); } let status = String::from_utf8_lossy(&status_output.stdout).parse::()?; // Files the diff reports as deleted but that exist on disk // (deleted from the index or from a commit, then recreated). // `git diff` compares them against the index, so compare their // disk contents against the merge base ourselves. let recreated: Vec<(RepoPath, Oid)> = status .entries .iter() .filter(|(_, status)| { matches!( *status, FileStatus::Untracked | FileStatus::Tracked(TrackedStatus { index_status: StatusCode::Deleted, worktree_status: StatusCode::Added, }) ) }) .filter_map(|(path, _)| match tree_diff.entries.get(path) { Some(TreeDiffStatus::Deleted { old }) => Some((path.clone(), *old)), _ => None, }) .collect(); if recreated.is_empty() { return Ok(tree_diff); } let merge_base_output = git .build_command(&["merge-base", merge_base_ref.as_ref(), "HEAD"]) .output() .await?; if !merge_base_output.status.success() { let stderr = String::from_utf8_lossy(&merge_base_output.stderr); anyhow::bail!("git merge-base failed: {stderr}"); } let merge_base = String::from_utf8_lossy(&merge_base_output.stdout); let merge_base = merge_base.trim(); for (path, old) in recreated { let full_path = working_directory.join(path.as_std_path()); let metadata = match smol::fs::symlink_metadata(&full_path).await { Ok(metadata) => metadata, Err(_) => continue, }; let base_entry = git .build_command( &["ls-tree", merge_base, "--", path.as_unix_str()].map(OsString::from), ) .output() .await?; if !base_entry.status.success() { continue; } let base_mode = String::from_utf8_lossy(&base_entry.stdout); let Some(base_mode) = base_mode.split_ascii_whitespace().next() else { continue; }; let current_mode = if metadata.file_type().is_symlink() { "120000" } else { #[cfg(unix)] { use std::os::unix::fs::PermissionsExt as _; if metadata.permissions().mode() & 0o111 == 0 { "100644" } else { "100755" } } #[cfg(not(unix))] { "100644" } }; if current_mode != base_mode { tree_diff .entries .insert(path.clone(), TreeDiffStatus::Modified { old }); continue; } let hash_output = if metadata.file_type().is_symlink() { let target = smol::fs::read_link(&full_path).await?; let mut child = git .build_command(&["hash-object", "--stdin"]) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .spawn()?; let mut stdin = child.stdin.take().context("hash-object has no stdin")?; stdin .write_all(target.as_os_str().as_encoded_bytes()) .await?; stdin.flush().await?; drop(stdin); child.output().await? } else { git.build_command(&[ OsString::from("hash-object"), OsString::from(format!("--path={}", path.as_unix_str())), OsString::from("--"), full_path.into_os_string(), ]) .output() .await? }; if !hash_output.status.success() { continue; } let worktree_oid = String::from_utf8_lossy(&hash_output.stdout); if worktree_oid.trim() == old.to_string() { tree_diff.entries.remove(&path); } else { tree_diff .entries .insert(path.clone(), TreeDiffStatus::Modified { old }); } } Ok(tree_diff) }) .boxed() } fn stash_entries(&self) -> BoxFuture<'static, Result> { let git = self.git_binary_in_worktree(); self.executor .spawn(async move { let git = git?; // Use `%gs` (reflog subject), not `%s` (commit subject). See // https://github.com/zed-industries/zed/issues/55234 for further // context. // // A stash is a regular commit object whose tree records the working // directory state. The list of stashes is not an array — it's the // reflog of `refs/stash`, where each reflog entry points at a stash // commit. From git-stash(1): // // "The latest stash you created is stored in `refs/stash`; older // stashes are found in the reflog of this reference and can be // named using the usual reflog syntax." // -- https://git-scm.com/docs/git-stash (DESCRIPTION) // // `git stash store` is documented as: "Store a given stash created via // `git stash create` ... in the stash ref, updating the stash reflog." // Crucially, it does not rewrite the underlying commit object — it // only adds a reflog entry pointing at the existing commit. // // From git's pretty-format docs: // // %s subject // %gs reflog subject // // -- https://git-scm.com/docs/pretty-formats // // So `%s` would return the ORIGINAL message forever, regardless of how // many times the stash gets renamed externally — no amount of cache // invalidation or fs-watching can fix that, because the data we asked // git for genuinely never changes. `%gs` reads the reflog message that // `store -m` actually updates, and matches what `git stash list` shows // by default. let output = git .build_command(&["stash", "list", "--pretty=format:%gd%x00%H%x00%ct%x00%gs"]) .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 stash list failed: {stderr}"); } }) .boxed() } fn branches(&self) -> BoxFuture<'_, Result> { let git = 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 output = git.build_command(&args).output().await?; let error = if output.status.success() { None } else { let error = format_branch_scan_error(&output); log::warn!("failed to get git branches with commit metadata: {error}"); Some(error.into()) }; 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(BranchesScanResult { branches, error }) }) .boxed() } fn worktrees(&self) -> BoxFuture<'_, Result>> { let git = self.git_binary(); let main_worktree_path = original_repo_path_from_common_dir(&self.common_dir); self.executor .spawn(async move { let output = git .build_command(&["worktree", "list", "--porcelain"]) .output() .await?; if output.status.success() { let stdout = String::from_utf8_lossy(&output.stdout); Ok(parse_worktrees_from_str( &stdout, main_worktree_path.as_deref(), )) } else { let stderr = String::from_utf8_lossy(&output.stderr); anyhow::bail!("git worktree list failed: {stderr}"); } }) .boxed() } fn worktree_created_at( &self, worktree_path: PathBuf, ) -> BoxFuture<'_, Result>> { self.executor .spawn(async move { match std::fs::metadata(&worktree_path) { Err(error) if error.kind() == std::io::ErrorKind::NotFound => { return Ok(None); } Err(error) => { return Err(error).with_context(|| { format!("failed to stat {}", worktree_path.display()) }); } Ok(_) => {} } let git_dir = linked_worktree_git_dir(&worktree_path)?; let metadata = std::fs::metadata(&git_dir) .with_context(|| format!("failed to stat {}", git_dir.display()))?; let created_at = metadata.created().with_context(|| { format!("creation time unavailable for {}", git_dir.display()) })?; Ok(Some(created_at)) }) .boxed() } fn create_worktree( &self, target: CreateWorktreeTarget, path: PathBuf, ) -> BoxFuture<'_, Result<()>> { let git = self.git_binary(); let mut args = vec![OsString::from("worktree"), OsString::from("add")]; match &target { CreateWorktreeTarget::ExistingBranch { branch_name } => { args.push(OsString::from("--")); args.push(OsString::from(path.as_os_str())); args.push(OsString::from(branch_name)); } CreateWorktreeTarget::NewBranch { branch_name, base_sha: start_point, } => { args.push(OsString::from("-b")); args.push(OsString::from(branch_name)); args.push(OsString::from("--")); args.push(OsString::from(path.as_os_str())); args.push(OsString::from(start_point.as_deref().unwrap_or("HEAD"))); } CreateWorktreeTarget::Detached { base_sha: start_point, } => { args.push(OsString::from("--detach")); args.push(OsString::from("--")); args.push(OsString::from(path.as_os_str())); args.push(OsString::from(start_point.as_deref().unwrap_or("HEAD"))); } } self.executor .spawn(async move { std::fs::create_dir_all(path.parent().unwrap_or(&path))?; 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 = self.git_binary(); self.executor .spawn(async move { let mut args: Vec = vec!["worktree".into(), "remove".into()]; if force { args.push("--force".into()); } args.push("--".into()); args.push(path.as_os_str().into()); git.run(&args).await?; anyhow::Ok(()) }) .boxed() } fn rename_worktree(&self, old_path: PathBuf, new_path: PathBuf) -> BoxFuture<'_, Result<()>> { let git = self.git_binary(); self.executor .spawn(async move { let args: Vec = vec![ "worktree".into(), "move".into(), "--".into(), old_path.as_os_str().into(), new_path.as_os_str().into(), ]; git.run(&args).await?; anyhow::Ok(()) }) .boxed() } fn checkout_branch_in_worktree( &self, branch_name: String, worktree_path: PathBuf, create: bool, ) -> BoxFuture<'_, Result<()>> { let git_binary = GitBinary::new( self.any_git_binary_path.clone(), worktree_path, self.path(), self.executor.clone(), self.is_trusted(), ); self.executor .spawn(async move { if create { git_binary.run(&["checkout", "-b", &branch_name]).await?; } else { git_binary.run(&["checkout", &branch_name]).await?; } anyhow::Ok(()) }) .boxed() } fn change_branch(&self, name: String) -> BoxFuture<'_, Result<()>> { let git_binary = self.git_binary_in_worktree(); self.executor .spawn(async move { let git_binary = git_binary?; let local_ref = format!("refs/heads/{name}"); if git_binary .run(&["show-ref", "--verify", "--quiet", &local_ref]) .await .is_ok() { git_binary.run(&["checkout", &name]).await?; return anyhow::Ok(()); } let remote_ref = format!("refs/remotes/{name}"); if git_binary .run(&["show-ref", "--verify", "--quiet", &remote_ref]) .await .is_ok() { let name = match git_binary.run(&["symbolic-ref", &remote_ref]).await { Ok(resolved) => resolved .strip_prefix("refs/remotes/") .map(str::to_owned) .unwrap_or(name), Err(_) => name, }; let (_, branch_name) = name.split_once('/').context("Unexpected branch format")?; let local_branch_ref = format!("refs/heads/{branch_name}"); if git_binary .run(&["show-ref", "--verify", "--quiet", &local_branch_ref]) .await .is_ok() { git_binary .run(&["branch", "--set-upstream-to", &name, branch_name]) .await?; } else { git_binary .run(&["branch", "--track", branch_name, &name]) .await?; } git_binary.run(&["checkout", branch_name]).await?; return anyhow::Ok(()); } anyhow::bail!("Branch '{}' not found", name); }) .boxed() } fn create_branch( &self, name: String, base_branch: Option, ) -> BoxFuture<'_, Result<()>> { let git_binary = self.git_binary_in_worktree(); self.executor .spawn(async move { let git_binary = git_binary?; let mut args = vec!["switch", "-c", &name]; let base_branch_str; if let Some(ref base) = base_branch { 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_in_worktree(); self.executor .spawn(async move { let git_binary = git_binary?; git_binary .run(&["branch", "-m", &branch, &new_name]) .await?; anyhow::Ok(()) }) .boxed() } fn delete_branch( &self, is_remote: bool, name: String, force: bool, ) -> BoxFuture<'_, Result<()>> { let git_binary = self.git_binary_in_worktree(); self.executor .spawn(async move { let git_binary = git_binary?; let flag = delete_branch_flag(is_remote, force); git_binary.run(&["branch", flag, &name]).await?; anyhow::Ok(()) }) .boxed() } fn blame( &self, path: RepoPath, content: Rope, line_ending: LineEnding, ) -> BoxFuture<'_, Result> { let git = self.git_binary_in_worktree(); self.executor .spawn(async move { let git = git?; crate::blame::Blame::for_path(&git, &path, &content, line_ending).await }) .boxed() } fn blame_at_revision( &self, path: RepoPath, revision: Oid, ) -> BoxFuture<'_, Result> { let git = self.git_binary_in_worktree(); self.executor .spawn(async move { let git = git?; crate::blame::Blame::for_path_at_revision(&git, &path, revision).await }) .boxed() } fn diff(&self, diff: DiffType) -> BoxFuture<'_, Result> { let git = self.git_binary_in_worktree(); self.executor .spawn(async move { let git = git?; let output = match diff { DiffType::HeadToIndex => { git.build_command(&["diff", "--staged"]).output().await? } 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, diff: DiffStatType, path_prefixes: &[RepoPath], ) -> BoxFuture<'static, Result> { let path_prefixes = path_prefixes.to_vec(); let git_binary = self.git_binary_in_worktree(); self.executor .spawn(async move { let git_binary = git_binary?; let mut args: Vec = vec!["diff".into(), "--numstat".into(), "--no-renames".into()]; match diff { DiffStatType::HeadToIndex => args.extend(["--cached".into(), "HEAD".into()]), DiffStatType::HeadToWorktree => args.push("HEAD".into()), DiffStatType::IndexToWorktree => {} } 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 = self.git_binary_in_worktree(); self.executor .spawn(async move { let git = git?; if !paths.is_empty() { let output = git .build_command(&["update-index", "--add", "--remove", "--"]) .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 = self.git_binary_in_worktree(); self.executor .spawn(async move { let git = git?; if !paths.is_empty() { 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, message: Option, env: Arc>, ) -> BoxFuture<'_, Result<()>> { let git = self.git_binary_in_worktree(); self.executor .spawn(async move { let git = git?; let mut args = vec!["stash", "push", "--quiet", "--include-untracked"]; if let Some(message) = message.as_deref() { args.extend_from_slice(&["--message", message]); } args.push("--"); let output = git .build_command(&args) .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_staged( &self, message: Option, env: Arc>, ) -> BoxFuture<'_, Result<()>> { let git = self.git_binary_in_worktree(); self.executor .spawn(async move { let git = git?; // `--staged` cannot be expressed as a pathspec: a partially staged // file would otherwise have its unstaged hunks stashed too. let mut args = vec!["stash", "push", "--quiet", "--staged"]; if let Some(message) = message.as_deref() { args.extend_from_slice(&["--message", message]); } let output = git.build_command(&args).envs(env.iter()).output().await?; anyhow::ensure!( output.status.success(), "Failed to stash staged changes (requires git 2.35 or newer):\n{}", String::from_utf8_lossy(&output.stderr) ); Ok(()) }) .boxed() } fn stash_pop( &self, index: Option, env: Arc>, ) -> BoxFuture<'_, Result<()>> { let git = self.git_binary_in_worktree(); self.executor .spawn(async move { let git = git?; let mut args = vec!["stash".to_string(), "pop".to_string()]; if let Some(index) = index { args.push(format!("stash@{{{}}}", index)); } 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 = self.git_binary_in_worktree(); self.executor .spawn(async move { let git = git?; let mut args = vec!["stash".to_string(), "apply".to_string()]; if let Some(index) = index { args.push(format!("stash@{{{}}}", index)); } 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 = self.git_binary_in_worktree(); self.executor .spawn(async move { let git = git?; let mut args = vec!["stash".to_string(), "drop".to_string()]; if let Some(index) = index { args.push(format!("stash@{{{}}}", index)); } 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, askpass: AskPassDelegate, env: Arc>, ) -> BoxFuture<'_, Result<()>> { let git = self.git_binary_in_worktree(); // 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?; git.run_remote_command(Some(askpass), &env, &["commit", "--quiet", "-m"], |cmd| { cmd.arg(&message.to_string()) .arg("--cleanup=strip") .stdout(Stdio::piped()) .stderr(Stdio::piped()); if options.amend { cmd.arg("--amend"); } if options.signoff { cmd.arg("--signoff"); } if options.allow_empty { cmd.arg("--allow-empty"); } if options.no_verify { cmd.arg("--no-verify"); } if let Some((name, email)) = name_and_email { cmd.arg("--author").arg(&format!("{name} <{email}>")); } }) .await?; Ok(()) } .boxed() } fn create_ref(&self, ref_name: String, commit: String) -> BoxFuture<'_, Result<()>> { self.edit_ref(RefEdit::Create { ref_name, commit }) } fn update_ref(&self, ref_name: String, commit: String) -> BoxFuture<'_, Result<()>> { self.edit_ref(RefEdit::Update { ref_name, commit }) } fn delete_ref(&self, ref_name: String) -> BoxFuture<'_, Result<()>> { self.edit_ref(RefEdit::Delete { ref_name }) } fn repair_worktrees(&self) -> BoxFuture<'_, Result<()>> { let git = self.git_binary(); self.executor .spawn(async move { let args: Vec = vec!["worktree".into(), "repair".into()]; git.run(&args).await?; Ok(()) }) .boxed() } fn push( &self, branch_name: String, remote_branch_name: String, remote_name: String, options: Option, askpass: AskPassDelegate, env: Arc>, cx: AsyncApp, ) -> BoxFuture<'_, Result> { let working_directory = self.command_directory(); let git_directory = self.path(); let executor = cx.background_executor().clone(); let git_binary_path = self.system_git_binary_path.clone(); 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 git = GitBinary::new( git_binary_path, working_directory, git_directory, executor, is_trusted, ); git.run_remote_command(Some(askpass), &env, &["push"], |command| { command .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()); }) .await } .boxed() } fn pull( &self, branch_name: Option, remote_name: String, rebase: bool, askpass: AskPassDelegate, env: Arc>, cx: AsyncApp, ) -> BoxFuture<'_, Result> { let working_directory = self.command_directory(); let git_directory = self.path(); let executor = cx.background_executor().clone(); let git_binary_path = self.system_git_binary_path.clone(); 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 git = GitBinary::new( git_binary_path, working_directory, git_directory, executor, is_trusted, ); git.run_remote_command(Some(askpass), &env, &["pull"], |command| { if rebase { command.arg("--rebase"); } command .arg(remote_name) .args(branch_name) .stdout(Stdio::piped()) .stderr(Stdio::piped()); }) .await } .boxed() } fn fetch( &self, fetch_options: FetchOptions, askpass: Option, env: Arc>, cx: AsyncApp, ) -> BoxFuture<'_, Result> { let working_directory = self.command_directory(); let git_directory = self.path(); let remote_name = format!("{}", fetch_options); let git_binary_path = self.system_git_binary_path.clone(); 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 git = GitBinary::new( git_binary_path, working_directory, git_directory, executor, is_trusted, ); git.run_remote_command(askpass, &env, &["fetch", &remote_name], |command| { command.stdout(Stdio::piped()).stderr(Stdio::piped()); }) .await } .boxed() } fn get_push_remote(&self, branch: String) -> BoxFuture<'_, Result>> { let git = self.git_binary(); self.executor .spawn(async move { 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 = self.git_binary(); self.executor .spawn(async move { 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 = self.git_binary(); self.executor .spawn(async move { 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 git_binary = self.git_binary(); self.executor .spawn(async move { git_binary.run(&["remote", "remove", &name]).await?; Ok(()) }) .boxed() } fn create_remote(&self, name: String, url: String) -> BoxFuture<'_, Result<()>> { let git_binary = self.git_binary(); self.executor .spawn(async move { git_binary.run(&["remote", "add", &name, &url]).await?; Ok(()) }) .boxed() } fn check_for_pushed_commit(&self) -> BoxFuture<'_, Result>> { let git = self.git_binary_in_worktree(); self.executor .spawn(async move { // This command outputs a list of remote tracking refs, e.g.: // refs/remotes/origin/HEAD // refs/remotes/origin/main let Ok(output) = git? .run(&[ "for-each-ref", "--format=%(refname)", "--contains", "HEAD", "refs/remotes/", ]) .await else { return Ok(Vec::new()); }; Ok(output .lines() .map(|line| line.trim()) .filter(|line| !line.ends_with("/HEAD")) .filter_map(|line| line.strip_prefix("refs/remotes/")) .map(SharedString::from) .collect()) }) .boxed() } fn create_archive_checkpoint(&self) -> BoxFuture<'_, Result<(String, String)>> { let git = self.git_binary_in_worktree(); self.executor .spawn(async move { let mut git = git?.envs(checkpoint_author_envs()); let head_sha = git .run(&["rev-parse", "HEAD"]) .await .context("failed to read HEAD")?; // Capture the staged state: write-tree reads the current index let staged_tree = git .run(&["write-tree"]) .await .context("failed to write staged tree")?; let staged_sha = git .run(&[ "commit-tree", &staged_tree, "-p", &head_sha, "-m", "WIP staged", ]) .await .context("failed to create staged commit")?; // Capture the full state (staged + unstaged + untracked) using // a temporary index so we don't disturb the real one. let unstaged_sha = git .with_temp_index(async |git| { git.run(&["add", "--all"]).await?; let full_tree = git.run(&["write-tree"]).await?; let sha = git .run(&[ "commit-tree", &full_tree, "-p", &staged_sha, "-m", "WIP unstaged", ]) .await?; Ok(sha) }) .await .context("failed to create unstaged commit")?; Ok((staged_sha, unstaged_sha)) }) .boxed() } fn restore_archive_checkpoint( &self, staged_sha: String, unstaged_sha: String, ) -> BoxFuture<'_, Result<()>> { let git = self.git_binary_in_worktree(); self.executor .spawn(async move { let git = git?; // First, set the index AND working tree to match the unstaged // tree. --reset -u computes a tree-level diff between the // current index and unstaged_sha's tree and applies additions, // modifications, and deletions to the working directory. git.run(&["read-tree", "--reset", "-u", &unstaged_sha]) .await .context("failed to restore working directory from unstaged commit")?; // Then replace just the index with the staged tree. Without -u // this doesn't touch the working directory, so the result is: // working tree = unstaged state, index = staged state. git.run(&["read-tree", &staged_sha]) .await .context("failed to restore index from staged commit")?; Ok(()) }) .boxed() } fn default_branch( &self, include_remote_name: bool, ) -> BoxFuture<'_, Result>> { let git = self.git_binary(); self.executor .spawn(async move { let output = git .run(&[ "for-each-ref", "--format=%(refname)\t%(symref)", "refs/remotes/upstream/HEAD", "refs/remotes/origin/HEAD", "refs/heads/", ]) .await .unwrap_or_default(); let refs: HashMap<&str, &str> = output .lines() .filter_map(|line| line.split_once('\t')) .collect(); if let Some(target) = refs.get("refs/remotes/upstream/HEAD") { let strip_prefix = if include_remote_name { "refs/remotes/" } else { "refs/remotes/upstream/" }; if let Some(branch) = target.strip_prefix(strip_prefix) { return Ok(Some(branch.into())); } } if let Some(target) = refs.get("refs/remotes/origin/HEAD") { let strip_prefix = if include_remote_name { "refs/remotes/" } else { "refs/remotes/origin/" }; if let Some(branch) = target.strip_prefix(strip_prefix) { return Ok(Some(branch.into())); } } let local_branch_exists = |branch: &str| refs.contains_key(format!("refs/heads/{branch}").as_str()); if let Ok(default_branch) = git.run(&["config", "init.defaultBranch"]).await { if local_branch_exists(&default_branch) { return Ok(Some(default_branch.into())); } } if local_branch_exists("main") { return Ok(Some("main".into())); } if local_branch_exists("master") { return Ok(Some("master".into())); } Ok(None) }) .boxed() } fn run_hook( &self, hook: RunHook, env: Arc>, ) -> BoxFuture<'_, Result<()>> { let git_binary = self.git_binary_in_worktree(); let git_dir = self.git_dir.clone(); let help_output = self.any_git_binary_help_output(); // Note: Do not spawn these commands on the background thread, as this causes some git hooks to hang. 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 = git_dir.join("hooks").join(hook.as_str()); if hook_abs_path.is_file() && git_binary.is_trusted { #[allow(clippy::disallowed_methods)] let output = new_command(&hook_abs_path) .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 = self.git_binary(); async move { let log_source_args = log_source.get_args(); let mut git_log_command = vec!["log", GRAPH_COMMIT_FORMAT, log_order.as_arg()]; git_log_command.extend(log_source_args.iter().map(|arg| arg.as_ref())); let mut command = git.build_command(&git_log_command); command.stdout(Stdio::piped()); command.stderr(Stdio::piped()); let mut child = command.spawn()?; let stdout = child.stdout.take().context("failed to get stdout")?; let stderr = child.stderr.take().context("failed to get stderr")?; 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(); } } let status = child.status().await?; if !status.success() { let mut stderr_output = String::new(); BufReader::new(stderr) .read_to_string(&mut stderr_output) .await .log_err(); if stderr_output.is_empty() { anyhow::bail!("git log command failed with {}", status); } else { anyhow::bail!("git log command failed with {}: {}", status, stderr_output); } } Ok(()) } .boxed() } fn search_commits( &self, log_source: LogSource, search_args: SearchCommitArgs, request_tx: Sender, ) -> BoxFuture<'_, Result<()>> { let git = self.git_binary(); async move { let log_source_args = log_source.get_args(); let mut args = vec!["log", SEARCH_COMMIT_FORMAT]; let hash_query = commit_hash_search_query(search_args.query.as_str()) .map(|query| query.to_ascii_lowercase()); if hash_query.is_none() { args.push("--fixed-strings"); if !search_args.case_sensitive { args.push("--regexp-ignore-case"); } args.push("--grep"); args.push(search_args.query.as_str()); } args.extend(log_source_args.iter().map(|arg| arg.as_ref())); let mut command = git.build_command(&args); 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(); loop { line_buffer.clear(); let bytes_read = reader.read_line(&mut line_buffer).await?; if bytes_read == 0 { break; } let sha = line_buffer.trim_end_matches('\n'); if let Some(hash_query) = hash_query.as_ref() && !sha.to_ascii_lowercase().starts_with(hash_query) { continue; } if let Ok(oid) = Oid::from_str(sha) && request_tx.send(oid).await.is_err() { break; } } child.status().await?; Ok(()) } .boxed() } fn file_history_changed_files( &self, paths: Vec, commit_limit: usize, ) -> BoxFuture<'_, Result>> { let git = self.git_binary(); let shallow_file_path = self.common_dir.join("shallow"); async move { if paths.is_empty() { return Ok(Vec::new()); } if commit_limit == 0 { return Ok(vec![FileHistoryChangedFileSets::default(); paths.len()]); } let max_count_arg = format!("--max-count={commit_limit}"); let mut args = [ "log", max_count_arg.as_str(), "--full-diff", "--no-renames", "--name-only", "-z", "--format=%x1e%H", "--", ] .map(OsString::from) .to_vec(); args.extend(paths.iter().map(|path| OsString::from(path.as_unix_str()))); let output = git.build_command(&args).output().await?; anyhow::ensure!( output.status.success(), "git log failed:\n{}", String::from_utf8_lossy(&output.stderr) ); let shallow_boundary_oids = read_shallow_file(&shallow_file_path) .await? .map(|contents| { contents .lines() .map(|line| line.trim().to_string()) .collect::>() }) .unwrap_or_default(); let stdout = String::from_utf8_lossy(&output.stdout); Ok(parse_file_history_changed_files_output( &stdout, &paths, &shallow_boundary_oids, )) } .boxed() } fn commit_data_reader(&self) -> Result { let git_binary = self.git_binary(); let (request_tx, request_rx) = async_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: async_channel::Receiver, ) -> Result<()> { let mut process = git .build_command(&["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_file_history_changed_files_output( output: &str, queried_paths: &[RepoPath], shallow_boundary_oids: &HashSet, ) -> Vec { let mut histories = vec![FileHistoryChangedFileSets::default(); queried_paths.len()]; for record in output.split('\x1e') { let mut fields = record.split('\0'); let sha = fields.next().unwrap_or_default().trim(); if shallow_boundary_oids.contains(sha) { continue; } let changed_files = fields .filter_map(|field| { let path = field.trim_start_matches('\n'); if path.is_empty() { return None; } RepoPath::new(path).ok() }) .collect::>(); if changed_files.is_empty() { continue; } let file_set = changed_files.iter().cloned().collect::>(); for (index, queried_path) in queried_paths.iter().enumerate() { if changed_files.contains(queried_path) { histories[index].file_sets.push(file_set.clone()); } } } histories } 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(", ") .filter(|decoration| *decoration != "grafted" && *decoration != "replaced") .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("status"), OsString::from("--porcelain=v1"), OsString::from("--untracked-files=all"), OsString::from("--no-renames"), OsString::from("-z"), OsString::from("--"), ]; args.extend(path_prefixes.iter().map(|path_prefix| { if path_prefix.is_empty() { Path::new(".").into() } else { path_prefix.as_std_path().into() } })); args } pub(crate) struct GitBinary { git_binary_path: PathBuf, working_directory: PathBuf, git_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, git_directory: PathBuf, executor: BackgroundExecutor, is_trusted: bool, ) -> Self { Self { git_binary_path, working_directory, git_directory, executor, index_file_path: None, envs: HashMap::default(), is_trusted, } } 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.git_directory.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) } fn path_for_index_id(&self, id: Uuid) -> PathBuf { self.git_directory.join(format!("index-{}.tmp", id)) } pub async fn run(&self, args: &[S]) -> 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: &[S]) -> 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: &[S]) -> util::command::Command where S: AsRef, { self.build_command_with_config(&[], args) } #[allow(clippy::disallowed_methods)] fn build_command_with_config( &self, config: &[(&str, &str)], args: &[S], ) -> util::command::Command where S: AsRef, { let mut command = new_command(&self.git_binary_path); command.current_dir(&self.working_directory); // Disabled to stop malicious actors from running arbitrary commands via fsmonitor hooks command.args(["-c", "core.fsmonitor=false"]); // Prepended signature lines would corrupt our --format parsers. command.args(["-c", "log.showSignature=false"]); command.arg("--no-optional-locks"); // Internal commands must be non-interactive so background tasks never block on user input. 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="]); } for (key, value) in config { command.args(["-c", &format!("{key}={value}")]); } // If the `diff` command is being used, we'll want to add the // `--no-ext-diff` flag when working on an untrusted repository, // preventing any external diff programs from being invoked. It goes // directly after the subcommand: callers end their revisions with `--`, // and anything after that separator is read as a pathspec rather than as // an option. let mut args = args.iter(); if let Some(subcommand) = args.next() { let is_diff = subcommand.as_ref() == "diff"; command.arg(subcommand); if !self.is_trusted && is_diff { command.arg("--no-ext-diff"); } 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 } /// Git, credential helpers, and SSH have separate prompting mechanisms, so /// omitting an askpass delegate must configure more than Zed's prompt path. async fn run_remote_command( &self, askpass: Option, env: &HashMap, args: &[S], configure: impl FnOnce(&mut util::command::Command), ) -> Result where S: AsRef, { let mut config = Vec::new(); if askpass.is_none() { // Narrower than clearing `credential.helper`, which would also disable // helpers that answer from a keychain without prompting — exactly what // makes an unattended fetch useful. Only prompting is forbidden, and // this covers git's own prompt as well as helpers that honor it. config.push(("credential.interactive", "false")); // Auto-maintenance can repack the entire repository, far outweighing // the fetch that triggered it. A background caller should not start // that work on the user's behalf. config.push(("gc.auto", "0")); config.push(("core.askPass", "")); } let mut command = self.build_command_with_config(&config, args); command.envs(env.iter()); configure(&mut command); match askpass { Some(delegate) => self.run_with_askpass(delegate, env, command).await, None => { // These are set after the project environment has been applied // above, so that environment cannot override them. command.env("GIT_TERMINAL_PROMPT", "0"); // `credential.interactive` says nothing about ssh, which prompts // for key passphrases and host key confirmation on its own. Batch // mode is composed onto the ssh command git would use anyway // rather than replacing it, so a custom `core.sshCommand` keeps // working. if let Some(ssh_command) = self.batch_mode_ssh_command(env).await { command.env("GIT_SSH_COMMAND", ssh_command); } // Neither of these should be able to route back to a prompt via a // value inherited from the environment Zed was launched with. command.env_remove("GIT_ASKPASS"); command.env_remove("SSH_ASKPASS"); remote_command_output(command.output().await?) } } } /// The ssh command git should use for background work, if one can be /// composed. See [`compose_batch_mode_ssh_command`]. async fn batch_mode_ssh_command(&self, env: &HashMap) -> Option { // Same precedence the spawned command ends up with: the project // environment is applied over `self.envs`, and both over the environment // Zed itself was launched with, which the child inherits. let inherited = |key: &str| { env.get(key) .or_else(|| self.envs.get(key)) .cloned() .or_else(|| std::env::var(key).ok()) }; let ssh_command_env = inherited("GIT_SSH_COMMAND"); let core_ssh_command = if ssh_command_env.is_some() { None } else if self.is_trusted { // Absent from config, git exits non-zero rather than printing nothing. self.run(&["config", "--get", "core.sshCommand"]).await.ok() } else { // Pinned by `build_command`. Reading the repository's own value back // here would hand it to git through an environment variable that // outranks the pin. Some("ssh".to_string()) }; compose_batch_mode_ssh_command( ssh_command_env.as_deref(), core_ssh_command.as_deref(), inherited("GIT_SSH").as_deref(), ) } async fn run_with_askpass( &self, delegate: AskPassDelegate, env: &HashMap, mut command: util::command::Command, ) -> Result { if env.contains_key("GIT_ASKPASS") { let git_process = command.spawn()?; return remote_command_output(git_process.output().await?); } let ask_pass = AskPassSession::new(self.executor.clone(), delegate).await?; command .env("GIT_ASKPASS", ask_pass.script_path()) .env("SSH_ASKPASS", ask_pass.script_path()) .env("SSH_ASKPASS_REQUIRE", "force"); if !env.contains_key("GIT_CONFIG_COUNT") && let Some(gpg_wrapper) = ask_pass.gpg_wrapper_path() { command .env("GIT_CONFIG_COUNT", "1") .env("GIT_CONFIG_KEY_0", "gpg.program") .env("GIT_CONFIG_VALUE_0", gpg_wrapper); } #[cfg(target_os = "windows")] command.env("ZED_ASKPASS_SOCKET", ask_pass.socket_path()); let git_process = command.spawn()?; run_askpass_command(ask_pass, git_process).await } } /// Composes batch mode onto the ssh command git would otherwise pick, or returns /// `None` when the only thing configured is the deprecated `GIT_SSH`. /// /// git resolves its ssh command as `GIT_SSH_COMMAND`, then `core.sshCommand`, /// then `GIT_SSH`, then plain `ssh`. Setting `GIT_SSH_COMMAND` therefore silently /// replaces a `GIT_SSH` program (e.g. plink on Windows), and since `GIT_SSH` names /// a bare program rather than a shell command there is nothing to compose onto. /// Those setups are left alone and keep whatever prompting behavior they have. fn compose_batch_mode_ssh_command( ssh_command_env: Option<&str>, core_ssh_command: Option<&str>, git_ssh_env: Option<&str>, ) -> Option { let configured = ssh_command_env .or(core_ssh_command) .map(str::trim) .filter(|command| !command.is_empty()); let base = match configured { Some(configured) => configured, None if git_ssh_env.is_some_and(|program| !program.trim().is_empty()) => return None, None => "ssh", }; Some(format!("{base} -o BatchMode=yes")) } fn remote_command_output(output: Output) -> Result { 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(Error, Debug)] #[error("Git command failed:\n{stdout}{stderr}\n")] struct GitBinaryCommandError { stdout: String, stderr: String, status: ExitStatus, } async fn run_askpass_command( mut ask_pass: AskPassSession, git_process: util::command::Child, ) -> anyhow::Result { select_biased! { // Git can legitimately run long without prompting (e.g. large fetches, // hooks), so completion is determined by the process itself. result = ask_pass.run(None).fuse() => { match result { AskPassResult::CancelledByUser => { Err(anyhow!(REMOTE_CANCELLED_BY_USER))? } AskPassResult::Timedout => { // Unreachable since no timeout is passed to run() 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::from_unix_str(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_unix_str(proto)?.into(); Ok(Self(rel_path)) } pub fn from_rel_path(path: &RelPath) -> RepoPath { Self(Arc::from(path)) } pub fn as_std_path(&self) -> &Path { 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::from_unix_str(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 format_branch_scan_error(output: &Output) -> String { let stderr = String::from_utf8_lossy(&output.stderr) .trim() .replace('\n', " "); if stderr.is_empty() { format!("git for-each-ref exited with {}", output.status) } else { stderr } } 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 std::{ ffi::{OsStr, OsString}, fs, }; use super::*; use gpui::TestAppContext; #[test] fn test_create_ref_requires_ref_to_not_exist() { assert_eq!( RefEdit::Create { ref_name: "refs/tags/v1.0.0".to_string(), commit: "abcdef".to_string(), } .into_args(), ["update-ref", "refs/tags/v1.0.0", "abcdef", ""].map(OsString::from) ); } #[test] fn test_commit_hash_search_query_accepts_sha1_and_sha256_hashes() { assert_eq!( commit_hash_search_query("0123456789abcdef0123456789abcdef01234567"), Some("0123456789abcdef0123456789abcdef01234567") ); assert_eq!( commit_hash_search_query( "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" ), Some("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef") ); assert_eq!( commit_hash_search_query( "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0" ), None ); } fn disable_git_global_config() { unsafe { std::env::set_var("GIT_CONFIG_GLOBAL", ""); std::env::set_var("GIT_CONFIG_SYSTEM", ""); } } #[allow(clippy::disallowed_methods)] #[track_caller] fn git_command_output(working_directory: &Path, arguments: I) -> String where I: IntoIterator, S: AsRef, { let output = std::process::Command::new("git") .args(arguments) .current_dir(working_directory) .env("GIT_CONFIG_GLOBAL", "") .env("GIT_CONFIG_SYSTEM", "") .env("GIT_AUTHOR_NAME", "test") .env("GIT_AUTHOR_EMAIL", "test@zed.dev") .env("GIT_COMMITTER_NAME", "test") .env("GIT_COMMITTER_EMAIL", "test@zed.dev") .output() .expect("failed to run git command"); assert!( output.status.success(), "git command failed: {}", String::from_utf8_lossy(&output.stderr) ); String::from_utf8(output.stdout) .expect("git command output was not valid UTF-8") .trim() .to_string() } #[track_caller] fn git_command(working_directory: &Path, arguments: I) where I: IntoIterator, S: AsRef, { git_command_output(working_directory, arguments); } fn git_init_repo(path: &Path) { fs::create_dir_all(path).expect("failed to create repo directory"); git_command(path, ["init", "-b", "main"]); } fn clone_remote_repository_with_main_and_feature(temp_dir: &Path) -> (PathBuf, PathBuf) { let remote_directory = temp_dir.join("remote.git"); let seed_directory = temp_dir.join("seed"); let clone_directory = temp_dir.join("clone"); git_command( temp_dir, [ OsString::from("init"), OsString::from("--bare"), OsString::from("-b"), OsString::from("main"), remote_directory.as_os_str().into(), ], ); git_init_repo(&seed_directory); fs::write(seed_directory.join("file.txt"), "main").unwrap(); git_command(&seed_directory, ["add", "file.txt"]); git_command(&seed_directory, ["commit", "-m", "initial"]); git_command(&seed_directory, ["switch", "-c", "feature"]); fs::write(seed_directory.join("feature.txt"), "feature").unwrap(); git_command(&seed_directory, ["add", "feature.txt"]); git_command(&seed_directory, ["commit", "-m", "feature"]); git_command( &seed_directory, [ OsString::from("remote"), OsString::from("add"), OsString::from("origin"), remote_directory.as_os_str().into(), ], ); git_command(&seed_directory, ["push", "-u", "origin", "main"]); git_command(&seed_directory, ["push", "-u", "origin", "feature"]); git_command( temp_dir, [ OsString::from("clone"), remote_directory.as_os_str().into(), clone_directory.as_os_str().into(), ], ); (remote_directory, clone_directory) } fn test_commit_envs() -> HashMap { let mut env = checkpoint_author_envs(); env.insert("GIT_ASKPASS".to_string(), "false".to_string()); env } #[track_caller] fn assert_same_path(left: impl AsRef, right: impl AsRef) { assert_eq!( fs::canonicalize(left.as_ref()).unwrap(), fs::canonicalize(right.as_ref()).unwrap() ); } #[gpui::test] async fn test_real_git_repository_new_resolves_normal_repository_paths( cx: &mut TestAppContext, ) { disable_git_global_config(); cx.executor().allow_parking(); let repo_dir = tempfile::tempdir().unwrap(); git_init_repo(repo_dir.path()); let repository = RealGitRepository::new( &repo_dir.path().join(".git"), None, Some("git".into()), cx.executor(), ) .unwrap(); assert_same_path(&repository.git_dir, repo_dir.path().join(".git")); assert_same_path(&repository.common_dir, repo_dir.path().join(".git")); assert_same_path( repository.working_directory.as_ref().unwrap(), repo_dir.path(), ); assert_same_path( original_repo_path_from_common_dir(&repository.common_dir).unwrap(), repo_dir.path(), ); } #[gpui::test] async fn test_merge_base_worktree_diff_handles_recreated_index_deletion( cx: &mut TestAppContext, ) { disable_git_global_config(); cx.executor().allow_parking(); let repo_dir = tempfile::tempdir().unwrap(); git_init_repo(repo_dir.path()); let file_path = repo_dir.path().join("file.txt"); fs::write(&file_path, "base\n").unwrap(); git_command(repo_dir.path(), ["add", "file.txt"]); git_command(repo_dir.path(), ["commit", "-m", "base"]); let base_oid = git_command_output(repo_dir.path(), ["rev-parse", "HEAD:file.txt"]) .parse() .unwrap(); fs::write(&file_path, "head\n").unwrap(); git_command(repo_dir.path(), ["add", "file.txt"]); git_command(repo_dir.path(), ["commit", "-m", "head"]); git_command(repo_dir.path(), ["rm", "--cached", "file.txt"]); fs::write(&file_path, "base\n").unwrap(); let repository = RealGitRepository::new( &repo_dir.path().join(".git"), None, Some("git".into()), cx.executor(), ) .unwrap(); assert_eq!( repository .diff_tree(DiffTreeType::MergeBaseWithWorktree { base: "HEAD^".into(), }) .await .unwrap(), TreeDiff { entries: HashMap::default(), } ); fs::write(&file_path, "worktree\n").unwrap(); assert_eq!( repository .diff_tree(DiffTreeType::MergeBaseWithWorktree { base: "HEAD^".into(), }) .await .unwrap(), TreeDiff { entries: HashMap::from_iter([( RepoPath::new("file.txt").unwrap(), TreeDiffStatus::Modified { old: base_oid }, )]), } ); } #[gpui::test] async fn test_checkout_files_from_index_keeps_staged_changes(cx: &mut TestAppContext) { disable_git_global_config(); cx.executor().allow_parking(); let repo_dir = tempfile::tempdir().unwrap(); git_init_repo(repo_dir.path()); let file_path = repo_dir.path().join("file.txt"); fs::write(&file_path, "head\n").unwrap(); git_command(repo_dir.path(), ["add", "file.txt"]); git_command(repo_dir.path(), ["commit", "-m", "head"]); fs::write(&file_path, "staged\n").unwrap(); git_command(repo_dir.path(), ["add", "file.txt"]); fs::write(&file_path, "unstaged\n").unwrap(); let repository = RealGitRepository::new( &repo_dir.path().join(".git"), None, Some("git".into()), cx.executor(), ) .unwrap(); let paths = vec![RepoPath::new("file.txt").unwrap()]; repository .checkout_files(None, paths.clone(), Arc::default()) .await .unwrap(); assert_eq!(fs::read_to_string(&file_path).unwrap(), "staged\n"); assert_eq!( git_command_output(repo_dir.path(), ["show", ":file.txt"]), "staged" ); repository .checkout_files(Some("HEAD".into()), paths, Arc::default()) .await .unwrap(); assert_eq!(fs::read_to_string(&file_path).unwrap(), "head\n"); assert_eq!( git_command_output(repo_dir.path(), ["show", ":file.txt"]), "head" ); } #[gpui::test] async fn test_merge_base_worktree_diff_handles_committed_deletion_recreated_on_disk( cx: &mut TestAppContext, ) { disable_git_global_config(); cx.executor().allow_parking(); let repo_dir = tempfile::tempdir().unwrap(); git_init_repo(repo_dir.path()); let file_path = repo_dir.path().join("file.txt"); fs::write(&file_path, "base\n").unwrap(); git_command(repo_dir.path(), ["add", "file.txt"]); git_command(repo_dir.path(), ["commit", "-m", "base"]); let base_oid = git_command_output(repo_dir.path(), ["rev-parse", "HEAD:file.txt"]) .parse() .unwrap(); git_command(repo_dir.path(), ["rm", "file.txt"]); git_command(repo_dir.path(), ["commit", "-m", "delete"]); fs::write(&file_path, "base\n").unwrap(); let repository = RealGitRepository::new( &repo_dir.path().join(".git"), None, Some("git".into()), cx.executor(), ) .unwrap(); assert_eq!( repository .diff_tree(DiffTreeType::MergeBaseWithWorktree { base: "HEAD^".into(), }) .await .unwrap(), TreeDiff { entries: HashMap::default(), } ); fs::write(&file_path, "worktree\n").unwrap(); assert_eq!( repository .diff_tree(DiffTreeType::MergeBaseWithWorktree { base: "HEAD^".into(), }) .await .unwrap(), TreeDiff { entries: HashMap::from_iter([( RepoPath::new("file.txt").unwrap(), TreeDiffStatus::Modified { old: base_oid }, )]), } ); } #[cfg(unix)] #[gpui::test] async fn test_merge_base_worktree_diff_handles_recreated_symlink(cx: &mut TestAppContext) { use std::os::unix::fs::symlink; disable_git_global_config(); cx.executor().allow_parking(); let repo_dir = tempfile::tempdir().unwrap(); git_init_repo(repo_dir.path()); let file_path = repo_dir.path().join("file.txt"); symlink("base-target", &file_path).unwrap(); git_command(repo_dir.path(), ["add", "file.txt"]); git_command(repo_dir.path(), ["commit", "-m", "base"]); let base_oid = git_command_output(repo_dir.path(), ["rev-parse", "HEAD:file.txt"]) .parse() .unwrap(); fs::remove_file(&file_path).unwrap(); symlink("head-target", &file_path).unwrap(); git_command(repo_dir.path(), ["add", "file.txt"]); git_command(repo_dir.path(), ["commit", "-m", "head"]); git_command(repo_dir.path(), ["rm", "--cached", "file.txt"]); fs::remove_file(&file_path).unwrap(); symlink("base-target", &file_path).unwrap(); let repository = RealGitRepository::new( &repo_dir.path().join(".git"), None, Some("git".into()), cx.executor(), ) .unwrap(); assert_eq!( repository .diff_tree(DiffTreeType::MergeBaseWithWorktree { base: "HEAD^".into(), }) .await .unwrap(), TreeDiff { entries: HashMap::default(), } ); fs::remove_file(&file_path).unwrap(); fs::write(&file_path, "base-target").unwrap(); assert_eq!( repository .diff_tree(DiffTreeType::MergeBaseWithWorktree { base: "HEAD^".into(), }) .await .unwrap(), TreeDiff { entries: HashMap::from_iter([( RepoPath::new("file.txt").unwrap(), TreeDiffStatus::Modified { old: base_oid }, )]), } ); } #[test] fn test_read_commit_blob_binary_detection() { let mut pickle = b"\x80\x02X\0\x04\0\0".to_vec(); pickle.extend_from_slice(&b"A\0".repeat(512)); pickle.extend_from_slice(b"q\0."); let mut cases = vec![ (pickle, true), (b"hello\n".to_vec(), false), (b"\xFF\xFEH\0e\0l\0l\0o\0\n\0".to_vec(), true), (b"\xFE\xFF\0H\0e\0l\0l\0o\0\n".to_vec(), true), (b"H\0e\0l\0l\0o\0\n\0".to_vec(), true), (b"\0H\0e\0l\0l\0o\0\n".to_vec(), true), (b"\xEF\xBB\xBFhello\0".to_vec(), true), (b"\x89PNG\r\n\x1a\n".to_vec(), true), ]; for offset in [4096, 7999, 8000] { let mut bytes = vec![b'a'; offset]; bytes.push(0); cases.push((bytes, offset < 8000)); } for (bytes, expected_binary) in cases { let mut output = format!("{}\n", bytes.len()).into_bytes(); output.extend_from_slice(&bytes); output.push(b'\n'); let object = smol::block_on(read_commit_blob( &mut smol::io::Cursor::new(output), &mut String::new(), &mut [0], )) .unwrap(); assert_eq!(object.is_binary, expected_binary); assert_eq!(object.content, bytes); } } #[gpui::test] async fn test_load_commit_with_type_changed_file(cx: &mut TestAppContext) { disable_git_global_config(); cx.executor().allow_parking(); let repo_dir = tempfile::tempdir().expect("failed to create temporary repository"); git_init_repo(repo_dir.path()); fs::write(repo_dir.path().join("file.txt"), "regular contents\n") .expect("failed to write regular file"); git_command(repo_dir.path(), ["add", "file.txt"]); git_command(repo_dir.path(), ["commit", "-m", "initial"]); let repository = RealGitRepository::new( &repo_dir.path().join(".git"), None, Some("git".into()), cx.executor(), ) .expect("failed to open repository"); fs::write(repo_dir.path().join("file.txt"), "target") .expect("failed to write symlink target"); let symlink_blob = repository .git_binary() .run(&["hash-object", "-w", "file.txt"]) .await .expect("failed to write symlink blob"); git_command( repo_dir.path(), [ OsString::from("update-index"), OsString::from("--cacheinfo"), OsString::from("120000"), OsString::from(symlink_blob), OsString::from("file.txt"), ], ); git_command(repo_dir.path(), ["commit", "-m", "type change"]); let commit_diff = repository .load_commit("HEAD".to_string(), false, cx.to_async()) .await .expect("failed to load type-changed commit"); assert_eq!(commit_diff.files.len(), 1); let file = commit_diff .files .first() .expect("type-changed file should be present"); assert_eq!(file.path.as_unix_str(), "file.txt"); assert_eq!( file.old_content.as_deref(), Some(b"regular contents\n".as_slice()) ); assert_eq!(file.new_content.as_deref(), Some(b"target".as_slice())); assert_eq!(file.status(), CommitFileStatus::Modified); } #[gpui::test] async fn test_load_commit_with_gitlink_changes(cx: &mut TestAppContext) { const FIRST_SUBMODULE_COMMIT: &str = "1111111111111111111111111111111111111111"; const SECOND_SUBMODULE_COMMIT: &str = "2222222222222222222222222222222222222222"; disable_git_global_config(); cx.executor().allow_parking(); let repo_dir = tempfile::tempdir().expect("failed to create temporary repository"); git_init_repo(repo_dir.path()); fs::write(repo_dir.path().join("README.md"), "parent repository\n") .expect("failed to write regular file"); git_command(repo_dir.path(), ["add", "README.md"]); git_command( repo_dir.path(), [ "update-index", "--add", "--cacheinfo", crate::commit::GITLINK_MODE, FIRST_SUBMODULE_COMMIT, "modules/example", ], ); git_command(repo_dir.path(), ["commit", "-m", "add submodule"]); let repository = RealGitRepository::new( &repo_dir.path().join(".git"), None, Some("git".into()), cx.executor(), ) .expect("failed to open repository"); let commit_diff = repository .load_commit("HEAD".to_string(), false, cx.to_async()) .await .expect("failed to load commit that adds a gitlink"); assert_eq!(commit_diff.files.len(), 2); let gitlink = commit_diff .files .iter() .find(|file| file.path.as_unix_str() == "modules/example") .expect("gitlink should be present alongside the regular file"); assert_eq!(gitlink.status(), CommitFileStatus::Added); assert_eq!(gitlink.old_content, None); assert_eq!( gitlink.new_content.as_deref(), Some(b"Subproject commit 1111111111111111111111111111111111111111\n".as_slice()) ); assert!(!gitlink.is_binary); git_command( repo_dir.path(), [ "update-index", "--cacheinfo", crate::commit::GITLINK_MODE, SECOND_SUBMODULE_COMMIT, "modules/example", ], ); git_command(repo_dir.path(), ["commit", "-m", "update submodule"]); let commit_diff = repository .load_commit("HEAD".to_string(), false, cx.to_async()) .await .expect("failed to load commit that updates a gitlink"); let [gitlink] = commit_diff.files.as_slice() else { panic!("expected one updated gitlink"); }; assert_eq!(gitlink.status(), CommitFileStatus::Modified); assert_eq!( gitlink.old_content.as_deref(), Some(b"Subproject commit 1111111111111111111111111111111111111111\n".as_slice()) ); assert_eq!( gitlink.new_content.as_deref(), Some(b"Subproject commit 2222222222222222222222222222222222222222\n".as_slice()) ); assert!(!gitlink.is_binary); git_command(repo_dir.path(), ["rm", "--cached", "modules/example"]); git_command(repo_dir.path(), ["commit", "-m", "remove submodule"]); let commit_diff = repository .load_commit("HEAD".to_string(), false, cx.to_async()) .await .expect("failed to load commit that deletes a gitlink"); let [gitlink] = commit_diff.files.as_slice() else { panic!("expected one deleted gitlink"); }; assert_eq!(gitlink.status(), CommitFileStatus::Deleted); assert_eq!( gitlink.old_content.as_deref(), Some(b"Subproject commit 2222222222222222222222222222222222222222\n".as_slice()) ); assert_eq!(gitlink.new_content, None); assert!(!gitlink.is_binary); } #[gpui::test] async fn test_load_commit_shallow_boundary(cx: &mut TestAppContext) { disable_git_global_config(); cx.executor().allow_parking(); let source_dir = tempfile::tempdir().expect("failed to create source repository"); git_init_repo(source_dir.path()); fs::write(source_dir.path().join("a.txt"), "one\n").expect("failed to write a.txt"); git_command(source_dir.path(), ["add", "a.txt"]); git_command(source_dir.path(), ["commit", "-m", "first"]); fs::write(source_dir.path().join("a.txt"), "two\n").expect("failed to update a.txt"); fs::write(source_dir.path().join("b.txt"), "new\n").expect("failed to write b.txt"); git_command(source_dir.path(), ["add", "a.txt", "b.txt"]); git_command(source_dir.path(), ["commit", "-m", "second"]); let clone_dir = tempfile::tempdir().expect("failed to create clone directory"); git_command( clone_dir.path(), [ "clone".to_string(), "--depth=1".to_string(), format!("file://{}", source_dir.path().display()), "shallow".to_string(), ], ); let repository = RealGitRepository::new( &clone_dir.path().join("shallow").join(".git"), None, Some("git".into()), cx.executor(), ) .expect("failed to open shallow repository"); let commit_diff = repository .load_commit("HEAD".to_string(), false, cx.to_async()) .await .expect("failed to load boundary commit"); assert!(commit_diff.is_shallow_boundary); assert_eq!(commit_diff.files.len(), 0); let commit_diff = repository .load_commit("HEAD".to_string(), true, cx.to_async()) .await .expect("failed to load boundary commit snapshot"); assert!(!commit_diff.is_shallow_boundary); let files = commit_diff .files .iter() .map(|file| { ( file.path.as_unix_str().to_owned(), file.old_content.clone(), file.status(), ) }) .collect::>(); assert_eq!( files, vec![ ("a.txt".to_string(), None, CommitFileStatus::Added), ("b.txt".to_string(), None, CommitFileStatus::Added), ] ); let source_repository = RealGitRepository::new( &source_dir.path().join(".git"), None, Some("git".into()), cx.executor(), ) .expect("failed to open source repository"); let commit_diff = source_repository .load_commit("HEAD~1".to_string(), false, cx.to_async()) .await .expect("failed to load root commit"); assert!(!commit_diff.is_shallow_boundary); assert_eq!(commit_diff.files.len(), 1); } #[gpui::test] async fn test_check_access(cx: &mut TestAppContext) { disable_git_global_config(); cx.executor().allow_parking(); let repo_dir = tempfile::tempdir().unwrap(); let repository = RealGitRepository::new( &repo_dir.path().join(".git"), None, Some("git".into()), cx.executor(), ) .unwrap(); assert!(repository.check_access().await.is_err()); git_init_repo(repo_dir.path()); assert!(repository.check_access().await.is_ok()); } #[gpui::test] async fn test_real_git_repository_new_resolves_linked_worktree_paths(cx: &mut TestAppContext) { disable_git_global_config(); cx.executor().allow_parking(); let temp_dir = tempfile::tempdir().unwrap(); let repo_dir = temp_dir.path().join("repo"); let worktree_dir = temp_dir.path().join("worktree"); git_init_repo(&repo_dir); fs::write(repo_dir.join("file.txt"), "initial").unwrap(); git_command(&repo_dir, ["add", "file.txt"]); git_command(&repo_dir, ["commit", "-m", "initial"]); git_command( &repo_dir, [ OsString::from("worktree"), OsString::from("add"), OsString::from("-b"), OsString::from("feature"), worktree_dir.as_os_str().into(), ], ); let repository = RealGitRepository::new( &worktree_dir.join(".git"), None, Some("git".into()), cx.executor(), ) .unwrap(); assert_same_path( repository.working_directory.as_ref().unwrap(), &worktree_dir, ); assert_same_path(&repository.common_dir, repo_dir.join(".git")); assert_same_path( original_repo_path_from_common_dir(&repository.common_dir).unwrap(), repo_dir, ); } #[gpui::test] async fn test_real_git_repository_new_supports_bare_repositories(cx: &mut TestAppContext) { disable_git_global_config(); cx.executor().allow_parking(); let temp_dir = tempfile::tempdir().unwrap(); let repo_dir = temp_dir.path().join("repo.git"); git_command( temp_dir.path(), [ OsString::from("init"), OsString::from("--bare"), repo_dir.as_os_str().into(), ], ); let repository = RealGitRepository::new(&repo_dir, None, Some("git".into()), cx.executor()).unwrap(); assert_same_path(&repository.git_dir, &repo_dir); assert_same_path(&repository.common_dir, &repo_dir); assert_eq!(repository.working_directory, None); assert_same_path(repository.main_repository_path(), &repo_dir); assert_eq!( repository .git_binary() .run(&["rev-parse", "--is-bare-repository"]) .await .unwrap(), "true" ); } #[gpui::test] async fn test_change_branch_creates_local_tracking_branch_from_remote(cx: &mut TestAppContext) { disable_git_global_config(); cx.executor().allow_parking(); let temp_dir = tempfile::tempdir().unwrap(); let (_remote_directory, clone_directory) = clone_remote_repository_with_main_and_feature(temp_dir.path()); let repository = RealGitRepository::new( &clone_directory.join(".git"), None, Some("git".into()), cx.executor(), ) .unwrap(); let git = repository.git_binary_in_worktree().unwrap(); assert!( git.run(&[ "show-ref", "--verify", "--quiet", "refs/remotes/origin/feature" ]) .await .is_ok() ); assert!( git.run(&["show-ref", "--verify", "--quiet", "refs/heads/feature"]) .await .is_err() ); repository .change_branch("origin/feature".to_string()) .await .unwrap(); let git = repository.git_binary_in_worktree().unwrap(); assert_eq!( git.run(&["branch", "--show-current"]).await.unwrap(), "feature" ); assert_eq!( git.run(&["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}",]) .await .unwrap(), "origin/feature" ); git.run(&["checkout", "main"]).await.unwrap(); git.run(&["branch", "--unset-upstream", "feature"]) .await .unwrap(); repository .change_branch("origin/feature".to_string()) .await .unwrap(); let git = repository.git_binary_in_worktree().unwrap(); assert_eq!( git.run(&["branch", "--show-current"]).await.unwrap(), "feature" ); assert_eq!( git.run(&["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}",]) .await .unwrap(), "origin/feature" ); } #[gpui::test] async fn test_change_branch_resolves_remote_head_to_tracking_branch(cx: &mut TestAppContext) { disable_git_global_config(); cx.executor().allow_parking(); let temp_dir = tempfile::tempdir().unwrap(); let (_remote_directory, clone_directory) = clone_remote_repository_with_main_and_feature(temp_dir.path()); let repository = RealGitRepository::new( &clone_directory.join(".git"), None, Some("git".into()), cx.executor(), ) .unwrap(); let git = repository.git_binary_in_worktree().unwrap(); git.run(&[ "symbolic-ref", "refs/remotes/origin/HEAD", "refs/remotes/origin/feature", ]) .await .unwrap(); assert_eq!( git.run(&["symbolic-ref", "refs/remotes/origin/HEAD"]) .await .unwrap(), "refs/remotes/origin/feature" ); assert!( git.run(&["show-ref", "--verify", "--quiet", "refs/heads/feature"]) .await .is_err() ); repository .change_branch("origin/HEAD".to_string()) .await .unwrap(); let git = repository.git_binary_in_worktree().unwrap(); assert_eq!( git.run(&["branch", "--show-current"]).await.unwrap(), "feature" ); assert_eq!( git.run(&["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}",]) .await .unwrap(), "origin/feature" ); } #[gpui::test] async fn test_change_branch_resolves_non_origin_remote_head(cx: &mut TestAppContext) { disable_git_global_config(); cx.executor().allow_parking(); let temp_dir = tempfile::tempdir().unwrap(); let (remote_directory, clone_directory) = clone_remote_repository_with_main_and_feature(temp_dir.path()); git_command( &clone_directory, [ OsString::from("remote"), OsString::from("add"), OsString::from("upstream"), remote_directory.as_os_str().into(), ], ); git_command(&clone_directory, ["fetch", "upstream"]); let repository = RealGitRepository::new( &clone_directory.join(".git"), None, Some("git".into()), cx.executor(), ) .unwrap(); let git = repository.git_binary_in_worktree().unwrap(); git.run(&[ "symbolic-ref", "refs/remotes/upstream/HEAD", "refs/remotes/upstream/main", ]) .await .unwrap(); git.run(&["checkout", "-b", "scratch"]).await.unwrap(); repository .change_branch("upstream/HEAD".to_string()) .await .unwrap(); let git = repository.git_binary_in_worktree().unwrap(); assert_eq!( git.run(&["branch", "--show-current"]).await.unwrap(), "main" ); assert_eq!( git.run(&["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}",]) .await .unwrap(), "upstream/main" ); } #[gpui::test] fn test_real_git_repository_new_rejects_malformed_git_file(cx: &mut TestAppContext) { disable_git_global_config(); let temp_dir = tempfile::tempdir().unwrap(); let worktree_dir = temp_dir.path().join("worktree"); fs::create_dir_all(&worktree_dir).unwrap(); fs::write(worktree_dir.join(".git"), "not a gitdir file\n").unwrap(); let error = match RealGitRepository::new( &worktree_dir.join(".git"), None, Some("git".into()), cx.executor(), ) { Ok(_) => panic!("malformed .git file should be rejected"), Err(error) => error, }; assert!( error .to_string() .contains("expected .git file to start with 'gitdir: '"), "unexpected error: {error:#}" ); } #[test] fn test_initial_graph_commit_data_tag_names() { let commit = InitialGraphCommitData { sha: Oid::from_bytes(&[0; 20]).unwrap(), parents: SmallVec::new(), ref_names: vec![ SharedString::from("HEAD -> main"), SharedString::from("origin/main"), SharedString::from("tag: v1.0.0"), SharedString::from("tag: v1.1.0"), SharedString::from("tag: "), SharedString::from("refs/heads/feature"), ], }; assert_eq!(commit.tag_names(), ["v1.0.0", "v1.1.0"]); } #[test] fn test_parse_file_history_changed_files_output() { let queried_paths = vec![ RepoPath::new("src/a.rs").unwrap(), RepoPath::new("src/b.rs").unwrap(), ]; let output = concat!( "\x1e1111111111111111111111111111111111111111\0\nsrc/a.rs\0src/shared.rs\0", "\x1e2222222222222222222222222222222222222222\0\nsrc/b.rs\0src/shared.rs\0", "\x1e3333333333333333333333333333333333333333\0\nsrc/a.rs\0src/b.rs\0src/shared.rs\0", ); let histories = parse_file_history_changed_files_output(output, &queried_paths, &HashSet::default()); assert_eq!(histories.len(), 2); assert_eq!( histories[0].file_sets, vec![ vec![ RepoPath::new("src/a.rs").unwrap(), RepoPath::new("src/shared.rs").unwrap(), ], vec![ RepoPath::new("src/a.rs").unwrap(), RepoPath::new("src/b.rs").unwrap(), RepoPath::new("src/shared.rs").unwrap(), ], ] ); assert_eq!( histories[1].file_sets, vec![ vec![ RepoPath::new("src/b.rs").unwrap(), RepoPath::new("src/shared.rs").unwrap(), ], vec![ RepoPath::new("src/a.rs").unwrap(), RepoPath::new("src/b.rs").unwrap(), RepoPath::new("src/shared.rs").unwrap(), ], ] ); } #[test] fn test_parse_file_history_changed_files_output_skips_shallow_boundary() { let queried_paths = vec![RepoPath::new("src/a.rs").unwrap()]; let output = concat!( "\x1e1111111111111111111111111111111111111111\0\nsrc/a.rs\0src/shared.rs\0", "\x1e2222222222222222222222222222222222222222\0\nsrc/a.rs\0src/b.rs\0src/shared.rs\0", ); let shallow_boundary_oids = HashSet::from_iter(["2222222222222222222222222222222222222222".to_string()]); let histories = parse_file_history_changed_files_output(output, &queried_paths, &shallow_boundary_oids); assert_eq!(histories.len(), 1); assert_eq!( histories[0].file_sets, vec![vec![ RepoPath::new("src/a.rs").unwrap(), RepoPath::new("src/shared.rs").unwrap(), ]] ); } #[test] fn test_parse_initial_graph_output_filters_graft_decorations() { let line = "0f36a166633a057bf7dd660508d237cad2606cab\x00\x00grafted, HEAD -> refs/heads/main, refs/remotes/origin/main"; let commits = parse_initial_graph_output([line].into_iter()); assert_eq!(commits.len(), 1); assert_eq!(commits[0].parents.len(), 0); assert_eq!( commits[0].ref_names, vec![ SharedString::from("HEAD -> refs/heads/main"), SharedString::from("refs/remotes/origin/main"), ] ); } #[gpui::test] async fn test_initial_graph_data_accepts_sha_log_source(cx: &mut TestAppContext) { disable_git_global_config(); cx.executor().allow_parking(); let repo_dir = tempfile::tempdir().unwrap(); git_init_repo(repo_dir.path()); fs::write(repo_dir.path().join("file"), "initial").unwrap(); git_command(repo_dir.path(), ["add", "file"]); git_command(repo_dir.path(), ["commit", "-m", "Initial commit"]); let commit_sha: Oid = git_command_output(repo_dir.path(), ["rev-parse", "HEAD"]) .parse() .unwrap(); let repo = RealGitRepository::new( &repo_dir.path().join(".git"), None, Some("git".into()), cx.executor(), ) .unwrap(); let (request_tx, request_rx) = async_channel::unbounded(); repo.initial_graph_data(LogSource::Sha(commit_sha), LogOrder::DateOrder, request_tx) .await .unwrap(); let graph_data = request_rx.recv().await.unwrap(); assert_eq!(graph_data.len(), 1); assert_eq!(graph_data[0].sha, commit_sha); } /// A branch whose name also names a path in the working tree - `docs/rewrite` in a /// repository that also has a `docs/rewrite` directory - made git reject the revision /// as ambiguous, so the git panel's History tab reported "Failed to load commit /// history" and commit search silently returned nothing. #[gpui::test] async fn test_initial_graph_data_with_branch_named_after_a_path(cx: &mut TestAppContext) { disable_git_global_config(); cx.executor().allow_parking(); let repo_dir = tempfile::tempdir().unwrap(); git_init_repo(repo_dir.path()); fs::create_dir_all(repo_dir.path().join("docs/rewrite")).unwrap(); fs::write(repo_dir.path().join("docs/rewrite/notes.md"), "notes").unwrap(); git_command(repo_dir.path(), ["add", "."]); git_command(repo_dir.path(), ["commit", "-m", "Add rewrite notes"]); git_command(repo_dir.path(), ["checkout", "-b", "docs/rewrite"]); let commit_sha: Oid = git_command_output(repo_dir.path(), ["rev-parse", "HEAD"]) .parse() .unwrap(); let repo = RealGitRepository::new( &repo_dir.path().join(".git"), None, Some("git".into()), cx.executor(), ) .unwrap(); let (request_tx, request_rx) = async_channel::unbounded(); repo.initial_graph_data( LogSource::Branch("docs/rewrite".into()), LogOrder::DateOrder, request_tx, ) .await .unwrap(); let graph_data = request_rx.recv().await.unwrap(); assert_eq!(graph_data.len(), 1); assert_eq!(graph_data[0].sha, commit_sha); } /// The branch diff passes the base ref straight to `git diff`, so a branch named after /// a path in the working tree broke it the same way. `git diff` takes the same `--` /// terminator, so the base ref is terminated like the log sources are. #[gpui::test] async fn test_merge_base_worktree_diff_with_branch_named_after_a_path(cx: &mut TestAppContext) { disable_git_global_config(); cx.executor().allow_parking(); let repo_dir = tempfile::tempdir().unwrap(); git_init_repo(repo_dir.path()); fs::create_dir_all(repo_dir.path().join("docs/rewrite")).unwrap(); let notes_path = repo_dir.path().join("docs/rewrite/notes.md"); fs::write(¬es_path, "notes\n").unwrap(); git_command(repo_dir.path(), ["add", "."]); git_command(repo_dir.path(), ["commit", "-m", "Add rewrite notes"]); git_command(repo_dir.path(), ["checkout", "-b", "docs/rewrite"]); let base_oid: Oid = git_command_output(repo_dir.path(), ["rev-parse", "HEAD:docs/rewrite/notes.md"]) .parse() .unwrap(); fs::write(¬es_path, "edited\n").unwrap(); let repo = RealGitRepository::new( &repo_dir.path().join(".git"), None, Some("git".into()), cx.executor(), ) .unwrap(); let diff = repo .diff_tree(DiffTreeType::MergeBaseWithWorktree { base: "docs/rewrite".into(), }) .await .unwrap(); assert_eq!( diff, TreeDiff { entries: HashMap::from_iter([( RepoPath::new("docs/rewrite/notes.md").unwrap(), TreeDiffStatus::Modified { old: base_oid }, )]), } ); } /// `show` takes the revision the user typed in the "open commit by ref" input, so a /// branch named after a path in the working tree reaches git unqualified there too. #[gpui::test] async fn test_show_with_branch_named_after_a_path(cx: &mut TestAppContext) { disable_git_global_config(); cx.executor().allow_parking(); let repo_dir = tempfile::tempdir().unwrap(); git_init_repo(repo_dir.path()); fs::create_dir_all(repo_dir.path().join("docs/rewrite")).unwrap(); fs::write(repo_dir.path().join("docs/rewrite/notes.md"), "notes").unwrap(); git_command(repo_dir.path(), ["add", "."]); git_command(repo_dir.path(), ["commit", "-m", "Add rewrite notes"]); git_command(repo_dir.path(), ["checkout", "-b", "docs/rewrite"]); let commit_sha: Oid = git_command_output(repo_dir.path(), ["rev-parse", "HEAD"]) .parse() .unwrap(); let repo = RealGitRepository::new( &repo_dir.path().join(".git"), None, Some("git".into()), cx.executor(), ) .unwrap(); let details = repo.show("docs/rewrite".to_string()).await.unwrap(); assert_eq!(details.sha.as_ref(), commit_sha.to_string()); } #[test] fn test_log_source_terminates_revisions() { // Every revision-taking source has to end the revision list, or git cannot tell a // branch name from an identically named path. `Path` is the exception: it states // the separator itself, before the path it is passing. for source in [ LogSource::All, LogSource::Branch("docs/rewrite".into()), LogSource::Sha(Oid::from_str("0000000000000000000000000000000000000000").unwrap()), ] { let args = source.get_args(); assert_eq!( args.last().map(|arg| arg.as_ref()), Some("--"), "{source:?} must terminate its revisions" ); } let path_source = LogSource::Path(RepoPath::new("docs/rewrite").unwrap()); let path_args = path_source.get_args(); assert_eq!( path_args.iter().filter(|arg| arg.as_ref() == "--").count(), 1, "Path states the separator itself and must not gain a second one" ); } #[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(), dir.path().join(".git"), 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(), dir.path().join(".git"), 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" ); git_init_repo(dir.path()); let git = GitBinary::new( PathBuf::from("git"), dir.path().to_path_buf(), dir.path().join(".git"), 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(); git_init_repo(dir.path()); let git = GitBinary::new( PathBuf::from("git"), dir.path().to_path_buf(), dir.path().join(".git"), 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(), dir.path().join(".git"), 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_build_command_disables_log_show_signature(cx: &mut TestAppContext) { cx.executor().allow_parking(); let dir = tempfile::tempdir().unwrap(); git_init_repo(dir.path()); let git = GitBinary::new( PathBuf::from("git"), dir.path().to_path_buf(), dir.path().join(".git"), cx.executor(), true, ); let output = git .build_command(&["config", "--get", "log.showSignature"]) .output() .await .expect("git config should run"); let stdout = String::from_utf8_lossy(&output.stdout); assert_eq!( stdout.trim(), "false", "log.showSignature should be disabled for trusted repos" ); let git = GitBinary::new( PathBuf::from("git"), dir.path().to_path_buf(), dir.path().join(".git"), cx.executor(), false, ); let output = git .build_command(&["config", "--get", "log.showSignature"]) .output() .await .expect("git config should run"); let stdout = String::from_utf8_lossy(&output.stdout); assert_eq!( stdout.trim(), "false", "log.showSignature should be disabled for untrusted repos" ); } #[gpui::test] async fn test_path_for_index_id_uses_real_git_directory(cx: &mut TestAppContext) { cx.executor().allow_parking(); let working_directory = PathBuf::from("/code/worktree"); let git_directory = PathBuf::from("/code/repo/.git/modules/worktree"); let git = GitBinary::new( PathBuf::from("git"), working_directory, git_directory.clone(), cx.executor(), false, ); let path = git.path_for_index_id(Uuid::nil()); assert_eq!( path, git_directory.join(format!("index-{}.tmp", Uuid::nil())) ); } #[cfg(unix)] #[gpui::test] async fn test_commit_runs_git_hooks(cx: &mut TestAppContext) { use std::os::unix::fs::PermissionsExt as _; disable_git_global_config(); cx.executor().allow_parking(); let repo_dir = tempfile::tempdir().unwrap(); git_init_repo(repo_dir.path()); let repo = RealGitRepository::new( &repo_dir.path().join(".git"), None, Some("git".into()), cx.executor(), ) .unwrap(); let hooks_dir = repo_dir.path().join(".git").join("hooks"); fs::create_dir_all(&hooks_dir).unwrap(); let write_hook = |name: &str, contents: &str| { let path = hooks_dir.join(name); fs::write(&path, contents).unwrap(); fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); }; write_hook("pre-commit", "#!/bin/sh\nexit 1\n"); fs::write(repo_dir.path().join("file"), "one").unwrap(); repo.stage_paths(vec![repo_path("file")], Arc::new(HashMap::default())) .await .unwrap(); // Hooks must not run for untrusted repositories. repo.commit( "Commit in untrusted repo".into(), None, CommitOptions::default(), AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}), Arc::new(test_commit_envs()), ) .await .expect("failing pre-commit hook should be skipped in untrusted repos"); repo.set_trusted(true); fs::write(repo_dir.path().join("file"), "two").unwrap(); repo.stage_paths(vec![repo_path("file")], Arc::new(HashMap::default())) .await .unwrap(); repo.commit( "Commit blocked by hook".into(), None, CommitOptions::default(), AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}), Arc::new(test_commit_envs()), ) .await .expect_err("failing pre-commit hook should abort the commit"); write_hook("pre-commit", "#!/bin/sh\nexit 0\n"); write_hook( "commit-msg", "#!/bin/sh\necho 'rewritten by commit-msg hook' > \"$1\"\n", ); repo.commit( "Original message".into(), None, CommitOptions::default(), AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}), Arc::new(test_commit_envs()), ) .await .unwrap(); let message = git_command_output(repo_dir.path(), ["log", "-1", "--pretty=%B"]); assert_eq!(message, "rewritten by commit-msg hook"); write_hook("pre-commit", "#!/bin/sh\nexit 1\n"); fs::write(repo_dir.path().join("file"), "three").unwrap(); repo.stage_paths(vec![repo_path("file")], Arc::new(HashMap::default())) .await .unwrap(); repo.commit( "Commit without verification".into(), None, CommitOptions { no_verify: true, ..Default::default() }, AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}), Arc::new(test_commit_envs()), ) .await .expect("--no-verify should skip pre-commit and commit-msg hooks"); let message = git_command_output(repo_dir.path(), ["log", "-1", "--pretty=%B"]); assert_eq!(message, "Commit without verification"); } #[gpui::test] async fn test_load_revisions(cx: &mut TestAppContext) { disable_git_global_config(); cx.executor().allow_parking(); let repo_dir = tempfile::tempdir().unwrap(); git_init_repo(repo_dir.path()); let file1_path = repo_dir.path().join("file1"); let file2_path = repo_dir.path().join("file2"); let space_file_path = repo_dir.path().join("file with spaces"); smol::fs::write(&file1_path, "file1 committed contents") .await .unwrap(); smol::fs::write(&file2_path, "file2 committed contents") .await .unwrap(); smol::fs::write(&space_file_path, "space file committed contents") .await .unwrap(); let repo = RealGitRepository::new( &repo_dir.path().join(".git"), None, Some("git".into()), cx.executor(), ) .unwrap(); // Stage files and commit repo.stage_paths( vec![ repo_path("file1"), repo_path("file2"), repo_path("file with spaces"), ], Arc::new(HashMap::default()), ) .await .unwrap(); repo.commit( "Initial commit".into(), None, CommitOptions::default(), AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}), Arc::new(test_commit_envs()), ) .await .unwrap(); // Now modify files in index but not yet committed smol::fs::write(&file1_path, "file1 index contents") .await .unwrap(); repo.stage_paths(vec![repo_path("file1")], Arc::new(HashMap::default())) .await .unwrap(); // Write working tree contents (not indexed, not committed) smol::fs::write(&file1_path, "file1 worktree contents") .await .unwrap(); // Now test load_revisions let results = repo .load_revisions( [ "HEAD:file1", ":file1", "HEAD:file2", ":file2", "HEAD:nonexistent", "HEAD:file with spaces", "HEAD:nonexistent file with spaces", ] .into_iter() .map(String::from) .collect(), ) .await .unwrap(); assert_eq!( results, vec![ Some(b"file1 committed contents".to_vec()), Some(b"file1 index contents".to_vec()), Some(b"file2 committed contents".to_vec()), Some(b"file2 committed contents".to_vec()), // untouched in index, should match HEAD None, Some(b"space file committed contents".to_vec()), None, ] ); } #[gpui::test] async fn test_blame_at_revision(cx: &mut TestAppContext) { disable_git_global_config(); cx.executor().allow_parking(); let repo_dir = tempfile::tempdir().unwrap(); git_init_repo(repo_dir.path()); let repo = RealGitRepository::new( &repo_dir.path().join(".git"), None, Some("git".into()), cx.executor(), ) .unwrap(); let file_name = "ürlich file1"; fs::write(repo_dir.path().join(file_name), "line one\n").unwrap(); git_command(repo_dir.path(), ["add", "-A"]); git_command(repo_dir.path(), ["commit", "-m", "First commit"]); let first_sha = git_command_output(repo_dir.path(), ["rev-parse", "HEAD"]); fs::write(repo_dir.path().join(file_name), "line one\nline two\n").unwrap(); git_command(repo_dir.path(), ["add", "-A"]); git_command(repo_dir.path(), ["commit", "-m", "Second commit"]); let second_sha = git_command_output(repo_dir.path(), ["rev-parse", "HEAD"]); let blame_at_head = repo .blame_at_revision(repo_path(file_name), second_sha.parse().unwrap()) .await .unwrap(); assert_eq!( blame_at_head .entries .iter() .map(|entry| { ( entry.sha.to_string(), entry.range.clone(), entry.filename.clone(), entry.previous.clone(), ) }) .collect::>(), vec![ (first_sha.clone(), 0..1, file_name.to_owned(), None), ( second_sha.clone(), 1..2, file_name.to_owned(), Some(format!("{first_sha} {file_name}")) ), ] ); let blame_at_first = repo .blame_at_revision(repo_path(file_name), first_sha.parse().unwrap()) .await .unwrap(); assert_eq!( blame_at_first .entries .iter() .map(|entry| (entry.sha.to_string(), entry.range.clone())) .collect::>(), vec![(first_sha.clone(), 0..1)] ); } #[gpui::test] async fn test_branches_return_head_when_commit_metadata_cannot_be_read( cx: &mut TestAppContext, ) { disable_git_global_config(); cx.executor().allow_parking(); let repo_dir = tempfile::tempdir().unwrap(); git_init_repo(repo_dir.path()); let repo = RealGitRepository::new( &repo_dir.path().join(".git"), None, Some("git".into()), cx.executor(), ) .unwrap(); 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(test_commit_envs()), ) .await .unwrap(); smol::fs::write( repo_dir.path().join(".git").join("refs/heads/broken"), "0a103ede22f159c792dc6405e0c8304d9bd4dc29\n", ) .await .unwrap(); let branches_scan = repo.branches().await.unwrap(); assert!(branches_scan.error.is_some()); let head_branch = branches_scan .branches .iter() .find(|branch| branch.is_head) .expect("branch list should include HEAD"); assert!(head_branch.ref_name.starts_with("refs/heads/")); assert!( branches_scan .branches .iter() .all(|branch| branch.ref_name.as_ref() != "refs/heads/broken") ); } #[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_directory_name_uses_path_style() { let cases = [ ( "/worktrees/zed/plum-warbler/zed", Some("/repos/zed"), PathStyle::Unix, "plum-warbler", ), ( "/worktrees/feature-1", Some("/repos/zed"), PathStyle::Unix, "feature-1", ), ("/worktrees/feature-1", None, PathStyle::Unix, "feature-1"), ( r"C:\worktrees\zed\plum-warbler\zed", Some(r"C:\repos\zed"), PathStyle::Windows, "plum-warbler", ), ( r"C:\worktrees\feature-1", Some(r"C:\repos\zed"), PathStyle::Windows, "feature-1", ), ( r"C:\worktrees\feature-1", None, PathStyle::Windows, "feature-1", ), ( "C:/worktrees/zed/plum-warbler/zed", Some("C:/repos/zed"), PathStyle::Windows, "plum-warbler", ), ]; for (worktree_path, name_anchor, path_style, expected) in cases { let worktree = Worktree { path: PathBuf::from(worktree_path), ref_name: None, sha: "8166e3d".into(), is_main: false, is_bare: false, }; assert_eq!( worktree.directory_name(name_anchor.map(Path::new), path_style), expected, "directory name for {worktree_path:?} anchored at {name_anchor:?} with {path_style:?}", ); } } #[test] fn test_parse_worktrees_from_str() { // Empty input let result = parse_worktrees_from_str("", None); 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, Some(Path::new("/home/user/project"))); 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, Some("refs/heads/main".into())); assert!(result[0].is_main); assert!(!result[0].is_bare); // Multiple worktrees let input = "worktree /home/user/project-wt\nHEAD def456\nbranch refs/heads/feature\n\n\ worktree /home/user/project\nHEAD abc123\nbranch refs/heads/main\n\n"; let result = parse_worktrees_from_str(input, Some(Path::new("/home/user/project"))); assert_eq!(result.len(), 2); assert_eq!(result[0].path, PathBuf::from("/home/user/project-wt")); assert_eq!(result[0].ref_name, Some("refs/heads/feature".into())); assert!(!result[0].is_main); assert!(!result[0].is_bare); assert_eq!(result[1].path, PathBuf::from("/home/user/project")); assert_eq!(result[1].ref_name, Some("refs/heads/main".into())); assert!(result[1].is_main); assert!(!result[1].is_bare); // Detached HEAD entry (included with ref_name: None) 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, Some(Path::new("/home/user/project"))); assert_eq!(result.len(), 2); assert_eq!(result[0].path, PathBuf::from("/home/user/project")); assert_eq!(result[0].ref_name, Some("refs/heads/main".into())); assert!(result[0].is_main); assert_eq!(result[1].path, PathBuf::from("/home/user/detached")); assert_eq!(result[1].ref_name, None); assert_eq!(result[1].sha.as_ref(), "def456"); assert!(!result[1].is_main); assert!(!result[1].is_bare); // Bare repo entry with no main worktree. 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, None); assert_eq!(result.len(), 2); assert_eq!(result[0].path, PathBuf::from("/home/user/bare.git")); assert_eq!(result[0].ref_name, None); assert!(!result[0].is_main); assert!(result[0].is_bare); assert_eq!(result[1].path, PathBuf::from("/home/user/project")); assert_eq!(result[1].ref_name, Some("refs/heads/main".into())); assert!(!result[1].is_main); assert!(!result[1].is_bare); // 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, Some(Path::new("/home/user/project"))); assert_eq!(result.len(), 3); assert_eq!(result[0].path, PathBuf::from("/home/user/project")); assert_eq!(result[0].ref_name, Some("refs/heads/main".into())); assert!(result[0].is_main); assert_eq!(result[1].path, PathBuf::from("/home/user/locked-wt")); assert_eq!(result[1].ref_name, Some("refs/heads/locked-branch".into())); assert!(!result[1].is_main); assert_eq!(result[2].path, PathBuf::from("/home/user/prunable-wt")); assert_eq!( result[2].ref_name, Some("refs/heads/prunable-branch".into()) ); assert!(!result[2].is_main); // 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, Some(Path::new("/home/user/project"))); 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, Some("refs/heads/main".into())); assert!(result[0].is_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, Some(Path::new("/home/user/project"))); 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, Some("refs/heads/main".into())); assert!(result[0].is_main); } #[gpui::test] async fn test_create_and_list_worktrees(cx: &mut TestAppContext) { disable_git_global_config(); cx.executor().allow_parking(); let temp_dir = tempfile::tempdir().unwrap(); let repo_dir = temp_dir.path().join("repo"); let worktrees_dir = temp_dir.path().join("worktrees"); fs::create_dir_all(&repo_dir).unwrap(); fs::create_dir_all(&worktrees_dir).unwrap(); git_init_repo(&repo_dir); let repo = RealGitRepository::new( &repo_dir.join(".git"), None, Some("git".into()), cx.executor(), ) .unwrap(); // Create an initial commit (required for worktrees) smol::fs::write(repo_dir.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(test_commit_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.canonicalize().unwrap() ); let worktree_path = worktrees_dir.join("some-worktree"); // Create a new worktree repo.create_worktree( CreateWorktreeTarget::NewBranch { branch_name: "test-branch".to_string(), base_sha: Some("HEAD".to_string()), }, worktree_path.clone(), ) .await .unwrap(); // List worktrees — should have two let worktrees = repo.worktrees().await.unwrap(); assert_eq!(worktrees.len(), 2); let new_worktree = worktrees .iter() .find(|w| w.display_name() == "test-branch") .expect("should find worktree with test-branch"); assert_eq!( new_worktree.path.canonicalize().unwrap(), worktree_path.canonicalize().unwrap(), ); // The new worktree's git metadata directory should report a creation // time, resolved via the worktree's `.git` file. let created_at = repo .worktree_created_at(worktree_path.clone()) .await .unwrap(); assert!( created_at.is_some(), "creation time should be available for a freshly created worktree" ); // A path with no worktree at all reports `None`. let missing = repo .worktree_created_at(worktrees_dir.join("does-not-exist")) .await .unwrap(); assert_eq!(missing, None); } #[gpui::test] async fn test_remove_worktree(cx: &mut TestAppContext) { disable_git_global_config(); cx.executor().allow_parking(); let temp_dir = tempfile::tempdir().unwrap(); let repo_dir = temp_dir.path().join("repo"); let worktrees_dir = temp_dir.path().join("worktrees"); git_init_repo(&repo_dir); let repo = RealGitRepository::new( &repo_dir.join(".git"), None, Some("git".into()), cx.executor(), ) .unwrap(); // Create an initial commit smol::fs::write(repo_dir.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(test_commit_envs()), ) .await .unwrap(); // Create a worktree let worktree_path = worktrees_dir.join("worktree-to-remove"); repo.create_worktree( CreateWorktreeTarget::NewBranch { branch_name: "to-remove".to_string(), base_sha: Some("HEAD".to_string()), }, worktree_path.clone(), ) .await .unwrap(); // Remove the worktree repo.remove_worktree(worktree_path.clone(), false) .await .unwrap(); // Verify the directory is removed let worktrees = repo.worktrees().await.unwrap(); assert_eq!(worktrees.len(), 1); assert!( worktrees.iter().all(|w| w.display_name() != "to-remove"), "removed worktree should not appear in list" ); assert!(!worktree_path.exists()); // Create a worktree let worktree_path = worktrees_dir.join("dirty-wt"); repo.create_worktree( CreateWorktreeTarget::NewBranch { branch_name: "dirty-wt".to_string(), base_sha: Some("HEAD".to_string()), }, worktree_path.clone(), ) .await .unwrap(); assert!(worktree_path.exists()); // 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()); } #[gpui::test] async fn test_rename_worktree(cx: &mut TestAppContext) { disable_git_global_config(); cx.executor().allow_parking(); let temp_dir = tempfile::tempdir().unwrap(); let repo_dir = temp_dir.path().join("repo"); let worktrees_dir = temp_dir.path().join("worktrees"); git_init_repo(&repo_dir); let repo = RealGitRepository::new( &repo_dir.join(".git"), None, Some("git".into()), cx.executor(), ) .unwrap(); // Create an initial commit smol::fs::write(repo_dir.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(test_commit_envs()), ) .await .unwrap(); // Create a worktree let old_path = worktrees_dir.join("old-worktree-name"); repo.create_worktree( CreateWorktreeTarget::NewBranch { branch_name: "old-name".to_string(), base_sha: Some("HEAD".to_string()), }, old_path.clone(), ) .await .unwrap(); assert!(old_path.exists()); // Move the worktree to a new path let new_path = worktrees_dir.join("new-worktree-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.display_name() == "old-name") .expect("should find worktree by branch name"); assert_eq!( moved_worktree.path.canonicalize().unwrap(), new_path.canonicalize().unwrap() ); } #[gpui::test] async fn test_archive_checkpoint_round_trip(cx: &mut TestAppContext) { disable_git_global_config(); cx.executor().allow_parking(); let repo_dir = tempfile::tempdir().unwrap(); let path = repo_dir.path(); git_init_repo(path); // Archive commits must not depend on the user having a Git identity. git_command(path, ["config", "user.useConfigOnly", "true"]); for name in ["staged", "unstaged", "deleted"] { fs::write(path.join(name), "initial").unwrap(); } git_command(path, ["add", "--all"]); git_command(path, ["commit", "-m", "Initial commit"]); fs::write(path.join("staged"), "staged change").unwrap(); git_command(path, ["add", "staged"]); fs::write(path.join("unstaged"), "unstaged change").unwrap(); fs::write(path.join("untracked"), "untracked").unwrap(); fs::remove_file(path.join("deleted")).unwrap(); let status_before = git_command_output(path, ["status", "--porcelain"]); let repo = RealGitRepository::new(&path.join(".git"), None, Some("git".into()), cx.executor()) .unwrap(); let (staged_sha, unstaged_sha) = repo.create_archive_checkpoint().await.unwrap(); assert_eq!( git_command_output(path, ["status", "--porcelain"]), status_before, "archiving must not touch the index or working tree" ); assert_eq!( git_command_output(path, ["for-each-ref", "refs/heads"]) .lines() .count(), 1, "archiving must not create branches" ); let leftover_temp_indexes = fs::read_dir(path.join(".git")) .unwrap() .filter_map(|entry| entry.ok()) .filter(|entry| entry.file_name().to_string_lossy().starts_with("index-")) .count(); assert_eq!(leftover_temp_indexes, 0); git_command(path, ["reset", "--hard"]); git_command(path, ["clean", "--force"]); repo.restore_archive_checkpoint(staged_sha, unstaged_sha) .await .unwrap(); assert_eq!( git_command_output(path, ["status", "--porcelain"]), status_before ); assert_eq!( fs::read_to_string(path.join("staged")).unwrap(), "staged change" ); assert_eq!( fs::read_to_string(path.join("unstaged")).unwrap(), "unstaged change" ); assert_eq!( fs::read_to_string(path.join("untracked")).unwrap(), "untracked" ); assert!(!path.join("deleted").exists()); } #[gpui::test] async fn test_initial_graph_data_ref_set(cx: &mut TestAppContext) { disable_git_global_config(); cx.executor().allow_parking(); let repo_dir = tempfile::tempdir().unwrap(); git_init_repo(repo_dir.path()); let repo = RealGitRepository::new( &repo_dir.path().join(".git"), None, Some("git".into()), cx.executor(), ) .unwrap(); let git = repo.git_binary(); let graph_commits = async || { let (tx, rx) = smol::channel::unbounded(); repo.initial_graph_data(LogSource::All, LogOrder::DateOrder, tx) .await .unwrap(); let mut commits = std::collections::HashSet::new(); while let Ok(chunk) = rx.try_recv() { for commit in chunk { commits.insert(commit.sha); } } commits }; // Commits the worktree without moving HEAD or any branch. let commit_worktree = |parent: Option<&str>| -> Oid { git_command(repo_dir.path(), ["add", "--all"]); let tree = git_command_output(repo_dir.path(), ["write-tree"]); let mut arguments = vec!["commit-tree", tree.trim(), "-m", "commit"]; if let Some(parent) = parent { arguments.extend(["-p", parent]); } git_command_output(repo_dir.path(), arguments) .trim() .parse() .unwrap() }; smol::fs::write(repo_dir.path().join("file1"), "1") .await .unwrap(); let branch_sha = commit_worktree(None); repo.update_ref("refs/heads/main".into(), branch_sha.to_string()) .await .unwrap(); smol::fs::write(repo_dir.path().join("file2"), "2") .await .unwrap(); let hidden_sha = commit_worktree(Some(&branch_sha.to_string())); repo.update_ref("refs/custom/hidden".into(), hidden_sha.to_string()) .await .unwrap(); let graph = graph_commits().await; assert!(graph.contains(&branch_sha)); assert!(!graph.contains(&hidden_sha)); git.build_command(&["update-ref", "--no-deref", "HEAD", &hidden_sha.to_string()]) .output() .await .unwrap(); let graph = graph_commits().await; assert!(graph.contains(&branch_sha)); assert!(graph.contains(&hidden_sha)); } #[gpui::test] async fn test_check_for_pushed_commit(cx: &mut TestAppContext) { disable_git_global_config(); cx.executor().allow_parking(); let temp_dir = tempfile::tempdir().unwrap(); let repo_dir = temp_dir.path().join("repo"); git_init_repo(&repo_dir); let repo = RealGitRepository::new( &repo_dir.join(".git"), None, Some("git".into()), cx.executor(), ) .unwrap(); // New repo doesn't have any commits yet assert!(repo.check_for_pushed_commit().await.unwrap().is_empty()); git_command( &repo_dir, ["commit", "--allow-empty", "-m", "Initial commit"], ); // No remote branches exist yet assert!(repo.check_for_pushed_commit().await.unwrap().is_empty()); // Create simulated remote branches git_command( &repo_dir, ["update-ref", "refs/remotes/origin/main", "HEAD"], ); git_command( &repo_dir, ["update-ref", "refs/remotes/origin/other-branch", "HEAD"], ); assert_eq!( repo.check_for_pushed_commit().await.unwrap(), vec![ SharedString::from("origin/main"), SharedString::from("origin/other-branch") ] ); // Switch to a new branch, commit but do not push git_command(&repo_dir, ["switch", "-c", "local-feature"]); git_command(&repo_dir, ["commit", "--allow-empty", "-m", "Local commit"]); // New commit has not been pushed assert!(repo.check_for_pushed_commit().await.unwrap().is_empty()); } #[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")), Some(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")), Some(PathBuf::from("/code/zed5")) ); // Bare repo: no .git suffix, returns None (no working-tree root) assert_eq!( original_repo_path_from_common_dir(Path::new("/code/zed5.git")), None ); // Root-level .git directory assert_eq!( original_repo_path_from_common_dir(Path::new("/.git")), Some(PathBuf::from("/")) ); } #[gpui::test] async fn test_default_branch(cx: &mut TestAppContext) { disable_git_global_config(); cx.executor().allow_parking(); let repo_dir = tempfile::tempdir().unwrap(); git_init_repo(repo_dir.path()); let repo = RealGitRepository::new( &repo_dir.path().join(".git"), None, Some("git".into()), cx.executor(), ) .unwrap(); assert_eq!(repo.default_branch(false).await.unwrap(), None); git_command( repo_dir.path(), ["commit", "--allow-empty", "-m", "Initial commit"], ); assert_eq!( repo.default_branch(false).await.unwrap(), Some("main".into()) ); git_command( repo_dir.path(), ["update-ref", "refs/remotes/origin/main", "HEAD"], ); git_command( repo_dir.path(), [ "symbolic-ref", "refs/remotes/origin/HEAD", "refs/remotes/origin/main", ], ); assert_eq!( repo.default_branch(false).await.unwrap(), Some("main".into()) ); assert_eq!( repo.default_branch(true).await.unwrap(), Some("origin/main".into()) ); } #[test] fn test_parse_remote_urls() { let stdout = concat!( "origin\thttps://github.com/zed-industries/zed.git (fetch) [blob:none]\n", "origin\thttps://github.com/zed-industries/zed.git (push)\n", "upstream\t/Users/user/My Projects/upstream.git (fetch)\n", "upstream\t/Users/user/My Projects/upstream.git (push)\n", "a\t/x (fetch) dir (fetch)\n", "a\t/x (fetch) dir (push)\n", "archive\t/tmp/remote [archive].git (fetch)\n", "archive\t/tmp/remote [archive].git (push)\n", ); let remote_urls = parse_remote_urls(stdout); assert_eq!(remote_urls.len(), 4); assert_eq!( remote_urls.get("origin").map(String::as_str), Some("https://github.com/zed-industries/zed.git") ); assert_eq!( remote_urls.get("upstream").map(String::as_str), Some("/Users/user/My Projects/upstream.git") ); assert_eq!( remote_urls.get("a").map(String::as_str), Some("/x (fetch) dir") ); assert_eq!( remote_urls.get("archive").map(String::as_str), Some("/tmp/remote [archive].git") ); } #[gpui::test] async fn test_remote_urls(cx: &mut TestAppContext) { disable_git_global_config(); cx.executor().allow_parking(); let temp_dir = tempfile::tempdir().unwrap(); let repo_dir = temp_dir.path().join("repo"); std::fs::create_dir_all(&repo_dir).unwrap(); git_init_repo(&repo_dir); let repo = RealGitRepository::new( &repo_dir.join(".git"), None, Some("git".into()), cx.executor(), ) .unwrap(); let git = repo.git_binary(); git.run(&[ "remote", "add", "origin", "https://github.com/zed-industries/zed.git", ]) .await .unwrap(); git.run(&[ "remote", "add", "upstream", "/Users/user/My Projects/upstream.git", ]) .await .unwrap(); git.run(&["config", "remote.origin.promisor", "true"]) .await .unwrap(); git.run(&["config", "remote.origin.partialclonefilter", "blob:none"]) .await .unwrap(); let remote_urls = repo.remote_urls().await; assert_eq!(remote_urls.len(), 2); assert_eq!( remote_urls.get("origin").unwrap(), "https://github.com/zed-industries/zed.git" ); assert_eq!( remote_urls.get("upstream").unwrap(), "/Users/user/My Projects/upstream.git" ); } #[test] fn test_compose_batch_mode_ssh_command() { assert_eq!( compose_batch_mode_ssh_command(None, None, None).as_deref(), Some("ssh -o BatchMode=yes") ); assert_eq!( compose_batch_mode_ssh_command(Some("ssh -i key "), Some("ssh -F config"), None) .as_deref(), Some("ssh -i key -o BatchMode=yes"), "GIT_SSH_COMMAND outranks core.sshCommand" ); assert_eq!( compose_batch_mode_ssh_command(None, Some("ssh -F config"), Some("plink")).as_deref(), Some("ssh -F config -o BatchMode=yes"), "core.sshCommand outranks GIT_SSH, so it is still composed onto" ); assert_eq!( compose_batch_mode_ssh_command(Some(""), None, None).as_deref(), Some("ssh -o BatchMode=yes") ); assert_eq!( compose_batch_mode_ssh_command(None, None, Some("plink")), None, "setting GIT_SSH_COMMAND would silently replace the GIT_SSH program" ); } }