repository.rs

   1use crate::commit::parse_git_diff_name_status;
   2use crate::stash::GitStash;
   3use crate::status::{DiffTreeType, GitStatus, StatusCode, TreeDiff};
   4use crate::{Oid, RunHook, SHORT_SHA_LENGTH};
   5use anyhow::{Context as _, Result, anyhow, bail};
   6use collections::HashMap;
   7use futures::channel::oneshot;
   8use futures::future::BoxFuture;
   9use futures::io::BufWriter;
  10use futures::{AsyncWriteExt, FutureExt as _, select_biased};
  11use git2::{BranchType, ErrorCode};
  12use gpui::{AppContext as _, AsyncApp, BackgroundExecutor, SharedString, Task};
  13use parking_lot::Mutex;
  14use rope::Rope;
  15use schemars::JsonSchema;
  16use serde::Deserialize;
  17use smallvec::SmallVec;
  18use smol::channel::Sender;
  19use smol::io::{AsyncBufReadExt, AsyncReadExt, BufReader};
  20use text::LineEnding;
  21
  22use std::collections::HashSet;
  23use std::ffi::{OsStr, OsString};
  24use std::sync::atomic::AtomicBool;
  25
  26use std::process::ExitStatus;
  27use std::str::FromStr;
  28use std::{
  29    cmp::Ordering,
  30    future,
  31    path::{Path, PathBuf},
  32    sync::Arc,
  33};
  34use sum_tree::MapSeekTarget;
  35use thiserror::Error;
  36use util::command::{Stdio, new_command};
  37use util::paths::PathStyle;
  38use util::rel_path::RelPath;
  39use util::{ResultExt, paths};
  40use uuid::Uuid;
  41
  42pub use askpass::{AskPassDelegate, AskPassResult, AskPassSession};
  43
  44pub const REMOTE_CANCELLED_BY_USER: &str = "Operation cancelled by user";
  45
  46/// Format string used in graph log to get initial data for the git graph
  47/// %H - Full commit hash
  48/// %P - Parent hashes
  49/// %D - Ref names
  50/// %x00 - Null byte separator, used to split up commit data
  51static GRAPH_COMMIT_FORMAT: &str = "--format=%H%x00%P%x00%D";
  52
  53/// Used to get commits that match with a search
  54/// %H - Full commit hash
  55static SEARCH_COMMIT_FORMAT: &str = "--format=%H";
  56
  57/// Number of commits to load per chunk for the git graph.
  58pub const GRAPH_CHUNK_SIZE: usize = 1000;
  59
  60/// Default value for the `git.worktree_directory` setting.
  61pub const DEFAULT_WORKTREE_DIRECTORY: &str = "../worktrees";
  62
  63/// Determine the original (main) repository's working directory.
  64///
  65/// For linked worktrees, `common_dir` differs from `repository_dir` and
  66/// points to the main repo's `.git` directory, so we can derive the main
  67/// repo's working directory from it. For normal repos and submodules,
  68/// `common_dir` equals `repository_dir`, and the original repo is simply
  69/// `work_directory` itself.
  70pub fn original_repo_path(
  71    work_directory: &Path,
  72    common_dir: &Path,
  73    repository_dir: &Path,
  74) -> PathBuf {
  75    if common_dir != repository_dir {
  76        original_repo_path_from_common_dir(common_dir)
  77    } else {
  78        work_directory.to_path_buf()
  79    }
  80}
  81
  82/// Given the git common directory (from `commondir()`), derive the original
  83/// repository's working directory.
  84///
  85/// For a standard checkout, `common_dir` is `<work_dir>/.git`, so the parent
  86/// is the working directory. For a git worktree, `common_dir` is the **main**
  87/// repo's `.git` directory, so the parent is the original repo's working directory.
  88///
  89/// Falls back to returning `common_dir` itself if it doesn't end with `.git`
  90/// (e.g. bare repos or unusual layouts).
  91pub fn original_repo_path_from_common_dir(common_dir: &Path) -> PathBuf {
  92    if common_dir.file_name() == Some(OsStr::new(".git")) {
  93        common_dir
  94            .parent()
  95            .map(|p| p.to_path_buf())
  96            .unwrap_or_else(|| common_dir.to_path_buf())
  97    } else {
  98        common_dir.to_path_buf()
  99    }
 100}
 101
 102/// Commit data needed for the git graph visualization.
 103#[derive(Debug, Clone)]
 104pub struct GraphCommitData {
 105    pub sha: Oid,
 106    /// Most commits have a single parent, so we use a SmallVec to avoid allocations.
 107    pub parents: SmallVec<[Oid; 1]>,
 108    pub author_name: SharedString,
 109    pub author_email: SharedString,
 110    pub commit_timestamp: i64,
 111    pub subject: SharedString,
 112}
 113
 114#[derive(Debug)]
 115pub struct InitialGraphCommitData {
 116    pub sha: Oid,
 117    pub parents: SmallVec<[Oid; 1]>,
 118    pub ref_names: Vec<SharedString>,
 119}
 120
 121struct CommitDataRequest {
 122    sha: Oid,
 123    response_tx: oneshot::Sender<Result<GraphCommitData>>,
 124}
 125
 126pub struct CommitDataReader {
 127    request_tx: smol::channel::Sender<CommitDataRequest>,
 128    _task: Task<()>,
 129}
 130
 131impl CommitDataReader {
 132    pub async fn read(&self, sha: Oid) -> Result<GraphCommitData> {
 133        let (response_tx, response_rx) = oneshot::channel();
 134        self.request_tx
 135            .send(CommitDataRequest { sha, response_tx })
 136            .await
 137            .map_err(|_| anyhow!("commit data reader task closed"))?;
 138        response_rx
 139            .await
 140            .map_err(|_| anyhow!("commit data reader task dropped response"))?
 141    }
 142}
 143
 144fn parse_cat_file_commit(sha: Oid, content: &str) -> Option<GraphCommitData> {
 145    let mut parents = SmallVec::new();
 146    let mut author_name = SharedString::default();
 147    let mut author_email = SharedString::default();
 148    let mut commit_timestamp = 0i64;
 149    let mut in_headers = true;
 150    let mut subject = None;
 151
 152    for line in content.lines() {
 153        if in_headers {
 154            if line.is_empty() {
 155                in_headers = false;
 156                continue;
 157            }
 158
 159            if let Some(parent_sha) = line.strip_prefix("parent ") {
 160                if let Ok(oid) = Oid::from_str(parent_sha.trim()) {
 161                    parents.push(oid);
 162                }
 163            } else if let Some(author_line) = line.strip_prefix("author ") {
 164                if let Some((name_email, _timestamp_tz)) = author_line.rsplit_once(' ') {
 165                    if let Some((name_email, timestamp_str)) = name_email.rsplit_once(' ') {
 166                        if let Ok(ts) = timestamp_str.parse::<i64>() {
 167                            commit_timestamp = ts;
 168                        }
 169                        if let Some((name, email)) = name_email.rsplit_once(" <") {
 170                            author_name = SharedString::from(name.to_string());
 171                            author_email =
 172                                SharedString::from(email.trim_end_matches('>').to_string());
 173                        }
 174                    }
 175                }
 176            }
 177        } else if subject.is_none() {
 178            subject = Some(SharedString::from(line.to_string()));
 179        }
 180    }
 181
 182    Some(GraphCommitData {
 183        sha,
 184        parents,
 185        author_name,
 186        author_email,
 187        commit_timestamp,
 188        subject: subject.unwrap_or_default(),
 189    })
 190}
 191
 192#[derive(Clone, Debug, Hash, PartialEq, Eq)]
 193pub struct Branch {
 194    pub is_head: bool,
 195    pub ref_name: SharedString,
 196    pub upstream: Option<Upstream>,
 197    pub most_recent_commit: Option<CommitSummary>,
 198}
 199
 200impl Branch {
 201    pub fn name(&self) -> &str {
 202        self.ref_name
 203            .as_ref()
 204            .strip_prefix("refs/heads/")
 205            .or_else(|| self.ref_name.as_ref().strip_prefix("refs/remotes/"))
 206            .unwrap_or(self.ref_name.as_ref())
 207    }
 208
 209    pub fn is_remote(&self) -> bool {
 210        self.ref_name.starts_with("refs/remotes/")
 211    }
 212
 213    pub fn remote_name(&self) -> Option<&str> {
 214        self.ref_name
 215            .strip_prefix("refs/remotes/")
 216            .and_then(|stripped| stripped.split("/").next())
 217    }
 218
 219    pub fn tracking_status(&self) -> Option<UpstreamTrackingStatus> {
 220        self.upstream
 221            .as_ref()
 222            .and_then(|upstream| upstream.tracking.status())
 223    }
 224
 225    pub fn priority_key(&self) -> (bool, Option<i64>) {
 226        (
 227            self.is_head,
 228            self.most_recent_commit
 229                .as_ref()
 230                .map(|commit| commit.commit_timestamp),
 231        )
 232    }
 233}
 234
 235#[derive(Clone, Debug, Hash, PartialEq, Eq)]
 236pub struct Worktree {
 237    pub path: PathBuf,
 238    pub ref_name: Option<SharedString>,
 239    // todo(git_worktree) This type should be a Oid
 240    pub sha: SharedString,
 241    pub is_main: bool,
 242}
 243
 244impl Worktree {
 245    /// Returns a display name for the worktree, suitable for use in the UI.
 246    ///
 247    /// If the worktree is attached to a branch, returns the branch name.
 248    /// Otherwise, returns the short SHA of the worktree's HEAD commit.
 249    pub fn display_name(&self) -> &str {
 250        match self.ref_name {
 251            Some(ref ref_name) => ref_name
 252                .strip_prefix("refs/heads/")
 253                .or_else(|| ref_name.strip_prefix("refs/remotes/"))
 254                .unwrap_or(ref_name),
 255            // Detached HEAD — show the short SHA as a fallback.
 256            None => &self.sha[..self.sha.len().min(SHORT_SHA_LENGTH)],
 257        }
 258    }
 259}
 260
 261pub fn parse_worktrees_from_str<T: AsRef<str>>(raw_worktrees: T) -> Vec<Worktree> {
 262    let mut worktrees = Vec::new();
 263    let mut is_first = true;
 264    let normalized = raw_worktrees.as_ref().replace("\r\n", "\n");
 265    let entries = normalized.split("\n\n");
 266    for entry in entries {
 267        let mut path = None;
 268        let mut sha = None;
 269        let mut ref_name = None;
 270
 271        for line in entry.lines() {
 272            let line = line.trim();
 273            if line.is_empty() {
 274                continue;
 275            }
 276            if let Some(rest) = line.strip_prefix("worktree ") {
 277                path = Some(rest.to_string());
 278            } else if let Some(rest) = line.strip_prefix("HEAD ") {
 279                sha = Some(rest.to_string());
 280            } else if let Some(rest) = line.strip_prefix("branch ") {
 281                ref_name = Some(rest.to_string());
 282            }
 283            // Ignore other lines: detached, bare, locked, prunable, etc.
 284        }
 285
 286        if let (Some(path), Some(sha)) = (path, sha) {
 287            worktrees.push(Worktree {
 288                path: PathBuf::from(path),
 289                ref_name: ref_name.map(Into::into),
 290                sha: sha.into(),
 291                is_main: is_first,
 292            });
 293            is_first = false;
 294        }
 295    }
 296
 297    worktrees
 298}
 299
 300#[derive(Clone, Debug, Hash, PartialEq, Eq)]
 301pub struct Upstream {
 302    pub ref_name: SharedString,
 303    pub tracking: UpstreamTracking,
 304}
 305
 306impl Upstream {
 307    pub fn is_remote(&self) -> bool {
 308        self.remote_name().is_some()
 309    }
 310
 311    pub fn remote_name(&self) -> Option<&str> {
 312        self.ref_name
 313            .strip_prefix("refs/remotes/")
 314            .and_then(|stripped| stripped.split("/").next())
 315    }
 316
 317    pub fn stripped_ref_name(&self) -> Option<&str> {
 318        self.ref_name.strip_prefix("refs/remotes/")
 319    }
 320
 321    pub fn branch_name(&self) -> Option<&str> {
 322        self.ref_name
 323            .strip_prefix("refs/remotes/")
 324            .and_then(|stripped| stripped.split_once('/').map(|(_, name)| name))
 325    }
 326}
 327
 328#[derive(Clone, Copy, Default)]
 329pub struct CommitOptions {
 330    pub amend: bool,
 331    pub signoff: bool,
 332    pub allow_empty: bool,
 333}
 334
 335#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
 336pub enum UpstreamTracking {
 337    /// Remote ref not present in local repository.
 338    Gone,
 339    /// Remote ref present in local repository (fetched from remote).
 340    Tracked(UpstreamTrackingStatus),
 341}
 342
 343impl From<UpstreamTrackingStatus> for UpstreamTracking {
 344    fn from(status: UpstreamTrackingStatus) -> Self {
 345        UpstreamTracking::Tracked(status)
 346    }
 347}
 348
 349impl UpstreamTracking {
 350    pub fn is_gone(&self) -> bool {
 351        matches!(self, UpstreamTracking::Gone)
 352    }
 353
 354    pub fn status(&self) -> Option<UpstreamTrackingStatus> {
 355        match self {
 356            UpstreamTracking::Gone => None,
 357            UpstreamTracking::Tracked(status) => Some(*status),
 358        }
 359    }
 360}
 361
 362#[derive(Debug, Clone)]
 363pub struct RemoteCommandOutput {
 364    pub stdout: String,
 365    pub stderr: String,
 366}
 367
 368impl RemoteCommandOutput {
 369    pub fn is_empty(&self) -> bool {
 370        self.stdout.is_empty() && self.stderr.is_empty()
 371    }
 372}
 373
 374#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
 375pub struct UpstreamTrackingStatus {
 376    pub ahead: u32,
 377    pub behind: u32,
 378}
 379
 380#[derive(Clone, Debug, Hash, PartialEq, Eq)]
 381pub struct CommitSummary {
 382    pub sha: SharedString,
 383    pub subject: SharedString,
 384    /// This is a unix timestamp
 385    pub commit_timestamp: i64,
 386    pub author_name: SharedString,
 387    pub has_parent: bool,
 388}
 389
 390#[derive(Clone, Debug, Default, Hash, PartialEq, Eq)]
 391pub struct CommitDetails {
 392    pub sha: SharedString,
 393    pub message: SharedString,
 394    pub commit_timestamp: i64,
 395    pub author_email: SharedString,
 396    pub author_name: SharedString,
 397}
 398
 399#[derive(Debug)]
 400pub struct CommitDiff {
 401    pub files: Vec<CommitFile>,
 402}
 403
 404#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
 405pub enum CommitFileStatus {
 406    Added,
 407    Modified,
 408    Deleted,
 409}
 410
 411#[derive(Debug)]
 412pub struct CommitFile {
 413    pub path: RepoPath,
 414    pub old_text: Option<String>,
 415    pub new_text: Option<String>,
 416    pub is_binary: bool,
 417}
 418
 419impl CommitFile {
 420    pub fn status(&self) -> CommitFileStatus {
 421        match (&self.old_text, &self.new_text) {
 422            (None, Some(_)) => CommitFileStatus::Added,
 423            (Some(_), None) => CommitFileStatus::Deleted,
 424            _ => CommitFileStatus::Modified,
 425        }
 426    }
 427}
 428
 429impl CommitDetails {
 430    pub fn short_sha(&self) -> SharedString {
 431        self.sha[..SHORT_SHA_LENGTH].to_string().into()
 432    }
 433}
 434
 435/// Detects if content is binary by checking for NUL bytes in the first 8000 bytes.
 436/// This matches git's binary detection heuristic.
 437pub fn is_binary_content(content: &[u8]) -> bool {
 438    let check_len = content.len().min(8000);
 439    content[..check_len].contains(&0)
 440}
 441
 442#[derive(Debug, Clone, Hash, PartialEq, Eq)]
 443pub struct Remote {
 444    pub name: SharedString,
 445}
 446
 447pub enum ResetMode {
 448    /// Reset the branch pointer, leave index and worktree unchanged (this will make it look like things that were
 449    /// committed are now staged).
 450    Soft,
 451    /// Reset the branch pointer and index, leave worktree unchanged (this makes it look as though things that were
 452    /// committed are now unstaged).
 453    Mixed,
 454}
 455
 456#[derive(Debug, Clone, Hash, PartialEq, Eq)]
 457pub enum FetchOptions {
 458    All,
 459    Remote(Remote),
 460}
 461
 462impl FetchOptions {
 463    pub fn to_proto(&self) -> Option<String> {
 464        match self {
 465            FetchOptions::All => None,
 466            FetchOptions::Remote(remote) => Some(remote.clone().name.into()),
 467        }
 468    }
 469
 470    pub fn from_proto(remote_name: Option<String>) -> Self {
 471        match remote_name {
 472            Some(name) => FetchOptions::Remote(Remote { name: name.into() }),
 473            None => FetchOptions::All,
 474        }
 475    }
 476
 477    pub fn name(&self) -> SharedString {
 478        match self {
 479            Self::All => "Fetch all remotes".into(),
 480            Self::Remote(remote) => remote.name.clone(),
 481        }
 482    }
 483}
 484
 485impl std::fmt::Display for FetchOptions {
 486    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
 487        match self {
 488            FetchOptions::All => write!(f, "--all"),
 489            FetchOptions::Remote(remote) => write!(f, "{}", remote.name),
 490        }
 491    }
 492}
 493
 494/// Modifies .git/info/exclude temporarily
 495pub struct GitExcludeOverride {
 496    git_exclude_path: PathBuf,
 497    original_excludes: Option<String>,
 498    added_excludes: Option<String>,
 499}
 500
 501impl GitExcludeOverride {
 502    const START_BLOCK_MARKER: &str = "\n\n#  ====== Auto-added by Zed: =======\n";
 503    const END_BLOCK_MARKER: &str = "\n#  ====== End of auto-added by Zed =======\n";
 504
 505    pub async fn new(git_exclude_path: PathBuf) -> Result<Self> {
 506        let original_excludes =
 507            smol::fs::read_to_string(&git_exclude_path)
 508                .await
 509                .ok()
 510                .map(|content| {
 511                    // Auto-generated lines are normally cleaned up in
 512                    // `restore_original()` or `drop()`, but may stuck in rare cases.
 513                    // Make sure to remove them.
 514                    Self::remove_auto_generated_block(&content)
 515                });
 516
 517        Ok(GitExcludeOverride {
 518            git_exclude_path,
 519            original_excludes,
 520            added_excludes: None,
 521        })
 522    }
 523
 524    pub async fn add_excludes(&mut self, excludes: &str) -> Result<()> {
 525        self.added_excludes = Some(if let Some(ref already_added) = self.added_excludes {
 526            format!("{already_added}\n{excludes}")
 527        } else {
 528            excludes.to_string()
 529        });
 530
 531        let mut content = self.original_excludes.clone().unwrap_or_default();
 532
 533        content.push_str(Self::START_BLOCK_MARKER);
 534        content.push_str(self.added_excludes.as_ref().unwrap());
 535        content.push_str(Self::END_BLOCK_MARKER);
 536
 537        smol::fs::write(&self.git_exclude_path, content).await?;
 538        Ok(())
 539    }
 540
 541    pub async fn restore_original(&mut self) -> Result<()> {
 542        if let Some(ref original) = self.original_excludes {
 543            smol::fs::write(&self.git_exclude_path, original).await?;
 544        } else if self.git_exclude_path.exists() {
 545            smol::fs::remove_file(&self.git_exclude_path).await?;
 546        }
 547
 548        self.added_excludes = None;
 549
 550        Ok(())
 551    }
 552
 553    fn remove_auto_generated_block(content: &str) -> String {
 554        let start_marker = Self::START_BLOCK_MARKER;
 555        let end_marker = Self::END_BLOCK_MARKER;
 556        let mut content = content.to_string();
 557
 558        let start_index = content.find(start_marker);
 559        let end_index = content.rfind(end_marker);
 560
 561        if let (Some(start), Some(end)) = (start_index, end_index) {
 562            if end > start {
 563                content.replace_range(start..end + end_marker.len(), "");
 564            }
 565        }
 566
 567        // Older versions of Zed didn't have end-of-block markers,
 568        // so it's impossible to determine auto-generated lines.
 569        // Conservatively remove the standard list of excludes
 570        let standard_excludes = format!(
 571            "{}{}",
 572            Self::START_BLOCK_MARKER,
 573            include_str!("./checkpoint.gitignore")
 574        );
 575        content = content.replace(&standard_excludes, "");
 576
 577        content
 578    }
 579}
 580
 581impl Drop for GitExcludeOverride {
 582    fn drop(&mut self) {
 583        if self.added_excludes.is_some() {
 584            let git_exclude_path = self.git_exclude_path.clone();
 585            let original_excludes = self.original_excludes.clone();
 586            smol::spawn(async move {
 587                if let Some(original) = original_excludes {
 588                    smol::fs::write(&git_exclude_path, original).await
 589                } else {
 590                    smol::fs::remove_file(&git_exclude_path).await
 591                }
 592            })
 593            .detach();
 594        }
 595    }
 596}
 597
 598#[derive(Clone, Debug, Default, PartialEq, Eq, Hash, Copy)]
 599pub enum LogOrder {
 600    #[default]
 601    DateOrder,
 602    TopoOrder,
 603    AuthorDateOrder,
 604    ReverseChronological,
 605}
 606
 607impl LogOrder {
 608    pub fn as_arg(&self) -> &'static str {
 609        match self {
 610            LogOrder::DateOrder => "--date-order",
 611            LogOrder::TopoOrder => "--topo-order",
 612            LogOrder::AuthorDateOrder => "--author-date-order",
 613            LogOrder::ReverseChronological => "--reverse",
 614        }
 615    }
 616}
 617
 618#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)]
 619pub enum LogSource {
 620    #[default]
 621    All,
 622    Branch(SharedString),
 623    Sha(Oid),
 624    File(RepoPath),
 625}
 626
 627impl LogSource {
 628    fn get_arg(&self) -> Result<&str> {
 629        match self {
 630            LogSource::All => Ok("--all"),
 631            LogSource::Branch(branch) => Ok(branch.as_str()),
 632            LogSource::Sha(oid) => {
 633                str::from_utf8(oid.as_bytes()).context("Failed to build str from sha")
 634            }
 635            LogSource::File(_) => Ok("--follow"),
 636        }
 637    }
 638}
 639
 640pub struct SearchCommitArgs {
 641    pub query: SharedString,
 642    pub case_sensitive: bool,
 643}
 644
 645pub trait GitRepository: Send + Sync {
 646    fn reload_index(&self);
 647
 648    /// Returns the contents of an entry in the repository's index, or None if there is no entry for the given path.
 649    ///
 650    /// Also returns `None` for symlinks.
 651    fn load_index_text(&self, path: RepoPath) -> BoxFuture<'_, Option<String>>;
 652
 653    /// 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.
 654    ///
 655    /// Also returns `None` for symlinks.
 656    fn load_committed_text(&self, path: RepoPath) -> BoxFuture<'_, Option<String>>;
 657    fn load_blob_content(&self, oid: Oid) -> BoxFuture<'_, Result<String>>;
 658
 659    fn set_index_text(
 660        &self,
 661        path: RepoPath,
 662        content: Option<String>,
 663        env: Arc<HashMap<String, String>>,
 664        is_executable: bool,
 665    ) -> BoxFuture<'_, anyhow::Result<()>>;
 666
 667    /// Returns the URL of the remote with the given name.
 668    fn remote_url(&self, name: &str) -> BoxFuture<'_, Option<String>>;
 669
 670    /// Resolve a list of refs to SHAs.
 671    fn revparse_batch(&self, revs: Vec<String>) -> BoxFuture<'_, Result<Vec<Option<String>>>>;
 672
 673    fn head_sha(&self) -> BoxFuture<'_, Option<String>> {
 674        async move {
 675            self.revparse_batch(vec!["HEAD".into()])
 676                .await
 677                .unwrap_or_default()
 678                .into_iter()
 679                .next()
 680                .flatten()
 681        }
 682        .boxed()
 683    }
 684
 685    fn merge_message(&self) -> BoxFuture<'_, Option<String>>;
 686
 687    fn status(&self, path_prefixes: &[RepoPath]) -> Task<Result<GitStatus>>;
 688    fn diff_tree(&self, request: DiffTreeType) -> BoxFuture<'_, Result<TreeDiff>>;
 689
 690    fn stash_entries(&self) -> BoxFuture<'_, Result<GitStash>>;
 691
 692    fn branches(&self) -> BoxFuture<'_, Result<Vec<Branch>>>;
 693
 694    fn change_branch(&self, name: String) -> BoxFuture<'_, Result<()>>;
 695    fn create_branch(&self, name: String, base_branch: Option<String>)
 696    -> BoxFuture<'_, Result<()>>;
 697    fn rename_branch(&self, branch: String, new_name: String) -> BoxFuture<'_, Result<()>>;
 698
 699    fn delete_branch(&self, is_remote: bool, name: String) -> BoxFuture<'_, Result<()>>;
 700
 701    fn worktrees(&self) -> BoxFuture<'_, Result<Vec<Worktree>>>;
 702
 703    fn create_worktree(
 704        &self,
 705        branch_name: Option<String>,
 706        path: PathBuf,
 707        from_commit: Option<String>,
 708    ) -> BoxFuture<'_, Result<()>>;
 709
 710    fn remove_worktree(&self, path: PathBuf, force: bool) -> BoxFuture<'_, Result<()>>;
 711
 712    fn rename_worktree(&self, old_path: PathBuf, new_path: PathBuf) -> BoxFuture<'_, Result<()>>;
 713
 714    fn reset(
 715        &self,
 716        commit: String,
 717        mode: ResetMode,
 718        env: Arc<HashMap<String, String>>,
 719    ) -> BoxFuture<'_, Result<()>>;
 720
 721    fn checkout_files(
 722        &self,
 723        commit: String,
 724        paths: Vec<RepoPath>,
 725        env: Arc<HashMap<String, String>>,
 726    ) -> BoxFuture<'_, Result<()>>;
 727
 728    fn show(&self, commit: String) -> BoxFuture<'_, Result<CommitDetails>>;
 729
 730    fn load_commit(&self, commit: String, cx: AsyncApp) -> BoxFuture<'_, Result<CommitDiff>>;
 731    fn blame(
 732        &self,
 733        path: RepoPath,
 734        content: Rope,
 735        line_ending: LineEnding,
 736    ) -> BoxFuture<'_, Result<crate::blame::Blame>>;
 737
 738    /// Returns the absolute path to the repository. For worktrees, this will be the path to the
 739    /// worktree's gitdir within the main repository (typically `.git/worktrees/<name>`).
 740    fn path(&self) -> PathBuf;
 741
 742    fn main_repository_path(&self) -> PathBuf;
 743
 744    /// Updates the index to match the worktree at the given paths.
 745    ///
 746    /// If any of the paths have been deleted from the worktree, they will be removed from the index if found there.
 747    fn stage_paths(
 748        &self,
 749        paths: Vec<RepoPath>,
 750        env: Arc<HashMap<String, String>>,
 751    ) -> BoxFuture<'_, Result<()>>;
 752    /// Updates the index to match HEAD at the given paths.
 753    ///
 754    /// If any of the paths were previously staged but do not exist in HEAD, they will be removed from the index.
 755    fn unstage_paths(
 756        &self,
 757        paths: Vec<RepoPath>,
 758        env: Arc<HashMap<String, String>>,
 759    ) -> BoxFuture<'_, Result<()>>;
 760
 761    fn run_hook(
 762        &self,
 763        hook: RunHook,
 764        env: Arc<HashMap<String, String>>,
 765    ) -> BoxFuture<'_, Result<()>>;
 766
 767    fn commit(
 768        &self,
 769        message: SharedString,
 770        name_and_email: Option<(SharedString, SharedString)>,
 771        options: CommitOptions,
 772        askpass: AskPassDelegate,
 773        env: Arc<HashMap<String, String>>,
 774    ) -> BoxFuture<'_, Result<()>>;
 775
 776    fn stash_paths(
 777        &self,
 778        paths: Vec<RepoPath>,
 779        env: Arc<HashMap<String, String>>,
 780    ) -> BoxFuture<'_, Result<()>>;
 781
 782    fn stash_pop(
 783        &self,
 784        index: Option<usize>,
 785        env: Arc<HashMap<String, String>>,
 786    ) -> BoxFuture<'_, Result<()>>;
 787
 788    fn stash_apply(
 789        &self,
 790        index: Option<usize>,
 791        env: Arc<HashMap<String, String>>,
 792    ) -> BoxFuture<'_, Result<()>>;
 793
 794    fn stash_drop(
 795        &self,
 796        index: Option<usize>,
 797        env: Arc<HashMap<String, String>>,
 798    ) -> BoxFuture<'_, Result<()>>;
 799
 800    fn push(
 801        &self,
 802        branch_name: String,
 803        remote_branch_name: String,
 804        upstream_name: String,
 805        options: Option<PushOptions>,
 806        askpass: AskPassDelegate,
 807        env: Arc<HashMap<String, String>>,
 808        // This method takes an AsyncApp to ensure it's invoked on the main thread,
 809        // otherwise git-credentials-manager won't work.
 810        cx: AsyncApp,
 811    ) -> BoxFuture<'_, Result<RemoteCommandOutput>>;
 812
 813    fn pull(
 814        &self,
 815        branch_name: Option<String>,
 816        upstream_name: String,
 817        rebase: bool,
 818        askpass: AskPassDelegate,
 819        env: Arc<HashMap<String, String>>,
 820        // This method takes an AsyncApp to ensure it's invoked on the main thread,
 821        // otherwise git-credentials-manager won't work.
 822        cx: AsyncApp,
 823    ) -> BoxFuture<'_, Result<RemoteCommandOutput>>;
 824
 825    fn fetch(
 826        &self,
 827        fetch_options: FetchOptions,
 828        askpass: AskPassDelegate,
 829        env: Arc<HashMap<String, String>>,
 830        // This method takes an AsyncApp to ensure it's invoked on the main thread,
 831        // otherwise git-credentials-manager won't work.
 832        cx: AsyncApp,
 833    ) -> BoxFuture<'_, Result<RemoteCommandOutput>>;
 834
 835    fn get_push_remote(&self, branch: String) -> BoxFuture<'_, Result<Option<Remote>>>;
 836
 837    fn get_branch_remote(&self, branch: String) -> BoxFuture<'_, Result<Option<Remote>>>;
 838
 839    fn get_all_remotes(&self) -> BoxFuture<'_, Result<Vec<Remote>>>;
 840
 841    fn remove_remote(&self, name: String) -> BoxFuture<'_, Result<()>>;
 842
 843    fn create_remote(&self, name: String, url: String) -> BoxFuture<'_, Result<()>>;
 844
 845    /// returns a list of remote branches that contain HEAD
 846    fn check_for_pushed_commit(&self) -> BoxFuture<'_, Result<Vec<SharedString>>>;
 847
 848    /// Run git diff
 849    fn diff(&self, diff: DiffType) -> BoxFuture<'_, Result<String>>;
 850
 851    fn diff_stat(
 852        &self,
 853        path_prefixes: &[RepoPath],
 854    ) -> BoxFuture<'_, Result<crate::status::GitDiffStat>>;
 855
 856    /// Creates a checkpoint for the repository.
 857    fn checkpoint(&self) -> BoxFuture<'static, Result<GitRepositoryCheckpoint>>;
 858
 859    /// Resets to a previously-created checkpoint.
 860    fn restore_checkpoint(&self, checkpoint: GitRepositoryCheckpoint) -> BoxFuture<'_, Result<()>>;
 861
 862    /// Compares two checkpoints, returning true if they are equal
 863    fn compare_checkpoints(
 864        &self,
 865        left: GitRepositoryCheckpoint,
 866        right: GitRepositoryCheckpoint,
 867    ) -> BoxFuture<'_, Result<bool>>;
 868
 869    /// Computes a diff between two checkpoints.
 870    fn diff_checkpoints(
 871        &self,
 872        base_checkpoint: GitRepositoryCheckpoint,
 873        target_checkpoint: GitRepositoryCheckpoint,
 874    ) -> BoxFuture<'_, Result<String>>;
 875
 876    fn default_branch(
 877        &self,
 878        include_remote_name: bool,
 879    ) -> BoxFuture<'_, Result<Option<SharedString>>>;
 880
 881    /// Runs `git rev-list --parents` to get the commit graph structure.
 882    /// Returns commit SHAs and their parent SHAs for building the graph visualization.
 883    fn initial_graph_data(
 884        &self,
 885        log_source: LogSource,
 886        log_order: LogOrder,
 887        request_tx: Sender<Vec<Arc<InitialGraphCommitData>>>,
 888    ) -> BoxFuture<'_, Result<()>>;
 889
 890    fn search_commits(
 891        &self,
 892        log_source: LogSource,
 893        search_args: SearchCommitArgs,
 894        request_tx: Sender<Oid>,
 895    ) -> BoxFuture<'_, Result<()>>;
 896
 897    fn commit_data_reader(&self) -> Result<CommitDataReader>;
 898
 899    fn update_ref(&self, ref_name: String, commit: String) -> BoxFuture<'_, Result<()>>;
 900
 901    fn delete_ref(&self, ref_name: String) -> BoxFuture<'_, Result<()>>;
 902
 903    fn repair_worktrees(&self) -> BoxFuture<'_, Result<()>>;
 904
 905    fn set_trusted(&self, trusted: bool);
 906    fn is_trusted(&self) -> bool;
 907}
 908
 909pub enum DiffType {
 910    HeadToIndex,
 911    HeadToWorktree,
 912    MergeBase { base_ref: SharedString },
 913}
 914
 915#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, JsonSchema)]
 916pub enum PushOptions {
 917    SetUpstream,
 918    Force,
 919}
 920
 921impl std::fmt::Debug for dyn GitRepository {
 922    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
 923        f.debug_struct("dyn GitRepository<...>").finish()
 924    }
 925}
 926
 927pub struct RealGitRepository {
 928    pub repository: Arc<Mutex<git2::Repository>>,
 929    pub system_git_binary_path: Option<PathBuf>,
 930    pub any_git_binary_path: PathBuf,
 931    any_git_binary_help_output: Arc<Mutex<Option<SharedString>>>,
 932    executor: BackgroundExecutor,
 933    is_trusted: Arc<AtomicBool>,
 934}
 935
 936impl RealGitRepository {
 937    pub fn new(
 938        dotgit_path: &Path,
 939        bundled_git_binary_path: Option<PathBuf>,
 940        system_git_binary_path: Option<PathBuf>,
 941        executor: BackgroundExecutor,
 942    ) -> Result<Self> {
 943        let any_git_binary_path = system_git_binary_path
 944            .clone()
 945            .or(bundled_git_binary_path)
 946            .context("no git binary available")?;
 947        log::info!(
 948            "opening git repository at {dotgit_path:?} using git binary {any_git_binary_path:?}"
 949        );
 950        let workdir_root = dotgit_path.parent().context(".git has no parent")?;
 951        let repository =
 952            git2::Repository::open(workdir_root).context("creating libgit2 repository")?;
 953        Ok(Self {
 954            repository: Arc::new(Mutex::new(repository)),
 955            system_git_binary_path,
 956            any_git_binary_path,
 957            executor,
 958            any_git_binary_help_output: Arc::new(Mutex::new(None)),
 959            is_trusted: Arc::new(AtomicBool::new(false)),
 960        })
 961    }
 962
 963    fn working_directory(&self) -> Result<PathBuf> {
 964        self.repository
 965            .lock()
 966            .workdir()
 967            .context("failed to read git work directory")
 968            .map(Path::to_path_buf)
 969    }
 970
 971    fn git_binary(&self) -> Result<GitBinary> {
 972        Ok(GitBinary::new(
 973            self.any_git_binary_path.clone(),
 974            self.working_directory()
 975                .with_context(|| "Can't run git commands without a working directory")?,
 976            self.path(),
 977            self.executor.clone(),
 978            self.is_trusted(),
 979        ))
 980    }
 981
 982    async fn any_git_binary_help_output(&self) -> SharedString {
 983        if let Some(output) = self.any_git_binary_help_output.lock().clone() {
 984            return output;
 985        }
 986        let git_binary = self.git_binary();
 987        let output: SharedString = self
 988            .executor
 989            .spawn(async move { git_binary?.run(&["help", "-a"]).await })
 990            .await
 991            .unwrap_or_default()
 992            .into();
 993        *self.any_git_binary_help_output.lock() = Some(output.clone());
 994        output
 995    }
 996}
 997
 998#[derive(Clone, Debug)]
 999pub struct GitRepositoryCheckpoint {
1000    pub commit_sha: Oid,
1001}
1002
1003#[derive(Debug)]
1004pub struct GitCommitter {
1005    pub name: Option<String>,
1006    pub email: Option<String>,
1007}
1008
1009pub async fn get_git_committer(cx: &AsyncApp) -> GitCommitter {
1010    if cfg!(any(feature = "test-support", test)) {
1011        return GitCommitter {
1012            name: None,
1013            email: None,
1014        };
1015    }
1016
1017    let git_binary_path =
1018        if cfg!(target_os = "macos") && option_env!("ZED_BUNDLE").as_deref() == Some("true") {
1019            cx.update(|cx| {
1020                cx.path_for_auxiliary_executable("git")
1021                    .context("could not find git binary path")
1022                    .log_err()
1023            })
1024        } else {
1025            None
1026        };
1027
1028    let git = GitBinary::new(
1029        git_binary_path.unwrap_or(PathBuf::from("git")),
1030        paths::home_dir().clone(),
1031        paths::home_dir().join(".git"),
1032        cx.background_executor().clone(),
1033        true,
1034    );
1035
1036    cx.background_spawn(async move {
1037        let name = git
1038            .run(&["config", "--global", "user.name"])
1039            .await
1040            .log_err();
1041        let email = git
1042            .run(&["config", "--global", "user.email"])
1043            .await
1044            .log_err();
1045        GitCommitter { name, email }
1046    })
1047    .await
1048}
1049
1050impl GitRepository for RealGitRepository {
1051    fn reload_index(&self) {
1052        if let Ok(mut index) = self.repository.lock().index() {
1053            _ = index.read(false);
1054        }
1055    }
1056
1057    fn path(&self) -> PathBuf {
1058        let repo = self.repository.lock();
1059        repo.path().into()
1060    }
1061
1062    fn main_repository_path(&self) -> PathBuf {
1063        let repo = self.repository.lock();
1064        repo.commondir().into()
1065    }
1066
1067    fn show(&self, commit: String) -> BoxFuture<'_, Result<CommitDetails>> {
1068        let git_binary = self.git_binary();
1069        self.executor
1070            .spawn(async move {
1071                let git = git_binary?;
1072                let output = git
1073                    .build_command(&[
1074                        "show",
1075                        "--no-patch",
1076                        "--format=%H%x00%B%x00%at%x00%ae%x00%an%x00",
1077                        &commit,
1078                    ])
1079                    .output()
1080                    .await?;
1081                let output = std::str::from_utf8(&output.stdout)?;
1082                let fields = output.split('\0').collect::<Vec<_>>();
1083                if fields.len() != 6 {
1084                    bail!("unexpected git-show output for {commit:?}: {output:?}")
1085                }
1086                let sha = fields[0].to_string().into();
1087                let message = fields[1].to_string().into();
1088                let commit_timestamp = fields[2].parse()?;
1089                let author_email = fields[3].to_string().into();
1090                let author_name = fields[4].to_string().into();
1091                Ok(CommitDetails {
1092                    sha,
1093                    message,
1094                    commit_timestamp,
1095                    author_email,
1096                    author_name,
1097                })
1098            })
1099            .boxed()
1100    }
1101
1102    fn load_commit(&self, commit: String, cx: AsyncApp) -> BoxFuture<'_, Result<CommitDiff>> {
1103        if self.repository.lock().workdir().is_none() {
1104            return future::ready(Err(anyhow!("no working directory"))).boxed();
1105        }
1106        let git_binary = self.git_binary();
1107        cx.background_spawn(async move {
1108            let git = git_binary?;
1109            let show_output = git
1110                .build_command(&[
1111                    "show",
1112                    "--format=",
1113                    "-z",
1114                    "--no-renames",
1115                    "--name-status",
1116                    "--first-parent",
1117                ])
1118                .arg(&commit)
1119                .stdin(Stdio::null())
1120                .stdout(Stdio::piped())
1121                .stderr(Stdio::piped())
1122                .output()
1123                .await
1124                .context("starting git show process")?;
1125
1126            let show_stdout = String::from_utf8_lossy(&show_output.stdout);
1127            let changes = parse_git_diff_name_status(&show_stdout);
1128            let parent_sha = format!("{}^", commit);
1129
1130            let mut cat_file_process = git
1131                .build_command(&["cat-file", "--batch=%(objectsize)"])
1132                .stdin(Stdio::piped())
1133                .stdout(Stdio::piped())
1134                .stderr(Stdio::piped())
1135                .spawn()
1136                .context("starting git cat-file process")?;
1137
1138            let mut files = Vec::<CommitFile>::new();
1139            let mut stdin = BufWriter::with_capacity(512, cat_file_process.stdin.take().unwrap());
1140            let mut stdout = BufReader::new(cat_file_process.stdout.take().unwrap());
1141            let mut info_line = String::new();
1142            let mut newline = [b'\0'];
1143            for (path, status_code) in changes {
1144                // git-show outputs `/`-delimited paths even on Windows.
1145                let Some(rel_path) = RelPath::unix(path).log_err() else {
1146                    continue;
1147                };
1148
1149                match status_code {
1150                    StatusCode::Modified => {
1151                        stdin.write_all(commit.as_bytes()).await?;
1152                        stdin.write_all(b":").await?;
1153                        stdin.write_all(path.as_bytes()).await?;
1154                        stdin.write_all(b"\n").await?;
1155                        stdin.write_all(parent_sha.as_bytes()).await?;
1156                        stdin.write_all(b":").await?;
1157                        stdin.write_all(path.as_bytes()).await?;
1158                        stdin.write_all(b"\n").await?;
1159                    }
1160                    StatusCode::Added => {
1161                        stdin.write_all(commit.as_bytes()).await?;
1162                        stdin.write_all(b":").await?;
1163                        stdin.write_all(path.as_bytes()).await?;
1164                        stdin.write_all(b"\n").await?;
1165                    }
1166                    StatusCode::Deleted => {
1167                        stdin.write_all(parent_sha.as_bytes()).await?;
1168                        stdin.write_all(b":").await?;
1169                        stdin.write_all(path.as_bytes()).await?;
1170                        stdin.write_all(b"\n").await?;
1171                    }
1172                    _ => continue,
1173                }
1174                stdin.flush().await?;
1175
1176                info_line.clear();
1177                stdout.read_line(&mut info_line).await?;
1178
1179                let len = info_line.trim_end().parse().with_context(|| {
1180                    format!("invalid object size output from cat-file {info_line}")
1181                })?;
1182                let mut text_bytes = vec![0; len];
1183                stdout.read_exact(&mut text_bytes).await?;
1184                stdout.read_exact(&mut newline).await?;
1185
1186                let mut old_text = None;
1187                let mut new_text = None;
1188                let mut is_binary = is_binary_content(&text_bytes);
1189                let text = if is_binary {
1190                    String::new()
1191                } else {
1192                    String::from_utf8_lossy(&text_bytes).to_string()
1193                };
1194
1195                match status_code {
1196                    StatusCode::Modified => {
1197                        info_line.clear();
1198                        stdout.read_line(&mut info_line).await?;
1199                        let len = info_line.trim_end().parse().with_context(|| {
1200                            format!("invalid object size output from cat-file {}", info_line)
1201                        })?;
1202                        let mut parent_bytes = vec![0; len];
1203                        stdout.read_exact(&mut parent_bytes).await?;
1204                        stdout.read_exact(&mut newline).await?;
1205                        is_binary = is_binary || is_binary_content(&parent_bytes);
1206                        if is_binary {
1207                            old_text = Some(String::new());
1208                            new_text = Some(String::new());
1209                        } else {
1210                            old_text = Some(String::from_utf8_lossy(&parent_bytes).to_string());
1211                            new_text = Some(text);
1212                        }
1213                    }
1214                    StatusCode::Added => new_text = Some(text),
1215                    StatusCode::Deleted => old_text = Some(text),
1216                    _ => continue,
1217                }
1218
1219                files.push(CommitFile {
1220                    path: RepoPath(Arc::from(rel_path)),
1221                    old_text,
1222                    new_text,
1223                    is_binary,
1224                })
1225            }
1226
1227            Ok(CommitDiff { files })
1228        })
1229        .boxed()
1230    }
1231
1232    fn reset(
1233        &self,
1234        commit: String,
1235        mode: ResetMode,
1236        env: Arc<HashMap<String, String>>,
1237    ) -> BoxFuture<'_, Result<()>> {
1238        let git_binary = self.git_binary();
1239        async move {
1240            let mode_flag = match mode {
1241                ResetMode::Mixed => "--mixed",
1242                ResetMode::Soft => "--soft",
1243            };
1244
1245            let git = git_binary?;
1246            let output = git
1247                .build_command(&["reset", mode_flag, &commit])
1248                .envs(env.iter())
1249                .output()
1250                .await?;
1251            anyhow::ensure!(
1252                output.status.success(),
1253                "Failed to reset:\n{}",
1254                String::from_utf8_lossy(&output.stderr),
1255            );
1256            Ok(())
1257        }
1258        .boxed()
1259    }
1260
1261    fn checkout_files(
1262        &self,
1263        commit: String,
1264        paths: Vec<RepoPath>,
1265        env: Arc<HashMap<String, String>>,
1266    ) -> BoxFuture<'_, Result<()>> {
1267        let git_binary = self.git_binary();
1268        async move {
1269            if paths.is_empty() {
1270                return Ok(());
1271            }
1272
1273            let git = git_binary?;
1274            let output = git
1275                .build_command(&["checkout", &commit, "--"])
1276                .envs(env.iter())
1277                .args(paths.iter().map(|path| path.as_unix_str()))
1278                .output()
1279                .await?;
1280            anyhow::ensure!(
1281                output.status.success(),
1282                "Failed to checkout files:\n{}",
1283                String::from_utf8_lossy(&output.stderr),
1284            );
1285            Ok(())
1286        }
1287        .boxed()
1288    }
1289
1290    fn load_index_text(&self, path: RepoPath) -> BoxFuture<'_, Option<String>> {
1291        // https://git-scm.com/book/en/v2/Git-Internals-Git-Objects
1292        const GIT_MODE_SYMLINK: u32 = 0o120000;
1293
1294        let repo = self.repository.clone();
1295        self.executor
1296            .spawn(async move {
1297                fn logic(repo: &git2::Repository, path: &RepoPath) -> Result<Option<String>> {
1298                    let mut index = repo.index()?;
1299                    index.read(false)?;
1300
1301                    const STAGE_NORMAL: i32 = 0;
1302                    // git2 unwraps internally on empty paths or `.`
1303                    if path.is_empty() {
1304                        bail!("empty path has no index text");
1305                    }
1306                    let Some(entry) = index.get_path(path.as_std_path(), STAGE_NORMAL) else {
1307                        return Ok(None);
1308                    };
1309                    if entry.mode == GIT_MODE_SYMLINK {
1310                        return Ok(None);
1311                    }
1312
1313                    let content = repo.find_blob(entry.id)?.content().to_owned();
1314                    Ok(String::from_utf8(content).ok())
1315                }
1316
1317                logic(&repo.lock(), &path)
1318                    .context("loading index text")
1319                    .log_err()
1320                    .flatten()
1321            })
1322            .boxed()
1323    }
1324
1325    fn load_committed_text(&self, path: RepoPath) -> BoxFuture<'_, Option<String>> {
1326        let repo = self.repository.clone();
1327        self.executor
1328            .spawn(async move {
1329                fn logic(repo: &git2::Repository, path: &RepoPath) -> Result<Option<String>> {
1330                    let head = repo.head()?.peel_to_tree()?;
1331                    // git2 unwraps internally on empty paths or `.`
1332                    if path.is_empty() {
1333                        return Err(anyhow!("empty path has no committed text"));
1334                    }
1335                    let Some(entry) = head.get_path(path.as_std_path()).ok() else {
1336                        return Ok(None);
1337                    };
1338                    if entry.filemode() == i32::from(git2::FileMode::Link) {
1339                        return Ok(None);
1340                    }
1341                    let content = repo.find_blob(entry.id())?.content().to_owned();
1342                    Ok(String::from_utf8(content).ok())
1343                }
1344
1345                logic(&repo.lock(), &path)
1346                    .context("loading committed text")
1347                    .log_err()
1348                    .flatten()
1349            })
1350            .boxed()
1351    }
1352
1353    fn load_blob_content(&self, oid: Oid) -> BoxFuture<'_, Result<String>> {
1354        let repo = self.repository.clone();
1355        self.executor
1356            .spawn(async move {
1357                let repo = repo.lock();
1358                let content = repo.find_blob(oid.0)?.content().to_owned();
1359                Ok(String::from_utf8(content)?)
1360            })
1361            .boxed()
1362    }
1363
1364    fn set_index_text(
1365        &self,
1366        path: RepoPath,
1367        content: Option<String>,
1368        env: Arc<HashMap<String, String>>,
1369        is_executable: bool,
1370    ) -> BoxFuture<'_, anyhow::Result<()>> {
1371        let git_binary = self.git_binary();
1372        self.executor
1373            .spawn(async move {
1374                let git = git_binary?;
1375                let mode = if is_executable { "100755" } else { "100644" };
1376
1377                if let Some(content) = content {
1378                    let mut child = git
1379                        .build_command(&["hash-object", "-w", "--stdin"])
1380                        .envs(env.iter())
1381                        .stdin(Stdio::piped())
1382                        .stdout(Stdio::piped())
1383                        .spawn()?;
1384                    let mut stdin = child.stdin.take().unwrap();
1385                    stdin.write_all(content.as_bytes()).await?;
1386                    stdin.flush().await?;
1387                    drop(stdin);
1388                    let output = child.output().await?.stdout;
1389                    let sha = str::from_utf8(&output)?.trim();
1390
1391                    log::debug!("indexing SHA: {sha}, path {path:?}");
1392
1393                    let output = git
1394                        .build_command(&["update-index", "--add", "--cacheinfo", mode, sha])
1395                        .envs(env.iter())
1396                        .arg(path.as_unix_str())
1397                        .output()
1398                        .await?;
1399
1400                    anyhow::ensure!(
1401                        output.status.success(),
1402                        "Failed to stage:\n{}",
1403                        String::from_utf8_lossy(&output.stderr)
1404                    );
1405                } else {
1406                    log::debug!("removing path {path:?} from the index");
1407                    let output = git
1408                        .build_command(&["update-index", "--force-remove"])
1409                        .envs(env.iter())
1410                        .arg(path.as_unix_str())
1411                        .output()
1412                        .await?;
1413                    anyhow::ensure!(
1414                        output.status.success(),
1415                        "Failed to unstage:\n{}",
1416                        String::from_utf8_lossy(&output.stderr)
1417                    );
1418                }
1419
1420                Ok(())
1421            })
1422            .boxed()
1423    }
1424
1425    fn remote_url(&self, name: &str) -> BoxFuture<'_, Option<String>> {
1426        let repo = self.repository.clone();
1427        let name = name.to_owned();
1428        self.executor
1429            .spawn(async move {
1430                let repo = repo.lock();
1431                let remote = repo.find_remote(&name).ok()?;
1432                remote.url().map(|url| url.to_string())
1433            })
1434            .boxed()
1435    }
1436
1437    fn revparse_batch(&self, revs: Vec<String>) -> BoxFuture<'_, Result<Vec<Option<String>>>> {
1438        let git_binary = self.git_binary();
1439        self.executor
1440            .spawn(async move {
1441                let git = git_binary?;
1442                let mut process = git
1443                    .build_command(&["cat-file", "--batch-check=%(objectname)"])
1444                    .stdin(Stdio::piped())
1445                    .stdout(Stdio::piped())
1446                    .stderr(Stdio::piped())
1447                    .spawn()?;
1448
1449                let stdin = process
1450                    .stdin
1451                    .take()
1452                    .context("no stdin for git cat-file subprocess")?;
1453                let mut stdin = BufWriter::new(stdin);
1454                for rev in &revs {
1455                    stdin.write_all(rev.as_bytes()).await?;
1456                    stdin.write_all(b"\n").await?;
1457                }
1458                stdin.flush().await?;
1459                drop(stdin);
1460
1461                let output = process.output().await?;
1462                let output = std::str::from_utf8(&output.stdout)?;
1463                let shas = output
1464                    .lines()
1465                    .map(|line| {
1466                        if line.ends_with("missing") {
1467                            None
1468                        } else {
1469                            Some(line.to_string())
1470                        }
1471                    })
1472                    .collect::<Vec<_>>();
1473
1474                if shas.len() != revs.len() {
1475                    // In an octopus merge, git cat-file still only outputs the first sha from MERGE_HEAD.
1476                    bail!("unexpected number of shas")
1477                }
1478
1479                Ok(shas)
1480            })
1481            .boxed()
1482    }
1483
1484    fn merge_message(&self) -> BoxFuture<'_, Option<String>> {
1485        let path = self.path().join("MERGE_MSG");
1486        self.executor
1487            .spawn(async move { std::fs::read_to_string(&path).ok() })
1488            .boxed()
1489    }
1490
1491    fn status(&self, path_prefixes: &[RepoPath]) -> Task<Result<GitStatus>> {
1492        let git = match self.git_binary() {
1493            Ok(git) => git,
1494            Err(e) => return Task::ready(Err(e)),
1495        };
1496        let args = git_status_args(path_prefixes);
1497        log::debug!("Checking for git status in {path_prefixes:?}");
1498        self.executor.spawn(async move {
1499            let output = git.build_command(&args).output().await?;
1500            if output.status.success() {
1501                let stdout = String::from_utf8_lossy(&output.stdout);
1502                stdout.parse()
1503            } else {
1504                let stderr = String::from_utf8_lossy(&output.stderr);
1505                anyhow::bail!("git status failed: {stderr}");
1506            }
1507        })
1508    }
1509
1510    fn diff_tree(&self, request: DiffTreeType) -> BoxFuture<'_, Result<TreeDiff>> {
1511        let git = match self.git_binary() {
1512            Ok(git) => git,
1513            Err(e) => return Task::ready(Err(e)).boxed(),
1514        };
1515
1516        let mut args = vec![
1517            OsString::from("diff-tree"),
1518            OsString::from("-r"),
1519            OsString::from("-z"),
1520            OsString::from("--no-renames"),
1521        ];
1522        match request {
1523            DiffTreeType::MergeBase { base, head } => {
1524                args.push("--merge-base".into());
1525                args.push(OsString::from(base.as_str()));
1526                args.push(OsString::from(head.as_str()));
1527            }
1528            DiffTreeType::Since { base, head } => {
1529                args.push(OsString::from(base.as_str()));
1530                args.push(OsString::from(head.as_str()));
1531            }
1532        }
1533
1534        self.executor
1535            .spawn(async move {
1536                let output = git.build_command(&args).output().await?;
1537                if output.status.success() {
1538                    let stdout = String::from_utf8_lossy(&output.stdout);
1539                    stdout.parse()
1540                } else {
1541                    let stderr = String::from_utf8_lossy(&output.stderr);
1542                    anyhow::bail!("git status failed: {stderr}");
1543                }
1544            })
1545            .boxed()
1546    }
1547
1548    fn stash_entries(&self) -> BoxFuture<'_, Result<GitStash>> {
1549        let git_binary = self.git_binary();
1550        self.executor
1551            .spawn(async move {
1552                let git = git_binary?;
1553                let output = git
1554                    .build_command(&["stash", "list", "--pretty=format:%gd%x00%H%x00%ct%x00%s"])
1555                    .output()
1556                    .await?;
1557                if output.status.success() {
1558                    let stdout = String::from_utf8_lossy(&output.stdout);
1559                    stdout.parse()
1560                } else {
1561                    let stderr = String::from_utf8_lossy(&output.stderr);
1562                    anyhow::bail!("git status failed: {stderr}");
1563                }
1564            })
1565            .boxed()
1566    }
1567
1568    fn branches(&self) -> BoxFuture<'_, Result<Vec<Branch>>> {
1569        let git_binary = self.git_binary();
1570        self.executor
1571            .spawn(async move {
1572                let fields = [
1573                    "%(HEAD)",
1574                    "%(objectname)",
1575                    "%(parent)",
1576                    "%(refname)",
1577                    "%(upstream)",
1578                    "%(upstream:track)",
1579                    "%(committerdate:unix)",
1580                    "%(authorname)",
1581                    "%(contents:subject)",
1582                ]
1583                .join("%00");
1584                let args = vec![
1585                    "for-each-ref",
1586                    "refs/heads/**/*",
1587                    "refs/remotes/**/*",
1588                    "--format",
1589                    &fields,
1590                ];
1591                let git = git_binary?;
1592                let output = git.build_command(&args).output().await?;
1593
1594                anyhow::ensure!(
1595                    output.status.success(),
1596                    "Failed to git git branches:\n{}",
1597                    String::from_utf8_lossy(&output.stderr)
1598                );
1599
1600                let input = String::from_utf8_lossy(&output.stdout);
1601
1602                let mut branches = parse_branch_input(&input)?;
1603                if branches.is_empty() {
1604                    let args = vec!["symbolic-ref", "--quiet", "HEAD"];
1605
1606                    let output = git.build_command(&args).output().await?;
1607
1608                    // git symbolic-ref returns a non-0 exit code if HEAD points
1609                    // to something other than a branch
1610                    if output.status.success() {
1611                        let name = String::from_utf8_lossy(&output.stdout).trim().to_string();
1612
1613                        branches.push(Branch {
1614                            ref_name: name.into(),
1615                            is_head: true,
1616                            upstream: None,
1617                            most_recent_commit: None,
1618                        });
1619                    }
1620                }
1621
1622                Ok(branches)
1623            })
1624            .boxed()
1625    }
1626
1627    fn worktrees(&self) -> BoxFuture<'_, Result<Vec<Worktree>>> {
1628        let git_binary = self.git_binary();
1629        self.executor
1630            .spawn(async move {
1631                let git = git_binary?;
1632                let output = git
1633                    .build_command(&["worktree", "list", "--porcelain"])
1634                    .output()
1635                    .await?;
1636                if output.status.success() {
1637                    let stdout = String::from_utf8_lossy(&output.stdout);
1638                    Ok(parse_worktrees_from_str(&stdout))
1639                } else {
1640                    let stderr = String::from_utf8_lossy(&output.stderr);
1641                    anyhow::bail!("git worktree list failed: {stderr}");
1642                }
1643            })
1644            .boxed()
1645    }
1646
1647    fn create_worktree(
1648        &self,
1649        branch_name: Option<String>,
1650        path: PathBuf,
1651        from_commit: Option<String>,
1652    ) -> BoxFuture<'_, Result<()>> {
1653        let git_binary = self.git_binary();
1654        let mut args = vec![OsString::from("worktree"), OsString::from("add")];
1655        if let Some(branch_name) = &branch_name {
1656            args.push(OsString::from("-b"));
1657            args.push(OsString::from(branch_name.as_str()));
1658        } else {
1659            args.push(OsString::from("--detach"));
1660        }
1661        args.push(OsString::from("--"));
1662        args.push(OsString::from(path.as_os_str()));
1663        if let Some(from_commit) = from_commit {
1664            args.push(OsString::from(from_commit));
1665        } else {
1666            args.push(OsString::from("HEAD"));
1667        }
1668
1669        self.executor
1670            .spawn(async move {
1671                std::fs::create_dir_all(path.parent().unwrap_or(&path))?;
1672                let git = git_binary?;
1673                let output = git.build_command(&args).output().await?;
1674                if output.status.success() {
1675                    Ok(())
1676                } else {
1677                    let stderr = String::from_utf8_lossy(&output.stderr);
1678                    anyhow::bail!("git worktree add failed: {stderr}");
1679                }
1680            })
1681            .boxed()
1682    }
1683
1684    fn remove_worktree(&self, path: PathBuf, force: bool) -> BoxFuture<'_, Result<()>> {
1685        let git_binary = self.git_binary();
1686
1687        self.executor
1688            .spawn(async move {
1689                let mut args: Vec<OsString> = vec!["worktree".into(), "remove".into()];
1690                if force {
1691                    args.push("--force".into());
1692                }
1693                args.push("--".into());
1694                args.push(path.as_os_str().into());
1695                git_binary?.run(&args).await?;
1696                anyhow::Ok(())
1697            })
1698            .boxed()
1699    }
1700
1701    fn rename_worktree(&self, old_path: PathBuf, new_path: PathBuf) -> BoxFuture<'_, Result<()>> {
1702        let git_binary = self.git_binary();
1703
1704        self.executor
1705            .spawn(async move {
1706                let args: Vec<OsString> = vec![
1707                    "worktree".into(),
1708                    "move".into(),
1709                    "--".into(),
1710                    old_path.as_os_str().into(),
1711                    new_path.as_os_str().into(),
1712                ];
1713                git_binary?.run(&args).await?;
1714                anyhow::Ok(())
1715            })
1716            .boxed()
1717    }
1718
1719    fn change_branch(&self, name: String) -> BoxFuture<'_, Result<()>> {
1720        let repo = self.repository.clone();
1721        let git_binary = self.git_binary();
1722        let branch = self.executor.spawn(async move {
1723            let repo = repo.lock();
1724            let branch = if let Ok(branch) = repo.find_branch(&name, BranchType::Local) {
1725                branch
1726            } else if let Ok(revision) = repo.find_branch(&name, BranchType::Remote) {
1727                let (_, branch_name) = name.split_once("/").context("Unexpected branch format")?;
1728
1729                let revision = revision.get();
1730                let branch_commit = revision.peel_to_commit()?;
1731                let mut branch = match repo.branch(&branch_name, &branch_commit, false) {
1732                    Ok(branch) => branch,
1733                    Err(err) if err.code() == ErrorCode::Exists => {
1734                        repo.find_branch(&branch_name, BranchType::Local)?
1735                    }
1736                    Err(err) => {
1737                        return Err(err.into());
1738                    }
1739                };
1740
1741                branch.set_upstream(Some(&name))?;
1742                branch
1743            } else {
1744                anyhow::bail!("Branch '{}' not found", name);
1745            };
1746
1747            Ok(branch
1748                .name()?
1749                .context("cannot checkout anonymous branch")?
1750                .to_string())
1751        });
1752
1753        self.executor
1754            .spawn(async move {
1755                let branch = branch.await?;
1756                git_binary?.run(&["checkout", &branch]).await?;
1757                anyhow::Ok(())
1758            })
1759            .boxed()
1760    }
1761
1762    fn create_branch(
1763        &self,
1764        name: String,
1765        base_branch: Option<String>,
1766    ) -> BoxFuture<'_, Result<()>> {
1767        let git_binary = self.git_binary();
1768
1769        self.executor
1770            .spawn(async move {
1771                let mut args = vec!["switch", "-c", &name];
1772                let base_branch_str;
1773                if let Some(ref base) = base_branch {
1774                    base_branch_str = base.clone();
1775                    args.push(&base_branch_str);
1776                }
1777
1778                git_binary?.run(&args).await?;
1779                anyhow::Ok(())
1780            })
1781            .boxed()
1782    }
1783
1784    fn rename_branch(&self, branch: String, new_name: String) -> BoxFuture<'_, Result<()>> {
1785        let git_binary = self.git_binary();
1786
1787        self.executor
1788            .spawn(async move {
1789                git_binary?
1790                    .run(&["branch", "-m", &branch, &new_name])
1791                    .await?;
1792                anyhow::Ok(())
1793            })
1794            .boxed()
1795    }
1796
1797    fn delete_branch(&self, is_remote: bool, name: String) -> BoxFuture<'_, Result<()>> {
1798        let git_binary = self.git_binary();
1799
1800        self.executor
1801            .spawn(async move {
1802                git_binary?
1803                    .run(&["branch", if is_remote { "-dr" } else { "-d" }, &name])
1804                    .await?;
1805                anyhow::Ok(())
1806            })
1807            .boxed()
1808    }
1809
1810    fn blame(
1811        &self,
1812        path: RepoPath,
1813        content: Rope,
1814        line_ending: LineEnding,
1815    ) -> BoxFuture<'_, Result<crate::blame::Blame>> {
1816        let git = self.git_binary();
1817
1818        self.executor
1819            .spawn(async move {
1820                crate::blame::Blame::for_path(&git?, &path, &content, line_ending).await
1821            })
1822            .boxed()
1823    }
1824
1825    fn diff(&self, diff: DiffType) -> BoxFuture<'_, Result<String>> {
1826        let git_binary = self.git_binary();
1827        self.executor
1828            .spawn(async move {
1829                let git = git_binary?;
1830                let output = match diff {
1831                    DiffType::HeadToIndex => {
1832                        git.build_command(&["diff", "--staged"]).output().await?
1833                    }
1834                    DiffType::HeadToWorktree => git.build_command(&["diff"]).output().await?,
1835                    DiffType::MergeBase { base_ref } => {
1836                        git.build_command(&["diff", "--merge-base", base_ref.as_ref()])
1837                            .output()
1838                            .await?
1839                    }
1840                };
1841
1842                anyhow::ensure!(
1843                    output.status.success(),
1844                    "Failed to run git diff:\n{}",
1845                    String::from_utf8_lossy(&output.stderr)
1846                );
1847                Ok(String::from_utf8_lossy(&output.stdout).to_string())
1848            })
1849            .boxed()
1850    }
1851
1852    fn diff_stat(
1853        &self,
1854        path_prefixes: &[RepoPath],
1855    ) -> BoxFuture<'_, Result<crate::status::GitDiffStat>> {
1856        let path_prefixes = path_prefixes.to_vec();
1857        let git_binary = self.git_binary();
1858
1859        self.executor
1860            .spawn(async move {
1861                let git_binary = git_binary?;
1862                let mut args: Vec<String> = vec![
1863                    "diff".into(),
1864                    "--numstat".into(),
1865                    "--no-renames".into(),
1866                    "HEAD".into(),
1867                ];
1868                if !path_prefixes.is_empty() {
1869                    args.push("--".into());
1870                    args.extend(
1871                        path_prefixes
1872                            .iter()
1873                            .map(|p| p.as_std_path().to_string_lossy().into_owned()),
1874                    );
1875                }
1876                let output = git_binary.run(&args).await?;
1877                Ok(crate::status::parse_numstat(&output))
1878            })
1879            .boxed()
1880    }
1881
1882    fn stage_paths(
1883        &self,
1884        paths: Vec<RepoPath>,
1885        env: Arc<HashMap<String, String>>,
1886    ) -> BoxFuture<'_, Result<()>> {
1887        let git_binary = self.git_binary();
1888        self.executor
1889            .spawn(async move {
1890                if !paths.is_empty() {
1891                    let git = git_binary?;
1892                    let output = git
1893                        .build_command(&["update-index", "--add", "--remove", "--"])
1894                        .envs(env.iter())
1895                        .args(paths.iter().map(|p| p.as_unix_str()))
1896                        .output()
1897                        .await?;
1898                    anyhow::ensure!(
1899                        output.status.success(),
1900                        "Failed to stage paths:\n{}",
1901                        String::from_utf8_lossy(&output.stderr),
1902                    );
1903                }
1904                Ok(())
1905            })
1906            .boxed()
1907    }
1908
1909    fn unstage_paths(
1910        &self,
1911        paths: Vec<RepoPath>,
1912        env: Arc<HashMap<String, String>>,
1913    ) -> BoxFuture<'_, Result<()>> {
1914        let git_binary = self.git_binary();
1915
1916        self.executor
1917            .spawn(async move {
1918                if !paths.is_empty() {
1919                    let git = git_binary?;
1920                    let output = git
1921                        .build_command(&["reset", "--quiet", "--"])
1922                        .envs(env.iter())
1923                        .args(paths.iter().map(|p| p.as_std_path()))
1924                        .output()
1925                        .await?;
1926
1927                    anyhow::ensure!(
1928                        output.status.success(),
1929                        "Failed to unstage:\n{}",
1930                        String::from_utf8_lossy(&output.stderr),
1931                    );
1932                }
1933                Ok(())
1934            })
1935            .boxed()
1936    }
1937
1938    fn stash_paths(
1939        &self,
1940        paths: Vec<RepoPath>,
1941        env: Arc<HashMap<String, String>>,
1942    ) -> BoxFuture<'_, Result<()>> {
1943        let git_binary = self.git_binary();
1944        self.executor
1945            .spawn(async move {
1946                let git = git_binary?;
1947                let output = git
1948                    .build_command(&["stash", "push", "--quiet", "--include-untracked"])
1949                    .envs(env.iter())
1950                    .args(paths.iter().map(|p| p.as_unix_str()))
1951                    .output()
1952                    .await?;
1953
1954                anyhow::ensure!(
1955                    output.status.success(),
1956                    "Failed to stash:\n{}",
1957                    String::from_utf8_lossy(&output.stderr)
1958                );
1959                Ok(())
1960            })
1961            .boxed()
1962    }
1963
1964    fn stash_pop(
1965        &self,
1966        index: Option<usize>,
1967        env: Arc<HashMap<String, String>>,
1968    ) -> BoxFuture<'_, Result<()>> {
1969        let git_binary = self.git_binary();
1970        self.executor
1971            .spawn(async move {
1972                let git = git_binary?;
1973                let mut args = vec!["stash".to_string(), "pop".to_string()];
1974                if let Some(index) = index {
1975                    args.push(format!("stash@{{{}}}", index));
1976                }
1977                let output = git.build_command(&args).envs(env.iter()).output().await?;
1978
1979                anyhow::ensure!(
1980                    output.status.success(),
1981                    "Failed to stash pop:\n{}",
1982                    String::from_utf8_lossy(&output.stderr)
1983                );
1984                Ok(())
1985            })
1986            .boxed()
1987    }
1988
1989    fn stash_apply(
1990        &self,
1991        index: Option<usize>,
1992        env: Arc<HashMap<String, String>>,
1993    ) -> BoxFuture<'_, Result<()>> {
1994        let git_binary = self.git_binary();
1995        self.executor
1996            .spawn(async move {
1997                let git = git_binary?;
1998                let mut args = vec!["stash".to_string(), "apply".to_string()];
1999                if let Some(index) = index {
2000                    args.push(format!("stash@{{{}}}", index));
2001                }
2002                let output = git.build_command(&args).envs(env.iter()).output().await?;
2003
2004                anyhow::ensure!(
2005                    output.status.success(),
2006                    "Failed to apply stash:\n{}",
2007                    String::from_utf8_lossy(&output.stderr)
2008                );
2009                Ok(())
2010            })
2011            .boxed()
2012    }
2013
2014    fn stash_drop(
2015        &self,
2016        index: Option<usize>,
2017        env: Arc<HashMap<String, String>>,
2018    ) -> BoxFuture<'_, Result<()>> {
2019        let git_binary = self.git_binary();
2020        self.executor
2021            .spawn(async move {
2022                let git = git_binary?;
2023                let mut args = vec!["stash".to_string(), "drop".to_string()];
2024                if let Some(index) = index {
2025                    args.push(format!("stash@{{{}}}", index));
2026                }
2027                let output = git.build_command(&args).envs(env.iter()).output().await?;
2028
2029                anyhow::ensure!(
2030                    output.status.success(),
2031                    "Failed to stash drop:\n{}",
2032                    String::from_utf8_lossy(&output.stderr)
2033                );
2034                Ok(())
2035            })
2036            .boxed()
2037    }
2038
2039    fn commit(
2040        &self,
2041        message: SharedString,
2042        name_and_email: Option<(SharedString, SharedString)>,
2043        options: CommitOptions,
2044        ask_pass: AskPassDelegate,
2045        env: Arc<HashMap<String, String>>,
2046    ) -> BoxFuture<'_, Result<()>> {
2047        let git_binary = self.git_binary();
2048        let executor = self.executor.clone();
2049        // Note: Do not spawn this command on the background thread, it might pop open the credential helper
2050        // which we want to block on.
2051        async move {
2052            let git = git_binary?;
2053            let mut cmd = git.build_command(&["commit", "--quiet", "-m"]);
2054            cmd.envs(env.iter())
2055                .arg(&message.to_string())
2056                .arg("--cleanup=strip")
2057                .arg("--no-verify")
2058                .stdout(Stdio::piped())
2059                .stderr(Stdio::piped());
2060
2061            if options.amend {
2062                cmd.arg("--amend");
2063            }
2064
2065            if options.signoff {
2066                cmd.arg("--signoff");
2067            }
2068
2069            if options.allow_empty {
2070                cmd.arg("--allow-empty");
2071            }
2072
2073            if let Some((name, email)) = name_and_email {
2074                cmd.arg("--author").arg(&format!("{name} <{email}>"));
2075            }
2076
2077            run_git_command(env, ask_pass, cmd, executor).await?;
2078
2079            Ok(())
2080        }
2081        .boxed()
2082    }
2083
2084    fn update_ref(&self, ref_name: String, commit: String) -> BoxFuture<'_, Result<()>> {
2085        let git_binary = self.git_binary();
2086        self.executor
2087            .spawn(async move {
2088                let args: Vec<OsString> = vec!["update-ref".into(), ref_name.into(), commit.into()];
2089                git_binary?.run(&args).await?;
2090                Ok(())
2091            })
2092            .boxed()
2093    }
2094
2095    fn delete_ref(&self, ref_name: String) -> BoxFuture<'_, Result<()>> {
2096        let git_binary = self.git_binary();
2097        self.executor
2098            .spawn(async move {
2099                let args: Vec<OsString> = vec!["update-ref".into(), "-d".into(), ref_name.into()];
2100                git_binary?.run(&args).await?;
2101                Ok(())
2102            })
2103            .boxed()
2104    }
2105
2106    fn repair_worktrees(&self) -> BoxFuture<'_, Result<()>> {
2107        let git_binary = self.git_binary();
2108        self.executor
2109            .spawn(async move {
2110                let args: Vec<OsString> = vec!["worktree".into(), "repair".into()];
2111                git_binary?.run(&args).await?;
2112                Ok(())
2113            })
2114            .boxed()
2115    }
2116
2117    fn push(
2118        &self,
2119        branch_name: String,
2120        remote_branch_name: String,
2121        remote_name: String,
2122        options: Option<PushOptions>,
2123        ask_pass: AskPassDelegate,
2124        env: Arc<HashMap<String, String>>,
2125        cx: AsyncApp,
2126    ) -> BoxFuture<'_, Result<RemoteCommandOutput>> {
2127        let working_directory = self.working_directory();
2128        let git_directory = self.path();
2129        let executor = cx.background_executor().clone();
2130        let git_binary_path = self.system_git_binary_path.clone();
2131        let is_trusted = self.is_trusted();
2132        // Note: Do not spawn this command on the background thread, it might pop open the credential helper
2133        // which we want to block on.
2134        async move {
2135            let git_binary_path = git_binary_path.context("git not found on $PATH, can't push")?;
2136            let working_directory = working_directory?;
2137            let git = GitBinary::new(
2138                git_binary_path,
2139                working_directory,
2140                git_directory,
2141                executor.clone(),
2142                is_trusted,
2143            );
2144            let mut command = git.build_command(&["push"]);
2145            command
2146                .envs(env.iter())
2147                .args(options.map(|option| match option {
2148                    PushOptions::SetUpstream => "--set-upstream",
2149                    PushOptions::Force => "--force-with-lease",
2150                }))
2151                .arg(remote_name)
2152                .arg(format!("{}:{}", branch_name, remote_branch_name))
2153                .stdin(Stdio::null())
2154                .stdout(Stdio::piped())
2155                .stderr(Stdio::piped());
2156
2157            run_git_command(env, ask_pass, command, executor).await
2158        }
2159        .boxed()
2160    }
2161
2162    fn pull(
2163        &self,
2164        branch_name: Option<String>,
2165        remote_name: String,
2166        rebase: bool,
2167        ask_pass: AskPassDelegate,
2168        env: Arc<HashMap<String, String>>,
2169        cx: AsyncApp,
2170    ) -> BoxFuture<'_, Result<RemoteCommandOutput>> {
2171        let working_directory = self.working_directory();
2172        let git_directory = self.path();
2173        let executor = cx.background_executor().clone();
2174        let git_binary_path = self.system_git_binary_path.clone();
2175        let is_trusted = self.is_trusted();
2176        // Note: Do not spawn this command on the background thread, it might pop open the credential helper
2177        // which we want to block on.
2178        async move {
2179            let git_binary_path = git_binary_path.context("git not found on $PATH, can't pull")?;
2180            let working_directory = working_directory?;
2181            let git = GitBinary::new(
2182                git_binary_path,
2183                working_directory,
2184                git_directory,
2185                executor.clone(),
2186                is_trusted,
2187            );
2188            let mut command = git.build_command(&["pull"]);
2189            command.envs(env.iter());
2190
2191            if rebase {
2192                command.arg("--rebase");
2193            }
2194
2195            command
2196                .arg(remote_name)
2197                .args(branch_name)
2198                .stdout(Stdio::piped())
2199                .stderr(Stdio::piped());
2200
2201            run_git_command(env, ask_pass, command, executor).await
2202        }
2203        .boxed()
2204    }
2205
2206    fn fetch(
2207        &self,
2208        fetch_options: FetchOptions,
2209        ask_pass: AskPassDelegate,
2210        env: Arc<HashMap<String, String>>,
2211        cx: AsyncApp,
2212    ) -> BoxFuture<'_, Result<RemoteCommandOutput>> {
2213        let working_directory = self.working_directory();
2214        let git_directory = self.path();
2215        let remote_name = format!("{}", fetch_options);
2216        let git_binary_path = self.system_git_binary_path.clone();
2217        let executor = cx.background_executor().clone();
2218        let is_trusted = self.is_trusted();
2219        // Note: Do not spawn this command on the background thread, it might pop open the credential helper
2220        // which we want to block on.
2221        async move {
2222            let git_binary_path = git_binary_path.context("git not found on $PATH, can't fetch")?;
2223            let working_directory = working_directory?;
2224            let git = GitBinary::new(
2225                git_binary_path,
2226                working_directory,
2227                git_directory,
2228                executor.clone(),
2229                is_trusted,
2230            );
2231            let mut command = git.build_command(&["fetch", &remote_name]);
2232            command
2233                .envs(env.iter())
2234                .stdout(Stdio::piped())
2235                .stderr(Stdio::piped());
2236
2237            run_git_command(env, ask_pass, command, executor).await
2238        }
2239        .boxed()
2240    }
2241
2242    fn get_push_remote(&self, branch: String) -> BoxFuture<'_, Result<Option<Remote>>> {
2243        let git_binary = self.git_binary();
2244        self.executor
2245            .spawn(async move {
2246                let git = git_binary?;
2247                let output = git
2248                    .build_command(&["rev-parse", "--abbrev-ref"])
2249                    .arg(format!("{branch}@{{push}}"))
2250                    .output()
2251                    .await?;
2252                if !output.status.success() {
2253                    return Ok(None);
2254                }
2255                let remote_name = String::from_utf8_lossy(&output.stdout)
2256                    .split('/')
2257                    .next()
2258                    .map(|name| Remote {
2259                        name: name.trim().to_string().into(),
2260                    });
2261
2262                Ok(remote_name)
2263            })
2264            .boxed()
2265    }
2266
2267    fn get_branch_remote(&self, branch: String) -> BoxFuture<'_, Result<Option<Remote>>> {
2268        let git_binary = self.git_binary();
2269        self.executor
2270            .spawn(async move {
2271                let git = git_binary?;
2272                let output = git
2273                    .build_command(&["config", "--get"])
2274                    .arg(format!("branch.{branch}.remote"))
2275                    .output()
2276                    .await?;
2277                if !output.status.success() {
2278                    return Ok(None);
2279                }
2280
2281                let remote_name = String::from_utf8_lossy(&output.stdout);
2282                return Ok(Some(Remote {
2283                    name: remote_name.trim().to_string().into(),
2284                }));
2285            })
2286            .boxed()
2287    }
2288
2289    fn get_all_remotes(&self) -> BoxFuture<'_, Result<Vec<Remote>>> {
2290        let git_binary = self.git_binary();
2291        self.executor
2292            .spawn(async move {
2293                let git = git_binary?;
2294                let output = git.build_command(&["remote", "-v"]).output().await?;
2295
2296                anyhow::ensure!(
2297                    output.status.success(),
2298                    "Failed to get all remotes:\n{}",
2299                    String::from_utf8_lossy(&output.stderr)
2300                );
2301                let remote_names: HashSet<Remote> = String::from_utf8_lossy(&output.stdout)
2302                    .lines()
2303                    .filter(|line| !line.is_empty())
2304                    .filter_map(|line| {
2305                        let mut split_line = line.split_whitespace();
2306                        let remote_name = split_line.next()?;
2307
2308                        Some(Remote {
2309                            name: remote_name.trim().to_string().into(),
2310                        })
2311                    })
2312                    .collect();
2313
2314                Ok(remote_names.into_iter().collect())
2315            })
2316            .boxed()
2317    }
2318
2319    fn remove_remote(&self, name: String) -> BoxFuture<'_, Result<()>> {
2320        let repo = self.repository.clone();
2321        self.executor
2322            .spawn(async move {
2323                let repo = repo.lock();
2324                repo.remote_delete(&name)?;
2325
2326                Ok(())
2327            })
2328            .boxed()
2329    }
2330
2331    fn create_remote(&self, name: String, url: String) -> BoxFuture<'_, Result<()>> {
2332        let repo = self.repository.clone();
2333        self.executor
2334            .spawn(async move {
2335                let repo = repo.lock();
2336                repo.remote(&name, url.as_ref())?;
2337                Ok(())
2338            })
2339            .boxed()
2340    }
2341
2342    fn check_for_pushed_commit(&self) -> BoxFuture<'_, Result<Vec<SharedString>>> {
2343        let git_binary = self.git_binary();
2344        self.executor
2345            .spawn(async move {
2346                let git = git_binary?;
2347                let git_cmd = async |args: &[&str]| -> Result<String> {
2348                    let output = git.build_command(args).output().await?;
2349                    anyhow::ensure!(
2350                        output.status.success(),
2351                        String::from_utf8_lossy(&output.stderr).to_string()
2352                    );
2353                    Ok(String::from_utf8(output.stdout)?)
2354                };
2355
2356                let head = git_cmd(&["rev-parse", "HEAD"])
2357                    .await
2358                    .context("Failed to get HEAD")?
2359                    .trim()
2360                    .to_owned();
2361
2362                let mut remote_branches = vec![];
2363                let mut add_if_matching = async |remote_head: &str| {
2364                    if let Ok(merge_base) = git_cmd(&["merge-base", &head, remote_head]).await
2365                        && merge_base.trim() == head
2366                        && let Some(s) = remote_head.strip_prefix("refs/remotes/")
2367                    {
2368                        remote_branches.push(s.to_owned().into());
2369                    }
2370                };
2371
2372                // check the main branch of each remote
2373                let remotes = git_cmd(&["remote"])
2374                    .await
2375                    .context("Failed to get remotes")?;
2376                for remote in remotes.lines() {
2377                    if let Ok(remote_head) =
2378                        git_cmd(&["symbolic-ref", &format!("refs/remotes/{remote}/HEAD")]).await
2379                    {
2380                        add_if_matching(remote_head.trim()).await;
2381                    }
2382                }
2383
2384                // ... and the remote branch that the checked-out one is tracking
2385                if let Ok(remote_head) =
2386                    git_cmd(&["rev-parse", "--symbolic-full-name", "@{u}"]).await
2387                {
2388                    add_if_matching(remote_head.trim()).await;
2389                }
2390
2391                Ok(remote_branches)
2392            })
2393            .boxed()
2394    }
2395
2396    fn checkpoint(&self) -> BoxFuture<'static, Result<GitRepositoryCheckpoint>> {
2397        let git_binary = self.git_binary();
2398        self.executor
2399            .spawn(async move {
2400                let mut git = git_binary?.envs(checkpoint_author_envs());
2401                git.with_temp_index(async |git| {
2402                    let head_sha = git.run(&["rev-parse", "HEAD"]).await.ok();
2403                    let mut excludes = exclude_files(git).await?;
2404
2405                    git.run(&["add", "--all"]).await?;
2406                    let tree = git.run(&["write-tree"]).await?;
2407                    let checkpoint_sha = if let Some(head_sha) = head_sha.as_deref() {
2408                        git.run(&["commit-tree", &tree, "-p", head_sha, "-m", "Checkpoint"])
2409                            .await?
2410                    } else {
2411                        git.run(&["commit-tree", &tree, "-m", "Checkpoint"]).await?
2412                    };
2413
2414                    excludes.restore_original().await?;
2415
2416                    Ok(GitRepositoryCheckpoint {
2417                        commit_sha: checkpoint_sha.parse()?,
2418                    })
2419                })
2420                .await
2421            })
2422            .boxed()
2423    }
2424
2425    fn restore_checkpoint(&self, checkpoint: GitRepositoryCheckpoint) -> BoxFuture<'_, Result<()>> {
2426        let git_binary = self.git_binary();
2427        self.executor
2428            .spawn(async move {
2429                let git = git_binary?;
2430                git.run(&[
2431                    "restore",
2432                    "--source",
2433                    &checkpoint.commit_sha.to_string(),
2434                    "--worktree",
2435                    ".",
2436                ])
2437                .await?;
2438
2439                // TODO: We don't track binary and large files anymore,
2440                //       so the following call would delete them.
2441                //       Implement an alternative way to track files added by agent.
2442                //
2443                // git.with_temp_index(async move |git| {
2444                //     git.run(&["read-tree", &checkpoint.commit_sha.to_string()])
2445                //         .await?;
2446                //     git.run(&["clean", "-d", "--force"]).await
2447                // })
2448                // .await?;
2449
2450                Ok(())
2451            })
2452            .boxed()
2453    }
2454
2455    fn compare_checkpoints(
2456        &self,
2457        left: GitRepositoryCheckpoint,
2458        right: GitRepositoryCheckpoint,
2459    ) -> BoxFuture<'_, Result<bool>> {
2460        let git_binary = self.git_binary();
2461        self.executor
2462            .spawn(async move {
2463                let git = git_binary?;
2464                let result = git
2465                    .run(&[
2466                        "diff-tree",
2467                        "--quiet",
2468                        &left.commit_sha.to_string(),
2469                        &right.commit_sha.to_string(),
2470                    ])
2471                    .await;
2472                match result {
2473                    Ok(_) => Ok(true),
2474                    Err(error) => {
2475                        if let Some(GitBinaryCommandError { status, .. }) =
2476                            error.downcast_ref::<GitBinaryCommandError>()
2477                            && status.code() == Some(1)
2478                        {
2479                            return Ok(false);
2480                        }
2481
2482                        Err(error)
2483                    }
2484                }
2485            })
2486            .boxed()
2487    }
2488
2489    fn diff_checkpoints(
2490        &self,
2491        base_checkpoint: GitRepositoryCheckpoint,
2492        target_checkpoint: GitRepositoryCheckpoint,
2493    ) -> BoxFuture<'_, Result<String>> {
2494        let git_binary = self.git_binary();
2495        self.executor
2496            .spawn(async move {
2497                let git = git_binary?;
2498                git.run(&[
2499                    "diff",
2500                    "--find-renames",
2501                    "--patch",
2502                    &base_checkpoint.commit_sha.to_string(),
2503                    &target_checkpoint.commit_sha.to_string(),
2504                ])
2505                .await
2506            })
2507            .boxed()
2508    }
2509
2510    fn default_branch(
2511        &self,
2512        include_remote_name: bool,
2513    ) -> BoxFuture<'_, Result<Option<SharedString>>> {
2514        let git_binary = self.git_binary();
2515        self.executor
2516            .spawn(async move {
2517                let git = git_binary?;
2518
2519                let strip_prefix = if include_remote_name {
2520                    "refs/remotes/"
2521                } else {
2522                    "refs/remotes/upstream/"
2523                };
2524
2525                if let Ok(output) = git
2526                    .run(&["symbolic-ref", "refs/remotes/upstream/HEAD"])
2527                    .await
2528                {
2529                    let output = output
2530                        .strip_prefix(strip_prefix)
2531                        .map(|s| SharedString::from(s.to_owned()));
2532                    return Ok(output);
2533                }
2534
2535                let strip_prefix = if include_remote_name {
2536                    "refs/remotes/"
2537                } else {
2538                    "refs/remotes/origin/"
2539                };
2540
2541                if let Ok(output) = git.run(&["symbolic-ref", "refs/remotes/origin/HEAD"]).await {
2542                    return Ok(output
2543                        .strip_prefix(strip_prefix)
2544                        .map(|s| SharedString::from(s.to_owned())));
2545                }
2546
2547                if let Ok(default_branch) = git.run(&["config", "init.defaultBranch"]).await {
2548                    if git.run(&["rev-parse", &default_branch]).await.is_ok() {
2549                        return Ok(Some(default_branch.into()));
2550                    }
2551                }
2552
2553                if git.run(&["rev-parse", "master"]).await.is_ok() {
2554                    return Ok(Some("master".into()));
2555                }
2556
2557                Ok(None)
2558            })
2559            .boxed()
2560    }
2561
2562    fn run_hook(
2563        &self,
2564        hook: RunHook,
2565        env: Arc<HashMap<String, String>>,
2566    ) -> BoxFuture<'_, Result<()>> {
2567        let git_binary = self.git_binary();
2568        let repository = self.repository.clone();
2569        let help_output = self.any_git_binary_help_output();
2570
2571        // Note: Do not spawn these commands on the background thread, as this causes some git hooks to hang.
2572        async move {
2573            let git_binary = git_binary?;
2574
2575            let working_directory = git_binary.working_directory.clone();
2576            if !help_output
2577                .await
2578                .lines()
2579                .any(|line| line.trim().starts_with("hook "))
2580            {
2581                let hook_abs_path = repository.lock().path().join("hooks").join(hook.as_str());
2582                if hook_abs_path.is_file() && git_binary.is_trusted {
2583                    #[allow(clippy::disallowed_methods)]
2584                    let output = new_command(&hook_abs_path)
2585                        .envs(env.iter())
2586                        .current_dir(&working_directory)
2587                        .output()
2588                        .await?;
2589
2590                    if !output.status.success() {
2591                        return Err(GitBinaryCommandError {
2592                            stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
2593                            stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
2594                            status: output.status,
2595                        }
2596                        .into());
2597                    }
2598                }
2599
2600                return Ok(());
2601            }
2602
2603            if git_binary.is_trusted {
2604                let git_binary = git_binary.envs(HashMap::clone(&env));
2605                git_binary
2606                    .run(&["hook", "run", "--ignore-missing", hook.as_str()])
2607                    .await?;
2608            }
2609            Ok(())
2610        }
2611        .boxed()
2612    }
2613
2614    fn initial_graph_data(
2615        &self,
2616        log_source: LogSource,
2617        log_order: LogOrder,
2618        request_tx: Sender<Vec<Arc<InitialGraphCommitData>>>,
2619    ) -> BoxFuture<'_, Result<()>> {
2620        let git_binary = self.git_binary();
2621
2622        async move {
2623            let git = git_binary?;
2624
2625            let mut git_log_command = vec![
2626                "log",
2627                GRAPH_COMMIT_FORMAT,
2628                log_order.as_arg(),
2629                log_source.get_arg()?,
2630            ];
2631
2632            if let LogSource::File(file_path) = &log_source {
2633                git_log_command.extend(["--", file_path.as_unix_str()]);
2634            }
2635
2636            let mut command = git.build_command(&git_log_command);
2637            command.stdout(Stdio::piped());
2638            command.stderr(Stdio::piped());
2639
2640            let mut child = command.spawn()?;
2641            let stdout = child.stdout.take().context("failed to get stdout")?;
2642            let stderr = child.stderr.take().context("failed to get stderr")?;
2643            let mut reader = BufReader::new(stdout);
2644
2645            let mut line_buffer = String::new();
2646            let mut lines: Vec<String> = Vec::with_capacity(GRAPH_CHUNK_SIZE);
2647
2648            loop {
2649                line_buffer.clear();
2650                let bytes_read = reader.read_line(&mut line_buffer).await?;
2651
2652                if bytes_read == 0 {
2653                    if !lines.is_empty() {
2654                        let commits = parse_initial_graph_output(lines.iter().map(|s| s.as_str()));
2655                        if request_tx.send(commits).await.is_err() {
2656                            log::warn!(
2657                                "initial_graph_data: receiver dropped while sending commits"
2658                            );
2659                        }
2660                    }
2661                    break;
2662                }
2663
2664                let line = line_buffer.trim_end_matches('\n').to_string();
2665                lines.push(line);
2666
2667                if lines.len() >= GRAPH_CHUNK_SIZE {
2668                    let commits = parse_initial_graph_output(lines.iter().map(|s| s.as_str()));
2669                    if request_tx.send(commits).await.is_err() {
2670                        log::warn!("initial_graph_data: receiver dropped while streaming commits");
2671                        break;
2672                    }
2673                    lines.clear();
2674                }
2675            }
2676
2677            let status = child.status().await?;
2678            if !status.success() {
2679                let mut stderr_output = String::new();
2680                let _ = BufReader::new(stderr)
2681                    .read_to_string(&mut stderr_output)
2682                    .await;
2683
2684                if stderr_output.is_empty() {
2685                    anyhow::bail!("git log command failed with {}", status);
2686                } else {
2687                    anyhow::bail!("git log command failed with {}: {}", status, stderr_output);
2688                }
2689            }
2690
2691            Ok(())
2692        }
2693        .boxed()
2694    }
2695
2696    fn search_commits(
2697        &self,
2698        log_source: LogSource,
2699        search_args: SearchCommitArgs,
2700        request_tx: Sender<Oid>,
2701    ) -> BoxFuture<'_, Result<()>> {
2702        let git_binary = self.git_binary();
2703
2704        async move {
2705            let git = git_binary?;
2706
2707            let mut args = vec!["log", SEARCH_COMMIT_FORMAT, log_source.get_arg()?];
2708
2709            args.push("--fixed-strings");
2710
2711            if !search_args.case_sensitive {
2712                args.push("--regexp-ignore-case");
2713            }
2714
2715            args.push("--grep");
2716            args.push(search_args.query.as_str());
2717
2718            if let LogSource::File(file_path) = &log_source {
2719                args.extend(["--", file_path.as_unix_str()]);
2720            }
2721
2722            let mut command = git.build_command(&args);
2723            command.stdout(Stdio::piped());
2724            command.stderr(Stdio::null());
2725
2726            let mut child = command.spawn()?;
2727            let stdout = child.stdout.take().context("failed to get stdout")?;
2728            let mut reader = BufReader::new(stdout);
2729
2730            let mut line_buffer = String::new();
2731
2732            loop {
2733                line_buffer.clear();
2734                let bytes_read = reader.read_line(&mut line_buffer).await?;
2735
2736                if bytes_read == 0 {
2737                    break;
2738                }
2739
2740                let sha = line_buffer.trim_end_matches('\n');
2741
2742                if let Ok(oid) = Oid::from_str(sha)
2743                    && request_tx.send(oid).await.is_err()
2744                {
2745                    break;
2746                }
2747            }
2748
2749            child.status().await?;
2750            Ok(())
2751        }
2752        .boxed()
2753    }
2754
2755    fn commit_data_reader(&self) -> Result<CommitDataReader> {
2756        let git_binary = self.git_binary()?;
2757
2758        let (request_tx, request_rx) = smol::channel::bounded::<CommitDataRequest>(64);
2759
2760        let task = self.executor.spawn(async move {
2761            if let Err(error) = run_commit_data_reader(git_binary, request_rx).await {
2762                log::error!("commit data reader failed: {error:?}");
2763            }
2764        });
2765
2766        Ok(CommitDataReader {
2767            request_tx,
2768            _task: task,
2769        })
2770    }
2771
2772    fn set_trusted(&self, trusted: bool) {
2773        self.is_trusted
2774            .store(trusted, std::sync::atomic::Ordering::Release);
2775    }
2776
2777    fn is_trusted(&self) -> bool {
2778        self.is_trusted.load(std::sync::atomic::Ordering::Acquire)
2779    }
2780}
2781
2782async fn run_commit_data_reader(
2783    git: GitBinary,
2784    request_rx: smol::channel::Receiver<CommitDataRequest>,
2785) -> Result<()> {
2786    let mut process = git
2787        .build_command(&["cat-file", "--batch"])
2788        .stdin(Stdio::piped())
2789        .stdout(Stdio::piped())
2790        .stderr(Stdio::piped())
2791        .spawn()
2792        .context("starting git cat-file --batch process")?;
2793
2794    let mut stdin = BufWriter::new(process.stdin.take().context("no stdin")?);
2795    let mut stdout = BufReader::new(process.stdout.take().context("no stdout")?);
2796
2797    const MAX_BATCH_SIZE: usize = 64;
2798
2799    while let Ok(first_request) = request_rx.recv().await {
2800        let mut pending_requests = vec![first_request];
2801
2802        while pending_requests.len() < MAX_BATCH_SIZE {
2803            match request_rx.try_recv() {
2804                Ok(request) => pending_requests.push(request),
2805                Err(_) => break,
2806            }
2807        }
2808
2809        for request in &pending_requests {
2810            stdin.write_all(request.sha.to_string().as_bytes()).await?;
2811            stdin.write_all(b"\n").await?;
2812        }
2813        stdin.flush().await?;
2814
2815        for request in pending_requests {
2816            let result = read_single_commit_response(&mut stdout, &request.sha).await;
2817            request.response_tx.send(result).ok();
2818        }
2819    }
2820
2821    drop(stdin);
2822    process.kill().ok();
2823
2824    Ok(())
2825}
2826
2827async fn read_single_commit_response<R: smol::io::AsyncBufRead + Unpin>(
2828    stdout: &mut R,
2829    sha: &Oid,
2830) -> Result<GraphCommitData> {
2831    let mut header_bytes = Vec::new();
2832    stdout.read_until(b'\n', &mut header_bytes).await?;
2833    let header_line = String::from_utf8_lossy(&header_bytes);
2834
2835    let parts: Vec<&str> = header_line.trim().split(' ').collect();
2836    if parts.len() < 3 {
2837        bail!("invalid cat-file header: {header_line}");
2838    }
2839
2840    let object_type = parts[1];
2841    if object_type == "missing" {
2842        bail!("object not found: {}", sha);
2843    }
2844
2845    if object_type != "commit" {
2846        bail!("expected commit object, got {object_type}");
2847    }
2848
2849    let size: usize = parts[2]
2850        .parse()
2851        .with_context(|| format!("invalid object size: {}", parts[2]))?;
2852
2853    let mut content = vec![0u8; size];
2854    stdout.read_exact(&mut content).await?;
2855
2856    let mut newline = [0u8; 1];
2857    stdout.read_exact(&mut newline).await?;
2858
2859    let content_str = String::from_utf8_lossy(&content);
2860    parse_cat_file_commit(*sha, &content_str)
2861        .ok_or_else(|| anyhow!("failed to parse commit {}", sha))
2862}
2863
2864fn parse_initial_graph_output<'a>(
2865    lines: impl Iterator<Item = &'a str>,
2866) -> Vec<Arc<InitialGraphCommitData>> {
2867    lines
2868        .filter(|line| !line.is_empty())
2869        .filter_map(|line| {
2870            // Format: "SHA\x00PARENT1 PARENT2...\x00REF1, REF2, ..."
2871            let mut parts = line.split('\x00');
2872
2873            let sha = Oid::from_str(parts.next()?).ok()?;
2874            let parents_str = parts.next()?;
2875            let parents = parents_str
2876                .split_whitespace()
2877                .filter_map(|p| Oid::from_str(p).ok())
2878                .collect();
2879
2880            let ref_names_str = parts.next().unwrap_or("");
2881            let ref_names = if ref_names_str.is_empty() {
2882                Vec::new()
2883            } else {
2884                ref_names_str
2885                    .split(", ")
2886                    .map(|s| SharedString::from(s.to_string()))
2887                    .collect()
2888            };
2889
2890            Some(Arc::new(InitialGraphCommitData {
2891                sha,
2892                parents,
2893                ref_names,
2894            }))
2895        })
2896        .collect()
2897}
2898
2899fn git_status_args(path_prefixes: &[RepoPath]) -> Vec<OsString> {
2900    let mut args = vec![
2901        OsString::from("status"),
2902        OsString::from("--porcelain=v1"),
2903        OsString::from("--untracked-files=all"),
2904        OsString::from("--no-renames"),
2905        OsString::from("-z"),
2906    ];
2907    args.extend(path_prefixes.iter().map(|path_prefix| {
2908        if path_prefix.is_empty() {
2909            Path::new(".").into()
2910        } else {
2911            path_prefix.as_std_path().into()
2912        }
2913    }));
2914    args
2915}
2916
2917/// Temporarily git-ignore commonly ignored files and files over 2MB
2918async fn exclude_files(git: &GitBinary) -> Result<GitExcludeOverride> {
2919    const MAX_SIZE: u64 = 2 * 1024 * 1024; // 2 MB
2920    let mut excludes = git.with_exclude_overrides().await?;
2921    excludes
2922        .add_excludes(include_str!("./checkpoint.gitignore"))
2923        .await?;
2924
2925    let working_directory = git.working_directory.clone();
2926    let untracked_files = git.list_untracked_files().await?;
2927    let excluded_paths = untracked_files.into_iter().map(|path| {
2928        let working_directory = working_directory.clone();
2929        smol::spawn(async move {
2930            let full_path = working_directory.join(path.clone());
2931            match smol::fs::metadata(&full_path).await {
2932                Ok(metadata) if metadata.is_file() && metadata.len() >= MAX_SIZE => {
2933                    Some(PathBuf::from("/").join(path.clone()))
2934                }
2935                _ => None,
2936            }
2937        })
2938    });
2939
2940    let excluded_paths = futures::future::join_all(excluded_paths).await;
2941    let excluded_paths = excluded_paths.into_iter().flatten().collect::<Vec<_>>();
2942
2943    if !excluded_paths.is_empty() {
2944        let exclude_patterns = excluded_paths
2945            .into_iter()
2946            .map(|path| path.to_string_lossy().into_owned())
2947            .collect::<Vec<_>>()
2948            .join("\n");
2949        excludes.add_excludes(&exclude_patterns).await?;
2950    }
2951
2952    Ok(excludes)
2953}
2954
2955pub(crate) struct GitBinary {
2956    git_binary_path: PathBuf,
2957    working_directory: PathBuf,
2958    git_directory: PathBuf,
2959    executor: BackgroundExecutor,
2960    index_file_path: Option<PathBuf>,
2961    envs: HashMap<String, String>,
2962    is_trusted: bool,
2963}
2964
2965impl GitBinary {
2966    pub(crate) fn new(
2967        git_binary_path: PathBuf,
2968        working_directory: PathBuf,
2969        git_directory: PathBuf,
2970        executor: BackgroundExecutor,
2971        is_trusted: bool,
2972    ) -> Self {
2973        Self {
2974            git_binary_path,
2975            working_directory,
2976            git_directory,
2977            executor,
2978            index_file_path: None,
2979            envs: HashMap::default(),
2980            is_trusted,
2981        }
2982    }
2983
2984    async fn list_untracked_files(&self) -> Result<Vec<PathBuf>> {
2985        let status_output = self
2986            .run(&["status", "--porcelain=v1", "--untracked-files=all", "-z"])
2987            .await?;
2988
2989        let paths = status_output
2990            .split('\0')
2991            .filter(|entry| entry.len() >= 3 && entry.starts_with("?? "))
2992            .map(|entry| PathBuf::from(&entry[3..]))
2993            .collect::<Vec<_>>();
2994        Ok(paths)
2995    }
2996
2997    fn envs(mut self, envs: HashMap<String, String>) -> Self {
2998        self.envs = envs;
2999        self
3000    }
3001
3002    pub async fn with_temp_index<R>(
3003        &mut self,
3004        f: impl AsyncFnOnce(&Self) -> Result<R>,
3005    ) -> Result<R> {
3006        let index_file_path = self.path_for_index_id(Uuid::new_v4());
3007
3008        let delete_temp_index = util::defer({
3009            let index_file_path = index_file_path.clone();
3010            let executor = self.executor.clone();
3011            move || {
3012                executor
3013                    .spawn(async move {
3014                        smol::fs::remove_file(index_file_path).await.log_err();
3015                    })
3016                    .detach();
3017            }
3018        });
3019
3020        // Copy the default index file so that Git doesn't have to rebuild the
3021        // whole index from scratch. This might fail if this is an empty repository.
3022        smol::fs::copy(self.git_directory.join("index"), &index_file_path)
3023            .await
3024            .ok();
3025
3026        self.index_file_path = Some(index_file_path.clone());
3027        let result = f(self).await;
3028        self.index_file_path = None;
3029        let result = result?;
3030
3031        smol::fs::remove_file(index_file_path).await.ok();
3032        delete_temp_index.abort();
3033
3034        Ok(result)
3035    }
3036
3037    pub async fn with_exclude_overrides(&self) -> Result<GitExcludeOverride> {
3038        let path = self.git_directory.join("info").join("exclude");
3039
3040        GitExcludeOverride::new(path).await
3041    }
3042
3043    fn path_for_index_id(&self, id: Uuid) -> PathBuf {
3044        self.git_directory.join(format!("index-{}.tmp", id))
3045    }
3046
3047    pub async fn run<S>(&self, args: &[S]) -> Result<String>
3048    where
3049        S: AsRef<OsStr>,
3050    {
3051        let mut stdout = self.run_raw(args).await?;
3052        if stdout.chars().last() == Some('\n') {
3053            stdout.pop();
3054        }
3055        Ok(stdout)
3056    }
3057
3058    /// Returns the result of the command without trimming the trailing newline.
3059    pub async fn run_raw<S>(&self, args: &[S]) -> Result<String>
3060    where
3061        S: AsRef<OsStr>,
3062    {
3063        let mut command = self.build_command(args);
3064        let output = command.output().await?;
3065        anyhow::ensure!(
3066            output.status.success(),
3067            GitBinaryCommandError {
3068                stdout: String::from_utf8_lossy(&output.stdout).to_string(),
3069                stderr: String::from_utf8_lossy(&output.stderr).to_string(),
3070                status: output.status,
3071            }
3072        );
3073        Ok(String::from_utf8(output.stdout)?)
3074    }
3075
3076    #[allow(clippy::disallowed_methods)]
3077    pub(crate) fn build_command<S>(&self, args: &[S]) -> util::command::Command
3078    where
3079        S: AsRef<OsStr>,
3080    {
3081        let mut command = new_command(&self.git_binary_path);
3082        command.current_dir(&self.working_directory);
3083        command.args(["-c", "core.fsmonitor=false"]);
3084        command.arg("--no-optional-locks");
3085        command.arg("--no-pager");
3086
3087        if !self.is_trusted {
3088            command.args(["-c", "core.hooksPath=/dev/null"]);
3089            command.args(["-c", "core.sshCommand=ssh"]);
3090            command.args(["-c", "credential.helper="]);
3091            command.args(["-c", "protocol.ext.allow=never"]);
3092            command.args(["-c", "diff.external="]);
3093        }
3094        command.args(args);
3095
3096        // If the `diff` command is being used, we'll want to add the
3097        // `--no-ext-diff` flag when working on an untrusted repository,
3098        // preventing any external diff programs from being invoked.
3099        if !self.is_trusted && args.iter().any(|arg| arg.as_ref() == "diff") {
3100            command.arg("--no-ext-diff");
3101        }
3102
3103        if let Some(index_file_path) = self.index_file_path.as_ref() {
3104            command.env("GIT_INDEX_FILE", index_file_path);
3105        }
3106        command.envs(&self.envs);
3107        command
3108    }
3109}
3110
3111#[derive(Error, Debug)]
3112#[error("Git command failed:\n{stdout}{stderr}\n")]
3113struct GitBinaryCommandError {
3114    stdout: String,
3115    stderr: String,
3116    status: ExitStatus,
3117}
3118
3119async fn run_git_command(
3120    env: Arc<HashMap<String, String>>,
3121    ask_pass: AskPassDelegate,
3122    mut command: util::command::Command,
3123    executor: BackgroundExecutor,
3124) -> Result<RemoteCommandOutput> {
3125    if env.contains_key("GIT_ASKPASS") {
3126        let git_process = command.spawn()?;
3127        let output = git_process.output().await?;
3128        anyhow::ensure!(
3129            output.status.success(),
3130            "{}",
3131            String::from_utf8_lossy(&output.stderr)
3132        );
3133        Ok(RemoteCommandOutput {
3134            stdout: String::from_utf8_lossy(&output.stdout).to_string(),
3135            stderr: String::from_utf8_lossy(&output.stderr).to_string(),
3136        })
3137    } else {
3138        let ask_pass = AskPassSession::new(executor, ask_pass).await?;
3139        command
3140            .env("GIT_ASKPASS", ask_pass.script_path())
3141            .env("SSH_ASKPASS", ask_pass.script_path())
3142            .env("SSH_ASKPASS_REQUIRE", "force");
3143        let git_process = command.spawn()?;
3144
3145        run_askpass_command(ask_pass, git_process).await
3146    }
3147}
3148
3149async fn run_askpass_command(
3150    mut ask_pass: AskPassSession,
3151    git_process: util::command::Child,
3152) -> anyhow::Result<RemoteCommandOutput> {
3153    select_biased! {
3154        result = ask_pass.run().fuse() => {
3155            match result {
3156                AskPassResult::CancelledByUser => {
3157                    Err(anyhow!(REMOTE_CANCELLED_BY_USER))?
3158                }
3159                AskPassResult::Timedout => {
3160                    Err(anyhow!("Connecting to host timed out"))?
3161                }
3162            }
3163        }
3164        output = git_process.output().fuse() => {
3165            let output = output?;
3166            anyhow::ensure!(
3167                output.status.success(),
3168                "{}",
3169                String::from_utf8_lossy(&output.stderr)
3170            );
3171            Ok(RemoteCommandOutput {
3172                stdout: String::from_utf8_lossy(&output.stdout).to_string(),
3173                stderr: String::from_utf8_lossy(&output.stderr).to_string(),
3174            })
3175        }
3176    }
3177}
3178
3179#[derive(Clone, Ord, Hash, PartialOrd, Eq, PartialEq)]
3180pub struct RepoPath(Arc<RelPath>);
3181
3182impl std::fmt::Debug for RepoPath {
3183    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3184        self.0.fmt(f)
3185    }
3186}
3187
3188impl RepoPath {
3189    pub fn new<S: AsRef<str> + ?Sized>(s: &S) -> Result<Self> {
3190        let rel_path = RelPath::unix(s.as_ref())?;
3191        Ok(Self::from_rel_path(rel_path))
3192    }
3193
3194    pub fn from_std_path(path: &Path, path_style: PathStyle) -> Result<Self> {
3195        let rel_path = RelPath::new(path, path_style)?;
3196        Ok(Self::from_rel_path(&rel_path))
3197    }
3198
3199    pub fn from_proto(proto: &str) -> Result<Self> {
3200        let rel_path = RelPath::from_proto(proto)?;
3201        Ok(Self(rel_path))
3202    }
3203
3204    pub fn from_rel_path(path: &RelPath) -> RepoPath {
3205        Self(Arc::from(path))
3206    }
3207
3208    pub fn as_std_path(&self) -> &Path {
3209        // git2 does not like empty paths and our RelPath infra turns `.` into ``
3210        // so undo that here
3211        if self.is_empty() {
3212            Path::new(".")
3213        } else {
3214            self.0.as_std_path()
3215        }
3216    }
3217}
3218
3219#[cfg(any(test, feature = "test-support"))]
3220pub fn repo_path<S: AsRef<str> + ?Sized>(s: &S) -> RepoPath {
3221    RepoPath(RelPath::unix(s.as_ref()).unwrap().into())
3222}
3223
3224impl AsRef<Arc<RelPath>> for RepoPath {
3225    fn as_ref(&self) -> &Arc<RelPath> {
3226        &self.0
3227    }
3228}
3229
3230impl std::ops::Deref for RepoPath {
3231    type Target = RelPath;
3232
3233    fn deref(&self) -> &Self::Target {
3234        &self.0
3235    }
3236}
3237
3238#[derive(Debug)]
3239pub struct RepoPathDescendants<'a>(pub &'a RepoPath);
3240
3241impl MapSeekTarget<RepoPath> for RepoPathDescendants<'_> {
3242    fn cmp_cursor(&self, key: &RepoPath) -> Ordering {
3243        if key.starts_with(self.0) {
3244            Ordering::Greater
3245        } else {
3246            self.0.cmp(key)
3247        }
3248    }
3249}
3250
3251fn parse_branch_input(input: &str) -> Result<Vec<Branch>> {
3252    let mut branches = Vec::new();
3253    for line in input.split('\n') {
3254        if line.is_empty() {
3255            continue;
3256        }
3257        let mut fields = line.split('\x00');
3258        let Some(head) = fields.next() else {
3259            continue;
3260        };
3261        let Some(head_sha) = fields.next().map(|f| f.to_string().into()) else {
3262            continue;
3263        };
3264        let Some(parent_sha) = fields.next().map(|f| f.to_string()) else {
3265            continue;
3266        };
3267        let Some(ref_name) = fields.next().map(|f| f.to_string().into()) else {
3268            continue;
3269        };
3270        let Some(upstream_name) = fields.next().map(|f| f.to_string()) else {
3271            continue;
3272        };
3273        let Some(upstream_tracking) = fields.next().and_then(|f| parse_upstream_track(f).ok())
3274        else {
3275            continue;
3276        };
3277        let Some(commiterdate) = fields.next().and_then(|f| f.parse::<i64>().ok()) else {
3278            continue;
3279        };
3280        let Some(author_name) = fields.next().map(|f| f.to_string().into()) else {
3281            continue;
3282        };
3283        let Some(subject) = fields.next().map(|f| f.to_string().into()) else {
3284            continue;
3285        };
3286
3287        branches.push(Branch {
3288            is_head: head == "*",
3289            ref_name,
3290            most_recent_commit: Some(CommitSummary {
3291                sha: head_sha,
3292                subject,
3293                commit_timestamp: commiterdate,
3294                author_name: author_name,
3295                has_parent: !parent_sha.is_empty(),
3296            }),
3297            upstream: if upstream_name.is_empty() {
3298                None
3299            } else {
3300                Some(Upstream {
3301                    ref_name: upstream_name.into(),
3302                    tracking: upstream_tracking,
3303                })
3304            },
3305        })
3306    }
3307
3308    Ok(branches)
3309}
3310
3311fn parse_upstream_track(upstream_track: &str) -> Result<UpstreamTracking> {
3312    if upstream_track.is_empty() {
3313        return Ok(UpstreamTracking::Tracked(UpstreamTrackingStatus {
3314            ahead: 0,
3315            behind: 0,
3316        }));
3317    }
3318
3319    let upstream_track = upstream_track.strip_prefix("[").context("missing [")?;
3320    let upstream_track = upstream_track.strip_suffix("]").context("missing [")?;
3321    let mut ahead: u32 = 0;
3322    let mut behind: u32 = 0;
3323    for component in upstream_track.split(", ") {
3324        if component == "gone" {
3325            return Ok(UpstreamTracking::Gone);
3326        }
3327        if let Some(ahead_num) = component.strip_prefix("ahead ") {
3328            ahead = ahead_num.parse::<u32>()?;
3329        }
3330        if let Some(behind_num) = component.strip_prefix("behind ") {
3331            behind = behind_num.parse::<u32>()?;
3332        }
3333    }
3334    Ok(UpstreamTracking::Tracked(UpstreamTrackingStatus {
3335        ahead,
3336        behind,
3337    }))
3338}
3339
3340fn checkpoint_author_envs() -> HashMap<String, String> {
3341    HashMap::from_iter([
3342        ("GIT_AUTHOR_NAME".to_string(), "Zed".to_string()),
3343        ("GIT_AUTHOR_EMAIL".to_string(), "hi@zed.dev".to_string()),
3344        ("GIT_COMMITTER_NAME".to_string(), "Zed".to_string()),
3345        ("GIT_COMMITTER_EMAIL".to_string(), "hi@zed.dev".to_string()),
3346    ])
3347}
3348
3349#[cfg(test)]
3350mod tests {
3351    use std::fs;
3352
3353    use super::*;
3354    use gpui::TestAppContext;
3355
3356    fn disable_git_global_config() {
3357        unsafe {
3358            std::env::set_var("GIT_CONFIG_GLOBAL", "");
3359            std::env::set_var("GIT_CONFIG_SYSTEM", "");
3360        }
3361    }
3362
3363    #[gpui::test]
3364    async fn test_build_command_untrusted_includes_both_safety_args(cx: &mut TestAppContext) {
3365        cx.executor().allow_parking();
3366        let dir = tempfile::tempdir().unwrap();
3367        let git = GitBinary::new(
3368            PathBuf::from("git"),
3369            dir.path().to_path_buf(),
3370            dir.path().join(".git"),
3371            cx.executor(),
3372            false,
3373        );
3374        let output = git
3375            .build_command(&["version"])
3376            .output()
3377            .await
3378            .expect("git version should succeed");
3379        assert!(output.status.success());
3380
3381        let git = GitBinary::new(
3382            PathBuf::from("git"),
3383            dir.path().to_path_buf(),
3384            dir.path().join(".git"),
3385            cx.executor(),
3386            false,
3387        );
3388        let output = git
3389            .build_command(&["config", "--get", "core.fsmonitor"])
3390            .output()
3391            .await
3392            .expect("git config should run");
3393        let stdout = String::from_utf8_lossy(&output.stdout);
3394        assert_eq!(
3395            stdout.trim(),
3396            "false",
3397            "fsmonitor should be disabled for untrusted repos"
3398        );
3399
3400        git2::Repository::init(dir.path()).unwrap();
3401        let git = GitBinary::new(
3402            PathBuf::from("git"),
3403            dir.path().to_path_buf(),
3404            dir.path().join(".git"),
3405            cx.executor(),
3406            false,
3407        );
3408        let output = git
3409            .build_command(&["config", "--get", "core.hooksPath"])
3410            .output()
3411            .await
3412            .expect("git config should run");
3413        let stdout = String::from_utf8_lossy(&output.stdout);
3414        assert_eq!(
3415            stdout.trim(),
3416            "/dev/null",
3417            "hooksPath should be /dev/null for untrusted repos"
3418        );
3419    }
3420
3421    #[gpui::test]
3422    async fn test_build_command_trusted_only_disables_fsmonitor(cx: &mut TestAppContext) {
3423        cx.executor().allow_parking();
3424        let dir = tempfile::tempdir().unwrap();
3425        git2::Repository::init(dir.path()).unwrap();
3426
3427        let git = GitBinary::new(
3428            PathBuf::from("git"),
3429            dir.path().to_path_buf(),
3430            dir.path().join(".git"),
3431            cx.executor(),
3432            true,
3433        );
3434        let output = git
3435            .build_command(&["config", "--get", "core.fsmonitor"])
3436            .output()
3437            .await
3438            .expect("git config should run");
3439        let stdout = String::from_utf8_lossy(&output.stdout);
3440        assert_eq!(
3441            stdout.trim(),
3442            "false",
3443            "fsmonitor should be disabled even for trusted repos"
3444        );
3445
3446        let git = GitBinary::new(
3447            PathBuf::from("git"),
3448            dir.path().to_path_buf(),
3449            dir.path().join(".git"),
3450            cx.executor(),
3451            true,
3452        );
3453        let output = git
3454            .build_command(&["config", "--get", "core.hooksPath"])
3455            .output()
3456            .await
3457            .expect("git config should run");
3458        assert!(
3459            !output.status.success(),
3460            "hooksPath should NOT be overridden for trusted repos"
3461        );
3462    }
3463
3464    #[gpui::test]
3465    async fn test_path_for_index_id_uses_real_git_directory(cx: &mut TestAppContext) {
3466        cx.executor().allow_parking();
3467        let working_directory = PathBuf::from("/code/worktree");
3468        let git_directory = PathBuf::from("/code/repo/.git/modules/worktree");
3469        let git = GitBinary::new(
3470            PathBuf::from("git"),
3471            working_directory,
3472            git_directory.clone(),
3473            cx.executor(),
3474            false,
3475        );
3476
3477        let path = git.path_for_index_id(Uuid::nil());
3478
3479        assert_eq!(
3480            path,
3481            git_directory.join(format!("index-{}.tmp", Uuid::nil()))
3482        );
3483    }
3484
3485    #[gpui::test]
3486    async fn test_checkpoint_basic(cx: &mut TestAppContext) {
3487        disable_git_global_config();
3488
3489        cx.executor().allow_parking();
3490
3491        let repo_dir = tempfile::tempdir().unwrap();
3492
3493        git2::Repository::init(repo_dir.path()).unwrap();
3494        let file_path = repo_dir.path().join("file");
3495        smol::fs::write(&file_path, "initial").await.unwrap();
3496
3497        let repo = RealGitRepository::new(
3498            &repo_dir.path().join(".git"),
3499            None,
3500            Some("git".into()),
3501            cx.executor(),
3502        )
3503        .unwrap();
3504
3505        repo.stage_paths(vec![repo_path("file")], Arc::new(HashMap::default()))
3506            .await
3507            .unwrap();
3508        repo.commit(
3509            "Initial commit".into(),
3510            None,
3511            CommitOptions::default(),
3512            AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}),
3513            Arc::new(checkpoint_author_envs()),
3514        )
3515        .await
3516        .unwrap();
3517
3518        smol::fs::write(&file_path, "modified before checkpoint")
3519            .await
3520            .unwrap();
3521        smol::fs::write(repo_dir.path().join("new_file_before_checkpoint"), "1")
3522            .await
3523            .unwrap();
3524        let checkpoint = repo.checkpoint().await.unwrap();
3525
3526        // Ensure the user can't see any branches after creating a checkpoint.
3527        assert_eq!(repo.branches().await.unwrap().len(), 1);
3528
3529        smol::fs::write(&file_path, "modified after checkpoint")
3530            .await
3531            .unwrap();
3532        repo.stage_paths(vec![repo_path("file")], Arc::new(HashMap::default()))
3533            .await
3534            .unwrap();
3535        repo.commit(
3536            "Commit after checkpoint".into(),
3537            None,
3538            CommitOptions::default(),
3539            AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}),
3540            Arc::new(checkpoint_author_envs()),
3541        )
3542        .await
3543        .unwrap();
3544
3545        smol::fs::remove_file(repo_dir.path().join("new_file_before_checkpoint"))
3546            .await
3547            .unwrap();
3548        smol::fs::write(repo_dir.path().join("new_file_after_checkpoint"), "2")
3549            .await
3550            .unwrap();
3551
3552        // Ensure checkpoint stays alive even after a Git GC.
3553        repo.gc().await.unwrap();
3554        repo.restore_checkpoint(checkpoint.clone()).await.unwrap();
3555
3556        assert_eq!(
3557            smol::fs::read_to_string(&file_path).await.unwrap(),
3558            "modified before checkpoint"
3559        );
3560        assert_eq!(
3561            smol::fs::read_to_string(repo_dir.path().join("new_file_before_checkpoint"))
3562                .await
3563                .unwrap(),
3564            "1"
3565        );
3566        // See TODO above
3567        // assert_eq!(
3568        //     smol::fs::read_to_string(repo_dir.path().join("new_file_after_checkpoint"))
3569        //         .await
3570        //         .ok(),
3571        //     None
3572        // );
3573    }
3574
3575    #[gpui::test]
3576    async fn test_checkpoint_empty_repo(cx: &mut TestAppContext) {
3577        disable_git_global_config();
3578
3579        cx.executor().allow_parking();
3580
3581        let repo_dir = tempfile::tempdir().unwrap();
3582        git2::Repository::init(repo_dir.path()).unwrap();
3583        let repo = RealGitRepository::new(
3584            &repo_dir.path().join(".git"),
3585            None,
3586            Some("git".into()),
3587            cx.executor(),
3588        )
3589        .unwrap();
3590
3591        smol::fs::write(repo_dir.path().join("foo"), "foo")
3592            .await
3593            .unwrap();
3594        let checkpoint_sha = repo.checkpoint().await.unwrap();
3595
3596        // Ensure the user can't see any branches after creating a checkpoint.
3597        assert_eq!(repo.branches().await.unwrap().len(), 1);
3598
3599        smol::fs::write(repo_dir.path().join("foo"), "bar")
3600            .await
3601            .unwrap();
3602        smol::fs::write(repo_dir.path().join("baz"), "qux")
3603            .await
3604            .unwrap();
3605        repo.restore_checkpoint(checkpoint_sha).await.unwrap();
3606        assert_eq!(
3607            smol::fs::read_to_string(repo_dir.path().join("foo"))
3608                .await
3609                .unwrap(),
3610            "foo"
3611        );
3612        // See TODOs above
3613        // assert_eq!(
3614        //     smol::fs::read_to_string(repo_dir.path().join("baz"))
3615        //         .await
3616        //         .ok(),
3617        //     None
3618        // );
3619    }
3620
3621    #[gpui::test]
3622    async fn test_compare_checkpoints(cx: &mut TestAppContext) {
3623        disable_git_global_config();
3624
3625        cx.executor().allow_parking();
3626
3627        let repo_dir = tempfile::tempdir().unwrap();
3628        git2::Repository::init(repo_dir.path()).unwrap();
3629        let repo = RealGitRepository::new(
3630            &repo_dir.path().join(".git"),
3631            None,
3632            Some("git".into()),
3633            cx.executor(),
3634        )
3635        .unwrap();
3636
3637        smol::fs::write(repo_dir.path().join("file1"), "content1")
3638            .await
3639            .unwrap();
3640        let checkpoint1 = repo.checkpoint().await.unwrap();
3641
3642        smol::fs::write(repo_dir.path().join("file2"), "content2")
3643            .await
3644            .unwrap();
3645        let checkpoint2 = repo.checkpoint().await.unwrap();
3646
3647        assert!(
3648            !repo
3649                .compare_checkpoints(checkpoint1, checkpoint2.clone())
3650                .await
3651                .unwrap()
3652        );
3653
3654        let checkpoint3 = repo.checkpoint().await.unwrap();
3655        assert!(
3656            repo.compare_checkpoints(checkpoint2, checkpoint3)
3657                .await
3658                .unwrap()
3659        );
3660    }
3661
3662    #[gpui::test]
3663    async fn test_checkpoint_exclude_binary_files(cx: &mut TestAppContext) {
3664        disable_git_global_config();
3665
3666        cx.executor().allow_parking();
3667
3668        let repo_dir = tempfile::tempdir().unwrap();
3669        let text_path = repo_dir.path().join("main.rs");
3670        let bin_path = repo_dir.path().join("binary.o");
3671
3672        git2::Repository::init(repo_dir.path()).unwrap();
3673
3674        smol::fs::write(&text_path, "fn main() {}").await.unwrap();
3675
3676        smol::fs::write(&bin_path, "some binary file here")
3677            .await
3678            .unwrap();
3679
3680        let repo = RealGitRepository::new(
3681            &repo_dir.path().join(".git"),
3682            None,
3683            Some("git".into()),
3684            cx.executor(),
3685        )
3686        .unwrap();
3687
3688        // initial commit
3689        repo.stage_paths(vec![repo_path("main.rs")], Arc::new(HashMap::default()))
3690            .await
3691            .unwrap();
3692        repo.commit(
3693            "Initial commit".into(),
3694            None,
3695            CommitOptions::default(),
3696            AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}),
3697            Arc::new(checkpoint_author_envs()),
3698        )
3699        .await
3700        .unwrap();
3701
3702        let checkpoint = repo.checkpoint().await.unwrap();
3703
3704        smol::fs::write(&text_path, "fn main() { println!(\"Modified\"); }")
3705            .await
3706            .unwrap();
3707        smol::fs::write(&bin_path, "Modified binary file")
3708            .await
3709            .unwrap();
3710
3711        repo.restore_checkpoint(checkpoint).await.unwrap();
3712
3713        // Text files should be restored to checkpoint state,
3714        // but binaries should not (they aren't tracked)
3715        assert_eq!(
3716            smol::fs::read_to_string(&text_path).await.unwrap(),
3717            "fn main() {}"
3718        );
3719
3720        assert_eq!(
3721            smol::fs::read_to_string(&bin_path).await.unwrap(),
3722            "Modified binary file"
3723        );
3724    }
3725
3726    #[test]
3727    fn test_branches_parsing() {
3728        // suppress "help: octal escapes are not supported, `\0` is always null"
3729        #[allow(clippy::octal_escapes)]
3730        let input = "*\0060964da10574cd9bf06463a53bf6e0769c5c45e\0\0refs/heads/zed-patches\0refs/remotes/origin/zed-patches\0\01733187470\0John Doe\0generated protobuf\n";
3731        assert_eq!(
3732            parse_branch_input(input).unwrap(),
3733            vec![Branch {
3734                is_head: true,
3735                ref_name: "refs/heads/zed-patches".into(),
3736                upstream: Some(Upstream {
3737                    ref_name: "refs/remotes/origin/zed-patches".into(),
3738                    tracking: UpstreamTracking::Tracked(UpstreamTrackingStatus {
3739                        ahead: 0,
3740                        behind: 0
3741                    })
3742                }),
3743                most_recent_commit: Some(CommitSummary {
3744                    sha: "060964da10574cd9bf06463a53bf6e0769c5c45e".into(),
3745                    subject: "generated protobuf".into(),
3746                    commit_timestamp: 1733187470,
3747                    author_name: SharedString::new_static("John Doe"),
3748                    has_parent: false,
3749                })
3750            }]
3751        )
3752    }
3753
3754    #[test]
3755    fn test_branches_parsing_containing_refs_with_missing_fields() {
3756        #[allow(clippy::octal_escapes)]
3757        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";
3758
3759        let branches = parse_branch_input(input).unwrap();
3760        assert_eq!(branches.len(), 2);
3761        assert_eq!(
3762            branches,
3763            vec![
3764                Branch {
3765                    is_head: false,
3766                    ref_name: "refs/heads/dev".into(),
3767                    upstream: None,
3768                    most_recent_commit: Some(CommitSummary {
3769                        sha: "eb0cae33272689bd11030822939dd2701c52f81e".into(),
3770                        subject: "Add feature".into(),
3771                        commit_timestamp: 1762948725,
3772                        author_name: SharedString::new_static("Zed"),
3773                        has_parent: true,
3774                    })
3775                },
3776                Branch {
3777                    is_head: true,
3778                    ref_name: "refs/heads/main".into(),
3779                    upstream: None,
3780                    most_recent_commit: Some(CommitSummary {
3781                        sha: "895951d681e5561478c0acdd6905e8aacdfd2249".into(),
3782                        subject: "Initial commit".into(),
3783                        commit_timestamp: 1762948695,
3784                        author_name: SharedString::new_static("Zed"),
3785                        has_parent: false,
3786                    })
3787                }
3788            ]
3789        )
3790    }
3791
3792    #[test]
3793    fn test_upstream_branch_name() {
3794        let upstream = Upstream {
3795            ref_name: "refs/remotes/origin/feature/branch".into(),
3796            tracking: UpstreamTracking::Tracked(UpstreamTrackingStatus {
3797                ahead: 0,
3798                behind: 0,
3799            }),
3800        };
3801        assert_eq!(upstream.branch_name(), Some("feature/branch"));
3802
3803        let upstream = Upstream {
3804            ref_name: "refs/remotes/upstream/main".into(),
3805            tracking: UpstreamTracking::Tracked(UpstreamTrackingStatus {
3806                ahead: 0,
3807                behind: 0,
3808            }),
3809        };
3810        assert_eq!(upstream.branch_name(), Some("main"));
3811
3812        let upstream = Upstream {
3813            ref_name: "refs/heads/local".into(),
3814            tracking: UpstreamTracking::Tracked(UpstreamTrackingStatus {
3815                ahead: 0,
3816                behind: 0,
3817            }),
3818        };
3819        assert_eq!(upstream.branch_name(), None);
3820
3821        // Test case where upstream branch name differs from what might be the local branch name
3822        let upstream = Upstream {
3823            ref_name: "refs/remotes/origin/feature/git-pull-request".into(),
3824            tracking: UpstreamTracking::Tracked(UpstreamTrackingStatus {
3825                ahead: 0,
3826                behind: 0,
3827            }),
3828        };
3829        assert_eq!(upstream.branch_name(), Some("feature/git-pull-request"));
3830    }
3831
3832    #[test]
3833    fn test_parse_worktrees_from_str() {
3834        // Empty input
3835        let result = parse_worktrees_from_str("");
3836        assert!(result.is_empty());
3837
3838        // Single worktree (main)
3839        let input = "worktree /home/user/project\nHEAD abc123def\nbranch refs/heads/main\n\n";
3840        let result = parse_worktrees_from_str(input);
3841        assert_eq!(result.len(), 1);
3842        assert_eq!(result[0].path, PathBuf::from("/home/user/project"));
3843        assert_eq!(result[0].sha.as_ref(), "abc123def");
3844        assert_eq!(result[0].ref_name, Some("refs/heads/main".into()));
3845        assert!(result[0].is_main);
3846
3847        // Multiple worktrees
3848        let input = "worktree /home/user/project\nHEAD abc123\nbranch refs/heads/main\n\n\
3849                      worktree /home/user/project-wt\nHEAD def456\nbranch refs/heads/feature\n\n";
3850        let result = parse_worktrees_from_str(input);
3851        assert_eq!(result.len(), 2);
3852        assert_eq!(result[0].path, PathBuf::from("/home/user/project"));
3853        assert_eq!(result[0].ref_name, Some("refs/heads/main".into()));
3854        assert!(result[0].is_main);
3855        assert_eq!(result[1].path, PathBuf::from("/home/user/project-wt"));
3856        assert_eq!(result[1].ref_name, Some("refs/heads/feature".into()));
3857        assert!(!result[1].is_main);
3858
3859        // Detached HEAD entry (included with ref_name: None)
3860        let input = "worktree /home/user/project\nHEAD abc123\nbranch refs/heads/main\n\n\
3861                      worktree /home/user/detached\nHEAD def456\ndetached\n\n";
3862        let result = parse_worktrees_from_str(input);
3863        assert_eq!(result.len(), 2);
3864        assert_eq!(result[0].path, PathBuf::from("/home/user/project"));
3865        assert_eq!(result[0].ref_name, Some("refs/heads/main".into()));
3866        assert!(result[0].is_main);
3867        assert_eq!(result[1].path, PathBuf::from("/home/user/detached"));
3868        assert_eq!(result[1].ref_name, None);
3869        assert_eq!(result[1].sha.as_ref(), "def456");
3870        assert!(!result[1].is_main);
3871
3872        // Bare repo entry (included with ref_name: None)
3873        let input = "worktree /home/user/bare.git\nHEAD abc123\nbare\n\n\
3874                      worktree /home/user/project\nHEAD def456\nbranch refs/heads/main\n\n";
3875        let result = parse_worktrees_from_str(input);
3876        assert_eq!(result.len(), 2);
3877        assert_eq!(result[0].path, PathBuf::from("/home/user/bare.git"));
3878        assert_eq!(result[0].ref_name, None);
3879        assert!(result[0].is_main);
3880        assert_eq!(result[1].path, PathBuf::from("/home/user/project"));
3881        assert_eq!(result[1].ref_name, Some("refs/heads/main".into()));
3882        assert!(!result[1].is_main);
3883
3884        // Extra porcelain lines (locked, prunable) should be ignored
3885        let input = "worktree /home/user/project\nHEAD abc123\nbranch refs/heads/main\n\n\
3886                      worktree /home/user/locked-wt\nHEAD def456\nbranch refs/heads/locked-branch\nlocked\n\n\
3887                      worktree /home/user/prunable-wt\nHEAD 789aaa\nbranch refs/heads/prunable-branch\nprunable\n\n";
3888        let result = parse_worktrees_from_str(input);
3889        assert_eq!(result.len(), 3);
3890        assert_eq!(result[0].path, PathBuf::from("/home/user/project"));
3891        assert_eq!(result[0].ref_name, Some("refs/heads/main".into()));
3892        assert!(result[0].is_main);
3893        assert_eq!(result[1].path, PathBuf::from("/home/user/locked-wt"));
3894        assert_eq!(result[1].ref_name, Some("refs/heads/locked-branch".into()));
3895        assert!(!result[1].is_main);
3896        assert_eq!(result[2].path, PathBuf::from("/home/user/prunable-wt"));
3897        assert_eq!(
3898            result[2].ref_name,
3899            Some("refs/heads/prunable-branch".into())
3900        );
3901        assert!(!result[2].is_main);
3902
3903        // Leading/trailing whitespace on lines should be tolerated
3904        let input =
3905            "  worktree /home/user/project  \n  HEAD abc123  \n  branch refs/heads/main  \n\n";
3906        let result = parse_worktrees_from_str(input);
3907        assert_eq!(result.len(), 1);
3908        assert_eq!(result[0].path, PathBuf::from("/home/user/project"));
3909        assert_eq!(result[0].sha.as_ref(), "abc123");
3910        assert_eq!(result[0].ref_name, Some("refs/heads/main".into()));
3911        assert!(result[0].is_main);
3912
3913        // Windows-style line endings should be handled
3914        let input = "worktree /home/user/project\r\nHEAD abc123\r\nbranch refs/heads/main\r\n\r\n";
3915        let result = parse_worktrees_from_str(input);
3916        assert_eq!(result.len(), 1);
3917        assert_eq!(result[0].path, PathBuf::from("/home/user/project"));
3918        assert_eq!(result[0].sha.as_ref(), "abc123");
3919        assert_eq!(result[0].ref_name, Some("refs/heads/main".into()));
3920        assert!(result[0].is_main);
3921    }
3922
3923    #[gpui::test]
3924    async fn test_create_and_list_worktrees(cx: &mut TestAppContext) {
3925        disable_git_global_config();
3926        cx.executor().allow_parking();
3927
3928        let temp_dir = tempfile::tempdir().unwrap();
3929        let repo_dir = temp_dir.path().join("repo");
3930        let worktrees_dir = temp_dir.path().join("worktrees");
3931
3932        fs::create_dir_all(&repo_dir).unwrap();
3933        fs::create_dir_all(&worktrees_dir).unwrap();
3934
3935        git2::Repository::init(&repo_dir).unwrap();
3936
3937        let repo = RealGitRepository::new(
3938            &repo_dir.join(".git"),
3939            None,
3940            Some("git".into()),
3941            cx.executor(),
3942        )
3943        .unwrap();
3944
3945        // Create an initial commit (required for worktrees)
3946        smol::fs::write(repo_dir.join("file.txt"), "content")
3947            .await
3948            .unwrap();
3949        repo.stage_paths(vec![repo_path("file.txt")], Arc::new(HashMap::default()))
3950            .await
3951            .unwrap();
3952        repo.commit(
3953            "Initial commit".into(),
3954            None,
3955            CommitOptions::default(),
3956            AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}),
3957            Arc::new(checkpoint_author_envs()),
3958        )
3959        .await
3960        .unwrap();
3961
3962        // List worktrees — should have just the main one
3963        let worktrees = repo.worktrees().await.unwrap();
3964        assert_eq!(worktrees.len(), 1);
3965        assert_eq!(
3966            worktrees[0].path.canonicalize().unwrap(),
3967            repo_dir.canonicalize().unwrap()
3968        );
3969
3970        let worktree_path = worktrees_dir.join("some-worktree");
3971
3972        // Create a new worktree
3973        repo.create_worktree(
3974            Some("test-branch".to_string()),
3975            worktree_path.clone(),
3976            Some("HEAD".to_string()),
3977        )
3978        .await
3979        .unwrap();
3980
3981        // List worktrees — should have two
3982        let worktrees = repo.worktrees().await.unwrap();
3983        assert_eq!(worktrees.len(), 2);
3984
3985        let new_worktree = worktrees
3986            .iter()
3987            .find(|w| w.display_name() == "test-branch")
3988            .expect("should find worktree with test-branch");
3989        assert_eq!(
3990            new_worktree.path.canonicalize().unwrap(),
3991            worktree_path.canonicalize().unwrap(),
3992        );
3993    }
3994
3995    #[gpui::test]
3996    async fn test_remove_worktree(cx: &mut TestAppContext) {
3997        disable_git_global_config();
3998        cx.executor().allow_parking();
3999
4000        let temp_dir = tempfile::tempdir().unwrap();
4001        let repo_dir = temp_dir.path().join("repo");
4002        let worktrees_dir = temp_dir.path().join("worktrees");
4003        git2::Repository::init(&repo_dir).unwrap();
4004
4005        let repo = RealGitRepository::new(
4006            &repo_dir.join(".git"),
4007            None,
4008            Some("git".into()),
4009            cx.executor(),
4010        )
4011        .unwrap();
4012
4013        // Create an initial commit
4014        smol::fs::write(repo_dir.join("file.txt"), "content")
4015            .await
4016            .unwrap();
4017        repo.stage_paths(vec![repo_path("file.txt")], Arc::new(HashMap::default()))
4018            .await
4019            .unwrap();
4020        repo.commit(
4021            "Initial commit".into(),
4022            None,
4023            CommitOptions::default(),
4024            AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}),
4025            Arc::new(checkpoint_author_envs()),
4026        )
4027        .await
4028        .unwrap();
4029
4030        // Create a worktree
4031        let worktree_path = worktrees_dir.join("worktree-to-remove");
4032        repo.create_worktree(
4033            Some("to-remove".to_string()),
4034            worktree_path.clone(),
4035            Some("HEAD".to_string()),
4036        )
4037        .await
4038        .unwrap();
4039
4040        // Remove the worktree
4041        repo.remove_worktree(worktree_path.clone(), false)
4042            .await
4043            .unwrap();
4044
4045        // Verify the directory is removed
4046        let worktrees = repo.worktrees().await.unwrap();
4047        assert_eq!(worktrees.len(), 1);
4048        assert!(
4049            worktrees.iter().all(|w| w.display_name() != "to-remove"),
4050            "removed worktree should not appear in list"
4051        );
4052        assert!(!worktree_path.exists());
4053
4054        // Create a worktree
4055        let worktree_path = worktrees_dir.join("dirty-wt");
4056        repo.create_worktree(
4057            Some("dirty-wt".to_string()),
4058            worktree_path.clone(),
4059            Some("HEAD".to_string()),
4060        )
4061        .await
4062        .unwrap();
4063
4064        assert!(worktree_path.exists());
4065
4066        // Add uncommitted changes in the worktree
4067        smol::fs::write(worktree_path.join("dirty-file.txt"), "uncommitted")
4068            .await
4069            .unwrap();
4070
4071        // Non-force removal should fail with dirty worktree
4072        let result = repo.remove_worktree(worktree_path.clone(), false).await;
4073        assert!(
4074            result.is_err(),
4075            "non-force removal of dirty worktree should fail"
4076        );
4077
4078        // Force removal should succeed
4079        repo.remove_worktree(worktree_path.clone(), true)
4080            .await
4081            .unwrap();
4082
4083        let worktrees = repo.worktrees().await.unwrap();
4084        assert_eq!(worktrees.len(), 1);
4085        assert!(!worktree_path.exists());
4086    }
4087
4088    #[gpui::test]
4089    async fn test_rename_worktree(cx: &mut TestAppContext) {
4090        disable_git_global_config();
4091        cx.executor().allow_parking();
4092
4093        let temp_dir = tempfile::tempdir().unwrap();
4094        let repo_dir = temp_dir.path().join("repo");
4095        let worktrees_dir = temp_dir.path().join("worktrees");
4096
4097        git2::Repository::init(&repo_dir).unwrap();
4098
4099        let repo = RealGitRepository::new(
4100            &repo_dir.join(".git"),
4101            None,
4102            Some("git".into()),
4103            cx.executor(),
4104        )
4105        .unwrap();
4106
4107        // Create an initial commit
4108        smol::fs::write(repo_dir.join("file.txt"), "content")
4109            .await
4110            .unwrap();
4111        repo.stage_paths(vec![repo_path("file.txt")], Arc::new(HashMap::default()))
4112            .await
4113            .unwrap();
4114        repo.commit(
4115            "Initial commit".into(),
4116            None,
4117            CommitOptions::default(),
4118            AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}),
4119            Arc::new(checkpoint_author_envs()),
4120        )
4121        .await
4122        .unwrap();
4123
4124        // Create a worktree
4125        let old_path = worktrees_dir.join("old-worktree-name");
4126        repo.create_worktree(
4127            Some("old-name".to_string()),
4128            old_path.clone(),
4129            Some("HEAD".to_string()),
4130        )
4131        .await
4132        .unwrap();
4133
4134        assert!(old_path.exists());
4135
4136        // Move the worktree to a new path
4137        let new_path = worktrees_dir.join("new-worktree-name");
4138        repo.rename_worktree(old_path.clone(), new_path.clone())
4139            .await
4140            .unwrap();
4141
4142        // Verify the old path is gone and new path exists
4143        assert!(!old_path.exists());
4144        assert!(new_path.exists());
4145
4146        // Verify it shows up in worktree list at the new path
4147        let worktrees = repo.worktrees().await.unwrap();
4148        assert_eq!(worktrees.len(), 2);
4149        let moved_worktree = worktrees
4150            .iter()
4151            .find(|w| w.display_name() == "old-name")
4152            .expect("should find worktree by branch name");
4153        assert_eq!(
4154            moved_worktree.path.canonicalize().unwrap(),
4155            new_path.canonicalize().unwrap()
4156        );
4157    }
4158
4159    #[test]
4160    fn test_original_repo_path_from_common_dir() {
4161        // Normal repo: common_dir is <work_dir>/.git
4162        assert_eq!(
4163            original_repo_path_from_common_dir(Path::new("/code/zed5/.git")),
4164            PathBuf::from("/code/zed5")
4165        );
4166
4167        // Worktree: common_dir is the main repo's .git
4168        // (same result — that's the point, it always traces back to the original)
4169        assert_eq!(
4170            original_repo_path_from_common_dir(Path::new("/code/zed5/.git")),
4171            PathBuf::from("/code/zed5")
4172        );
4173
4174        // Bare repo: no .git suffix, returns as-is
4175        assert_eq!(
4176            original_repo_path_from_common_dir(Path::new("/code/zed5.git")),
4177            PathBuf::from("/code/zed5.git")
4178        );
4179
4180        // Root-level .git directory
4181        assert_eq!(
4182            original_repo_path_from_common_dir(Path::new("/.git")),
4183            PathBuf::from("/")
4184        );
4185    }
4186
4187    impl RealGitRepository {
4188        /// Force a Git garbage collection on the repository.
4189        fn gc(&self) -> BoxFuture<'_, Result<()>> {
4190            let working_directory = self.working_directory();
4191            let git_directory = self.path();
4192            let git_binary_path = self.any_git_binary_path.clone();
4193            let executor = self.executor.clone();
4194            self.executor
4195                .spawn(async move {
4196                    let git_binary_path = git_binary_path.clone();
4197                    let working_directory = working_directory?;
4198                    let git = GitBinary::new(
4199                        git_binary_path,
4200                        working_directory,
4201                        git_directory,
4202                        executor,
4203                        true,
4204                    );
4205                    git.run(&["gc", "--prune"]).await?;
4206                    Ok(())
4207                })
4208                .boxed()
4209        }
4210    }
4211}