Files
zed/crates/git/src/repository.rs
T
saberoueslati 5688167d22 recent_projects: Fix worktree path resolving to bare repo in recent projects modal (#52996)
## Context

When using a bare-repo-based git worktree layout (e.g. `foo/.bare` as
the bare repository and `foo/my-feature` as a linked worktree), the
"recent projects" modal was showing `foo/.bare` instead of
`foo/my-feature`.

The root cause was in `original_repo_path_from_common_dir` — when
resolving a linked worktree back to its "main" repo, the function reads
the `commondir` file which, for bare repos, points to the bare directory
itself (e.g. `foo/.bare`). Since that path doesn't end in `.git`, the
old code fell back to returning it as-is. This bare repo path was then
substituted into the workspace's stored paths, causing the modal to show
the bare directory instead of the actual worktree the user had opened.

The fix makes `original_repo_path_from_common_dir` return
`Option<PathBuf>`, returning `None` when `common_dir` doesn't end with
`.git`. `resolve_git_worktree_to_main_repo` propagates this `None`,
meaning the original worktree path is preserved in recent projects
rather than being replaced with the bare repo path.

Closes #52931

Video of the manual test after the fix is below :

[Screencast from 2026-04-02
16-05-48.webm](https://github.com/user-attachments/assets/9659c7a7-c095-4c23-af59-17715f84ce3e)

## How to Review

- **`crates/git/src/repository.rs`** :
`original_repo_path_from_common_dir` changed to return
`Option<PathBuf>`. Returns `None` for bare repos (no `.git` suffix). The
existing `original_repo_path` call site falls back to `work_directory`
when `None` is returned, preserving its prior behaviour. Unit test
expectations updated accordingly, with the bare-repo case now asserting
`None`.

- **`crates/project/src/git_store.rs`** :
`resolve_git_worktree_to_main_repo` now simply forwards the
`Option<PathBuf>` returned by `original_repo_path_from_common_dir`
directly, propagating `None` for bare repos so the caller keeps the
original worktree path.

- **`crates/workspace/src/persistence.rs`** : New test
`test_resolve_worktree_workspaces_bare_repo` exercises the exact
scenario from the issue: a workspace entry pointing to a linked worktree
whose `commondir` resolves to a bare repo. Asserts the path is left
unchanged.

## Self-Review Checklist

- [x] I've reviewed my own diff for quality, security, and reliability
- [ ] Unsafe blocks (if any) have justifying comments
- [x] The content is consistent with the UI/UX checklist
- [x] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable

Release Notes:

- Fixed recent projects modal showing `.bare` folder instead of the
worktree directory for bare-repo-based git worktree setups
2026-04-14 12:27:37 +03:00

4495 lines
149 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 collections::HashMap;
use futures::channel::oneshot;
use futures::future::BoxFuture;
use futures::io::BufWriter;
use futures::{AsyncWriteExt, FutureExt as _, select_biased};
use git2::{BranchType, ErrorCode};
use gpui::{AppContext as _, AsyncApp, BackgroundExecutor, SharedString, Task};
use parking_lot::Mutex;
use rope::Rope;
use schemars::JsonSchema;
use serde::Deserialize;
use smallvec::SmallVec;
use smol::channel::Sender;
use smol::io::{AsyncBufReadExt, AsyncReadExt, BufReader};
use text::LineEnding;
use std::collections::HashSet;
use std::ffi::{OsStr, OsString};
use std::sync::atomic::AtomicBool;
use std::process::ExitStatus;
use std::str::FromStr;
use std::{
cmp::Ordering,
future,
path::{Path, PathBuf},
sync::Arc,
};
use sum_tree::MapSeekTarget;
use thiserror::Error;
use util::command::{Stdio, new_command};
use util::paths::PathStyle;
use util::rel_path::RelPath;
use util::{ResultExt, 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";
/// Determine the original (main) repository's working directory.
///
/// For linked worktrees, `common_dir` differs from `repository_dir` and
/// points to the main repo's `.git` directory, so we can derive the main
/// repo's working directory from it. For normal repos and submodules,
/// `common_dir` equals `repository_dir`, and the original repo is simply
/// `work_directory` itself.
pub fn original_repo_path(
work_directory: &Path,
common_dir: &Path,
repository_dir: &Path,
) -> PathBuf {
if common_dir != repository_dir {
original_repo_path_from_common_dir(common_dir)
.unwrap_or_else(|| work_directory.to_path_buf())
} else {
work_directory.to_path_buf()
}
}
/// 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 GraphCommitData {
pub sha: Oid,
/// Most commits have a single parent, so we use a SmallVec to avoid allocations.
pub parents: SmallVec<[Oid; 1]>,
pub author_name: SharedString,
pub author_email: SharedString,
pub commit_timestamp: i64,
pub subject: SharedString,
}
#[derive(Debug)]
pub struct InitialGraphCommitData {
pub sha: Oid,
pub parents: SmallVec<[Oid; 1]>,
pub ref_names: Vec<SharedString>,
}
struct CommitDataRequest {
sha: Oid,
response_tx: oneshot::Sender<Result<GraphCommitData>>,
}
pub struct CommitDataReader {
request_tx: smol::channel::Sender<CommitDataRequest>,
_task: Task<()>,
}
impl CommitDataReader {
pub async fn read(&self, sha: Oid) -> Result<GraphCommitData> {
let (response_tx, response_rx) = oneshot::channel();
self.request_tx
.send(CommitDataRequest { sha, response_tx })
.await
.map_err(|_| anyhow!("commit data reader task closed"))?;
response_rx
.await
.map_err(|_| anyhow!("commit data reader task dropped response"))?
}
}
fn parse_cat_file_commit(sha: Oid, content: &str) -> Option<GraphCommitData> {
let mut parents = SmallVec::new();
let mut author_name = SharedString::default();
let mut author_email = SharedString::default();
let mut commit_timestamp = 0i64;
let mut in_headers = true;
let mut subject = None;
for line in content.lines() {
if in_headers {
if line.is_empty() {
in_headers = false;
continue;
}
if let Some(parent_sha) = line.strip_prefix("parent ") {
if let Ok(oid) = Oid::from_str(parent_sha.trim()) {
parents.push(oid);
}
} else if let Some(author_line) = line.strip_prefix("author ") {
if let Some((name_email, _timestamp_tz)) = author_line.rsplit_once(' ') {
if let Some((name_email, timestamp_str)) = name_email.rsplit_once(' ') {
if let Ok(ts) = timestamp_str.parse::<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()));
}
}
Some(GraphCommitData {
sha,
parents,
author_name,
author_email,
commit_timestamp,
subject: subject.unwrap_or_default(),
})
}
#[derive(Clone, Debug, Hash, PartialEq, Eq)]
pub struct Branch {
pub is_head: bool,
pub ref_name: SharedString,
pub upstream: Option<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,
}
/// 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 parse_worktrees_from_str<T: AsRef<str>>(raw_worktrees: T) -> Vec<Worktree> {
let mut worktrees = Vec::new();
let mut is_first = true;
let normalized = raw_worktrees.as_ref().replace("\r\n", "\n");
let entries = normalized.split("\n\n");
for entry in entries {
let mut path = None;
let mut sha = None;
let mut ref_name = None;
for line in entry.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
if let Some(rest) = line.strip_prefix("worktree ") {
path = Some(rest.to_string());
} else if let Some(rest) = line.strip_prefix("HEAD ") {
sha = Some(rest.to_string());
} else if let Some(rest) = line.strip_prefix("branch ") {
ref_name = Some(rest.to_string());
}
// Ignore other lines: detached, bare, locked, prunable, etc.
}
if let (Some(path), Some(sha)) = (path, sha) {
worktrees.push(Worktree {
path: PathBuf::from(path),
ref_name: ref_name.map(Into::into),
sha: sha.into(),
is_main: is_first,
});
is_first = false;
}
}
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(Clone, Debug, Hash, PartialEq, Eq)]
pub struct FileHistoryEntry {
pub sha: SharedString,
pub subject: SharedString,
pub message: SharedString,
pub commit_timestamp: i64,
pub author_name: SharedString,
pub author_email: SharedString,
}
#[derive(Debug, Clone)]
pub struct FileHistory {
pub entries: Vec<FileHistoryEntry>,
pub path: RepoPath,
}
#[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),
}
impl LogSource {
fn get_arg(&self) -> Result<&str> {
match self {
LogSource::All => Ok("--all"),
LogSource::Branch(branch) => Ok(branch.as_str()),
LogSource::Sha(oid) => {
str::from_utf8(oid.as_bytes()).context("Failed to build str from sha")
}
}
}
}
pub struct SearchCommitArgs {
pub query: SharedString,
pub case_sensitive: bool,
}
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) -> 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>>;
fn file_history(&self, path: RepoPath) -> BoxFuture<'_, Result<FileHistory>>;
fn file_history_paginated(
&self,
path: RepoPath,
skip: usize,
limit: Option<usize>,
) -> BoxFuture<'_, Result<FileHistory>>;
/// 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 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>,
}
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(&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(),
))
}
async fn any_git_binary_help_output(&self) -> SharedString {
if let Some(output) = self.any_git_binary_help_output.lock().clone() {
return output;
}
let git_binary = self.git_binary();
let output: SharedString = self
.executor
.spawn(async move { git_binary?.run(&["help", "-a"]).await })
.await
.unwrap_or_default()
.into();
*self.any_git_binary_help_output.lock() = Some(output.clone());
output
}
}
#[derive(Clone, Debug)]
pub struct GitRepositoryCheckpoint {
pub commit_sha: Oid,
}
#[derive(Debug)]
pub struct GitCommitter {
pub name: Option<String>,
pub email: Option<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_binary = self.git_binary();
self.executor
.spawn(async move {
let git = git_binary?;
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>> {
if self.repository.lock().workdir().is_none() {
return future::ready(Err(anyhow!("no working directory"))).boxed();
}
let git_binary = self.git_binary();
cx.background_spawn(async move {
let git = git_binary?;
let show_output = git
.build_command(&[
"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();
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();
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 set_index_text(
&self,
path: RepoPath,
content: Option<String>,
env: Arc<HashMap<String, String>>,
is_executable: bool,
) -> BoxFuture<'_, anyhow::Result<()>> {
let git_binary = self.git_binary();
self.executor
.spawn(async move {
let git = git_binary?;
let mode = if is_executable { "100755" } else { "100644" };
if let Some(content) = content {
let mut child = git
.build_command(&["hash-object", "-w", "--stdin"])
.envs(env.iter())
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn()?;
let mut stdin = child.stdin.take().unwrap();
stdin.write_all(content.as_bytes()).await?;
stdin.flush().await?;
drop(stdin);
let output = child.output().await?.stdout;
let sha = str::from_utf8(&output)?.trim();
log::debug!("indexing SHA: {sha}, path {path:?}");
let output = git
.build_command(&["update-index", "--add", "--cacheinfo", mode, sha])
.envs(env.iter())
.arg(path.as_unix_str())
.output()
.await?;
anyhow::ensure!(
output.status.success(),
"Failed to stage:\n{}",
String::from_utf8_lossy(&output.stderr)
);
} else {
log::debug!("removing path {path:?} from the index");
let output = git
.build_command(&["update-index", "--force-remove", "--"])
.envs(env.iter())
.arg(path.as_unix_str())
.output()
.await?;
anyhow::ensure!(
output.status.success(),
"Failed to unstage:\n{}",
String::from_utf8_lossy(&output.stderr)
);
}
Ok(())
})
.boxed()
}
fn remote_url(&self, name: &str) -> BoxFuture<'_, Option<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_binary = self.git_binary();
self.executor
.spawn(async move {
let git = git_binary?;
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() {
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() {
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();
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_binary = self.git_binary();
self.executor
.spawn(async move {
let fields = [
"%(HEAD)",
"%(objectname)",
"%(parent)",
"%(refname)",
"%(upstream)",
"%(upstream:track)",
"%(committerdate:unix)",
"%(authorname)",
"%(contents:subject)",
]
.join("%00");
let args = vec![
"for-each-ref",
"refs/heads/**/*",
"refs/remotes/**/*",
"--format",
&fields,
];
let git = git_binary?;
let output = git.build_command(&args).output().await?;
anyhow::ensure!(
output.status.success(),
"Failed to git git branches:\n{}",
String::from_utf8_lossy(&output.stderr)
);
let input = String::from_utf8_lossy(&output.stdout);
let mut branches = parse_branch_input(&input)?;
if branches.is_empty() {
let args = vec!["symbolic-ref", "--quiet", "HEAD"];
let output = git.build_command(&args).output().await?;
// git symbolic-ref returns a non-0 exit code if HEAD points
// to something other than a branch
if output.status.success() {
let name = String::from_utf8_lossy(&output.stdout).trim().to_string();
branches.push(Branch {
ref_name: name.into(),
is_head: true,
upstream: None,
most_recent_commit: None,
});
}
}
Ok(branches)
})
.boxed()
}
fn worktrees(&self) -> BoxFuture<'_, Result<Vec<Worktree>>> {
let git_binary = self.git_binary();
self.executor
.spawn(async move {
let git = git_binary?;
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))
} 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_binary = 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 git = git_binary?;
let output = git.build_command(&args).output().await?;
if output.status.success() {
Ok(())
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("git worktree add failed: {stderr}");
}
})
.boxed()
}
fn remove_worktree(&self, path: PathBuf, force: bool) -> BoxFuture<'_, Result<()>> {
let git_binary = self.git_binary();
self.executor
.spawn(async move {
let mut args: Vec<OsString> = vec!["worktree".into(), "remove".into()];
if force {
args.push("--force".into());
}
args.push("--".into());
args.push(path.as_os_str().into());
git_binary?.run(&args).await?;
anyhow::Ok(())
})
.boxed()
}
fn rename_worktree(&self, old_path: PathBuf, new_path: PathBuf) -> BoxFuture<'_, Result<()>> {
let git_binary = self.git_binary();
self.executor
.spawn(async move {
let args: Vec<OsString> = vec![
"worktree".into(),
"move".into(),
"--".into(),
old_path.as_os_str().into(),
new_path.as_os_str().into(),
];
git_binary?.run(&args).await?;
anyhow::Ok(())
})
.boxed()
}
fn 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();
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();
self.executor
.spawn(async move {
let mut args = vec!["switch", "-c", &name];
let base_branch_str;
if let Some(ref base) = base_branch {
base_branch_str = base.clone();
args.push(&base_branch_str);
}
git_binary?.run(&args).await?;
anyhow::Ok(())
})
.boxed()
}
fn rename_branch(&self, branch: String, new_name: String) -> BoxFuture<'_, Result<()>> {
let git_binary = self.git_binary();
self.executor
.spawn(async move {
git_binary?
.run(&["branch", "-m", &branch, &new_name])
.await?;
anyhow::Ok(())
})
.boxed()
}
fn delete_branch(&self, is_remote: bool, name: String) -> BoxFuture<'_, Result<()>> {
let git_binary = self.git_binary();
self.executor
.spawn(async move {
git_binary?
.run(&["branch", if is_remote { "-dr" } else { "-d" }, &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();
self.executor
.spawn(async move {
crate::blame::Blame::for_path(&git?, &path, &content, line_ending).await
})
.boxed()
}
fn file_history(&self, path: RepoPath) -> BoxFuture<'_, Result<FileHistory>> {
self.file_history_paginated(path, 0, None)
}
fn file_history_paginated(
&self,
path: RepoPath,
skip: usize,
limit: Option<usize>,
) -> BoxFuture<'_, Result<FileHistory>> {
let git_binary = self.git_binary();
self.executor
.spawn(async move {
let git = git_binary?;
// Use a unique delimiter with a hardcoded UUID to separate commits
// This essentially eliminates any chance of encountering the delimiter in actual commit data
let commit_delimiter =
concat!("<<COMMIT_END-", "3f8a9c2e-7d4b-4e1a-9f6c-8b5d2a1e4c3f>>",);
let format_string = format!(
"--pretty=format:%H%x00%s%x00%B%x00%at%x00%an%x00%ae{}",
commit_delimiter
);
let mut args = vec!["log", "--follow", &format_string];
let skip_str;
let limit_str;
if skip > 0 {
skip_str = skip.to_string();
args.push("--skip");
args.push(&skip_str);
}
if let Some(n) = limit {
limit_str = n.to_string();
args.push("-n");
args.push(&limit_str);
}
args.push("--");
let output = git
.build_command(&args)
.arg(path.as_unix_str())
.output()
.await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("git log failed: {stderr}");
}
let stdout = std::str::from_utf8(&output.stdout)?;
let mut entries = Vec::new();
for commit_block in stdout.split(commit_delimiter) {
let commit_block = commit_block.trim();
if commit_block.is_empty() {
continue;
}
let fields: Vec<&str> = commit_block.split('\0').collect();
if fields.len() >= 6 {
let sha = fields[0].trim().to_string().into();
let subject = fields[1].trim().to_string().into();
let message = fields[2].trim().to_string().into();
let commit_timestamp = fields[3].trim().parse().unwrap_or(0);
let author_name = fields[4].trim().to_string().into();
let author_email = fields[5].trim().to_string().into();
entries.push(FileHistoryEntry {
sha,
subject,
message,
commit_timestamp,
author_name,
author_email,
});
}
}
Ok(FileHistory { entries, path })
})
.boxed()
}
fn diff(&self, diff: DiffType) -> BoxFuture<'_, Result<String>> {
let git_binary = self.git_binary();
self.executor
.spawn(async move {
let git = git_binary?;
let output = match diff {
DiffType::HeadToIndex => {
git.build_command(&["diff", "--staged"]).output().await?
}
DiffType::HeadToWorktree => git.build_command(&["diff"]).output().await?,
DiffType::MergeBase { base_ref } => {
git.build_command(&["diff", "--merge-base", base_ref.as_ref()])
.output()
.await?
}
};
anyhow::ensure!(
output.status.success(),
"Failed to run git diff:\n{}",
String::from_utf8_lossy(&output.stderr)
);
Ok(String::from_utf8_lossy(&output.stdout).to_string())
})
.boxed()
}
fn diff_stat(
&self,
path_prefixes: &[RepoPath],
) -> BoxFuture<'_, Result<crate::status::GitDiffStat>> {
let path_prefixes = path_prefixes.to_vec();
let git_binary = self.git_binary();
self.executor
.spawn(async move {
let git_binary = git_binary?;
let mut args: Vec<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();
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();
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();
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();
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();
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();
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();
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<()>> {
let git_binary = self.git_binary();
self.executor
.spawn(async move {
let args: Vec<OsString> = vec!["update-ref".into(), ref_name.into(), commit.into()];
git_binary?.run(&args).await?;
Ok(())
})
.boxed()
}
fn delete_ref(&self, ref_name: String) -> BoxFuture<'_, Result<()>> {
let git_binary = self.git_binary();
self.executor
.spawn(async move {
let args: Vec<OsString> = vec!["update-ref".into(), "-d".into(), ref_name.into()];
git_binary?.run(&args).await?;
Ok(())
})
.boxed()
}
fn repair_worktrees(&self) -> BoxFuture<'_, Result<()>> {
let git_binary = self.git_binary();
self.executor
.spawn(async move {
let args: Vec<OsString> = vec!["worktree".into(), "repair".into()];
git_binary?.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();
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 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(&["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_binary = self.git_binary();
self.executor
.spawn(async move {
let git = git_binary?;
let output = git
.build_command(&["rev-parse", "--abbrev-ref"])
.arg(format!("{branch}@{{push}}"))
.output()
.await?;
if !output.status.success() {
return Ok(None);
}
let remote_name = String::from_utf8_lossy(&output.stdout)
.split('/')
.next()
.map(|name| Remote {
name: name.trim().to_string().into(),
});
Ok(remote_name)
})
.boxed()
}
fn get_branch_remote(&self, branch: String) -> BoxFuture<'_, Result<Option<Remote>>> {
let git_binary = self.git_binary();
self.executor
.spawn(async move {
let git = git_binary?;
let output = git
.build_command(&["config", "--get"])
.arg(format!("branch.{branch}.remote"))
.output()
.await?;
if !output.status.success() {
return Ok(None);
}
let remote_name = String::from_utf8_lossy(&output.stdout);
return Ok(Some(Remote {
name: remote_name.trim().to_string().into(),
}));
})
.boxed()
}
fn get_all_remotes(&self) -> BoxFuture<'_, Result<Vec<Remote>>> {
let git_binary = self.git_binary();
self.executor
.spawn(async move {
let git = git_binary?;
let output = git.build_command(&["remote", "-v"]).output().await?;
anyhow::ensure!(
output.status.success(),
"Failed to get all remotes:\n{}",
String::from_utf8_lossy(&output.stderr)
);
let remote_names: HashSet<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();
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();
self.executor
.spawn(async move {
let mut git = git_binary?.envs(checkpoint_author_envs());
git.with_temp_index(async |git| {
let head_sha = git.run(&["rev-parse", "HEAD"]).await.ok();
let mut excludes = exclude_files(git).await?;
git.run(&["add", "--all"]).await?;
let tree = git.run(&["write-tree"]).await?;
let checkpoint_sha = if let Some(head_sha) = head_sha.as_deref() {
git.run(&["commit-tree", &tree, "-p", head_sha, "-m", "Checkpoint"])
.await?
} else {
git.run(&["commit-tree", &tree, "-m", "Checkpoint"]).await?
};
excludes.restore_original().await?;
Ok(GitRepositoryCheckpoint {
commit_sha: checkpoint_sha.parse()?,
})
})
.await
})
.boxed()
}
fn restore_checkpoint(&self, checkpoint: GitRepositoryCheckpoint) -> BoxFuture<'_, Result<()>> {
let git_binary = self.git_binary();
self.executor
.spawn(async move {
let git = git_binary?;
git.run(&[
"restore",
"--source",
&checkpoint.commit_sha.to_string(),
"--worktree",
".",
])
.await?;
// TODO: We don't track binary and large files anymore,
// so the following call would delete them.
// Implement an alternative way to track files added by agent.
//
// git.with_temp_index(async move |git| {
// git.run(&["read-tree", &checkpoint.commit_sha.to_string()])
// .await?;
// git.run(&["clean", "-d", "--force"]).await
// })
// .await?;
Ok(())
})
.boxed()
}
fn create_archive_checkpoint(&self) -> BoxFuture<'_, Result<(String, String)>> {
let git_binary = self.git_binary();
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();
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();
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();
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_binary = self.git_binary();
self.executor
.spawn(async move {
let git = git_binary?;
let strip_prefix = if include_remote_name {
"refs/remotes/"
} else {
"refs/remotes/upstream/"
};
if let Ok(output) = git
.run(&["symbolic-ref", "refs/remotes/upstream/HEAD"])
.await
{
let output = output
.strip_prefix(strip_prefix)
.map(|s| SharedString::from(s.to_owned()));
return Ok(output);
}
let strip_prefix = if include_remote_name {
"refs/remotes/"
} else {
"refs/remotes/origin/"
};
if let Ok(output) = git.run(&["symbolic-ref", "refs/remotes/origin/HEAD"]).await {
return Ok(output
.strip_prefix(strip_prefix)
.map(|s| SharedString::from(s.to_owned())));
}
if let Ok(default_branch) = git.run(&["config", "init.defaultBranch"]).await {
if git.run(&["rev-parse", &default_branch]).await.is_ok() {
return Ok(Some(default_branch.into()));
}
}
if git.run(&["rev-parse", "master"]).await.is_ok() {
return Ok(Some("master".into()));
}
Ok(None)
})
.boxed()
}
fn run_hook(
&self,
hook: RunHook,
env: Arc<HashMap<String, String>>,
) -> BoxFuture<'_, Result<()>> {
let git_binary = self.git_binary();
let repository = self.repository.clone();
let help_output = self.any_git_binary_help_output();
// Note: Do not spawn these commands on the background thread, as this causes some git hooks to hang.
async move {
let git_binary = git_binary?;
let working_directory = git_binary.working_directory.clone();
if !help_output
.await
.lines()
.any(|line| line.trim().starts_with("hook "))
{
let hook_abs_path = repository.lock().path().join("hooks").join(hook.as_str());
if hook_abs_path.is_file() && git_binary.is_trusted {
#[allow(clippy::disallowed_methods)]
let output = new_command(&hook_abs_path)
.envs(env.iter())
.current_dir(&working_directory)
.output()
.await?;
if !output.status.success() {
return Err(GitBinaryCommandError {
stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
status: output.status,
}
.into());
}
}
return Ok(());
}
if git_binary.is_trusted {
let git_binary = git_binary.envs(HashMap::clone(&env));
git_binary
.run(&["hook", "run", "--ignore-missing", hook.as_str()])
.await?;
}
Ok(())
}
.boxed()
}
fn initial_graph_data(
&self,
log_source: LogSource,
log_order: LogOrder,
request_tx: Sender<Vec<Arc<InitialGraphCommitData>>>,
) -> BoxFuture<'_, Result<()>> {
let git_binary = self.git_binary();
async move {
let git = git_binary?;
let mut command = git.build_command(&[
"log",
GRAPH_COMMIT_FORMAT,
log_order.as_arg(),
log_source.get_arg()?,
]);
command.stdout(Stdio::piped());
command.stderr(Stdio::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_binary = self.git_binary();
async move {
let git = git_binary?;
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());
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) = smol::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: smol::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<GraphCommitData> {
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("");
assert!(result.is_empty());
// Single worktree (main)
let input = "worktree /home/user/project\nHEAD abc123def\nbranch refs/heads/main\n\n";
let result = parse_worktrees_from_str(input);
assert_eq!(result.len(), 1);
assert_eq!(result[0].path, PathBuf::from("/home/user/project"));
assert_eq!(result[0].sha.as_ref(), "abc123def");
assert_eq!(result[0].ref_name, Some("refs/heads/main".into()));
assert!(result[0].is_main);
// Multiple worktrees
let input = "worktree /home/user/project\nHEAD abc123\nbranch refs/heads/main\n\n\
worktree /home/user/project-wt\nHEAD def456\nbranch refs/heads/feature\n\n";
let result = parse_worktrees_from_str(input);
assert_eq!(result.len(), 2);
assert_eq!(result[0].path, PathBuf::from("/home/user/project"));
assert_eq!(result[0].ref_name, Some("refs/heads/main".into()));
assert!(result[0].is_main);
assert_eq!(result[1].path, PathBuf::from("/home/user/project-wt"));
assert_eq!(result[1].ref_name, Some("refs/heads/feature".into()));
assert!(!result[1].is_main);
// 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);
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);
// Bare repo entry (included with ref_name: None)
let input = "worktree /home/user/bare.git\nHEAD abc123\nbare\n\n\
worktree /home/user/project\nHEAD def456\nbranch refs/heads/main\n\n";
let result = parse_worktrees_from_str(input);
assert_eq!(result.len(), 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_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);
// Extra porcelain lines (locked, prunable) should be ignored
let input = "worktree /home/user/project\nHEAD abc123\nbranch refs/heads/main\n\n\
worktree /home/user/locked-wt\nHEAD def456\nbranch refs/heads/locked-branch\nlocked\n\n\
worktree /home/user/prunable-wt\nHEAD 789aaa\nbranch refs/heads/prunable-branch\nprunable\n\n";
let result = parse_worktrees_from_str(input);
assert_eq!(result.len(), 3);
assert_eq!(result[0].path, PathBuf::from("/home/user/project"));
assert_eq!(result[0].ref_name, 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);
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);
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()
}
}
}