Files
zed/crates/git/src/repository.rs
T
Richard Feldman 29622911de Prevent archival of manually-created worktrees (#58275)
This fixes archive cleanup for agent threads so Zed only removes
worktrees it explicitly created, rather than treating every linked
worktree under the configured managed directory as safe to delete. When
Zed creates a worktree, it now records it in the local database along
with the creation time of the worktree's git metadata directory
(`.git/worktrees/<name>/`). Archive planning requires that record, and
right before deleting anything, Zed re-stats the directory and compares
creation times: if the worktree was removed and recreated outside Zed
(or the time can't be read at all), deletion is skipped and the stale
record is dropped. Every failure mode fails safe by leaving the
directory untouched.

For remote (SSH) projects, the stat runs on the remote host via a new
`GitWorktreeCreatedAt` request. Worktrees created by a different Zed
install (another release channel, or another machine connecting to the
same host) have no record in the local database and are therefore never
auto-archived, which is the intended conservative behavior.

Closes https://github.com/zed-industries/zed/issues/57155

Closes AI-352

Release Notes:

- Fixed archiving an agent thread incorrectly deleting manually-created
git worktrees.
2026-06-16 15:36:59 +00:00

5424 lines
178 KiB
Rust

use crate::commit::parse_git_diff_name_status;
use crate::stash::GitStash;
use crate::status::{DiffTreeType, GitStatus, StatusCode, TreeDiff};
use crate::{Oid, RunHook, SHORT_SHA_LENGTH};
use anyhow::{Context as _, Result, anyhow, bail};
use 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::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, main_worktree_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(main_path) = main_worktree_path {
let main_dir = main_path.file_name().and_then(|n| n.to_str());
if main_dir == Some(dir_name) {
if let Some(parent_name) = self
.path
.parent()
.and_then(|p| p.file_name())
.and_then(|n| n.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,
}
#[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>,
}
#[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_text: Option<String>,
pub new_text: Option<String>,
pub is_binary: bool,
}
impl CommitFile {
pub fn status(&self) -> CommitFileStatus {
match (&self.old_text, &self.new_text) {
(None, Some(_)) => CommitFileStatus::Added,
(Some(_), None) => CommitFileStatus::Deleted,
_ => CommitFileStatus::Modified,
}
}
}
impl CommitDetails {
pub fn short_sha(&self) -> SharedString {
self.sha[..SHORT_SHA_LENGTH].to_string().into()
}
}
/// Detects if content is binary by checking for NUL bytes in the first 8000 bytes.
/// This matches git's binary detection heuristic.
pub fn is_binary_content(content: &[u8]) -> bool {
let check_len = content.len().min(8000);
content[..check_len].contains(&0)
}
#[derive(Debug, Clone, Hash, PartialEq, Eq)]
pub struct Remote {
pub name: SharedString,
}
pub enum ResetMode {
/// Reset the branch pointer, leave index and worktree unchanged (this will make it look like things that were
/// committed are now staged).
Soft,
/// Reset the branch pointer and index, leave worktree unchanged (this makes it look as though things that were
/// committed are now unstaged).
Mixed,
}
#[derive(Debug, Clone, Hash, PartialEq, Eq)]
pub enum FetchOptions {
All,
Remote(Remote),
}
impl FetchOptions {
pub fn to_proto(&self) -> Option<String> {
match self {
FetchOptions::All => None,
FetchOptions::Remote(remote) => Some(remote.clone().name.into()),
}
}
pub fn from_proto(remote_name: Option<String>) -> Self {
match remote_name {
Some(name) => FetchOptions::Remote(Remote { name: name.into() }),
None => FetchOptions::All,
}
}
pub fn name(&self) -> SharedString {
match self {
Self::All => "Fetch all remotes".into(),
Self::Remote(remote) => remote.name.clone(),
}
}
}
impl std::fmt::Display for FetchOptions {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
FetchOptions::All => write!(f, "--all"),
FetchOptions::Remote(remote) => write!(f, "{}", remote.name),
}
}
}
/// Modifies .git/info/exclude temporarily
pub struct GitExcludeOverride {
git_exclude_path: PathBuf,
original_excludes: Option<String>,
added_excludes: Option<String>,
}
impl GitExcludeOverride {
const START_BLOCK_MARKER: &str = "\n\n# ====== Auto-added by Zed: =======\n";
const END_BLOCK_MARKER: &str = "\n# ====== End of auto-added by Zed =======\n";
pub async fn new(git_exclude_path: PathBuf) -> Result<Self> {
let original_excludes =
smol::fs::read_to_string(&git_exclude_path)
.await
.ok()
.map(|content| {
// Auto-generated lines are normally cleaned up in
// `restore_original()` or `drop()`, but may stuck in rare cases.
// Make sure to remove them.
Self::remove_auto_generated_block(&content)
});
Ok(GitExcludeOverride {
git_exclude_path,
original_excludes,
added_excludes: None,
})
}
pub async fn add_excludes(&mut self, excludes: &str) -> Result<()> {
self.added_excludes = Some(if let Some(ref already_added) = self.added_excludes {
format!("{already_added}\n{excludes}")
} else {
excludes.to_string()
});
let mut content = self.original_excludes.clone().unwrap_or_default();
content.push_str(Self::START_BLOCK_MARKER);
content.push_str(self.added_excludes.as_ref().unwrap());
content.push_str(Self::END_BLOCK_MARKER);
smol::fs::write(&self.git_exclude_path, content).await?;
Ok(())
}
pub async fn restore_original(&mut self) -> Result<()> {
if let Some(ref original) = self.original_excludes {
smol::fs::write(&self.git_exclude_path, original).await?;
} else if self.git_exclude_path.exists() {
smol::fs::remove_file(&self.git_exclude_path).await?;
}
self.added_excludes = None;
Ok(())
}
fn remove_auto_generated_block(content: &str) -> String {
let start_marker = Self::START_BLOCK_MARKER;
let end_marker = Self::END_BLOCK_MARKER;
let mut content = content.to_string();
let start_index = content.find(start_marker);
let end_index = content.rfind(end_marker);
if let (Some(start), Some(end)) = (start_index, end_index) {
if end > start {
content.replace_range(start..end + end_marker.len(), "");
}
}
// Older versions of Zed didn't have end-of-block markers,
// so it's impossible to determine auto-generated lines.
// Conservatively remove the standard list of excludes
let standard_excludes = format!(
"{}{}",
Self::START_BLOCK_MARKER,
include_str!("./checkpoint.gitignore")
);
content = content.replace(&standard_excludes, "");
content
}
}
impl Drop for GitExcludeOverride {
fn drop(&mut self) {
if self.added_excludes.is_some() {
let git_exclude_path = self.git_exclude_path.clone();
let original_excludes = self.original_excludes.clone();
smol::spawn(async move {
if let Some(original) = original_excludes {
smol::fs::write(&git_exclude_path, original).await
} else {
smol::fs::remove_file(&git_exclude_path).await
}
})
.detach();
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Hash, Copy)]
pub enum LogOrder {
#[default]
DateOrder,
TopoOrder,
AuthorDateOrder,
ReverseChronological,
}
impl LogOrder {
pub fn as_arg(&self) -> &'static str {
match self {
LogOrder::DateOrder => "--date-order",
LogOrder::TopoOrder => "--topo-order",
LogOrder::AuthorDateOrder => "--author-date-order",
LogOrder::ReverseChronological => "--reverse",
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)]
pub enum LogSource {
#[default]
All,
Branch(SharedString),
Sha(Oid),
Path(RepoPath),
}
impl LogSource {
fn get_args(&self) -> Result<Vec<&str>> {
match self {
LogSource::All => Ok(vec![
"--ignore-missing", // needed in case of unborn HEAD
"--branches",
"--remotes",
"--tags",
"HEAD",
]),
LogSource::Branch(branch) => Ok(vec![branch.as_str()]),
LogSource::Sha(oid) => Ok(vec![
str::from_utf8(oid.as_bytes()).context("Failed to build str from sha")?,
]),
LogSource::Path(path) => Ok(vec!["--follow", "--", path.as_unix_str()]),
}
}
}
pub struct SearchCommitArgs {
pub query: SharedString,
pub case_sensitive: bool,
}
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<String>>;
/// 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<String>>;
fn load_blob_content(&self, oid: Oid) -> BoxFuture<'_, Result<String>>;
fn set_index_text(
&self,
path: RepoPath,
content: Option<String>,
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 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 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, cx: AsyncApp) -> BoxFuture<'_, Result<CommitDiff>>;
fn blame(
&self,
path: RepoPath,
content: Rope,
line_ending: LineEnding,
) -> 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<()>>;
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>,
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,
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(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
}
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,
])
.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, cx: AsyncApp) -> BoxFuture<'_, Result<CommitDiff>> {
let git = self.git_binary();
cx.background_spawn(async move {
let show_output = git
.build_command(&[
"show",
"--format=",
"-z",
"--no-renames",
"--name-status",
"--first-parent",
])
.arg(&commit)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.await
.context("starting git show process")?;
let show_stdout = String::from_utf8_lossy(&show_output.stdout);
let changes = parse_git_diff_name_status(&show_stdout);
let parent_sha = format!("{}^", commit);
let mut cat_file_process = git
.build_command(&["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 mut stdin = BufWriter::with_capacity(512, cat_file_process.stdin.take().unwrap());
let mut stdout = BufReader::new(cat_file_process.stdout.take().unwrap());
let mut info_line = String::new();
let mut newline = [b'\0'];
for (path, status_code) in changes {
// git-show outputs `/`-delimited paths even on Windows.
let Some(rel_path) = RelPath::unix(path).log_err() else {
continue;
};
match status_code {
StatusCode::Modified => {
stdin.write_all(commit.as_bytes()).await?;
stdin.write_all(b":").await?;
stdin.write_all(path.as_bytes()).await?;
stdin.write_all(b"\n").await?;
stdin.write_all(parent_sha.as_bytes()).await?;
stdin.write_all(b":").await?;
stdin.write_all(path.as_bytes()).await?;
stdin.write_all(b"\n").await?;
}
StatusCode::Added => {
stdin.write_all(commit.as_bytes()).await?;
stdin.write_all(b":").await?;
stdin.write_all(path.as_bytes()).await?;
stdin.write_all(b"\n").await?;
}
StatusCode::Deleted => {
stdin.write_all(parent_sha.as_bytes()).await?;
stdin.write_all(b":").await?;
stdin.write_all(path.as_bytes()).await?;
stdin.write_all(b"\n").await?;
}
_ => continue,
}
stdin.flush().await?;
info_line.clear();
stdout.read_line(&mut info_line).await?;
let len = info_line.trim_end().parse().with_context(|| {
format!("invalid object size output from cat-file {info_line}")
})?;
let mut text_bytes = vec![0; len];
stdout.read_exact(&mut text_bytes).await?;
stdout.read_exact(&mut newline).await?;
let mut old_text = None;
let mut new_text = None;
let mut is_binary = is_binary_content(&text_bytes);
let text = if is_binary {
String::new()
} else {
String::from_utf8_lossy(&text_bytes).to_string()
};
match status_code {
StatusCode::Modified => {
info_line.clear();
stdout.read_line(&mut info_line).await?;
let len = info_line.trim_end().parse().with_context(|| {
format!("invalid object size output from cat-file {}", info_line)
})?;
let mut parent_bytes = vec![0; len];
stdout.read_exact(&mut parent_bytes).await?;
stdout.read_exact(&mut newline).await?;
is_binary = is_binary || is_binary_content(&parent_bytes);
if is_binary {
old_text = Some(String::new());
new_text = Some(String::new());
} else {
old_text = Some(String::from_utf8_lossy(&parent_bytes).to_string());
new_text = Some(text);
}
}
StatusCode::Added => new_text = Some(text),
StatusCode::Deleted => old_text = Some(text),
_ => continue,
}
files.push(CommitFile {
path: RepoPath(Arc::from(rel_path)),
old_text,
new_text,
is_binary,
})
}
Ok(CommitDiff { files })
})
.boxed()
}
fn reset(
&self,
commit: String,
mode: ResetMode,
env: Arc<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_index_text(&self, path: RepoPath) -> BoxFuture<'_, Option<String>> {
let git_binary = self.git_binary();
let path_str = format!(":{}", path.as_unix_str());
self.executor
.spawn(async move {
let git = git_binary;
let output = git
.build_command(&["show", &path_str])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.await
.log_err()?;
if !output.status.success() {
return None;
}
String::from_utf8(output.stdout).ok()
})
.boxed()
}
fn load_committed_text(&self, path: RepoPath) -> BoxFuture<'_, Option<String>> {
let git = self.git_binary();
let path_str = format!("HEAD:{}", path.as_unix_str());
self.executor
.spawn(async move {
let output = git
.build_command(&["show", &path_str])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.await
.log_err()?;
if !output.status.success() {
return None;
}
String::from_utf8(output.stdout).ok()
})
.boxed()
}
fn load_blob_content(&self, oid: Oid) -> BoxFuture<'_, Result<String>> {
let git_binary = self.git_binary();
let oid_str = oid.to_string();
self.executor
.spawn(async move { git_binary.run_raw(&["cat-file", "blob", &oid_str]).await })
.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<String>,
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"])
.envs(env.iter())
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn()?;
let mut stdin = child.stdin.take().unwrap();
stdin.write_all(content.as_bytes()).await?;
stdin.flush().await?;
drop(stdin);
let output = child.output().await?.stdout;
let sha = str::from_utf8(&output)?.trim();
log::debug!("indexing SHA: {sha}, path {path:?}");
let output = git
.build_command(&["update-index", "--add", "--cacheinfo", mode, sha])
.envs(env.iter())
.arg(path.as_unix_str())
.output()
.await?;
anyhow::ensure!(
output.status.success(),
"Failed to stage:\n{}",
String::from_utf8_lossy(&output.stderr)
);
} else {
log::debug!("removing path {path:?} from the index");
let output = git
.build_command(&["update-index", "--force-remove", "--"])
.envs(env.iter())
.arg(path.as_unix_str())
.output()
.await?;
anyhow::ensure!(
output.status.success(),
"Failed to unstage:\n{}",
String::from_utf8_lossy(&output.stderr)
);
}
Ok(())
})
.boxed()
}
fn remote_urls(&self) -> BoxFuture<'_, HashMap<String, String>> {
let git = self.git_binary();
self.executor
.spawn(async move {
let mut urls = HashMap::default();
if let Ok(stdout) = git.run(&["remote", "-v"]).await {
for line in stdout.lines() {
if let Some(line) = line.strip_suffix(" (fetch)")
&& let Some((name, url)) = line.split_once(char::is_whitespace)
{
urls.insert(name.to_string(), url.trim_start().to_string());
}
}
}
urls
})
.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 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 diff_tree(&self, request: DiffTreeType) -> BoxFuture<'_, Result<TreeDiff>> {
let git = self.git_binary_in_worktree();
let mut args = vec![
OsString::from("diff-tree"),
OsString::from("-r"),
OsString::from("-z"),
OsString::from("--no-renames"),
];
match request {
DiffTreeType::MergeBase { base, head } => {
args.push("--merge-base".into());
args.push(OsString::from(base.as_str()));
args.push(OsString::from(head.as_str()));
}
DiffTreeType::Since { base, head } => {
args.push(OsString::from(base.as_str()));
args.push(OsString::from(head.as_str()));
}
}
self.executor
.spawn(async move {
let 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}");
}
})
.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 (_, 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 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,
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(),
"HEAD".into(),
];
if !path_prefixes.is_empty() {
args.push("--".into());
args.extend(
path_prefixes
.iter()
.map(|p| p.as_std_path().to_string_lossy().into_owned()),
);
}
let output = git_binary.run(&args).await?;
Ok(crate::status::parse_numstat(&output))
})
.boxed()
}
fn stage_paths(
&self,
paths: Vec<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>,
env: Arc<HashMap<String, String>>,
) -> BoxFuture<'_, Result<()>> {
let git = self.git_binary_in_worktree();
self.executor
.spawn(async move {
let git = git?;
let output = git
.build_command(&["stash", "push", "--quiet", "--include-untracked", "--"])
.envs(env.iter())
.args(paths.iter().map(|p| p.as_unix_str()))
.output()
.await?;
anyhow::ensure!(
output.status.success(),
"Failed to stash:\n{}",
String::from_utf8_lossy(&output.stderr)
);
Ok(())
})
.boxed()
}
fn stash_pop(
&self,
index: Option<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")
.arg("--no-verify")
.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 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();
let mut excludes = exclude_files(git).await?;
git.run(&["add", "--all"]).await?;
let tree = git.run(&["write-tree"]).await?;
let checkpoint_sha = if let Some(head_sha) = head_sha.as_deref() {
git.run(&["commit-tree", &tree, "-p", head_sha, "-m", "Checkpoint"])
.await?
} else {
git.run(&["commit-tree", &tree, "-m", "Checkpoint"]).await?
};
excludes.restore_original().await?;
Ok(GitRepositoryCheckpoint {
commit_sha: checkpoint_sha.parse()?,
})
})
.await
})
.boxed()
}
fn restore_checkpoint(&self, checkpoint: GitRepositoryCheckpoint) -> BoxFuture<'_, Result<()>> {
let git = 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 mut git_log_command = vec!["log", GRAPH_COMMIT_FORMAT, log_order.as_arg()];
git_log_command.extend(log_source.get_args()?);
let mut command = git.build_command(&git_log_command);
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 mut args = vec!["log", SEARCH_COMMIT_FORMAT];
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.get_args()?);
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 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();
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",
"--",
]
.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 stdout = String::from_utf8_lossy(&output.stdout);
Ok(parse_file_history_changed_files_output(&stdout, &paths))
}
.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],
) -> Vec<FileHistoryChangedFileSets> {
let mut histories = vec![FileHistoryChangedFileSets::default(); queried_paths.len()];
for record in output.split('\x1e') {
let changed_files = record
.split('\0')
.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(", ")
.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
}
/// Temporarily git-ignore commonly ignored files and files over 2MB
async fn exclude_files(git: &GitBinary) -> Result<GitExcludeOverride> {
const MAX_SIZE: u64 = 2 * 1024 * 1024; // 2 MB
let mut excludes = git.with_exclude_overrides().await?;
excludes
.add_excludes(include_str!("./checkpoint.gitignore"))
.await?;
let working_directory = git.working_directory.clone();
let untracked_files = git.list_untracked_files().await?;
let excluded_paths = untracked_files.into_iter().map(|path| {
let working_directory = working_directory.clone();
smol::spawn(async move {
let full_path = working_directory.join(path.clone());
match smol::fs::metadata(&full_path).await {
Ok(metadata) if metadata.is_file() && metadata.len() >= MAX_SIZE => {
Some(PathBuf::from("/").join(path.clone()))
}
_ => None,
}
})
});
let excluded_paths = futures::future::join_all(excluded_paths).await;
let excluded_paths = excluded_paths.into_iter().flatten().collect::<Vec<_>>();
if !excluded_paths.is_empty() {
let exclude_patterns = excluded_paths
.into_iter()
.map(|path| path.to_string_lossy().into_owned())
.collect::<Vec<_>>()
.join("\n");
excludes.add_excludes(&exclude_patterns).await?;
}
Ok(excludes)
}
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,
}
}
async fn list_untracked_files(&self) -> Result<Vec<PathBuf>> {
let status_output = self
.run(&["status", "--porcelain=v1", "--untracked-files=all", "-z"])
.await?;
let paths = status_output
.split('\0')
.filter(|entry| entry.len() >= 3 && entry.starts_with("?? "))
.map(|entry| PathBuf::from(&entry[3..]))
.collect::<Vec<_>>();
Ok(paths)
}
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)
}
pub async fn with_exclude_overrides(&self) -> Result<GitExcludeOverride> {
let path = self.git_directory.join("info").join("exclude");
GitExcludeOverride::new(path).await
}
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="]);
}
command.args(args);
// 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.
if !self.is_trusted && args.iter().any(|arg| arg.as_ref() == "diff") {
command.arg("--no-ext-diff");
}
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");
#[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! {
result = ask_pass.run().fuse() => {
match result {
AskPassResult::CancelledByUser => {
Err(anyhow!(REMOTE_CANCELLED_BY_USER))?
}
AskPassResult::Timedout => {
Err(anyhow!("Connecting to host timed out"))?
}
}
}
output = git_process.output().fuse() => {
let output = output?;
anyhow::ensure!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
Ok(RemoteCommandOutput {
stdout: String::from_utf8_lossy(&output.stdout).to_string(),
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
})
}
}
}
#[derive(Clone, Ord, Hash, PartialOrd, Eq, PartialEq)]
pub struct RepoPath(Arc<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::unix(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_proto(proto)?;
Ok(Self(rel_path))
}
pub fn from_rel_path(path: &RelPath) -> RepoPath {
Self(Arc::from(path))
}
pub fn as_std_path(&self) -> &Path {
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::unix(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;
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<I, S>(working_directory: &Path, arguments: I)
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)
);
}
fn git_init_repo(path: &Path) {
fs::create_dir_all(path).expect("failed to create repo directory");
git_command(path, ["init", "-b", "main"]);
}
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_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_dir = temp_dir.path().join("remote.git");
let seed_dir = temp_dir.path().join("seed");
let clone_dir = temp_dir.path().join("clone");
git_command(
temp_dir.path(),
[
OsString::from("init"),
OsString::from("--bare"),
OsString::from("-b"),
OsString::from("main"),
remote_dir.as_os_str().into(),
],
);
git_init_repo(&seed_dir);
fs::write(seed_dir.join("file.txt"), "main").unwrap();
git_command(&seed_dir, ["add", "file.txt"]);
git_command(&seed_dir, ["commit", "-m", "initial"]);
git_command(&seed_dir, ["switch", "-c", "feature"]);
fs::write(seed_dir.join("feature.txt"), "feature").unwrap();
git_command(&seed_dir, ["add", "feature.txt"]);
git_command(&seed_dir, ["commit", "-m", "feature"]);
git_command(
&seed_dir,
[
OsString::from("remote"),
OsString::from("add"),
OsString::from("origin"),
remote_dir.as_os_str().into(),
],
);
git_command(&seed_dir, ["push", "-u", "origin", "main"]);
git_command(&seed_dir, ["push", "-u", "origin", "feature"]);
git_command(
temp_dir.path(),
[
OsString::from("clone"),
remote_dir.as_os_str().into(),
clone_dir.as_os_str().into(),
],
);
let repository = RealGitRepository::new(
&clone_dir.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]
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!(
"\x1e\0\nsrc/a.rs\0src/shared.rs\0",
"\x1e\0\nsrc/b.rs\0src/shared.rs\0",
"\x1e\0\nsrc/a.rs\0src/b.rs\0src/shared.rs\0",
);
let histories = parse_file_history_changed_files_output(output, &queried_paths);
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(),
],
]
);
}
#[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
// );
}
#[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()
}
}
#[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();
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"
);
}
}