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