zed/crates/git/src/repository.rs
Smit Barmase 0de588c0c9
git_ui: Add force delete for unmerged branches (#55927)
Git's `-d` flag deletes a branch only if it's fully merged into its
upstream or HEAD - this is what we were using before, which caused the
"not fully merged" error. The `-D` flag force deletes a branch even with
unmerged changes (equivalent to `--delete --force`).

### Before

Deleting an unmerged branch failed with a "not fully merged" error
toast.

### After

- Deleting an unmerged branch prompts for confirmation to force delete
- Delete button tooltip shows "Hold alt to force delete" hint
- Holding **alt** turns the delete icon red and tooltip changes to
"Force Delete Branch"
- Force delete keybinding: `cmd-alt-shift-backspace`

Release Notes:

- Added confirmation prompt when deleting unmerged git branches, with
option to force delete.
- Added alt+click on delete button to force delete a branch immediately.
2026-05-06 20:08:42 +00:00

4543 lines
151 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 git2::{BranchType, ErrorCode};
use gpui::{AppContext as _, AsyncApp, BackgroundExecutor, SharedString, Task};
use parking_lot::Mutex;
use rope::Rope;
use schemars::JsonSchema;
use serde::Deserialize;
use smallvec::SmallVec;
use smol::io::{AsyncBufReadExt, AsyncReadExt, BufReader};
use text::LineEnding;
use std::collections::HashSet;
use std::ffi::{OsStr, OsString};
use std::sync::atomic::AtomicBool;
use std::process::ExitStatus;
use std::str::FromStr;
use std::{
cmp::Ordering,
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
}
}
/// 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>,
}
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>,
}
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(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_arg(&self) -> Result<&str> {
match self {
LogSource::All => Ok("--all"),
LogSource::Branch(branch) => Ok(branch.as_str()),
LogSource::Sha(oid) => {
str::from_utf8(oid.as_bytes()).context("Failed to build str from sha")
}
LogSource::Path(_) => Ok("--follow"),
}
}
}
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 {
fn reload_index(&self);
/// Returns the contents of an entry in the repository's index, or None if there is no entry for the given path.
///
/// Also returns `None` for symlinks.
fn load_index_text(&self, path: RepoPath) -> BoxFuture<'_, Option<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>>;
/// 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<'_, Result<GitStash>>;
fn branches(&self) -> BoxFuture<'_, Result<Vec<Branch>>>;
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>>>;
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<'_, 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 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 repository: Arc<Mutex<git2::Repository>>,
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 workdir_root = dotgit_path.parent().context(".git has no parent")?;
let repository =
git2::Repository::open(workdir_root).context("creating libgit2 repository")?;
Ok(Self {
repository: Arc::new(Mutex::new(repository)),
system_git_binary_path,
any_git_binary_path,
executor,
any_git_binary_help_output: Arc::new(Mutex::new(None)),
is_trusted: Arc::new(AtomicBool::new(false)),
})
}
fn working_directory(&self) -> Result<PathBuf> {
self.repository
.lock()
.workdir()
.context("failed to read git work directory")
.map(Path::to_path_buf)
}
fn git_binary_in_worktree(&self) -> Result<GitBinary> {
Ok(GitBinary::new(
self.any_git_binary_path.clone(),
self.working_directory()
.with_context(|| "Can't run git commands without a working directory")?,
self.path(),
self.executor.clone(),
self.is_trusted(),
))
}
fn git_binary(&self) -> GitBinary {
let repository = self.repository.lock();
let working_directory = repository
.workdir()
.unwrap_or_else(|| repository.path())
.to_path_buf();
GitBinary::new(
self.any_git_binary_path.clone(),
working_directory,
repository.path().to_path_buf(),
self.executor.clone(),
self.is_trusted(),
)
}
fn edit_ref(&self, edit: RefEdit) -> BoxFuture<'_, Result<()>> {
let git = self.git_binary();
self.executor
.spawn(async move {
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 reload_index(&self) {
if let Ok(mut index) = self.repository.lock().index() {
_ = index.read(false);
}
}
fn path(&self) -> PathBuf {
let repo = self.repository.lock();
repo.path().into()
}
fn main_repository_path(&self) -> PathBuf {
let repo = self.repository.lock();
repo.commondir().into()
}
fn show(&self, commit: String) -> BoxFuture<'_, Result<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_binary = self.git_binary_in_worktree();
async move {
let mode_flag = match mode {
ResetMode::Mixed => "--mixed",
ResetMode::Soft => "--soft",
};
let git = git_binary?;
let output = git
.build_command(&["reset", mode_flag, &commit])
.envs(env.iter())
.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_binary = self.git_binary_in_worktree();
async move {
if paths.is_empty() {
return Ok(());
}
let git = git_binary?;
let output = git
.build_command(&["checkout", &commit, "--"])
.envs(env.iter())
.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>> {
// https://git-scm.com/book/en/v2/Git-Internals-Git-Objects
const GIT_MODE_SYMLINK: u32 = 0o120000;
let repo = self.repository.clone();
self.executor
.spawn(async move {
fn logic(repo: &git2::Repository, path: &RepoPath) -> Result<Option<String>> {
let mut index = repo.index()?;
index.read(false)?;
const STAGE_NORMAL: i32 = 0;
// git2 unwraps internally on empty paths or `.`
if path.is_empty() {
bail!("empty path has no index text");
}
let Some(entry) = index.get_path(path.as_std_path(), STAGE_NORMAL) else {
return Ok(None);
};
if entry.mode == GIT_MODE_SYMLINK {
return Ok(None);
}
let content = repo.find_blob(entry.id)?.content().to_owned();
Ok(String::from_utf8(content).ok())
}
logic(&repo.lock(), &path)
.context("loading index text")
.log_err()
.flatten()
})
.boxed()
}
fn load_committed_text(&self, path: RepoPath) -> BoxFuture<'_, Option<String>> {
let repo = self.repository.clone();
self.executor
.spawn(async move {
fn logic(repo: &git2::Repository, path: &RepoPath) -> Result<Option<String>> {
let head = repo.head()?.peel_to_tree()?;
// git2 unwraps internally on empty paths or `.`
if path.is_empty() {
return Err(anyhow!("empty path has no committed text"));
}
let Some(entry) = head.get_path(path.as_std_path()).ok() else {
return Ok(None);
};
if entry.filemode() == i32::from(git2::FileMode::Link) {
return Ok(None);
}
let content = repo.find_blob(entry.id())?.content().to_owned();
Ok(String::from_utf8(content).ok())
}
logic(&repo.lock(), &path)
.context("loading committed text")
.log_err()
.flatten()
})
.boxed()
}
fn load_blob_content(&self, oid: Oid) -> BoxFuture<'_, Result<String>> {
let repo = self.repository.clone();
self.executor
.spawn(async move {
let repo = repo.lock();
let content = repo.find_blob(oid.0)?.content().to_owned();
Ok(String::from_utf8(content)?)
})
.boxed()
}
fn load_commit_template(&self) -> BoxFuture<'_, Result<Option<GitCommitTemplate>>> {
let working_directory_and_git_binary = self.working_directory().map(|working_directory| {
(
working_directory.clone(),
GitBinary::new(
self.any_git_binary_path.clone(),
working_directory,
self.path(),
self.executor.clone(),
self.is_trusted(),
),
)
});
self.executor
.spawn(async move {
let (working_directory, git_binary) = working_directory_and_git_binary?;
let output = git_binary
.build_command(&["config", "--get", "commit.template"])
.output()
.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_url(&self, name: &str) -> BoxFuture<'_, Option<String>> {
let repo = self.repository.clone();
let name = name.to_owned();
self.executor
.spawn(async move {
let repo = repo.lock();
let remote = repo.find_remote(&name).ok()?;
remote.url().map(|url| url.to_string())
})
.boxed()
}
fn revparse_batch(&self, revs: Vec<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 = match self.git_binary_in_worktree() {
Ok(git) => git,
Err(e) => return Task::ready(Err(e)),
};
let args = git_status_args(path_prefixes);
log::debug!("Checking for git status in {path_prefixes:?}");
self.executor.spawn(async move {
let output = git.build_command(&args).output().await?;
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
stdout.parse()
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("git status failed: {stderr}");
}
})
}
fn diff_tree(&self, request: DiffTreeType) -> BoxFuture<'_, Result<TreeDiff>> {
let git = match self.git_binary_in_worktree() {
Ok(git) => git,
Err(e) => return Task::ready(Err(e)).boxed(),
};
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 output = git.build_command(&args).output().await?;
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
stdout.parse()
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("git status failed: {stderr}");
}
})
.boxed()
}
fn stash_entries(&self) -> BoxFuture<'_, Result<GitStash>> {
let git_binary = self.git_binary_in_worktree();
self.executor
.spawn(async move {
let git = git_binary?;
let output = git
.build_command(&["stash", "list", "--pretty=format:%gd%x00%H%x00%ct%x00%s"])
.output()
.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<Vec<Branch>>> {
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?;
anyhow::ensure!(
output.status.success(),
"Failed to git git branches:\n{}",
String::from_utf8_lossy(&output.stderr)
);
let input = String::from_utf8_lossy(&output.stdout);
let mut branches = parse_branch_input(&input)?;
if branches.is_empty() {
let args = vec!["symbolic-ref", "--quiet", "HEAD"];
let output = git.build_command(&args).output().await?;
// git symbolic-ref returns a non-0 exit code if HEAD points
// to something other than a branch
if output.status.success() {
let name = String::from_utf8_lossy(&output.stdout).trim().to_string();
branches.push(Branch {
ref_name: name.into(),
is_head: true,
upstream: None,
most_recent_commit: None,
});
}
}
Ok(branches)
})
.boxed()
}
fn worktrees(&self) -> BoxFuture<'_, Result<Vec<Worktree>>> {
let git = self.git_binary();
let main_worktree_path = {
let repo = self.repository.lock();
let common_dir = repo.commondir().to_path_buf();
original_repo_path_from_common_dir(&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 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 repo = self.repository.clone();
let git_binary = self.git_binary_in_worktree();
let branch = self.executor.spawn(async move {
let repo = repo.lock();
let branch = if let Ok(branch) = repo.find_branch(&name, BranchType::Local) {
branch
} else if let Ok(revision) = repo.find_branch(&name, BranchType::Remote) {
let (_, branch_name) = name.split_once("/").context("Unexpected branch format")?;
let revision = revision.get();
let branch_commit = revision.peel_to_commit()?;
let mut branch = match repo.branch(&branch_name, &branch_commit, false) {
Ok(branch) => branch,
Err(err) if err.code() == ErrorCode::Exists => {
repo.find_branch(&branch_name, BranchType::Local)?
}
Err(err) => {
return Err(err.into());
}
};
branch.set_upstream(Some(&name))?;
branch
} else {
anyhow::bail!("Branch '{}' not found", name);
};
Ok(branch
.name()?
.context("cannot checkout anonymous branch")?
.to_string())
});
self.executor
.spawn(async move {
let branch = branch.await?;
git_binary?.run(&["checkout", &branch]).await?;
anyhow::Ok(())
})
.boxed()
}
fn create_branch(
&self,
name: String,
base_branch: Option<String>,
) -> BoxFuture<'_, Result<()>> {
let git_binary = self.git_binary_in_worktree();
self.executor
.spawn(async move {
let mut args = vec!["switch", "-c", &name];
let base_branch_str;
if let Some(ref base) = base_branch {
base_branch_str = base.clone();
args.push(&base_branch_str);
}
git_binary?.run(&args).await?;
anyhow::Ok(())
})
.boxed()
}
fn rename_branch(&self, branch: String, new_name: String) -> BoxFuture<'_, Result<()>> {
let git_binary = self.git_binary_in_worktree();
self.executor
.spawn(async move {
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 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 {
crate::blame::Blame::for_path(&git?, &path, &content, line_ending).await
})
.boxed()
}
fn diff(&self, diff: DiffType) -> BoxFuture<'_, Result<String>> {
let git_binary = self.git_binary_in_worktree();
self.executor
.spawn(async move {
let git = git_binary?;
let output = match diff {
DiffType::HeadToIndex => {
git.build_command(&["diff", "--staged"]).output().await?
}
DiffType::HeadToWorktree => git.build_command(&["diff"]).output().await?,
DiffType::MergeBase { base_ref } => {
git.build_command(&["diff", "--merge-base", base_ref.as_ref()])
.output()
.await?
}
};
anyhow::ensure!(
output.status.success(),
"Failed to run git diff:\n{}",
String::from_utf8_lossy(&output.stderr)
);
Ok(String::from_utf8_lossy(&output.stdout).to_string())
})
.boxed()
}
fn diff_stat(
&self,
path_prefixes: &[RepoPath],
) -> BoxFuture<'_, Result<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_binary = self.git_binary_in_worktree();
self.executor
.spawn(async move {
if !paths.is_empty() {
let git = git_binary?;
let output = git
.build_command(&["update-index", "--add", "--remove", "--"])
.envs(env.iter())
.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_binary = self.git_binary_in_worktree();
self.executor
.spawn(async move {
if !paths.is_empty() {
let git = git_binary?;
let output = git
.build_command(&["reset", "--quiet", "--"])
.envs(env.iter())
.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_binary = self.git_binary_in_worktree();
self.executor
.spawn(async move {
let git = git_binary?;
let output = git
.build_command(&["stash", "push", "--quiet", "--include-untracked", "--"])
.envs(env.iter())
.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_binary = self.git_binary_in_worktree();
self.executor
.spawn(async move {
let git = git_binary?;
let mut args = vec!["stash".to_string(), "pop".to_string()];
if let Some(index) = index {
args.push(format!("stash@{{{}}}", index));
}
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_binary = self.git_binary_in_worktree();
self.executor
.spawn(async move {
let git = git_binary?;
let mut args = vec!["stash".to_string(), "apply".to_string()];
if let Some(index) = index {
args.push(format!("stash@{{{}}}", index));
}
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_binary = self.git_binary_in_worktree();
self.executor
.spawn(async move {
let git = git_binary?;
let mut args = vec!["stash".to_string(), "drop".to_string()];
if let Some(index) = index {
args.push(format!("stash@{{{}}}", index));
}
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_binary = self.git_binary_in_worktree();
let executor = self.executor.clone();
// Note: Do not spawn this command on the background thread, it might pop open the credential helper
// which we want to block on.
async move {
let git = git_binary?;
let mut cmd = git.build_command(&["commit", "--quiet", "-m"]);
cmd.envs(env.iter())
.arg(&message.to_string())
.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.working_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 working_directory = working_directory?;
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.working_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 working_directory = working_directory?;
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.working_directory().unwrap_or(self.path());
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 repo = self.repository.clone();
self.executor
.spawn(async move {
let repo = repo.lock();
repo.remote_delete(&name)?;
Ok(())
})
.boxed()
}
fn create_remote(&self, name: String, url: String) -> BoxFuture<'_, Result<()>> {
let repo = self.repository.clone();
self.executor
.spawn(async move {
let repo = repo.lock();
repo.remote(&name, url.as_ref())?;
Ok(())
})
.boxed()
}
fn check_for_pushed_commit(&self) -> BoxFuture<'_, Result<Vec<SharedString>>> {
let git_binary = self.git_binary_in_worktree();
self.executor
.spawn(async move {
let git = git_binary?;
let git_cmd = async |args: &[&str]| -> Result<String> {
let output = git.build_command(args).output().await?;
anyhow::ensure!(
output.status.success(),
String::from_utf8_lossy(&output.stderr).to_string()
);
Ok(String::from_utf8(output.stdout)?)
};
let head = git_cmd(&["rev-parse", "HEAD"])
.await
.context("Failed to get HEAD")?
.trim()
.to_owned();
let mut remote_branches = vec![];
let mut add_if_matching = async |remote_head: &str| {
if let Ok(merge_base) = git_cmd(&["merge-base", &head, remote_head]).await
&& merge_base.trim() == head
&& let Some(s) = remote_head.strip_prefix("refs/remotes/")
{
remote_branches.push(s.to_owned().into());
}
};
// check the main branch of each remote
let remotes = git_cmd(&["remote"])
.await
.context("Failed to get remotes")?;
for remote in remotes.lines() {
if let Ok(remote_head) =
git_cmd(&["symbolic-ref", &format!("refs/remotes/{remote}/HEAD")]).await
{
add_if_matching(remote_head.trim()).await;
}
}
// ... and the remote branch that the checked-out one is tracking
if let Ok(remote_head) =
git_cmd(&["rev-parse", "--symbolic-full-name", "@{u}"]).await
{
add_if_matching(remote_head.trim()).await;
}
Ok(remote_branches)
})
.boxed()
}
fn checkpoint(&self) -> BoxFuture<'static, Result<GitRepositoryCheckpoint>> {
let git_binary = self.git_binary_in_worktree();
self.executor
.spawn(async move {
let mut git = git_binary?.envs(checkpoint_author_envs());
git.with_temp_index(async |git| {
let head_sha = git.run(&["rev-parse", "HEAD"]).await.ok();
let mut excludes = exclude_files(git).await?;
git.run(&["add", "--all"]).await?;
let tree = git.run(&["write-tree"]).await?;
let checkpoint_sha = if let Some(head_sha) = head_sha.as_deref() {
git.run(&["commit-tree", &tree, "-p", head_sha, "-m", "Checkpoint"])
.await?
} else {
git.run(&["commit-tree", &tree, "-m", "Checkpoint"]).await?
};
excludes.restore_original().await?;
Ok(GitRepositoryCheckpoint {
commit_sha: checkpoint_sha.parse()?,
})
})
.await
})
.boxed()
}
fn restore_checkpoint(&self, checkpoint: GitRepositoryCheckpoint) -> BoxFuture<'_, Result<()>> {
let git_binary = self.git_binary_in_worktree();
self.executor
.spawn(async move {
let git = git_binary?;
git.run(&[
"restore",
"--source",
&checkpoint.commit_sha.to_string(),
"--worktree",
".",
])
.await?;
// TODO: We don't track binary and large files anymore,
// so the following call would delete them.
// Implement an alternative way to track files added by agent.
//
// git.with_temp_index(async move |git| {
// git.run(&["read-tree", &checkpoint.commit_sha.to_string()])
// .await?;
// git.run(&["clean", "-d", "--force"]).await
// })
// .await?;
Ok(())
})
.boxed()
}
fn create_archive_checkpoint(&self) -> BoxFuture<'_, Result<(String, String)>> {
let git_binary = self.git_binary_in_worktree();
self.executor
.spawn(async move {
let mut git = git_binary?.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_binary = self.git_binary_in_worktree();
self.executor
.spawn(async move {
let git = git_binary?;
// First, set the index AND working tree to match the unstaged
// tree. --reset -u computes a tree-level diff between the
// current index and unstaged_sha's tree and applies additions,
// 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_binary = self.git_binary_in_worktree();
self.executor
.spawn(async move {
let git = git_binary?;
let result = git
.run(&[
"diff-tree",
"--quiet",
&left.commit_sha.to_string(),
&right.commit_sha.to_string(),
])
.await;
match result {
Ok(_) => Ok(true),
Err(error) => {
if let Some(GitBinaryCommandError { status, .. }) =
error.downcast_ref::<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_binary = self.git_binary_in_worktree();
self.executor
.spawn(async move {
let git = git_binary?;
git.run(&[
"diff",
"--find-renames",
"--patch",
&base_checkpoint.commit_sha.to_string(),
&target_checkpoint.commit_sha.to_string(),
])
.await
})
.boxed()
}
fn default_branch(
&self,
include_remote_name: bool,
) -> BoxFuture<'_, Result<Option<SharedString>>> {
let git = self.git_binary();
self.executor
.spawn(async move {
let strip_prefix = if include_remote_name {
"refs/remotes/"
} else {
"refs/remotes/upstream/"
};
if let Ok(output) = git
.run(&["symbolic-ref", "refs/remotes/upstream/HEAD"])
.await
{
let output = output
.strip_prefix(strip_prefix)
.map(|s| SharedString::from(s.to_owned()));
return Ok(output);
}
let strip_prefix = if include_remote_name {
"refs/remotes/"
} else {
"refs/remotes/origin/"
};
if let Ok(output) = git.run(&["symbolic-ref", "refs/remotes/origin/HEAD"]).await {
return Ok(output
.strip_prefix(strip_prefix)
.map(|s| SharedString::from(s.to_owned())));
}
if let Ok(default_branch) = git.run(&["config", "init.defaultBranch"]).await {
if git.run(&["rev-parse", &default_branch]).await.is_ok() {
return Ok(Some(default_branch.into()));
}
}
if git.run(&["rev-parse", "master"]).await.is_ok() {
return Ok(Some("master".into()));
}
Ok(None)
})
.boxed()
}
fn run_hook(
&self,
hook: RunHook,
env: Arc<HashMap<String, String>>,
) -> BoxFuture<'_, Result<()>> {
let git_binary = self.git_binary_in_worktree();
let repository = self.repository.clone();
let help_output = self.any_git_binary_help_output();
// Note: Do not spawn these commands on the background thread, as this causes some git hooks to hang.
async move {
let git_binary = git_binary?;
let working_directory = git_binary.working_directory.clone();
if !help_output
.await
.lines()
.any(|line| line.trim().starts_with("hook "))
{
let hook_abs_path = repository.lock().path().join("hooks").join(hook.as_str());
if hook_abs_path.is_file() && git_binary.is_trusted {
#[allow(clippy::disallowed_methods)]
let output = new_command(&hook_abs_path)
.envs(env.iter())
.current_dir(&working_directory)
.output()
.await?;
if !output.status.success() {
return Err(GitBinaryCommandError {
stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
status: output.status,
}
.into());
}
}
return Ok(());
}
if git_binary.is_trusted {
let git_binary = git_binary.envs(HashMap::clone(&env));
git_binary
.run(&["hook", "run", "--ignore-missing", hook.as_str()])
.await?;
}
Ok(())
}
.boxed()
}
fn initial_graph_data(
&self,
log_source: LogSource,
log_order: LogOrder,
request_tx: Sender<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(),
log_source.get_arg()?,
];
if let LogSource::Path(path) = &log_source {
git_log_command.extend(["--", path.as_unix_str()]);
}
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, log_source.get_arg()?];
args.push("--fixed-strings");
if !search_args.case_sensitive {
args.push("--regexp-ignore-case");
}
args.push("--grep");
args.push(search_args.query.as_str());
if let LogSource::Path(path) = &log_source {
args.extend(["--", path.as_unix_str()]);
}
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 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_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);
command.args(["-c", "core.fsmonitor=false"]);
command.arg("--no-optional-locks");
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");
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 {
// git2 does not like empty paths and our RelPath infra turns `.` into ``
// so undo that here
if self.is_empty() {
Path::new(".")
} else {
self.0.as_std_path()
}
}
}
#[cfg(any(test, feature = "test-support"))]
pub fn repo_path<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 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::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", "");
}
}
#[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"
);
git2::Repository::init(dir.path()).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(&["config", "--get", "core.hooksPath"])
.output()
.await
.expect("git config should run");
let stdout = String::from_utf8_lossy(&output.stdout);
assert_eq!(
stdout.trim(),
"/dev/null",
"hooksPath should be /dev/null for untrusted repos"
);
}
#[gpui::test]
async fn test_build_command_trusted_only_disables_fsmonitor(cx: &mut TestAppContext) {
cx.executor().allow_parking();
let dir = tempfile::tempdir().unwrap();
git2::Repository::init(dir.path()).unwrap();
let git = GitBinary::new(
PathBuf::from("git"),
dir.path().to_path_buf(),
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_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();
git2::Repository::init(repo_dir.path()).unwrap();
let file_path = repo_dir.path().join("file");
smol::fs::write(&file_path, "initial").await.unwrap();
let repo = RealGitRepository::new(
&repo_dir.path().join(".git"),
None,
Some("git".into()),
cx.executor(),
)
.unwrap();
repo.stage_paths(vec![repo_path("file")], Arc::new(HashMap::default()))
.await
.unwrap();
repo.commit(
"Initial commit".into(),
None,
CommitOptions::default(),
AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}),
Arc::new(checkpoint_author_envs()),
)
.await
.unwrap();
smol::fs::write(&file_path, "modified before checkpoint")
.await
.unwrap();
smol::fs::write(repo_dir.path().join("new_file_before_checkpoint"), "1")
.await
.unwrap();
let checkpoint = repo.checkpoint().await.unwrap();
// Ensure the user can't see any branches after creating a checkpoint.
assert_eq!(repo.branches().await.unwrap().len(), 1);
smol::fs::write(&file_path, "modified after checkpoint")
.await
.unwrap();
repo.stage_paths(vec![repo_path("file")], Arc::new(HashMap::default()))
.await
.unwrap();
repo.commit(
"Commit after checkpoint".into(),
None,
CommitOptions::default(),
AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}),
Arc::new(checkpoint_author_envs()),
)
.await
.unwrap();
smol::fs::remove_file(repo_dir.path().join("new_file_before_checkpoint"))
.await
.unwrap();
smol::fs::write(repo_dir.path().join("new_file_after_checkpoint"), "2")
.await
.unwrap();
// Ensure checkpoint stays alive even after a Git GC.
repo.gc().await.unwrap();
repo.restore_checkpoint(checkpoint.clone()).await.unwrap();
assert_eq!(
smol::fs::read_to_string(&file_path).await.unwrap(),
"modified before checkpoint"
);
assert_eq!(
smol::fs::read_to_string(repo_dir.path().join("new_file_before_checkpoint"))
.await
.unwrap(),
"1"
);
// See TODO above
// assert_eq!(
// smol::fs::read_to_string(repo_dir.path().join("new_file_after_checkpoint"))
// .await
// .ok(),
// None
// );
}
#[gpui::test]
async fn test_checkpoint_empty_repo(cx: &mut TestAppContext) {
disable_git_global_config();
cx.executor().allow_parking();
let repo_dir = tempfile::tempdir().unwrap();
git2::Repository::init(repo_dir.path()).unwrap();
let repo = RealGitRepository::new(
&repo_dir.path().join(".git"),
None,
Some("git".into()),
cx.executor(),
)
.unwrap();
smol::fs::write(repo_dir.path().join("foo"), "foo")
.await
.unwrap();
let checkpoint_sha = repo.checkpoint().await.unwrap();
// Ensure the user can't see any branches after creating a checkpoint.
assert_eq!(repo.branches().await.unwrap().len(), 1);
smol::fs::write(repo_dir.path().join("foo"), "bar")
.await
.unwrap();
smol::fs::write(repo_dir.path().join("baz"), "qux")
.await
.unwrap();
repo.restore_checkpoint(checkpoint_sha).await.unwrap();
assert_eq!(
smol::fs::read_to_string(repo_dir.path().join("foo"))
.await
.unwrap(),
"foo"
);
// See TODOs above
// assert_eq!(
// smol::fs::read_to_string(repo_dir.path().join("baz"))
// .await
// .ok(),
// None
// );
}
#[gpui::test]
async fn test_compare_checkpoints(cx: &mut TestAppContext) {
disable_git_global_config();
cx.executor().allow_parking();
let repo_dir = tempfile::tempdir().unwrap();
git2::Repository::init(repo_dir.path()).unwrap();
let repo = RealGitRepository::new(
&repo_dir.path().join(".git"),
None,
Some("git".into()),
cx.executor(),
)
.unwrap();
smol::fs::write(repo_dir.path().join("file1"), "content1")
.await
.unwrap();
let checkpoint1 = repo.checkpoint().await.unwrap();
smol::fs::write(repo_dir.path().join("file2"), "content2")
.await
.unwrap();
let checkpoint2 = repo.checkpoint().await.unwrap();
assert!(
!repo
.compare_checkpoints(checkpoint1, checkpoint2.clone())
.await
.unwrap()
);
let checkpoint3 = repo.checkpoint().await.unwrap();
assert!(
repo.compare_checkpoints(checkpoint2, checkpoint3)
.await
.unwrap()
);
}
#[gpui::test]
async fn test_checkpoint_exclude_binary_files(cx: &mut TestAppContext) {
disable_git_global_config();
cx.executor().allow_parking();
let repo_dir = tempfile::tempdir().unwrap();
let text_path = repo_dir.path().join("main.rs");
let bin_path = repo_dir.path().join("binary.o");
git2::Repository::init(repo_dir.path()).unwrap();
smol::fs::write(&text_path, "fn main() {}").await.unwrap();
smol::fs::write(&bin_path, "some binary file here")
.await
.unwrap();
let repo = RealGitRepository::new(
&repo_dir.path().join(".git"),
None,
Some("git".into()),
cx.executor(),
)
.unwrap();
// initial commit
repo.stage_paths(vec![repo_path("main.rs")], Arc::new(HashMap::default()))
.await
.unwrap();
repo.commit(
"Initial commit".into(),
None,
CommitOptions::default(),
AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}),
Arc::new(checkpoint_author_envs()),
)
.await
.unwrap();
let checkpoint = repo.checkpoint().await.unwrap();
smol::fs::write(&text_path, "fn main() { println!(\"Modified\"); }")
.await
.unwrap();
smol::fs::write(&bin_path, "Modified binary file")
.await
.unwrap();
repo.restore_checkpoint(checkpoint).await.unwrap();
// Text files should be restored to checkpoint state,
// but binaries should not (they aren't tracked)
assert_eq!(
smol::fs::read_to_string(&text_path).await.unwrap(),
"fn main() {}"
);
assert_eq!(
smol::fs::read_to_string(&bin_path).await.unwrap(),
"Modified binary file"
);
}
#[test]
fn test_branches_parsing() {
// suppress "help: octal escapes are not supported, `\0` is always null"
#[allow(clippy::octal_escapes)]
let input = "*\0060964da10574cd9bf06463a53bf6e0769c5c45e\0\0refs/heads/zed-patches\0refs/remotes/origin/zed-patches\0\01733187470\0John Doe\0generated protobuf\n";
assert_eq!(
parse_branch_input(input).unwrap(),
vec![Branch {
is_head: true,
ref_name: "refs/heads/zed-patches".into(),
upstream: Some(Upstream {
ref_name: "refs/remotes/origin/zed-patches".into(),
tracking: UpstreamTracking::Tracked(UpstreamTrackingStatus {
ahead: 0,
behind: 0
})
}),
most_recent_commit: Some(CommitSummary {
sha: "060964da10574cd9bf06463a53bf6e0769c5c45e".into(),
subject: "generated protobuf".into(),
commit_timestamp: 1733187470,
author_name: SharedString::new_static("John Doe"),
has_parent: false,
})
}]
)
}
#[test]
fn test_branches_parsing_containing_refs_with_missing_fields() {
#[allow(clippy::octal_escapes)]
let input = " \090012116c03db04344ab10d50348553aa94f1ea0\0refs/heads/broken\n \0eb0cae33272689bd11030822939dd2701c52f81e\0895951d681e5561478c0acdd6905e8aacdfd2249\0refs/heads/dev\0\0\01762948725\0Zed\0Add feature\n*\0895951d681e5561478c0acdd6905e8aacdfd2249\0\0refs/heads/main\0\0\01762948695\0Zed\0Initial commit\n";
let branches = parse_branch_input(input).unwrap();
assert_eq!(branches.len(), 2);
assert_eq!(
branches,
vec![
Branch {
is_head: false,
ref_name: "refs/heads/dev".into(),
upstream: None,
most_recent_commit: Some(CommitSummary {
sha: "eb0cae33272689bd11030822939dd2701c52f81e".into(),
subject: "Add feature".into(),
commit_timestamp: 1762948725,
author_name: SharedString::new_static("Zed"),
has_parent: true,
})
},
Branch {
is_head: true,
ref_name: "refs/heads/main".into(),
upstream: None,
most_recent_commit: Some(CommitSummary {
sha: "895951d681e5561478c0acdd6905e8aacdfd2249".into(),
subject: "Initial commit".into(),
commit_timestamp: 1762948695,
author_name: SharedString::new_static("Zed"),
has_parent: false,
})
}
]
)
}
#[test]
fn test_upstream_branch_name() {
let upstream = Upstream {
ref_name: "refs/remotes/origin/feature/branch".into(),
tracking: UpstreamTracking::Tracked(UpstreamTrackingStatus {
ahead: 0,
behind: 0,
}),
};
assert_eq!(upstream.branch_name(), Some("feature/branch"));
let upstream = Upstream {
ref_name: "refs/remotes/upstream/main".into(),
tracking: UpstreamTracking::Tracked(UpstreamTrackingStatus {
ahead: 0,
behind: 0,
}),
};
assert_eq!(upstream.branch_name(), Some("main"));
let upstream = Upstream {
ref_name: "refs/heads/local".into(),
tracking: UpstreamTracking::Tracked(UpstreamTrackingStatus {
ahead: 0,
behind: 0,
}),
};
assert_eq!(upstream.branch_name(), None);
// Test case where upstream branch name differs from what might be the local branch name
let upstream = Upstream {
ref_name: "refs/remotes/origin/feature/git-pull-request".into(),
tracking: UpstreamTracking::Tracked(UpstreamTrackingStatus {
ahead: 0,
behind: 0,
}),
};
assert_eq!(upstream.branch_name(), Some("feature/git-pull-request"));
}
#[test]
fn test_parse_worktrees_from_str() {
// Empty input
let result = parse_worktrees_from_str("", 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();
git2::Repository::init(&repo_dir).unwrap();
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(checkpoint_author_envs()),
)
.await
.unwrap();
// List worktrees — should have just the main one
let worktrees = repo.worktrees().await.unwrap();
assert_eq!(worktrees.len(), 1);
assert_eq!(
worktrees[0].path.canonicalize().unwrap(),
repo_dir.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(),
);
}
#[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");
git2::Repository::init(&repo_dir).unwrap();
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(checkpoint_author_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");
git2::Repository::init(&repo_dir).unwrap();
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(checkpoint_author_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()
);
}
#[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("/"))
);
}
impl RealGitRepository {
/// Force a Git garbage collection on the repository.
fn gc(&self) -> BoxFuture<'_, Result<()>> {
let working_directory = self.working_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 working_directory = working_directory?;
let git = GitBinary::new(
git_binary_path,
working_directory,
git_directory,
executor,
true,
);
git.run(&["gc", "--prune"]).await?;
Ok(())
})
.boxed()
}
}
}