Files
zed/crates/git/src/repository.rs
T
itsfuad 801c087af2 git: Separate revisions from paths in git commands (#63666)
Closes #63665

A branch whose name also matches a path in the working tree, such as a
branch `docs/rewrite` in a repository that also has a `docs/rewrite`
directory, was passed to git as a bare argument, so git could not tell
the revision from the path:

```
fatal: ambiguous argument 'docs/rewrite': both revision and filename
```

The git panel's History tab showed "Failed to load commit history",
commit search returned no results, branch diff failed, and opening a
commit by ref failed.

The revision lists are now terminated with `--`.

That also required moving `--no-ext-diff` in `build_command`. It was
appended after the caller's arguments, so once a terminator was present
it landed past the separator and git read it as a pathspec. The diff
then came back empty with exit status 0 and no error, which broke three
existing `merge_base_worktree` tests. It now goes directly after the
subcommand, where it cannot fall past a terminator. This also means it
is no longer swallowed as a pathspec by `diff_stat`, which already ended
its arguments with `--` and a path list.

`git merge-base` takes no pathspec, so it was never ambiguous and is
unchanged.

This also fixes the `fatal: ambiguous argument` error reported in
#61186. That report additionally shows an `error: invalid option:
--merge-base` failure when the base branch does not exist locally, which
is a separate problem and is not addressed here.

### Tests

Three regression tests, one per affected surface, each building a
repository whose branch name matches a directory. All three fail on
`main` with the ambiguity error. A fourth test pins that every
revision-taking `LogSource` terminates its revision list, and that
`LogSource::Path`, which states its own separator, does not gain a
second one.

`cargo test -p git`: 76 passed. Clippy and `cargo fmt` clean.

### Before

<img width="542" height="950" alt="Screenshot From 2026-09-03 09-15-13"
src="https://github.com/user-attachments/assets/c54577c7-5066-4cdd-a03c-c67c6803ad33"
/>


### After

<img width="386" height="471" alt="Screenshot From 2026-09-03 10-08-42"
src="https://github.com/user-attachments/assets/b985bb16-60a2-4f50-ad2a-609c6a126bfb"
/>

Release Notes:

- Fixed the git panel's History tab, commit search, branch diff, and
opening a commit by ref failing when the branch name matches a path in
the working tree
2026-09-03 11:14:41 +00:00

6978 lines
233 KiB
Rust

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 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 `<work_dir>/.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<PathBuf> {
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<PathBuf> {
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<PathBuf> {
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<SharedString>,
}
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<Result<CommitData>>,
}
pub struct CommitDataReader {
request_tx: async_channel::Sender<CommitDataRequest>,
_task: Task<()>,
}
impl CommitDataReader {
pub async fn read(&self, sha: Oid) -> Result<CommitData> {
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<CommitData>,
) -> Self {
let (request_tx, request_rx) = smol::channel::bounded::<CommitDataRequest>(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<CommitData> {
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::<i64>() {
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<Upstream>,
pub most_recent_commit: Option<CommitSummary>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct BranchesScanResult {
pub branches: Vec<Branch>,
pub error: Option<SharedString>,
}
impl From<Vec<Branch>> for BranchesScanResult {
fn from(branches: Vec<Branch>) -> 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<UpstreamTrackingStatus> {
self.upstream
.as_ref()
.and_then(|upstream| upstream.tracking.status())
}
pub fn priority_key(&self) -> (bool, Option<i64>) {
(
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<SharedString>,
// 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<String>,
},
/// 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<String>,
},
}
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>) -> String {
if self.is_main {
return "main worktree".to_string();
}
let dir_name = self
.path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or(self.display_name());
if let Some(name_anchor_path) = name_anchor_path {
let name_anchor_dir = name_anchor_path.file_name().and_then(|name| name.to_str());
if name_anchor_dir == Some(dir_name) {
if let Some(parent_name) = self
.path
.parent()
.and_then(|parent| parent.file_name())
.and_then(|name| name.to_str())
{
return parent_name.to_string();
}
}
}
dir_name.to_string()
}
}
pub fn parse_worktrees_from_str<T: AsRef<str>>(
raw_worktrees: T,
main_worktree_path: Option<&Path>,
) -> Vec<Worktree> {
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<UpstreamTrackingStatus> 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<UpstreamTrackingStatus> {
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<CommitFile>,
pub is_shallow_boundary: bool,
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct FileHistoryChangedFileSets {
pub file_sets: Vec<Vec<RepoPath>>,
}
#[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<Vec<u8>>,
pub new_content: Option<Vec<u8>>,
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()
}
}
/// Detects if content is binary by checking for NUL bytes in the first 8000 bytes.
/// This matches git's binary detection heuristic.
pub fn is_binary_content(content: &[u8]) -> bool {
let check_len = content.len().min(8000);
content[..check_len].contains(&0)
}
struct LoadedCommitObject {
content: Vec<u8>,
is_binary: bool,
}
async fn read_commit_blob<R: smol::io::AsyncBufRead + Unpin>(
stdout: &mut R,
info_line: &mut String,
newline: &mut [u8; 1],
) -> Result<LoadedCommitObject> {
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);
Ok(LoadedCommitObject {
content: bytes,
is_binary,
})
}
async fn load_commit_object<R: smol::io::AsyncBufRead + Unpin>(
object: Option<CommitDiffObject<'_>>,
stdout: &mut R,
info_line: &mut String,
newline: &mut [u8; 1],
) -> Result<Option<LoadedCommitObject>> {
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<Option<String>> {
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<bool> {
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<String> {
match self {
FetchOptions::All | FetchOptions::Unshallow => None,
FetchOptions::Remote(remote) => Some(remote.clone().name.into()),
}
}
pub fn from_proto(remote_name: Option<String>, 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<Cow<'_, str>> {
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<Vec<u8>>> {
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<Vec<u8>>> {
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<Vec<u8>>>;
fn set_index_text(
&self,
path: RepoPath,
content: Option<Vec<u8>>,
env: Arc<HashMap<String, String>>,
is_executable: bool,
) -> BoxFuture<'_, anyhow::Result<()>>;
/// Returns the URL of the remote with the given name.
fn remote_url(&self, name: &str) -> BoxFuture<'_, Option<String>> {
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<String, String>>;
/// Resolve a list of refs to SHAs.
fn revparse_batch(&self, revs: Vec<String>) -> BoxFuture<'_, Result<Vec<Option<String>>>>;
fn load_revisions(&self, revisions: Vec<String>)
-> BoxFuture<'_, Result<Vec<Option<Vec<u8>>>>>;
fn head_sha(&self) -> BoxFuture<'_, Option<String>> {
async move {
self.revparse_batch(vec!["HEAD".into()])
.await
.unwrap_or_default()
.into_iter()
.next()
.flatten()
}
.boxed()
}
fn merge_message(&self) -> BoxFuture<'_, Option<String>>;
fn status(&self, path_prefixes: &[RepoPath]) -> Task<Result<GitStatus>>;
fn diff_tree(&self, request: DiffTreeType) -> BoxFuture<'_, Result<TreeDiff>>;
fn stash_entries(&self) -> BoxFuture<'static, Result<GitStash>>;
fn check_access(&self) -> BoxFuture<'_, Result<()>> {
async move { Ok(()) }.boxed()
}
fn branches(&self) -> BoxFuture<'_, Result<BranchesScanResult>>;
fn change_branch(&self, name: String) -> BoxFuture<'_, Result<()>>;
fn create_branch(&self, name: String, base_branch: Option<String>)
-> 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<Vec<Worktree>>>;
/// Returns the creation time of a linked worktree's git metadata
/// directory (`.git/worktrees/<name>/`), 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<Option<SystemTime>>>;
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<HashMap<String, String>>,
) -> BoxFuture<'_, Result<()>>;
fn checkout_files(
&self,
commit: String,
paths: Vec<RepoPath>,
env: Arc<HashMap<String, String>>,
) -> BoxFuture<'_, Result<()>>;
fn show(&self, commit: String) -> BoxFuture<'_, Result<CommitDetails>>;
fn load_commit(
&self,
commit: String,
ignore_shallow_boundary: bool,
cx: AsyncApp,
) -> BoxFuture<'_, Result<CommitDiff>>;
fn blame(
&self,
path: RepoPath,
content: Rope,
line_ending: LineEnding,
) -> BoxFuture<'_, Result<crate::blame::Blame>>;
fn blame_at_revision(
&self,
path: RepoPath,
revision: Oid,
) -> BoxFuture<'_, Result<crate::blame::Blame>>;
/// 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/<name>`).
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<RepoPath>,
env: Arc<HashMap<String, String>>,
) -> 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<RepoPath>,
env: Arc<HashMap<String, String>>,
) -> 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<HashMap<String, String>>,
) -> BoxFuture<'_, Result<()>>;
fn commit(
&self,
message: SharedString,
name_and_email: Option<(SharedString, SharedString)>,
options: CommitOptions,
askpass: AskPassDelegate,
env: Arc<HashMap<String, String>>,
) -> BoxFuture<'_, Result<()>>;
fn stash_paths(
&self,
paths: Vec<RepoPath>,
message: Option<String>,
env: Arc<HashMap<String, String>>,
) -> BoxFuture<'_, Result<()>>;
fn stash_staged(
&self,
message: Option<String>,
env: Arc<HashMap<String, String>>,
) -> BoxFuture<'_, Result<()>>;
fn stash_pop(
&self,
index: Option<usize>,
env: Arc<HashMap<String, String>>,
) -> BoxFuture<'_, Result<()>>;
fn stash_apply(
&self,
index: Option<usize>,
env: Arc<HashMap<String, String>>,
) -> BoxFuture<'_, Result<()>>;
fn stash_drop(
&self,
index: Option<usize>,
env: Arc<HashMap<String, String>>,
) -> BoxFuture<'_, Result<()>>;
fn push(
&self,
branch_name: String,
remote_branch_name: String,
upstream_name: String,
options: Option<PushOptions>,
askpass: AskPassDelegate,
env: Arc<HashMap<String, String>>,
// 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<RemoteCommandOutput>>;
fn pull(
&self,
branch_name: Option<String>,
upstream_name: String,
rebase: bool,
askpass: AskPassDelegate,
env: Arc<HashMap<String, String>>,
// 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<RemoteCommandOutput>>;
fn fetch(
&self,
fetch_options: FetchOptions,
askpass: AskPassDelegate,
env: Arc<HashMap<String, String>>,
// 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<RemoteCommandOutput>>;
fn get_push_remote(&self, branch: String) -> BoxFuture<'_, Result<Option<Remote>>>;
fn get_branch_remote(&self, branch: String) -> BoxFuture<'_, Result<Option<Remote>>>;
fn get_all_remotes(&self) -> BoxFuture<'_, Result<Vec<Remote>>>;
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<Vec<SharedString>>>;
/// Run git diff
fn diff(&self, diff: DiffType) -> BoxFuture<'_, Result<String>>;
fn diff_stat(
&self,
diff: DiffStatType,
path_prefixes: &[RepoPath],
) -> BoxFuture<'static, Result<crate::status::GitDiffStat>>;
/// Creates a checkpoint for the repository.
fn checkpoint(&self) -> BoxFuture<'static, Result<GitRepositoryCheckpoint>>;
/// Resets to a previously-created checkpoint.
fn restore_checkpoint(&self, checkpoint: GitRepositoryCheckpoint) -> BoxFuture<'_, 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<()>>;
/// Compares two checkpoints, returning true if they are equal
fn compare_checkpoints(
&self,
left: GitRepositoryCheckpoint,
right: GitRepositoryCheckpoint,
) -> BoxFuture<'_, Result<bool>>;
/// Computes a diff between two checkpoints.
fn diff_checkpoints(
&self,
base_checkpoint: GitRepositoryCheckpoint,
target_checkpoint: GitRepositoryCheckpoint,
) -> BoxFuture<'_, Result<String>>;
fn load_commit_template(&self) -> BoxFuture<'_, Result<Option<GitCommitTemplate>>>;
fn default_branch(
&self,
include_remote_name: bool,
) -> BoxFuture<'_, Result<Option<SharedString>>>;
/// 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<Vec<Arc<InitialGraphCommitData>>>,
) -> BoxFuture<'_, Result<()>>;
fn search_commits(
&self,
log_source: LogSource,
search_args: SearchCommitArgs,
request_tx: Sender<Oid>,
) -> BoxFuture<'_, Result<()>>;
fn file_history_changed_files(
&self,
paths: Vec<RepoPath>,
commit_limit: usize,
) -> BoxFuture<'_, Result<Vec<FileHistoryChangedFileSets>>>;
fn commit_data_reader(&self) -> Result<CommitDataReader>;
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<PathBuf>,
pub system_git_binary_path: Option<PathBuf>,
pub any_git_binary_path: PathBuf,
any_git_binary_help_output: Arc<Mutex<Option<SharedString>>>,
executor: BackgroundExecutor,
is_trusted: Arc<AtomicBool>,
}
#[derive(Debug)]
pub enum RefEdit {
Update { ref_name: String, commit: String },
Delete { ref_name: String },
}
impl RefEdit {
fn into_args(self) -> Vec<OsString> {
match self {
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<PathBuf>,
system_git_binary_path: Option<PathBuf>,
executor: BackgroundExecutor,
) -> Result<Self> {
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<PathBuf> {
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<GitBinary> {
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(Clone, Debug)]
pub struct GitRepositoryCheckpoint {
pub commit_sha: Oid,
}
#[derive(Debug)]
pub struct GitCommitter {
pub name: Option<String>,
pub email: Option<String>,
}
#[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<String, String> {
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<CommitDetails>> {
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::<Vec<_>>();
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<CommitDiff>> {
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::<CommitFile>::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<HashMap<String, String>>,
) -> 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: String,
paths: Vec<RepoPath>,
env: Arc<HashMap<String, String>>,
) -> BoxFuture<'_, Result<()>> {
let git = self.git_binary_in_worktree();
async move {
let git = git?;
if paths.is_empty() {
return Ok(());
}
let output = git
.build_command(&["checkout", &commit, "--"])
.envs(env.iter())
.args(paths.iter().map(|path| path.as_unix_str()))
.output()
.await?;
anyhow::ensure!(
output.status.success(),
"Failed to checkout files:\n{}",
String::from_utf8_lossy(&output.stderr),
);
Ok(())
}
.boxed()
}
fn load_blob_content(&self, oid: Oid) -> BoxFuture<'_, Result<Vec<u8>>> {
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<Option<GitCommitTemplate>>> {
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<Vec<u8>>,
env: Arc<HashMap<String, String>>,
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<String, String>> {
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<String>) -> BoxFuture<'_, Result<Vec<Option<String>>>> {
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::<Vec<_>>();
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<String>,
) -> BoxFuture<'_, Result<Vec<Option<Vec<u8>>>>> {
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<String>> {
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<Result<GitStatus>> {
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<TreeDiff>> {
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::<TreeDiff>()?;
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::<GitStatus>()?;
// 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<GitStash>> {
let git = self.git_binary_in_worktree();
self.executor
.spawn(async move {
let git = git?;
let output = git
.build_command(&["stash", "list", "--pretty=format:%gd%x00%H%x00%ct%x00%s"])
.output()
.await?;
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
stdout.parse()
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("git status failed: {stderr}");
}
})
.boxed()
}
fn branches(&self) -> BoxFuture<'_, Result<BranchesScanResult>> {
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<Vec<Worktree>>> {
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<Option<SystemTime>>> {
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<OsString> = 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<OsString> = 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<String>,
) -> 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<crate::blame::Blame>> {
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<crate::blame::Blame>> {
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<String>> {
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<crate::status::GitDiffStat>> {
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<String> =
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<RepoPath>,
env: Arc<HashMap<String, String>>,
) -> 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<RepoPath>,
env: Arc<HashMap<String, String>>,
) -> 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<RepoPath>,
message: Option<String>,
env: Arc<HashMap<String, String>>,
) -> 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<String>,
env: Arc<HashMap<String, String>>,
) -> 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<usize>,
env: Arc<HashMap<String, String>>,
) -> 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<usize>,
env: Arc<HashMap<String, String>>,
) -> 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<usize>,
env: Arc<HashMap<String, String>>,
) -> 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,
ask_pass: AskPassDelegate,
env: Arc<HashMap<String, String>>,
) -> BoxFuture<'_, Result<()>> {
let git = self.git_binary_in_worktree();
let executor = self.executor.clone();
// Note: Do not spawn this command on the background thread, it might pop open the credential helper
// which we want to block on.
async move {
let git = git?;
let mut cmd = git.build_command(&["commit", "--quiet", "-m"]);
cmd.envs(env.iter())
.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}>"));
}
run_git_command(env, ask_pass, cmd, executor).await?;
Ok(())
}
.boxed()
}
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<OsString> = 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<PushOptions>,
ask_pass: AskPassDelegate,
env: Arc<HashMap<String, String>>,
cx: AsyncApp,
) -> BoxFuture<'_, Result<RemoteCommandOutput>> {
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.clone(),
is_trusted,
);
let mut command = git.build_command(&["push"]);
command
.envs(env.iter())
.args(options.map(|option| match option {
PushOptions::SetUpstream => "--set-upstream",
PushOptions::Force => "--force-with-lease",
}))
.arg(remote_name)
.arg(format!("{}:{}", branch_name, remote_branch_name))
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
run_git_command(env, ask_pass, command, executor).await
}
.boxed()
}
fn pull(
&self,
branch_name: Option<String>,
remote_name: String,
rebase: bool,
ask_pass: AskPassDelegate,
env: Arc<HashMap<String, String>>,
cx: AsyncApp,
) -> BoxFuture<'_, Result<RemoteCommandOutput>> {
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.clone(),
is_trusted,
);
let mut command = git.build_command(&["pull"]);
command.envs(env.iter());
if rebase {
command.arg("--rebase");
}
command
.arg(remote_name)
.args(branch_name)
.stdout(Stdio::piped())
.stderr(Stdio::piped());
run_git_command(env, ask_pass, command, executor).await
}
.boxed()
}
fn fetch(
&self,
fetch_options: FetchOptions,
ask_pass: AskPassDelegate,
env: Arc<HashMap<String, String>>,
cx: AsyncApp,
) -> BoxFuture<'_, Result<RemoteCommandOutput>> {
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.clone(),
is_trusted,
);
let mut command = git.build_command(&["fetch", &remote_name]);
command
.envs(env.iter())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
run_git_command(env, ask_pass, command, executor).await
}
.boxed()
}
fn get_push_remote(&self, branch: String) -> BoxFuture<'_, Result<Option<Remote>>> {
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<Option<Remote>>> {
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<Vec<Remote>>> {
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<Remote> = 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<Vec<SharedString>>> {
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 checkpoint(&self) -> BoxFuture<'static, Result<GitRepositoryCheckpoint>> {
let git = self.git_binary_in_worktree();
self.executor
.spawn(async move {
let mut git = git?.envs(checkpoint_author_envs());
git.with_temp_index(async |git| {
let head_sha = git.run(&["rev-parse", "HEAD"]).await.ok();
git.run(&["add", "--update"]).await?;
let untracked_files = untracked_files_for_checkpoint(git).await?;
add_files_to_index(git, &untracked_files).await?;
let tree = git.run(&["write-tree"]).await?;
let checkpoint_sha = if let Some(head_sha) = head_sha.as_deref() {
git.run(&["commit-tree", &tree, "-p", head_sha, "-m", "Checkpoint"])
.await?
} else {
git.run(&["commit-tree", &tree, "-m", "Checkpoint"]).await?
};
Ok(GitRepositoryCheckpoint {
commit_sha: checkpoint_sha.parse()?,
})
})
.await
})
.boxed()
}
fn restore_checkpoint(&self, checkpoint: GitRepositoryCheckpoint) -> BoxFuture<'_, Result<()>> {
let git = self.git_binary_in_worktree();
self.executor
.spawn(async move {
let git = git?;
git.run(&[
"restore",
"--source",
&checkpoint.commit_sha.to_string(),
"--worktree",
".",
])
.await?;
// TODO: We don't track binary and large files anymore,
// so the following call would delete them.
// Implement an alternative way to track files added by agent.
//
// git.with_temp_index(async move |git| {
// git.run(&["read-tree", &checkpoint.commit_sha.to_string()])
// .await?;
// git.run(&["clean", "-d", "--force"]).await
// })
// .await?;
Ok(())
})
.boxed()
}
fn 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 compare_checkpoints(
&self,
left: GitRepositoryCheckpoint,
right: GitRepositoryCheckpoint,
) -> BoxFuture<'_, Result<bool>> {
let git = self.git_binary_in_worktree();
self.executor
.spawn(async move {
let git = git?;
let result = git
.run(&[
"diff-tree",
"--quiet",
&left.commit_sha.to_string(),
&right.commit_sha.to_string(),
])
.await;
match result {
Ok(_) => Ok(true),
Err(error) => {
if let Some(GitBinaryCommandError { status, .. }) =
error.downcast_ref::<GitBinaryCommandError>()
&& status.code() == Some(1)
{
return Ok(false);
}
Err(error)
}
}
})
.boxed()
}
fn diff_checkpoints(
&self,
base_checkpoint: GitRepositoryCheckpoint,
target_checkpoint: GitRepositoryCheckpoint,
) -> BoxFuture<'_, Result<String>> {
let git = self.git_binary_in_worktree();
self.executor
.spawn(async move {
let git = git?;
git.run(&[
"diff",
"--find-renames",
"--patch",
&base_checkpoint.commit_sha.to_string(),
&target_checkpoint.commit_sha.to_string(),
])
.await
})
.boxed()
}
fn default_branch(
&self,
include_remote_name: bool,
) -> BoxFuture<'_, Result<Option<SharedString>>> {
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<HashMap<String, String>>,
) -> 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<Vec<Arc<InitialGraphCommitData>>>,
) -> 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<String> = 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<Oid>,
) -> 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<RepoPath>,
commit_limit: usize,
) -> BoxFuture<'_, Result<Vec<FileHistoryChangedFileSets>>> {
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::<HashSet<_>>()
})
.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<CommitDataReader> {
let git_binary = self.git_binary();
let (request_tx, request_rx) = async_channel::bounded::<CommitDataRequest>(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<CommitDataRequest>,
) -> 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<R: smol::io::AsyncBufRead + Unpin>(
stdout: &mut R,
sha: &Oid,
) -> Result<CommitData> {
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<String>,
) -> Vec<FileHistoryChangedFileSets> {
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::<std::collections::BTreeSet<_>>();
if changed_files.is_empty() {
continue;
}
let file_set = changed_files.iter().cloned().collect::<Vec<_>>();
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<Item = &'a str>,
) -> Vec<Arc<InitialGraphCommitData>> {
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<OsString> {
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
}
/// Lists untracked files that should be included in a checkpoint, skipping
/// commonly ignored file types and files over 2MB.
async fn untracked_files_for_checkpoint(git: &GitBinary) -> Result<Vec<String>> {
const MAX_SIZE: u64 = 2 * 1024 * 1024; // 2 MB
// The extra checkpoint excludes are passed ad hoc via --exclude-from
// rather than by mutating .git/info/exclude, whose writes would trigger a
// rescan of the repository. The scratch file is placed directly in the
// .git directory with a .tmp extension so that the worktree scanner
// filters out the events it generates.
let excludes_file_path = git
.git_directory
.join(format!("checkpoint-excludes-{}.tmp", Uuid::new_v4()));
let delete_excludes_file = util::defer({
let excludes_file_path = excludes_file_path.clone();
let executor = git.executor.clone();
move || {
executor
.spawn(async move {
smol::fs::remove_file(excludes_file_path).await.log_err();
})
.detach();
}
});
smol::fs::write(&excludes_file_path, include_str!("./checkpoint.gitignore")).await?;
let mut exclude_from_arg = OsString::from("--exclude-from=");
exclude_from_arg.push(&excludes_file_path);
let output = git
.run(&[
OsStr::new("ls-files"),
OsStr::new("--others"),
OsStr::new("--exclude-standard"),
OsStr::new("-z"),
exclude_from_arg.as_os_str(),
])
.await;
smol::fs::remove_file(&excludes_file_path).await.ok();
delete_excludes_file.abort();
let output = output?;
let working_directory = git.working_directory.clone();
let size_checks = output
.split('\0')
.filter(|path| !path.is_empty())
.map(|path| {
let full_path = working_directory.join(path);
let path = path.to_string();
smol::spawn(async move {
match smol::fs::metadata(&full_path).await {
Ok(metadata) if metadata.is_file() && metadata.len() >= MAX_SIZE => None,
_ => Some(path),
}
})
})
.collect::<Vec<_>>();
let untracked_files = futures::future::join_all(size_checks)
.await
.into_iter()
.flatten()
.collect();
Ok(untracked_files)
}
async fn add_files_to_index(git: &GitBinary, files: &[String]) -> Result<()> {
if files.is_empty() {
return Ok(());
}
let mut process = git
.build_command(&["update-index", "--add", "-z", "--stdin"])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()?;
let mut stdin = BufWriter::new(
process
.stdin
.take()
.context("no stdin for git update-index subprocess")?,
);
for file in files {
stdin.write_all(file.as_bytes()).await?;
stdin.write_all(b"\0").await?;
}
stdin.flush().await?;
drop(stdin);
let output = process.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(())
}
pub(crate) struct GitBinary {
git_binary_path: PathBuf,
working_directory: PathBuf,
git_directory: PathBuf,
executor: BackgroundExecutor,
index_file_path: Option<PathBuf>,
envs: HashMap<String, String>,
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<String, String>) -> Self {
self.envs = envs;
self
}
pub async fn with_temp_index<R>(
&mut self,
f: impl AsyncFnOnce(&Self) -> Result<R>,
) -> Result<R> {
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<S>(&self, args: &[S]) -> Result<String>
where
S: AsRef<OsStr>,
{
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<S>(&self, args: &[S]) -> Result<String>
where
S: AsRef<OsStr>,
{
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<S>(&self, args: &[S]) -> util::command::Command
where
S: AsRef<OsStr>,
{
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="]);
}
// 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
}
}
#[derive(Error, Debug)]
#[error("Git command failed:\n{stdout}{stderr}\n")]
struct GitBinaryCommandError {
stdout: String,
stderr: String,
status: ExitStatus,
}
async fn run_git_command(
env: Arc<HashMap<String, String>>,
ask_pass: AskPassDelegate,
mut command: util::command::Command,
executor: BackgroundExecutor,
) -> Result<RemoteCommandOutput> {
if env.contains_key("GIT_ASKPASS") {
let git_process = command.spawn()?;
let output = git_process.output().await?;
anyhow::ensure!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
Ok(RemoteCommandOutput {
stdout: String::from_utf8_lossy(&output.stdout).to_string(),
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
})
} else {
let ask_pass = AskPassSession::new(executor, ask_pass).await?;
command
.env("GIT_ASKPASS", ask_pass.script_path())
.env("SSH_ASKPASS", ask_pass.script_path())
.env("SSH_ASKPASS_REQUIRE", "force");
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
}
}
async fn run_askpass_command(
mut ask_pass: AskPassSession,
git_process: util::command::Child,
) -> anyhow::Result<RemoteCommandOutput> {
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<RelPath>);
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<S: AsRef<str> + ?Sized>(s: &S) -> Result<Self> {
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<Self> {
let rel_path = RelPath::new(path, path_style)?;
Ok(Self::from_rel_path(&rel_path))
}
pub fn from_proto(proto: &str) -> Result<Self> {
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<S: AsRef<str> + ?Sized>(s: &S) -> RepoPath {
RepoPath(RelPath::from_unix_str(s.as_ref()).unwrap().into())
}
impl AsRef<Arc<RelPath>> for RepoPath {
fn as_ref(&self) -> &Arc<RelPath> {
&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<RepoPath> 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<Vec<Branch>> {
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::<i64>().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<UpstreamTracking> {
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::<u32>()?;
}
if let Some(behind_num) = component.strip_prefix("behind ") {
behind = behind_num.parse::<u32>()?;
}
}
Ok(UpstreamTracking::Tracked(UpstreamTrackingStatus {
ahead,
behind,
}))
}
fn checkpoint_author_envs() -> HashMap<String, String> {
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_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<I, S>(working_directory: &Path, arguments: I) -> String
where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
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<I, S>(working_directory: &Path, arguments: I)
where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
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<String, String> {
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<Path>, right: impl AsRef<Path>) {
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_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 },
)]),
}
);
}
#[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::<Vec<_>>();
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(&notes_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(&notes_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()))
);
}
#[gpui::test]
async fn test_checkpoint_basic(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");
smol::fs::write(&file_path, "initial").await.unwrap();
let repo = RealGitRepository::new(
&repo_dir.path().join(".git"),
None,
Some("git".into()),
cx.executor(),
)
.unwrap();
repo.stage_paths(vec![repo_path("file")], Arc::new(HashMap::default()))
.await
.unwrap();
repo.commit(
"Initial commit".into(),
None,
CommitOptions::default(),
AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}),
Arc::new(test_commit_envs()),
)
.await
.unwrap();
smol::fs::write(&file_path, "modified before checkpoint")
.await
.unwrap();
smol::fs::write(repo_dir.path().join("new_file_before_checkpoint"), "1")
.await
.unwrap();
let checkpoint = repo.checkpoint().await.unwrap();
// Ensure the user can't see any branches after creating a checkpoint.
assert_eq!(repo.branches().await.unwrap().branches.len(), 1);
smol::fs::write(&file_path, "modified after checkpoint")
.await
.unwrap();
repo.stage_paths(vec![repo_path("file")], Arc::new(HashMap::default()))
.await
.unwrap();
repo.commit(
"Commit after checkpoint".into(),
None,
CommitOptions::default(),
AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}),
Arc::new(test_commit_envs()),
)
.await
.unwrap();
smol::fs::remove_file(repo_dir.path().join("new_file_before_checkpoint"))
.await
.unwrap();
smol::fs::write(repo_dir.path().join("new_file_after_checkpoint"), "2")
.await
.unwrap();
// Ensure checkpoint stays alive even after a Git GC.
repo.gc().await.unwrap();
repo.restore_checkpoint(checkpoint.clone()).await.unwrap();
assert_eq!(
smol::fs::read_to_string(&file_path).await.unwrap(),
"modified before checkpoint"
);
assert_eq!(
smol::fs::read_to_string(repo_dir.path().join("new_file_before_checkpoint"))
.await
.unwrap(),
"1"
);
// See TODO above
// assert_eq!(
// smol::fs::read_to_string(repo_dir.path().join("new_file_after_checkpoint"))
// .await
// .ok(),
// None
// );
}
#[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<_>>(),
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<_>>(),
vec![(first_sha.clone(), 0..1)]
);
}
#[gpui::test]
async fn test_checkpoint_empty_repo(cx: &mut TestAppContext) {
disable_git_global_config();
cx.executor().allow_parking();
let repo_dir = tempfile::tempdir().unwrap();
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("foo"), "foo")
.await
.unwrap();
let checkpoint_sha = repo.checkpoint().await.unwrap();
// Ensure the user can't see any branches after creating a checkpoint.
assert_eq!(repo.branches().await.unwrap().branches.len(), 1);
smol::fs::write(repo_dir.path().join("foo"), "bar")
.await
.unwrap();
smol::fs::write(repo_dir.path().join("baz"), "qux")
.await
.unwrap();
repo.restore_checkpoint(checkpoint_sha).await.unwrap();
assert_eq!(
smol::fs::read_to_string(repo_dir.path().join("foo"))
.await
.unwrap(),
"foo"
);
// See TODOs above
// assert_eq!(
// smol::fs::read_to_string(repo_dir.path().join("baz"))
// .await
// .ok(),
// None
// );
}
#[gpui::test]
async fn test_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")
);
}
#[gpui::test]
async fn test_compare_checkpoints(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("file1"), "content1")
.await
.unwrap();
let checkpoint1 = repo.checkpoint().await.unwrap();
smol::fs::write(repo_dir.path().join("file2"), "content2")
.await
.unwrap();
let checkpoint2 = repo.checkpoint().await.unwrap();
assert!(
!repo
.compare_checkpoints(checkpoint1, checkpoint2.clone())
.await
.unwrap()
);
let checkpoint3 = repo.checkpoint().await.unwrap();
assert!(
repo.compare_checkpoints(checkpoint2, checkpoint3)
.await
.unwrap()
);
}
#[gpui::test]
async fn test_checkpoint_exclude_binary_files(cx: &mut TestAppContext) {
disable_git_global_config();
cx.executor().allow_parking();
let repo_dir = tempfile::tempdir().unwrap();
let text_path = repo_dir.path().join("main.rs");
let bin_path = repo_dir.path().join("binary.o");
git_init_repo(repo_dir.path());
smol::fs::write(&text_path, "fn main() {}").await.unwrap();
smol::fs::write(&bin_path, "some binary file here")
.await
.unwrap();
let repo = RealGitRepository::new(
&repo_dir.path().join(".git"),
None,
Some("git".into()),
cx.executor(),
)
.unwrap();
// initial commit
repo.stage_paths(vec![repo_path("main.rs")], Arc::new(HashMap::default()))
.await
.unwrap();
repo.commit(
"Initial commit".into(),
None,
CommitOptions::default(),
AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}),
Arc::new(test_commit_envs()),
)
.await
.unwrap();
let checkpoint = repo.checkpoint().await.unwrap();
smol::fs::write(&text_path, "fn main() { println!(\"Modified\"); }")
.await
.unwrap();
smol::fs::write(&bin_path, "Modified binary file")
.await
.unwrap();
repo.restore_checkpoint(checkpoint).await.unwrap();
// Text files should be restored to checkpoint state,
// but binaries should not (they aren't tracked)
assert_eq!(
smol::fs::read_to_string(&text_path).await.unwrap(),
"fn main() {}"
);
assert_eq!(
smol::fs::read_to_string(&bin_path).await.unwrap(),
"Modified binary file"
);
}
#[test]
fn test_branches_parsing() {
// suppress "help: octal escapes are not supported, `\0` is always null"
#[allow(clippy::octal_escapes)]
let input = "*\0060964da10574cd9bf06463a53bf6e0769c5c45e\0\0refs/heads/zed-patches\0refs/remotes/origin/zed-patches\0\01733187470\0John Doe\0generated protobuf\n";
assert_eq!(
parse_branch_input(input).unwrap(),
vec![Branch {
is_head: true,
ref_name: "refs/heads/zed-patches".into(),
upstream: Some(Upstream {
ref_name: "refs/remotes/origin/zed-patches".into(),
tracking: UpstreamTracking::Tracked(UpstreamTrackingStatus {
ahead: 0,
behind: 0
})
}),
most_recent_commit: Some(CommitSummary {
sha: "060964da10574cd9bf06463a53bf6e0769c5c45e".into(),
subject: "generated protobuf".into(),
commit_timestamp: 1733187470,
author_name: SharedString::new_static("John Doe"),
has_parent: false,
})
}]
)
}
#[test]
fn test_branches_parsing_containing_refs_with_missing_fields() {
#[allow(clippy::octal_escapes)]
let input = " \090012116c03db04344ab10d50348553aa94f1ea0\0refs/heads/broken\n \0eb0cae33272689bd11030822939dd2701c52f81e\0895951d681e5561478c0acdd6905e8aacdfd2249\0refs/heads/dev\0\0\01762948725\0Zed\0Add feature\n*\0895951d681e5561478c0acdd6905e8aacdfd2249\0\0refs/heads/main\0\0\01762948695\0Zed\0Initial commit\n";
let branches = parse_branch_input(input).unwrap();
assert_eq!(branches.len(), 2);
assert_eq!(
branches,
vec![
Branch {
is_head: false,
ref_name: "refs/heads/dev".into(),
upstream: None,
most_recent_commit: Some(CommitSummary {
sha: "eb0cae33272689bd11030822939dd2701c52f81e".into(),
subject: "Add feature".into(),
commit_timestamp: 1762948725,
author_name: SharedString::new_static("Zed"),
has_parent: true,
})
},
Branch {
is_head: true,
ref_name: "refs/heads/main".into(),
upstream: None,
most_recent_commit: Some(CommitSummary {
sha: "895951d681e5561478c0acdd6905e8aacdfd2249".into(),
subject: "Initial commit".into(),
commit_timestamp: 1762948695,
author_name: SharedString::new_static("Zed"),
has_parent: false,
})
}
]
)
}
#[test]
fn test_upstream_branch_name() {
let upstream = Upstream {
ref_name: "refs/remotes/origin/feature/branch".into(),
tracking: UpstreamTracking::Tracked(UpstreamTrackingStatus {
ahead: 0,
behind: 0,
}),
};
assert_eq!(upstream.branch_name(), Some("feature/branch"));
let upstream = Upstream {
ref_name: "refs/remotes/upstream/main".into(),
tracking: UpstreamTracking::Tracked(UpstreamTrackingStatus {
ahead: 0,
behind: 0,
}),
};
assert_eq!(upstream.branch_name(), Some("main"));
let upstream = Upstream {
ref_name: "refs/heads/local".into(),
tracking: UpstreamTracking::Tracked(UpstreamTrackingStatus {
ahead: 0,
behind: 0,
}),
};
assert_eq!(upstream.branch_name(), None);
// Test case where upstream branch name differs from what might be the local branch name
let upstream = Upstream {
ref_name: "refs/remotes/origin/feature/git-pull-request".into(),
tracking: UpstreamTracking::Tracked(UpstreamTrackingStatus {
ahead: 0,
behind: 0,
}),
};
assert_eq!(upstream.branch_name(), Some("feature/git-pull-request"));
}
#[test]
fn test_parse_worktrees_from_str() {
// Empty input
let result = parse_worktrees_from_str("", 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_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
};
smol::fs::write(repo_dir.path().join("file1"), "1")
.await
.unwrap();
let branch_sha = repo.checkpoint().await.unwrap().commit_sha;
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 = repo.checkpoint().await.unwrap().commit_sha;
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 <work_dir>/.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())
);
}
impl RealGitRepository {
/// Force a Git garbage collection on the repository.
fn gc(&self) -> BoxFuture<'_, Result<()>> {
let working_directory = self.command_directory();
let git_directory = self.path();
let git_binary_path = self.any_git_binary_path.clone();
let executor = self.executor.clone();
self.executor
.spawn(async move {
let git_binary_path = git_binary_path.clone();
let git = GitBinary::new(
git_binary_path,
working_directory,
git_directory,
executor,
true,
);
git.run(&["gc", "--prune"]).await?;
Ok(())
})
.boxed()
}
}
#[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"
);
}
}