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::future::BoxFuture;
   8use futures::io::BufWriter;
   9use futures::{AsyncWriteExt, FutureExt as _, select_biased};
  10use git2::BranchType;
  11use gpui::{AppContext as _, AsyncApp, BackgroundExecutor, SharedString, Task};
  12use parking_lot::Mutex;
  13use rope::Rope;
  14use schemars::JsonSchema;
  15use serde::Deserialize;
  16use smol::io::{AsyncBufReadExt, AsyncReadExt, BufReader};
  17use std::ffi::{OsStr, OsString};
  18use std::process::{ExitStatus, Stdio};
  19use std::{
  20    cmp::Ordering,
  21    future,
  22    path::{Path, PathBuf},
  23    sync::Arc,
  24};
  25use sum_tree::MapSeekTarget;
  26use thiserror::Error;
  27use util::command::new_smol_command;
  28use util::paths::PathStyle;
  29use util::rel_path::RelPath;
  30use util::{ResultExt, paths};
  31use uuid::Uuid;
  32
  33pub use askpass::{AskPassDelegate, AskPassResult, AskPassSession};
  34
  35pub const REMOTE_CANCELLED_BY_USER: &str = "Operation cancelled by user";
  36
  37#[derive(Clone, Debug, Hash, PartialEq, Eq)]
  38pub struct Branch {
  39    pub is_head: bool,
  40    pub ref_name: SharedString,
  41    pub upstream: Option<Upstream>,
  42    pub most_recent_commit: Option<CommitSummary>,
  43}
  44
  45impl Branch {
  46    pub fn name(&self) -> &str {
  47        self.ref_name
  48            .as_ref()
  49            .strip_prefix("refs/heads/")
  50            .or_else(|| self.ref_name.as_ref().strip_prefix("refs/remotes/"))
  51            .unwrap_or(self.ref_name.as_ref())
  52    }
  53
  54    pub fn is_remote(&self) -> bool {
  55        self.ref_name.starts_with("refs/remotes/")
  56    }
  57
  58    pub fn tracking_status(&self) -> Option<UpstreamTrackingStatus> {
  59        self.upstream
  60            .as_ref()
  61            .and_then(|upstream| upstream.tracking.status())
  62    }
  63
  64    pub fn priority_key(&self) -> (bool, Option<i64>) {
  65        (
  66            self.is_head,
  67            self.most_recent_commit
  68                .as_ref()
  69                .map(|commit| commit.commit_timestamp),
  70        )
  71    }
  72}
  73
  74#[derive(Clone, Debug, Hash, PartialEq, Eq)]
  75pub struct Worktree {
  76    pub path: PathBuf,
  77    pub ref_name: SharedString,
  78    pub sha: SharedString,
  79}
  80
  81impl Worktree {
  82    pub fn branch(&self) -> &str {
  83        self.ref_name
  84            .as_ref()
  85            .strip_prefix("refs/heads/")
  86            .or_else(|| self.ref_name.as_ref().strip_prefix("refs/remotes/"))
  87            .unwrap_or(self.ref_name.as_ref())
  88    }
  89}
  90
  91pub fn parse_worktrees_from_str<T: AsRef<str>>(raw_worktrees: T) -> Vec<Worktree> {
  92    let mut worktrees = Vec::new();
  93    let entries = raw_worktrees.as_ref().split("\n\n");
  94    for entry in entries {
  95        let mut parts = entry.splitn(3, '\n');
  96        let path = parts
  97            .next()
  98            .and_then(|p| p.split_once(' ').map(|(_, path)| path.to_string()));
  99        let sha = parts
 100            .next()
 101            .and_then(|p| p.split_once(' ').map(|(_, sha)| sha.to_string()));
 102        let ref_name = parts
 103            .next()
 104            .and_then(|p| p.split_once(' ').map(|(_, ref_name)| ref_name.to_string()));
 105
 106        if let (Some(path), Some(sha), Some(ref_name)) = (path, sha, ref_name) {
 107            worktrees.push(Worktree {
 108                path: PathBuf::from(path),
 109                ref_name: ref_name.into(),
 110                sha: sha.into(),
 111            })
 112        }
 113    }
 114
 115    worktrees
 116}
 117
 118#[derive(Clone, Debug, Hash, PartialEq, Eq)]
 119pub struct Upstream {
 120    pub ref_name: SharedString,
 121    pub tracking: UpstreamTracking,
 122}
 123
 124impl Upstream {
 125    pub fn is_remote(&self) -> bool {
 126        self.remote_name().is_some()
 127    }
 128
 129    pub fn remote_name(&self) -> Option<&str> {
 130        self.ref_name
 131            .strip_prefix("refs/remotes/")
 132            .and_then(|stripped| stripped.split("/").next())
 133    }
 134
 135    pub fn stripped_ref_name(&self) -> Option<&str> {
 136        self.ref_name.strip_prefix("refs/remotes/")
 137    }
 138}
 139
 140#[derive(Clone, Copy, Default)]
 141pub struct CommitOptions {
 142    pub amend: bool,
 143    pub signoff: bool,
 144}
 145
 146#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
 147pub enum UpstreamTracking {
 148    /// Remote ref not present in local repository.
 149    Gone,
 150    /// Remote ref present in local repository (fetched from remote).
 151    Tracked(UpstreamTrackingStatus),
 152}
 153
 154impl From<UpstreamTrackingStatus> for UpstreamTracking {
 155    fn from(status: UpstreamTrackingStatus) -> Self {
 156        UpstreamTracking::Tracked(status)
 157    }
 158}
 159
 160impl UpstreamTracking {
 161    pub fn is_gone(&self) -> bool {
 162        matches!(self, UpstreamTracking::Gone)
 163    }
 164
 165    pub fn status(&self) -> Option<UpstreamTrackingStatus> {
 166        match self {
 167            UpstreamTracking::Gone => None,
 168            UpstreamTracking::Tracked(status) => Some(*status),
 169        }
 170    }
 171}
 172
 173#[derive(Debug, Clone)]
 174pub struct RemoteCommandOutput {
 175    pub stdout: String,
 176    pub stderr: String,
 177}
 178
 179impl RemoteCommandOutput {
 180    pub fn is_empty(&self) -> bool {
 181        self.stdout.is_empty() && self.stderr.is_empty()
 182    }
 183}
 184
 185#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
 186pub struct UpstreamTrackingStatus {
 187    pub ahead: u32,
 188    pub behind: u32,
 189}
 190
 191#[derive(Clone, Debug, Hash, PartialEq, Eq)]
 192pub struct CommitSummary {
 193    pub sha: SharedString,
 194    pub subject: SharedString,
 195    /// This is a unix timestamp
 196    pub commit_timestamp: i64,
 197    pub author_name: SharedString,
 198    pub has_parent: bool,
 199}
 200
 201#[derive(Clone, Debug, Default, Hash, PartialEq, Eq)]
 202pub struct CommitDetails {
 203    pub sha: SharedString,
 204    pub message: SharedString,
 205    pub commit_timestamp: i64,
 206    pub author_email: SharedString,
 207    pub author_name: SharedString,
 208}
 209
 210#[derive(Debug)]
 211pub struct CommitDiff {
 212    pub files: Vec<CommitFile>,
 213}
 214
 215#[derive(Debug)]
 216pub struct CommitFile {
 217    pub path: RepoPath,
 218    pub old_text: Option<String>,
 219    pub new_text: Option<String>,
 220}
 221
 222impl CommitDetails {
 223    pub fn short_sha(&self) -> SharedString {
 224        self.sha[..SHORT_SHA_LENGTH].to_string().into()
 225    }
 226}
 227
 228#[derive(Debug, Clone, Hash, PartialEq, Eq)]
 229pub struct Remote {
 230    pub name: SharedString,
 231}
 232
 233pub enum ResetMode {
 234    /// Reset the branch pointer, leave index and worktree unchanged (this will make it look like things that were
 235    /// committed are now staged).
 236    Soft,
 237    /// Reset the branch pointer and index, leave worktree unchanged (this makes it look as though things that were
 238    /// committed are now unstaged).
 239    Mixed,
 240}
 241
 242#[derive(Debug, Clone, Hash, PartialEq, Eq)]
 243pub enum FetchOptions {
 244    All,
 245    Remote(Remote),
 246}
 247
 248impl FetchOptions {
 249    pub fn to_proto(&self) -> Option<String> {
 250        match self {
 251            FetchOptions::All => None,
 252            FetchOptions::Remote(remote) => Some(remote.clone().name.into()),
 253        }
 254    }
 255
 256    pub fn from_proto(remote_name: Option<String>) -> Self {
 257        match remote_name {
 258            Some(name) => FetchOptions::Remote(Remote { name: name.into() }),
 259            None => FetchOptions::All,
 260        }
 261    }
 262
 263    pub fn name(&self) -> SharedString {
 264        match self {
 265            Self::All => "Fetch all remotes".into(),
 266            Self::Remote(remote) => remote.name.clone(),
 267        }
 268    }
 269}
 270
 271impl std::fmt::Display for FetchOptions {
 272    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
 273        match self {
 274            FetchOptions::All => write!(f, "--all"),
 275            FetchOptions::Remote(remote) => write!(f, "{}", remote.name),
 276        }
 277    }
 278}
 279
 280/// Modifies .git/info/exclude temporarily
 281pub struct GitExcludeOverride {
 282    git_exclude_path: PathBuf,
 283    original_excludes: Option<String>,
 284    added_excludes: Option<String>,
 285}
 286
 287impl GitExcludeOverride {
 288    const START_BLOCK_MARKER: &str = "\n\n#  ====== Auto-added by Zed: =======\n";
 289    const END_BLOCK_MARKER: &str = "\n#  ====== End of auto-added by Zed =======\n";
 290
 291    pub async fn new(git_exclude_path: PathBuf) -> Result<Self> {
 292        let original_excludes =
 293            smol::fs::read_to_string(&git_exclude_path)
 294                .await
 295                .ok()
 296                .map(|content| {
 297                    // Auto-generated lines are normally cleaned up in
 298                    // `restore_original()` or `drop()`, but may stuck in rare cases.
 299                    // Make sure to remove them.
 300                    Self::remove_auto_generated_block(&content)
 301                });
 302
 303        Ok(GitExcludeOverride {
 304            git_exclude_path,
 305            original_excludes,
 306            added_excludes: None,
 307        })
 308    }
 309
 310    pub async fn add_excludes(&mut self, excludes: &str) -> Result<()> {
 311        self.added_excludes = Some(if let Some(ref already_added) = self.added_excludes {
 312            format!("{already_added}\n{excludes}")
 313        } else {
 314            excludes.to_string()
 315        });
 316
 317        let mut content = self.original_excludes.clone().unwrap_or_default();
 318
 319        content.push_str(Self::START_BLOCK_MARKER);
 320        content.push_str(self.added_excludes.as_ref().unwrap());
 321        content.push_str(Self::END_BLOCK_MARKER);
 322
 323        smol::fs::write(&self.git_exclude_path, content).await?;
 324        Ok(())
 325    }
 326
 327    pub async fn restore_original(&mut self) -> Result<()> {
 328        if let Some(ref original) = self.original_excludes {
 329            smol::fs::write(&self.git_exclude_path, original).await?;
 330        } else if self.git_exclude_path.exists() {
 331            smol::fs::remove_file(&self.git_exclude_path).await?;
 332        }
 333
 334        self.added_excludes = None;
 335
 336        Ok(())
 337    }
 338
 339    fn remove_auto_generated_block(content: &str) -> String {
 340        let start_marker = Self::START_BLOCK_MARKER;
 341        let end_marker = Self::END_BLOCK_MARKER;
 342        let mut content = content.to_string();
 343
 344        let start_index = content.find(start_marker);
 345        let end_index = content.rfind(end_marker);
 346
 347        if let (Some(start), Some(end)) = (start_index, end_index) {
 348            if end > start {
 349                content.replace_range(start..end + end_marker.len(), "");
 350            }
 351        }
 352
 353        // Older versions of Zed didn't have end-of-block markers,
 354        // so it's impossible to determine auto-generated lines.
 355        // Conservatively remove the standard list of excludes
 356        let standard_excludes = format!(
 357            "{}{}",
 358            Self::START_BLOCK_MARKER,
 359            include_str!("./checkpoint.gitignore")
 360        );
 361        content = content.replace(&standard_excludes, "");
 362
 363        content
 364    }
 365}
 366
 367impl Drop for GitExcludeOverride {
 368    fn drop(&mut self) {
 369        if self.added_excludes.is_some() {
 370            let git_exclude_path = self.git_exclude_path.clone();
 371            let original_excludes = self.original_excludes.clone();
 372            smol::spawn(async move {
 373                if let Some(original) = original_excludes {
 374                    smol::fs::write(&git_exclude_path, original).await
 375                } else {
 376                    smol::fs::remove_file(&git_exclude_path).await
 377                }
 378            })
 379            .detach();
 380        }
 381    }
 382}
 383
 384pub trait GitRepository: Send + Sync {
 385    fn reload_index(&self);
 386
 387    /// Returns the contents of an entry in the repository's index, or None if there is no entry for the given path.
 388    ///
 389    /// Also returns `None` for symlinks.
 390    fn load_index_text(&self, path: RepoPath) -> BoxFuture<'_, Option<String>>;
 391
 392    /// 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.
 393    ///
 394    /// Also returns `None` for symlinks.
 395    fn load_committed_text(&self, path: RepoPath) -> BoxFuture<'_, Option<String>>;
 396    fn load_blob_content(&self, oid: Oid) -> BoxFuture<'_, Result<String>>;
 397
 398    fn set_index_text(
 399        &self,
 400        path: RepoPath,
 401        content: Option<String>,
 402        env: Arc<HashMap<String, String>>,
 403        is_executable: bool,
 404    ) -> BoxFuture<'_, anyhow::Result<()>>;
 405
 406    /// Returns the URL of the remote with the given name.
 407    fn remote_url(&self, name: &str) -> Option<String>;
 408
 409    /// Resolve a list of refs to SHAs.
 410    fn revparse_batch(&self, revs: Vec<String>) -> BoxFuture<'_, Result<Vec<Option<String>>>>;
 411
 412    fn head_sha(&self) -> BoxFuture<'_, Option<String>> {
 413        async move {
 414            self.revparse_batch(vec!["HEAD".into()])
 415                .await
 416                .unwrap_or_default()
 417                .into_iter()
 418                .next()
 419                .flatten()
 420        }
 421        .boxed()
 422    }
 423
 424    fn merge_message(&self) -> BoxFuture<'_, Option<String>>;
 425
 426    fn status(&self, path_prefixes: &[RepoPath]) -> Task<Result<GitStatus>>;
 427    fn diff_tree(&self, request: DiffTreeType) -> BoxFuture<'_, Result<TreeDiff>>;
 428
 429    fn stash_entries(&self) -> BoxFuture<'_, Result<GitStash>>;
 430
 431    fn branches(&self) -> BoxFuture<'_, Result<Vec<Branch>>>;
 432
 433    fn change_branch(&self, name: String) -> BoxFuture<'_, Result<()>>;
 434    fn create_branch(&self, name: String, base_branch: Option<String>)
 435    -> BoxFuture<'_, Result<()>>;
 436    fn rename_branch(&self, branch: String, new_name: String) -> BoxFuture<'_, Result<()>>;
 437
 438    fn worktrees(&self) -> BoxFuture<'_, Result<Vec<Worktree>>>;
 439
 440    fn create_worktree(
 441        &self,
 442        name: String,
 443        directory: PathBuf,
 444        from_commit: Option<String>,
 445    ) -> BoxFuture<'_, Result<()>>;
 446
 447    fn reset(
 448        &self,
 449        commit: String,
 450        mode: ResetMode,
 451        env: Arc<HashMap<String, String>>,
 452    ) -> BoxFuture<'_, Result<()>>;
 453
 454    fn checkout_files(
 455        &self,
 456        commit: String,
 457        paths: Vec<RepoPath>,
 458        env: Arc<HashMap<String, String>>,
 459    ) -> BoxFuture<'_, Result<()>>;
 460
 461    fn show(&self, commit: String) -> BoxFuture<'_, Result<CommitDetails>>;
 462
 463    fn load_commit(&self, commit: String, cx: AsyncApp) -> BoxFuture<'_, Result<CommitDiff>>;
 464    fn blame(&self, path: RepoPath, content: Rope) -> BoxFuture<'_, Result<crate::blame::Blame>>;
 465
 466    /// Returns the absolute path to the repository. For worktrees, this will be the path to the
 467    /// worktree's gitdir within the main repository (typically `.git/worktrees/<name>`).
 468    fn path(&self) -> PathBuf;
 469
 470    fn main_repository_path(&self) -> PathBuf;
 471
 472    /// Updates the index to match the worktree at the given paths.
 473    ///
 474    /// If any of the paths have been deleted from the worktree, they will be removed from the index if found there.
 475    fn stage_paths(
 476        &self,
 477        paths: Vec<RepoPath>,
 478        env: Arc<HashMap<String, String>>,
 479    ) -> BoxFuture<'_, Result<()>>;
 480    /// Updates the index to match HEAD at the given paths.
 481    ///
 482    /// If any of the paths were previously staged but do not exist in HEAD, they will be removed from the index.
 483    fn unstage_paths(
 484        &self,
 485        paths: Vec<RepoPath>,
 486        env: Arc<HashMap<String, String>>,
 487    ) -> BoxFuture<'_, Result<()>>;
 488
 489    fn run_hook(
 490        &self,
 491        hook: RunHook,
 492        env: Arc<HashMap<String, String>>,
 493    ) -> BoxFuture<'_, Result<()>>;
 494
 495    fn commit(
 496        &self,
 497        message: SharedString,
 498        name_and_email: Option<(SharedString, SharedString)>,
 499        options: CommitOptions,
 500        askpass: AskPassDelegate,
 501        env: Arc<HashMap<String, String>>,
 502    ) -> BoxFuture<'_, Result<()>>;
 503
 504    fn stash_paths(
 505        &self,
 506        paths: Vec<RepoPath>,
 507        env: Arc<HashMap<String, String>>,
 508    ) -> BoxFuture<'_, Result<()>>;
 509
 510    fn stash_pop(
 511        &self,
 512        index: Option<usize>,
 513        env: Arc<HashMap<String, String>>,
 514    ) -> BoxFuture<'_, Result<()>>;
 515
 516    fn stash_apply(
 517        &self,
 518        index: Option<usize>,
 519        env: Arc<HashMap<String, String>>,
 520    ) -> BoxFuture<'_, Result<()>>;
 521
 522    fn stash_drop(
 523        &self,
 524        index: Option<usize>,
 525        env: Arc<HashMap<String, String>>,
 526    ) -> BoxFuture<'_, Result<()>>;
 527
 528    fn push(
 529        &self,
 530        branch_name: String,
 531        upstream_name: String,
 532        options: Option<PushOptions>,
 533        askpass: AskPassDelegate,
 534        env: Arc<HashMap<String, String>>,
 535        // This method takes an AsyncApp to ensure it's invoked on the main thread,
 536        // otherwise git-credentials-manager won't work.
 537        cx: AsyncApp,
 538    ) -> BoxFuture<'_, Result<RemoteCommandOutput>>;
 539
 540    fn pull(
 541        &self,
 542        branch_name: Option<String>,
 543        upstream_name: String,
 544        rebase: bool,
 545        askpass: AskPassDelegate,
 546        env: Arc<HashMap<String, String>>,
 547        // This method takes an AsyncApp to ensure it's invoked on the main thread,
 548        // otherwise git-credentials-manager won't work.
 549        cx: AsyncApp,
 550    ) -> BoxFuture<'_, Result<RemoteCommandOutput>>;
 551
 552    fn fetch(
 553        &self,
 554        fetch_options: FetchOptions,
 555        askpass: AskPassDelegate,
 556        env: Arc<HashMap<String, String>>,
 557        // This method takes an AsyncApp to ensure it's invoked on the main thread,
 558        // otherwise git-credentials-manager won't work.
 559        cx: AsyncApp,
 560    ) -> BoxFuture<'_, Result<RemoteCommandOutput>>;
 561
 562    fn get_remotes(&self, branch_name: Option<String>) -> BoxFuture<'_, Result<Vec<Remote>>>;
 563
 564    /// returns a list of remote branches that contain HEAD
 565    fn check_for_pushed_commit(&self) -> BoxFuture<'_, Result<Vec<SharedString>>>;
 566
 567    /// Run git diff
 568    fn diff(&self, diff: DiffType) -> BoxFuture<'_, Result<String>>;
 569
 570    /// Creates a checkpoint for the repository.
 571    fn checkpoint(&self) -> BoxFuture<'static, Result<GitRepositoryCheckpoint>>;
 572
 573    /// Resets to a previously-created checkpoint.
 574    fn restore_checkpoint(&self, checkpoint: GitRepositoryCheckpoint) -> BoxFuture<'_, Result<()>>;
 575
 576    /// Compares two checkpoints, returning true if they are equal
 577    fn compare_checkpoints(
 578        &self,
 579        left: GitRepositoryCheckpoint,
 580        right: GitRepositoryCheckpoint,
 581    ) -> BoxFuture<'_, Result<bool>>;
 582
 583    /// Computes a diff between two checkpoints.
 584    fn diff_checkpoints(
 585        &self,
 586        base_checkpoint: GitRepositoryCheckpoint,
 587        target_checkpoint: GitRepositoryCheckpoint,
 588    ) -> BoxFuture<'_, Result<String>>;
 589
 590    fn default_branch(&self) -> BoxFuture<'_, Result<Option<SharedString>>>;
 591}
 592
 593pub enum DiffType {
 594    HeadToIndex,
 595    HeadToWorktree,
 596}
 597
 598#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, JsonSchema)]
 599pub enum PushOptions {
 600    SetUpstream,
 601    Force,
 602}
 603
 604impl std::fmt::Debug for dyn GitRepository {
 605    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
 606        f.debug_struct("dyn GitRepository<...>").finish()
 607    }
 608}
 609
 610pub struct RealGitRepository {
 611    pub repository: Arc<Mutex<git2::Repository>>,
 612    pub system_git_binary_path: Option<PathBuf>,
 613    pub any_git_binary_path: PathBuf,
 614    executor: BackgroundExecutor,
 615}
 616
 617impl RealGitRepository {
 618    pub fn new(
 619        dotgit_path: &Path,
 620        bundled_git_binary_path: Option<PathBuf>,
 621        system_git_binary_path: Option<PathBuf>,
 622        executor: BackgroundExecutor,
 623    ) -> Option<Self> {
 624        let any_git_binary_path = system_git_binary_path.clone().or(bundled_git_binary_path)?;
 625        let workdir_root = dotgit_path.parent()?;
 626        let repository = git2::Repository::open(workdir_root).log_err()?;
 627        Some(Self {
 628            repository: Arc::new(Mutex::new(repository)),
 629            system_git_binary_path,
 630            any_git_binary_path,
 631            executor,
 632        })
 633    }
 634
 635    fn working_directory(&self) -> Result<PathBuf> {
 636        self.repository
 637            .lock()
 638            .workdir()
 639            .context("failed to read git work directory")
 640            .map(Path::to_path_buf)
 641    }
 642}
 643
 644#[derive(Clone, Debug)]
 645pub struct GitRepositoryCheckpoint {
 646    pub commit_sha: Oid,
 647}
 648
 649#[derive(Debug)]
 650pub struct GitCommitter {
 651    pub name: Option<String>,
 652    pub email: Option<String>,
 653}
 654
 655pub async fn get_git_committer(cx: &AsyncApp) -> GitCommitter {
 656    if cfg!(any(feature = "test-support", test)) {
 657        return GitCommitter {
 658            name: None,
 659            email: None,
 660        };
 661    }
 662
 663    let git_binary_path =
 664        if cfg!(target_os = "macos") && option_env!("ZED_BUNDLE").as_deref() == Some("true") {
 665            cx.update(|cx| {
 666                cx.path_for_auxiliary_executable("git")
 667                    .context("could not find git binary path")
 668                    .log_err()
 669            })
 670            .ok()
 671            .flatten()
 672        } else {
 673            None
 674        };
 675
 676    let git = GitBinary::new(
 677        git_binary_path.unwrap_or(PathBuf::from("git")),
 678        paths::home_dir().clone(),
 679        cx.background_executor().clone(),
 680    );
 681
 682    cx.background_spawn(async move {
 683        let name = git.run(["config", "--global", "user.name"]).await.log_err();
 684        let email = git
 685            .run(["config", "--global", "user.email"])
 686            .await
 687            .log_err();
 688        GitCommitter { name, email }
 689    })
 690    .await
 691}
 692
 693impl GitRepository for RealGitRepository {
 694    fn reload_index(&self) {
 695        if let Ok(mut index) = self.repository.lock().index() {
 696            _ = index.read(false);
 697        }
 698    }
 699
 700    fn path(&self) -> PathBuf {
 701        let repo = self.repository.lock();
 702        repo.path().into()
 703    }
 704
 705    fn main_repository_path(&self) -> PathBuf {
 706        let repo = self.repository.lock();
 707        repo.commondir().into()
 708    }
 709
 710    fn show(&self, commit: String) -> BoxFuture<'_, Result<CommitDetails>> {
 711        let git_binary_path = self.any_git_binary_path.clone();
 712        let working_directory = self.working_directory();
 713        self.executor
 714            .spawn(async move {
 715                let working_directory = working_directory?;
 716                let output = new_smol_command(git_binary_path)
 717                    .current_dir(&working_directory)
 718                    .args([
 719                        "--no-optional-locks",
 720                        "show",
 721                        "--no-patch",
 722                        "--format=%H%x00%B%x00%at%x00%ae%x00%an%x00",
 723                        &commit,
 724                    ])
 725                    .output()
 726                    .await?;
 727                let output = std::str::from_utf8(&output.stdout)?;
 728                let fields = output.split('\0').collect::<Vec<_>>();
 729                if fields.len() != 6 {
 730                    bail!("unexpected git-show output for {commit:?}: {output:?}")
 731                }
 732                let sha = fields[0].to_string().into();
 733                let message = fields[1].to_string().into();
 734                let commit_timestamp = fields[2].parse()?;
 735                let author_email = fields[3].to_string().into();
 736                let author_name = fields[4].to_string().into();
 737                Ok(CommitDetails {
 738                    sha,
 739                    message,
 740                    commit_timestamp,
 741                    author_email,
 742                    author_name,
 743                })
 744            })
 745            .boxed()
 746    }
 747
 748    fn load_commit(&self, commit: String, cx: AsyncApp) -> BoxFuture<'_, Result<CommitDiff>> {
 749        let Some(working_directory) = self.repository.lock().workdir().map(ToOwned::to_owned)
 750        else {
 751            return future::ready(Err(anyhow!("no working directory"))).boxed();
 752        };
 753        let git_binary_path = self.any_git_binary_path.clone();
 754        cx.background_spawn(async move {
 755            let show_output = util::command::new_smol_command(&git_binary_path)
 756                .current_dir(&working_directory)
 757                .args([
 758                    "--no-optional-locks",
 759                    "show",
 760                    "--format=",
 761                    "-z",
 762                    "--no-renames",
 763                    "--name-status",
 764                    "--first-parent",
 765                ])
 766                .arg(&commit)
 767                .stdin(Stdio::null())
 768                .stdout(Stdio::piped())
 769                .stderr(Stdio::piped())
 770                .output()
 771                .await
 772                .context("starting git show process")?;
 773
 774            let show_stdout = String::from_utf8_lossy(&show_output.stdout);
 775            let changes = parse_git_diff_name_status(&show_stdout);
 776            let parent_sha = format!("{}^", commit);
 777
 778            let mut cat_file_process = util::command::new_smol_command(&git_binary_path)
 779                .current_dir(&working_directory)
 780                .args(["--no-optional-locks", "cat-file", "--batch=%(objectsize)"])
 781                .stdin(Stdio::piped())
 782                .stdout(Stdio::piped())
 783                .stderr(Stdio::piped())
 784                .spawn()
 785                .context("starting git cat-file process")?;
 786
 787            let mut files = Vec::<CommitFile>::new();
 788            let mut stdin = BufWriter::with_capacity(512, cat_file_process.stdin.take().unwrap());
 789            let mut stdout = BufReader::new(cat_file_process.stdout.take().unwrap());
 790            let mut info_line = String::new();
 791            let mut newline = [b'\0'];
 792            for (path, status_code) in changes {
 793                // git-show outputs `/`-delimited paths even on Windows.
 794                let Some(rel_path) = RelPath::unix(path).log_err() else {
 795                    continue;
 796                };
 797
 798                match status_code {
 799                    StatusCode::Modified => {
 800                        stdin.write_all(commit.as_bytes()).await?;
 801                        stdin.write_all(b":").await?;
 802                        stdin.write_all(path.as_bytes()).await?;
 803                        stdin.write_all(b"\n").await?;
 804                        stdin.write_all(parent_sha.as_bytes()).await?;
 805                        stdin.write_all(b":").await?;
 806                        stdin.write_all(path.as_bytes()).await?;
 807                        stdin.write_all(b"\n").await?;
 808                    }
 809                    StatusCode::Added => {
 810                        stdin.write_all(commit.as_bytes()).await?;
 811                        stdin.write_all(b":").await?;
 812                        stdin.write_all(path.as_bytes()).await?;
 813                        stdin.write_all(b"\n").await?;
 814                    }
 815                    StatusCode::Deleted => {
 816                        stdin.write_all(parent_sha.as_bytes()).await?;
 817                        stdin.write_all(b":").await?;
 818                        stdin.write_all(path.as_bytes()).await?;
 819                        stdin.write_all(b"\n").await?;
 820                    }
 821                    _ => continue,
 822                }
 823                stdin.flush().await?;
 824
 825                info_line.clear();
 826                stdout.read_line(&mut info_line).await?;
 827
 828                let len = info_line.trim_end().parse().with_context(|| {
 829                    format!("invalid object size output from cat-file {info_line}")
 830                })?;
 831                let mut text = vec![0; len];
 832                stdout.read_exact(&mut text).await?;
 833                stdout.read_exact(&mut newline).await?;
 834                let text = String::from_utf8_lossy(&text).to_string();
 835
 836                let mut old_text = None;
 837                let mut new_text = None;
 838                match status_code {
 839                    StatusCode::Modified => {
 840                        info_line.clear();
 841                        stdout.read_line(&mut info_line).await?;
 842                        let len = info_line.trim_end().parse().with_context(|| {
 843                            format!("invalid object size output from cat-file {}", info_line)
 844                        })?;
 845                        let mut parent_text = vec![0; len];
 846                        stdout.read_exact(&mut parent_text).await?;
 847                        stdout.read_exact(&mut newline).await?;
 848                        old_text = Some(String::from_utf8_lossy(&parent_text).to_string());
 849                        new_text = Some(text);
 850                    }
 851                    StatusCode::Added => new_text = Some(text),
 852                    StatusCode::Deleted => old_text = Some(text),
 853                    _ => continue,
 854                }
 855
 856                files.push(CommitFile {
 857                    path: RepoPath(Arc::from(rel_path)),
 858                    old_text,
 859                    new_text,
 860                })
 861            }
 862
 863            Ok(CommitDiff { files })
 864        })
 865        .boxed()
 866    }
 867
 868    fn reset(
 869        &self,
 870        commit: String,
 871        mode: ResetMode,
 872        env: Arc<HashMap<String, String>>,
 873    ) -> BoxFuture<'_, Result<()>> {
 874        async move {
 875            let working_directory = self.working_directory();
 876
 877            let mode_flag = match mode {
 878                ResetMode::Mixed => "--mixed",
 879                ResetMode::Soft => "--soft",
 880            };
 881
 882            let output = new_smol_command(&self.any_git_binary_path)
 883                .envs(env.iter())
 884                .current_dir(&working_directory?)
 885                .args(["reset", mode_flag, &commit])
 886                .output()
 887                .await?;
 888            anyhow::ensure!(
 889                output.status.success(),
 890                "Failed to reset:\n{}",
 891                String::from_utf8_lossy(&output.stderr),
 892            );
 893            Ok(())
 894        }
 895        .boxed()
 896    }
 897
 898    fn checkout_files(
 899        &self,
 900        commit: String,
 901        paths: Vec<RepoPath>,
 902        env: Arc<HashMap<String, String>>,
 903    ) -> BoxFuture<'_, Result<()>> {
 904        let working_directory = self.working_directory();
 905        let git_binary_path = self.any_git_binary_path.clone();
 906        async move {
 907            if paths.is_empty() {
 908                return Ok(());
 909            }
 910
 911            let output = new_smol_command(&git_binary_path)
 912                .current_dir(&working_directory?)
 913                .envs(env.iter())
 914                .args(["checkout", &commit, "--"])
 915                .args(paths.iter().map(|path| path.as_unix_str()))
 916                .output()
 917                .await?;
 918            anyhow::ensure!(
 919                output.status.success(),
 920                "Failed to checkout files:\n{}",
 921                String::from_utf8_lossy(&output.stderr),
 922            );
 923            Ok(())
 924        }
 925        .boxed()
 926    }
 927
 928    fn load_index_text(&self, path: RepoPath) -> BoxFuture<'_, Option<String>> {
 929        // https://git-scm.com/book/en/v2/Git-Internals-Git-Objects
 930        const GIT_MODE_SYMLINK: u32 = 0o120000;
 931
 932        let repo = self.repository.clone();
 933        self.executor
 934            .spawn(async move {
 935                fn logic(repo: &git2::Repository, path: &RepoPath) -> Result<Option<String>> {
 936                    // This check is required because index.get_path() unwraps internally :(
 937                    let mut index = repo.index()?;
 938                    index.read(false)?;
 939
 940                    const STAGE_NORMAL: i32 = 0;
 941                    let oid = match index.get_path(path.as_std_path(), STAGE_NORMAL) {
 942                        Some(entry) if entry.mode != GIT_MODE_SYMLINK => entry.id,
 943                        _ => return Ok(None),
 944                    };
 945
 946                    let content = repo.find_blob(oid)?.content().to_owned();
 947                    Ok(String::from_utf8(content).ok())
 948                }
 949
 950                match logic(&repo.lock(), &path) {
 951                    Ok(value) => return value,
 952                    Err(err) => log::error!("Error loading index text: {:?}", err),
 953                }
 954                None
 955            })
 956            .boxed()
 957    }
 958
 959    fn load_committed_text(&self, path: RepoPath) -> BoxFuture<'_, Option<String>> {
 960        let repo = self.repository.clone();
 961        self.executor
 962            .spawn(async move {
 963                let repo = repo.lock();
 964                let head = repo.head().ok()?.peel_to_tree().log_err()?;
 965                let entry = head.get_path(path.as_std_path()).ok()?;
 966                if entry.filemode() == i32::from(git2::FileMode::Link) {
 967                    return None;
 968                }
 969                let content = repo.find_blob(entry.id()).log_err()?.content().to_owned();
 970                String::from_utf8(content).ok()
 971            })
 972            .boxed()
 973    }
 974
 975    fn load_blob_content(&self, oid: Oid) -> BoxFuture<'_, Result<String>> {
 976        let repo = self.repository.clone();
 977        self.executor
 978            .spawn(async move {
 979                let repo = repo.lock();
 980                let content = repo.find_blob(oid.0)?.content().to_owned();
 981                Ok(String::from_utf8(content)?)
 982            })
 983            .boxed()
 984    }
 985
 986    fn set_index_text(
 987        &self,
 988        path: RepoPath,
 989        content: Option<String>,
 990        env: Arc<HashMap<String, String>>,
 991        is_executable: bool,
 992    ) -> BoxFuture<'_, anyhow::Result<()>> {
 993        let working_directory = self.working_directory();
 994        let git_binary_path = self.any_git_binary_path.clone();
 995        self.executor
 996            .spawn(async move {
 997                let working_directory = working_directory?;
 998                let mode = if is_executable { "100755" } else { "100644" };
 999
1000                if let Some(content) = content {
1001                    let mut child = new_smol_command(&git_binary_path)
1002                        .current_dir(&working_directory)
1003                        .envs(env.iter())
1004                        .args(["hash-object", "-w", "--stdin"])
1005                        .stdin(Stdio::piped())
1006                        .stdout(Stdio::piped())
1007                        .spawn()?;
1008                    let mut stdin = child.stdin.take().unwrap();
1009                    stdin.write_all(content.as_bytes()).await?;
1010                    stdin.flush().await?;
1011                    drop(stdin);
1012                    let output = child.output().await?.stdout;
1013                    let sha = str::from_utf8(&output)?.trim();
1014
1015                    log::debug!("indexing SHA: {sha}, path {path:?}");
1016
1017                    let output = new_smol_command(&git_binary_path)
1018                        .current_dir(&working_directory)
1019                        .envs(env.iter())
1020                        .args(["update-index", "--add", "--cacheinfo", mode, sha])
1021                        .arg(path.as_unix_str())
1022                        .output()
1023                        .await?;
1024
1025                    anyhow::ensure!(
1026                        output.status.success(),
1027                        "Failed to stage:\n{}",
1028                        String::from_utf8_lossy(&output.stderr)
1029                    );
1030                } else {
1031                    log::debug!("removing path {path:?} from the index");
1032                    let output = new_smol_command(&git_binary_path)
1033                        .current_dir(&working_directory)
1034                        .envs(env.iter())
1035                        .args(["update-index", "--force-remove"])
1036                        .arg(path.as_unix_str())
1037                        .output()
1038                        .await?;
1039                    anyhow::ensure!(
1040                        output.status.success(),
1041                        "Failed to unstage:\n{}",
1042                        String::from_utf8_lossy(&output.stderr)
1043                    );
1044                }
1045
1046                Ok(())
1047            })
1048            .boxed()
1049    }
1050
1051    fn remote_url(&self, name: &str) -> Option<String> {
1052        let repo = self.repository.lock();
1053        let remote = repo.find_remote(name).ok()?;
1054        remote.url().map(|url| url.to_string())
1055    }
1056
1057    fn revparse_batch(&self, revs: Vec<String>) -> BoxFuture<'_, Result<Vec<Option<String>>>> {
1058        let working_directory = self.working_directory();
1059        let git_binary_path = self.any_git_binary_path.clone();
1060        self.executor
1061            .spawn(async move {
1062                let working_directory = working_directory?;
1063                let mut process = new_smol_command(&git_binary_path)
1064                    .current_dir(&working_directory)
1065                    .args([
1066                        "--no-optional-locks",
1067                        "cat-file",
1068                        "--batch-check=%(objectname)",
1069                    ])
1070                    .stdin(Stdio::piped())
1071                    .stdout(Stdio::piped())
1072                    .stderr(Stdio::piped())
1073                    .spawn()?;
1074
1075                let stdin = process
1076                    .stdin
1077                    .take()
1078                    .context("no stdin for git cat-file subprocess")?;
1079                let mut stdin = BufWriter::new(stdin);
1080                for rev in &revs {
1081                    stdin.write_all(rev.as_bytes()).await?;
1082                    stdin.write_all(b"\n").await?;
1083                }
1084                stdin.flush().await?;
1085                drop(stdin);
1086
1087                let output = process.output().await?;
1088                let output = std::str::from_utf8(&output.stdout)?;
1089                let shas = output
1090                    .lines()
1091                    .map(|line| {
1092                        if line.ends_with("missing") {
1093                            None
1094                        } else {
1095                            Some(line.to_string())
1096                        }
1097                    })
1098                    .collect::<Vec<_>>();
1099
1100                if shas.len() != revs.len() {
1101                    // In an octopus merge, git cat-file still only outputs the first sha from MERGE_HEAD.
1102                    bail!("unexpected number of shas")
1103                }
1104
1105                Ok(shas)
1106            })
1107            .boxed()
1108    }
1109
1110    fn merge_message(&self) -> BoxFuture<'_, Option<String>> {
1111        let path = self.path().join("MERGE_MSG");
1112        self.executor
1113            .spawn(async move { std::fs::read_to_string(&path).ok() })
1114            .boxed()
1115    }
1116
1117    fn status(&self, path_prefixes: &[RepoPath]) -> Task<Result<GitStatus>> {
1118        let git_binary_path = self.any_git_binary_path.clone();
1119        let working_directory = match self.working_directory() {
1120            Ok(working_directory) => working_directory,
1121            Err(e) => return Task::ready(Err(e)),
1122        };
1123        let args = git_status_args(path_prefixes);
1124        log::debug!("Checking for git status in {path_prefixes:?}");
1125        self.executor.spawn(async move {
1126            let output = new_smol_command(&git_binary_path)
1127                .current_dir(working_directory)
1128                .args(args)
1129                .output()
1130                .await?;
1131            if output.status.success() {
1132                let stdout = String::from_utf8_lossy(&output.stdout);
1133                stdout.parse()
1134            } else {
1135                let stderr = String::from_utf8_lossy(&output.stderr);
1136                anyhow::bail!("git status failed: {stderr}");
1137            }
1138        })
1139    }
1140
1141    fn diff_tree(&self, request: DiffTreeType) -> BoxFuture<'_, Result<TreeDiff>> {
1142        let git_binary_path = self.any_git_binary_path.clone();
1143        let working_directory = match self.working_directory() {
1144            Ok(working_directory) => working_directory,
1145            Err(e) => return Task::ready(Err(e)).boxed(),
1146        };
1147
1148        let mut args = vec![
1149            OsString::from("--no-optional-locks"),
1150            OsString::from("diff-tree"),
1151            OsString::from("-r"),
1152            OsString::from("-z"),
1153            OsString::from("--no-renames"),
1154        ];
1155        match request {
1156            DiffTreeType::MergeBase { base, head } => {
1157                args.push("--merge-base".into());
1158                args.push(OsString::from(base.as_str()));
1159                args.push(OsString::from(head.as_str()));
1160            }
1161            DiffTreeType::Since { base, head } => {
1162                args.push(OsString::from(base.as_str()));
1163                args.push(OsString::from(head.as_str()));
1164            }
1165        }
1166
1167        self.executor
1168            .spawn(async move {
1169                let output = new_smol_command(&git_binary_path)
1170                    .current_dir(working_directory)
1171                    .args(args)
1172                    .output()
1173                    .await?;
1174                if output.status.success() {
1175                    let stdout = String::from_utf8_lossy(&output.stdout);
1176                    stdout.parse()
1177                } else {
1178                    let stderr = String::from_utf8_lossy(&output.stderr);
1179                    anyhow::bail!("git status failed: {stderr}");
1180                }
1181            })
1182            .boxed()
1183    }
1184
1185    fn stash_entries(&self) -> BoxFuture<'_, Result<GitStash>> {
1186        let git_binary_path = self.any_git_binary_path.clone();
1187        let working_directory = self.working_directory();
1188        self.executor
1189            .spawn(async move {
1190                let output = new_smol_command(&git_binary_path)
1191                    .current_dir(working_directory?)
1192                    .args(&["stash", "list", "--pretty=format:%gd%x00%H%x00%ct%x00%s"])
1193                    .output()
1194                    .await?;
1195                if output.status.success() {
1196                    let stdout = String::from_utf8_lossy(&output.stdout);
1197                    stdout.parse()
1198                } else {
1199                    let stderr = String::from_utf8_lossy(&output.stderr);
1200                    anyhow::bail!("git status failed: {stderr}");
1201                }
1202            })
1203            .boxed()
1204    }
1205
1206    fn branches(&self) -> BoxFuture<'_, Result<Vec<Branch>>> {
1207        let working_directory = self.working_directory();
1208        let git_binary_path = self.any_git_binary_path.clone();
1209        self.executor
1210            .spawn(async move {
1211                let fields = [
1212                    "%(HEAD)",
1213                    "%(objectname)",
1214                    "%(parent)",
1215                    "%(refname)",
1216                    "%(upstream)",
1217                    "%(upstream:track)",
1218                    "%(committerdate:unix)",
1219                    "%(authorname)",
1220                    "%(contents:subject)",
1221                ]
1222                .join("%00");
1223                let args = vec![
1224                    "for-each-ref",
1225                    "refs/heads/**/*",
1226                    "refs/remotes/**/*",
1227                    "--format",
1228                    &fields,
1229                ];
1230                let working_directory = working_directory?;
1231                let output = new_smol_command(&git_binary_path)
1232                    .current_dir(&working_directory)
1233                    .args(args)
1234                    .output()
1235                    .await?;
1236
1237                anyhow::ensure!(
1238                    output.status.success(),
1239                    "Failed to git git branches:\n{}",
1240                    String::from_utf8_lossy(&output.stderr)
1241                );
1242
1243                let input = String::from_utf8_lossy(&output.stdout);
1244
1245                let mut branches = parse_branch_input(&input)?;
1246                if branches.is_empty() {
1247                    let args = vec!["symbolic-ref", "--quiet", "HEAD"];
1248
1249                    let output = new_smol_command(&git_binary_path)
1250                        .current_dir(&working_directory)
1251                        .args(args)
1252                        .output()
1253                        .await?;
1254
1255                    // git symbolic-ref returns a non-0 exit code if HEAD points
1256                    // to something other than a branch
1257                    if output.status.success() {
1258                        let name = String::from_utf8_lossy(&output.stdout).trim().to_string();
1259
1260                        branches.push(Branch {
1261                            ref_name: name.into(),
1262                            is_head: true,
1263                            upstream: None,
1264                            most_recent_commit: None,
1265                        });
1266                    }
1267                }
1268
1269                Ok(branches)
1270            })
1271            .boxed()
1272    }
1273
1274    fn worktrees(&self) -> BoxFuture<'_, Result<Vec<Worktree>>> {
1275        let git_binary_path = self.any_git_binary_path.clone();
1276        let working_directory = self.working_directory();
1277        self.executor
1278            .spawn(async move {
1279                let output = new_smol_command(&git_binary_path)
1280                    .current_dir(working_directory?)
1281                    .args(&["--no-optional-locks", "worktree", "list", "--porcelain"])
1282                    .output()
1283                    .await?;
1284                if output.status.success() {
1285                    let stdout = String::from_utf8_lossy(&output.stdout);
1286                    Ok(parse_worktrees_from_str(&stdout))
1287                } else {
1288                    let stderr = String::from_utf8_lossy(&output.stderr);
1289                    anyhow::bail!("git worktree list failed: {stderr}");
1290                }
1291            })
1292            .boxed()
1293    }
1294
1295    fn create_worktree(
1296        &self,
1297        name: String,
1298        directory: PathBuf,
1299        from_commit: Option<String>,
1300    ) -> BoxFuture<'_, Result<()>> {
1301        let git_binary_path = self.any_git_binary_path.clone();
1302        let working_directory = self.working_directory();
1303        let final_path = directory.join(&name);
1304        let mut args = vec![
1305            OsString::from("--no-optional-locks"),
1306            OsString::from("worktree"),
1307            OsString::from("add"),
1308            OsString::from(final_path.as_os_str()),
1309        ];
1310        if let Some(from_commit) = from_commit {
1311            args.extend([
1312                OsString::from("-b"),
1313                OsString::from(name.as_str()),
1314                OsString::from(from_commit),
1315            ]);
1316        }
1317        self.executor
1318            .spawn(async move {
1319                let output = new_smol_command(&git_binary_path)
1320                    .current_dir(working_directory?)
1321                    .args(args)
1322                    .output()
1323                    .await?;
1324                if output.status.success() {
1325                    Ok(())
1326                } else {
1327                    let stderr = String::from_utf8_lossy(&output.stderr);
1328                    anyhow::bail!("git worktree list failed: {stderr}");
1329                }
1330            })
1331            .boxed()
1332    }
1333
1334    fn change_branch(&self, name: String) -> BoxFuture<'_, Result<()>> {
1335        let repo = self.repository.clone();
1336        let working_directory = self.working_directory();
1337        let git_binary_path = self.any_git_binary_path.clone();
1338        let executor = self.executor.clone();
1339        let branch = self.executor.spawn(async move {
1340            let repo = repo.lock();
1341            let branch = if let Ok(branch) = repo.find_branch(&name, BranchType::Local) {
1342                branch
1343            } else if let Ok(revision) = repo.find_branch(&name, BranchType::Remote) {
1344                let (_, branch_name) = name.split_once("/").context("Unexpected branch format")?;
1345                let revision = revision.get();
1346                let branch_commit = revision.peel_to_commit()?;
1347                let mut branch = repo.branch(&branch_name, &branch_commit, false)?;
1348                branch.set_upstream(Some(&name))?;
1349                branch
1350            } else {
1351                anyhow::bail!("Branch '{}' not found", name);
1352            };
1353
1354            Ok(branch
1355                .name()?
1356                .context("cannot checkout anonymous branch")?
1357                .to_string())
1358        });
1359
1360        self.executor
1361            .spawn(async move {
1362                let branch = branch.await?;
1363
1364                GitBinary::new(git_binary_path, working_directory?, executor)
1365                    .run(&["checkout", &branch])
1366                    .await?;
1367                anyhow::Ok(())
1368            })
1369            .boxed()
1370    }
1371
1372    fn create_branch(
1373        &self,
1374        name: String,
1375        base_branch: Option<String>,
1376    ) -> BoxFuture<'_, Result<()>> {
1377        let git_binary_path = self.any_git_binary_path.clone();
1378        let working_directory = self.working_directory();
1379        let executor = self.executor.clone();
1380
1381        self.executor
1382            .spawn(async move {
1383                let mut args = vec!["switch", "-c", &name];
1384                let base_branch_str;
1385                if let Some(ref base) = base_branch {
1386                    base_branch_str = base.clone();
1387                    args.push(&base_branch_str);
1388                }
1389
1390                GitBinary::new(git_binary_path, working_directory?, executor)
1391                    .run(&args)
1392                    .await?;
1393                anyhow::Ok(())
1394            })
1395            .boxed()
1396    }
1397
1398    fn rename_branch(&self, branch: String, new_name: String) -> BoxFuture<'_, Result<()>> {
1399        let git_binary_path = self.any_git_binary_path.clone();
1400        let working_directory = self.working_directory();
1401        let executor = self.executor.clone();
1402
1403        self.executor
1404            .spawn(async move {
1405                GitBinary::new(git_binary_path, working_directory?, executor)
1406                    .run(&["branch", "-m", &branch, &new_name])
1407                    .await?;
1408                anyhow::Ok(())
1409            })
1410            .boxed()
1411    }
1412
1413    fn blame(&self, path: RepoPath, content: Rope) -> BoxFuture<'_, Result<crate::blame::Blame>> {
1414        let working_directory = self.working_directory();
1415        let git_binary_path = self.any_git_binary_path.clone();
1416
1417        let remote_url = self
1418            .remote_url("upstream")
1419            .or_else(|| self.remote_url("origin"));
1420
1421        self.executor
1422            .spawn(async move {
1423                crate::blame::Blame::for_path(
1424                    &git_binary_path,
1425                    &working_directory?,
1426                    &path,
1427                    &content,
1428                    remote_url,
1429                )
1430                .await
1431            })
1432            .boxed()
1433    }
1434
1435    fn diff(&self, diff: DiffType) -> BoxFuture<'_, Result<String>> {
1436        let working_directory = self.working_directory();
1437        let git_binary_path = self.any_git_binary_path.clone();
1438        self.executor
1439            .spawn(async move {
1440                let args = match diff {
1441                    DiffType::HeadToIndex => Some("--staged"),
1442                    DiffType::HeadToWorktree => None,
1443                };
1444
1445                let output = new_smol_command(&git_binary_path)
1446                    .current_dir(&working_directory?)
1447                    .args(["diff"])
1448                    .args(args)
1449                    .output()
1450                    .await?;
1451
1452                anyhow::ensure!(
1453                    output.status.success(),
1454                    "Failed to run git diff:\n{}",
1455                    String::from_utf8_lossy(&output.stderr)
1456                );
1457                Ok(String::from_utf8_lossy(&output.stdout).to_string())
1458            })
1459            .boxed()
1460    }
1461
1462    fn stage_paths(
1463        &self,
1464        paths: Vec<RepoPath>,
1465        env: Arc<HashMap<String, String>>,
1466    ) -> BoxFuture<'_, Result<()>> {
1467        let working_directory = self.working_directory();
1468        let git_binary_path = self.any_git_binary_path.clone();
1469        self.executor
1470            .spawn(async move {
1471                if !paths.is_empty() {
1472                    let output = new_smol_command(&git_binary_path)
1473                        .current_dir(&working_directory?)
1474                        .envs(env.iter())
1475                        .args(["update-index", "--add", "--remove", "--"])
1476                        .args(paths.iter().map(|p| p.as_unix_str()))
1477                        .output()
1478                        .await?;
1479                    anyhow::ensure!(
1480                        output.status.success(),
1481                        "Failed to stage paths:\n{}",
1482                        String::from_utf8_lossy(&output.stderr),
1483                    );
1484                }
1485                Ok(())
1486            })
1487            .boxed()
1488    }
1489
1490    fn unstage_paths(
1491        &self,
1492        paths: Vec<RepoPath>,
1493        env: Arc<HashMap<String, String>>,
1494    ) -> BoxFuture<'_, Result<()>> {
1495        let working_directory = self.working_directory();
1496        let git_binary_path = self.any_git_binary_path.clone();
1497
1498        self.executor
1499            .spawn(async move {
1500                if !paths.is_empty() {
1501                    let output = new_smol_command(&git_binary_path)
1502                        .current_dir(&working_directory?)
1503                        .envs(env.iter())
1504                        .args(["reset", "--quiet", "--"])
1505                        .args(paths.iter().map(|p| p.as_std_path()))
1506                        .output()
1507                        .await?;
1508
1509                    anyhow::ensure!(
1510                        output.status.success(),
1511                        "Failed to unstage:\n{}",
1512                        String::from_utf8_lossy(&output.stderr),
1513                    );
1514                }
1515                Ok(())
1516            })
1517            .boxed()
1518    }
1519
1520    fn stash_paths(
1521        &self,
1522        paths: Vec<RepoPath>,
1523        env: Arc<HashMap<String, String>>,
1524    ) -> BoxFuture<'_, Result<()>> {
1525        let working_directory = self.working_directory();
1526        let git_binary_path = self.any_git_binary_path.clone();
1527        self.executor
1528            .spawn(async move {
1529                let mut cmd = new_smol_command(&git_binary_path);
1530                cmd.current_dir(&working_directory?)
1531                    .envs(env.iter())
1532                    .args(["stash", "push", "--quiet"])
1533                    .arg("--include-untracked");
1534
1535                cmd.args(paths.iter().map(|p| p.as_unix_str()));
1536
1537                let output = cmd.output().await?;
1538
1539                anyhow::ensure!(
1540                    output.status.success(),
1541                    "Failed to stash:\n{}",
1542                    String::from_utf8_lossy(&output.stderr)
1543                );
1544                Ok(())
1545            })
1546            .boxed()
1547    }
1548
1549    fn stash_pop(
1550        &self,
1551        index: Option<usize>,
1552        env: Arc<HashMap<String, String>>,
1553    ) -> BoxFuture<'_, Result<()>> {
1554        let working_directory = self.working_directory();
1555        let git_binary_path = self.any_git_binary_path.clone();
1556        self.executor
1557            .spawn(async move {
1558                let mut cmd = new_smol_command(git_binary_path);
1559                let mut args = vec!["stash".to_string(), "pop".to_string()];
1560                if let Some(index) = index {
1561                    args.push(format!("stash@{{{}}}", index));
1562                }
1563                cmd.current_dir(&working_directory?)
1564                    .envs(env.iter())
1565                    .args(args);
1566
1567                let output = cmd.output().await?;
1568
1569                anyhow::ensure!(
1570                    output.status.success(),
1571                    "Failed to stash pop:\n{}",
1572                    String::from_utf8_lossy(&output.stderr)
1573                );
1574                Ok(())
1575            })
1576            .boxed()
1577    }
1578
1579    fn stash_apply(
1580        &self,
1581        index: Option<usize>,
1582        env: Arc<HashMap<String, String>>,
1583    ) -> BoxFuture<'_, Result<()>> {
1584        let working_directory = self.working_directory();
1585        let git_binary_path = self.any_git_binary_path.clone();
1586        self.executor
1587            .spawn(async move {
1588                let mut cmd = new_smol_command(git_binary_path);
1589                let mut args = vec!["stash".to_string(), "apply".to_string()];
1590                if let Some(index) = index {
1591                    args.push(format!("stash@{{{}}}", index));
1592                }
1593                cmd.current_dir(&working_directory?)
1594                    .envs(env.iter())
1595                    .args(args);
1596
1597                let output = cmd.output().await?;
1598
1599                anyhow::ensure!(
1600                    output.status.success(),
1601                    "Failed to apply stash:\n{}",
1602                    String::from_utf8_lossy(&output.stderr)
1603                );
1604                Ok(())
1605            })
1606            .boxed()
1607    }
1608
1609    fn stash_drop(
1610        &self,
1611        index: Option<usize>,
1612        env: Arc<HashMap<String, String>>,
1613    ) -> BoxFuture<'_, Result<()>> {
1614        let working_directory = self.working_directory();
1615        let git_binary_path = self.any_git_binary_path.clone();
1616        self.executor
1617            .spawn(async move {
1618                let mut cmd = new_smol_command(git_binary_path);
1619                let mut args = vec!["stash".to_string(), "drop".to_string()];
1620                if let Some(index) = index {
1621                    args.push(format!("stash@{{{}}}", index));
1622                }
1623                cmd.current_dir(&working_directory?)
1624                    .envs(env.iter())
1625                    .args(args);
1626
1627                let output = cmd.output().await?;
1628
1629                anyhow::ensure!(
1630                    output.status.success(),
1631                    "Failed to stash drop:\n{}",
1632                    String::from_utf8_lossy(&output.stderr)
1633                );
1634                Ok(())
1635            })
1636            .boxed()
1637    }
1638
1639    fn commit(
1640        &self,
1641        message: SharedString,
1642        name_and_email: Option<(SharedString, SharedString)>,
1643        options: CommitOptions,
1644        ask_pass: AskPassDelegate,
1645        env: Arc<HashMap<String, String>>,
1646    ) -> BoxFuture<'_, Result<()>> {
1647        let working_directory = self.working_directory();
1648        let git_binary_path = self.any_git_binary_path.clone();
1649        let executor = self.executor.clone();
1650        // Note: Do not spawn this command on the background thread, it might pop open the credential helper
1651        // which we want to block on.
1652        async move {
1653            let mut cmd = new_smol_command(git_binary_path);
1654            cmd.current_dir(&working_directory?)
1655                .envs(env.iter())
1656                .args(["commit", "--quiet", "-m"])
1657                .arg(&message.to_string())
1658                .arg("--cleanup=strip")
1659                .arg("--no-verify")
1660                .stdout(smol::process::Stdio::piped())
1661                .stderr(smol::process::Stdio::piped());
1662
1663            if options.amend {
1664                cmd.arg("--amend");
1665            }
1666
1667            if options.signoff {
1668                cmd.arg("--signoff");
1669            }
1670
1671            if let Some((name, email)) = name_and_email {
1672                cmd.arg("--author").arg(&format!("{name} <{email}>"));
1673            }
1674
1675            run_git_command(env, ask_pass, cmd, &executor).await?;
1676
1677            Ok(())
1678        }
1679        .boxed()
1680    }
1681
1682    fn push(
1683        &self,
1684        branch_name: String,
1685        remote_name: String,
1686        options: Option<PushOptions>,
1687        ask_pass: AskPassDelegate,
1688        env: Arc<HashMap<String, String>>,
1689        cx: AsyncApp,
1690    ) -> BoxFuture<'_, Result<RemoteCommandOutput>> {
1691        let working_directory = self.working_directory();
1692        let executor = cx.background_executor().clone();
1693        let git_binary_path = self.system_git_binary_path.clone();
1694        // Note: Do not spawn this command on the background thread, it might pop open the credential helper
1695        // which we want to block on.
1696        async move {
1697            let git_binary_path = git_binary_path.context("git not found on $PATH, can't push")?;
1698            let working_directory = working_directory?;
1699            let mut command = new_smol_command(git_binary_path);
1700            command
1701                .envs(env.iter())
1702                .current_dir(&working_directory)
1703                .args(["push"])
1704                .args(options.map(|option| match option {
1705                    PushOptions::SetUpstream => "--set-upstream",
1706                    PushOptions::Force => "--force-with-lease",
1707                }))
1708                .arg(remote_name)
1709                .arg(format!("{}:{}", branch_name, branch_name))
1710                .stdin(smol::process::Stdio::null())
1711                .stdout(smol::process::Stdio::piped())
1712                .stderr(smol::process::Stdio::piped());
1713
1714            run_git_command(env, ask_pass, command, &executor).await
1715        }
1716        .boxed()
1717    }
1718
1719    fn pull(
1720        &self,
1721        branch_name: Option<String>,
1722        remote_name: String,
1723        rebase: bool,
1724        ask_pass: AskPassDelegate,
1725        env: Arc<HashMap<String, String>>,
1726        cx: AsyncApp,
1727    ) -> BoxFuture<'_, Result<RemoteCommandOutput>> {
1728        let working_directory = self.working_directory();
1729        let executor = cx.background_executor().clone();
1730        let git_binary_path = self.system_git_binary_path.clone();
1731        // Note: Do not spawn this command on the background thread, it might pop open the credential helper
1732        // which we want to block on.
1733        async move {
1734            let git_binary_path = git_binary_path.context("git not found on $PATH, can't pull")?;
1735            let mut command = new_smol_command(git_binary_path);
1736            command
1737                .envs(env.iter())
1738                .current_dir(&working_directory?)
1739                .arg("pull");
1740
1741            if rebase {
1742                command.arg("--rebase");
1743            }
1744
1745            command
1746                .arg(remote_name)
1747                .args(branch_name)
1748                .stdout(smol::process::Stdio::piped())
1749                .stderr(smol::process::Stdio::piped());
1750
1751            run_git_command(env, ask_pass, command, &executor).await
1752        }
1753        .boxed()
1754    }
1755
1756    fn fetch(
1757        &self,
1758        fetch_options: FetchOptions,
1759        ask_pass: AskPassDelegate,
1760        env: Arc<HashMap<String, String>>,
1761        cx: AsyncApp,
1762    ) -> BoxFuture<'_, Result<RemoteCommandOutput>> {
1763        let working_directory = self.working_directory();
1764        let remote_name = format!("{}", fetch_options);
1765        let git_binary_path = self.system_git_binary_path.clone();
1766        let executor = cx.background_executor().clone();
1767        // Note: Do not spawn this command on the background thread, it might pop open the credential helper
1768        // which we want to block on.
1769        async move {
1770            let git_binary_path = git_binary_path.context("git not found on $PATH, can't fetch")?;
1771            let mut command = new_smol_command(git_binary_path);
1772            command
1773                .envs(env.iter())
1774                .current_dir(&working_directory?)
1775                .args(["fetch", &remote_name])
1776                .stdout(smol::process::Stdio::piped())
1777                .stderr(smol::process::Stdio::piped());
1778
1779            run_git_command(env, ask_pass, command, &executor).await
1780        }
1781        .boxed()
1782    }
1783
1784    fn get_remotes(&self, branch_name: Option<String>) -> BoxFuture<'_, Result<Vec<Remote>>> {
1785        let working_directory = self.working_directory();
1786        let git_binary_path = self.any_git_binary_path.clone();
1787        self.executor
1788            .spawn(async move {
1789                let working_directory = working_directory?;
1790                if let Some(branch_name) = branch_name {
1791                    let output = new_smol_command(&git_binary_path)
1792                        .current_dir(&working_directory)
1793                        .args(["config", "--get"])
1794                        .arg(format!("branch.{}.remote", branch_name))
1795                        .output()
1796                        .await?;
1797
1798                    if output.status.success() {
1799                        let remote_name = String::from_utf8_lossy(&output.stdout);
1800
1801                        return Ok(vec![Remote {
1802                            name: remote_name.trim().to_string().into(),
1803                        }]);
1804                    }
1805                }
1806
1807                let output = new_smol_command(&git_binary_path)
1808                    .current_dir(&working_directory)
1809                    .args(["remote"])
1810                    .output()
1811                    .await?;
1812
1813                anyhow::ensure!(
1814                    output.status.success(),
1815                    "Failed to get remotes:\n{}",
1816                    String::from_utf8_lossy(&output.stderr)
1817                );
1818                let remote_names = String::from_utf8_lossy(&output.stdout)
1819                    .split('\n')
1820                    .filter(|name| !name.is_empty())
1821                    .map(|name| Remote {
1822                        name: name.trim().to_string().into(),
1823                    })
1824                    .collect();
1825                Ok(remote_names)
1826            })
1827            .boxed()
1828    }
1829
1830    fn check_for_pushed_commit(&self) -> BoxFuture<'_, Result<Vec<SharedString>>> {
1831        let working_directory = self.working_directory();
1832        let git_binary_path = self.any_git_binary_path.clone();
1833        self.executor
1834            .spawn(async move {
1835                let working_directory = working_directory?;
1836                let git_cmd = async |args: &[&str]| -> Result<String> {
1837                    let output = new_smol_command(&git_binary_path)
1838                        .current_dir(&working_directory)
1839                        .args(args)
1840                        .output()
1841                        .await?;
1842                    anyhow::ensure!(
1843                        output.status.success(),
1844                        String::from_utf8_lossy(&output.stderr).to_string()
1845                    );
1846                    Ok(String::from_utf8(output.stdout)?)
1847                };
1848
1849                let head = git_cmd(&["rev-parse", "HEAD"])
1850                    .await
1851                    .context("Failed to get HEAD")?
1852                    .trim()
1853                    .to_owned();
1854
1855                let mut remote_branches = vec![];
1856                let mut add_if_matching = async |remote_head: &str| {
1857                    if let Ok(merge_base) = git_cmd(&["merge-base", &head, remote_head]).await
1858                        && merge_base.trim() == head
1859                        && let Some(s) = remote_head.strip_prefix("refs/remotes/")
1860                    {
1861                        remote_branches.push(s.to_owned().into());
1862                    }
1863                };
1864
1865                // check the main branch of each remote
1866                let remotes = git_cmd(&["remote"])
1867                    .await
1868                    .context("Failed to get remotes")?;
1869                for remote in remotes.lines() {
1870                    if let Ok(remote_head) =
1871                        git_cmd(&["symbolic-ref", &format!("refs/remotes/{remote}/HEAD")]).await
1872                    {
1873                        add_if_matching(remote_head.trim()).await;
1874                    }
1875                }
1876
1877                // ... and the remote branch that the checked-out one is tracking
1878                if let Ok(remote_head) =
1879                    git_cmd(&["rev-parse", "--symbolic-full-name", "@{u}"]).await
1880                {
1881                    add_if_matching(remote_head.trim()).await;
1882                }
1883
1884                Ok(remote_branches)
1885            })
1886            .boxed()
1887    }
1888
1889    fn checkpoint(&self) -> BoxFuture<'static, Result<GitRepositoryCheckpoint>> {
1890        let working_directory = self.working_directory();
1891        let git_binary_path = self.any_git_binary_path.clone();
1892        let executor = self.executor.clone();
1893        self.executor
1894            .spawn(async move {
1895                let working_directory = working_directory?;
1896                let mut git = GitBinary::new(git_binary_path, working_directory.clone(), executor)
1897                    .envs(checkpoint_author_envs());
1898                git.with_temp_index(async |git| {
1899                    let head_sha = git.run(&["rev-parse", "HEAD"]).await.ok();
1900                    let mut excludes = exclude_files(git).await?;
1901
1902                    git.run(&["add", "--all"]).await?;
1903                    let tree = git.run(&["write-tree"]).await?;
1904                    let checkpoint_sha = if let Some(head_sha) = head_sha.as_deref() {
1905                        git.run(&["commit-tree", &tree, "-p", head_sha, "-m", "Checkpoint"])
1906                            .await?
1907                    } else {
1908                        git.run(&["commit-tree", &tree, "-m", "Checkpoint"]).await?
1909                    };
1910
1911                    excludes.restore_original().await?;
1912
1913                    Ok(GitRepositoryCheckpoint {
1914                        commit_sha: checkpoint_sha.parse()?,
1915                    })
1916                })
1917                .await
1918            })
1919            .boxed()
1920    }
1921
1922    fn restore_checkpoint(&self, checkpoint: GitRepositoryCheckpoint) -> BoxFuture<'_, Result<()>> {
1923        let working_directory = self.working_directory();
1924        let git_binary_path = self.any_git_binary_path.clone();
1925
1926        let executor = self.executor.clone();
1927        self.executor
1928            .spawn(async move {
1929                let working_directory = working_directory?;
1930
1931                let git = GitBinary::new(git_binary_path, working_directory, executor);
1932                git.run(&[
1933                    "restore",
1934                    "--source",
1935                    &checkpoint.commit_sha.to_string(),
1936                    "--worktree",
1937                    ".",
1938                ])
1939                .await?;
1940
1941                // TODO: We don't track binary and large files anymore,
1942                //       so the following call would delete them.
1943                //       Implement an alternative way to track files added by agent.
1944                //
1945                // git.with_temp_index(async move |git| {
1946                //     git.run(&["read-tree", &checkpoint.commit_sha.to_string()])
1947                //         .await?;
1948                //     git.run(&["clean", "-d", "--force"]).await
1949                // })
1950                // .await?;
1951
1952                Ok(())
1953            })
1954            .boxed()
1955    }
1956
1957    fn compare_checkpoints(
1958        &self,
1959        left: GitRepositoryCheckpoint,
1960        right: GitRepositoryCheckpoint,
1961    ) -> BoxFuture<'_, Result<bool>> {
1962        let working_directory = self.working_directory();
1963        let git_binary_path = self.any_git_binary_path.clone();
1964
1965        let executor = self.executor.clone();
1966        self.executor
1967            .spawn(async move {
1968                let working_directory = working_directory?;
1969                let git = GitBinary::new(git_binary_path, working_directory, executor);
1970                let result = git
1971                    .run(&[
1972                        "diff-tree",
1973                        "--quiet",
1974                        &left.commit_sha.to_string(),
1975                        &right.commit_sha.to_string(),
1976                    ])
1977                    .await;
1978                match result {
1979                    Ok(_) => Ok(true),
1980                    Err(error) => {
1981                        if let Some(GitBinaryCommandError { status, .. }) =
1982                            error.downcast_ref::<GitBinaryCommandError>()
1983                            && status.code() == Some(1)
1984                        {
1985                            return Ok(false);
1986                        }
1987
1988                        Err(error)
1989                    }
1990                }
1991            })
1992            .boxed()
1993    }
1994
1995    fn diff_checkpoints(
1996        &self,
1997        base_checkpoint: GitRepositoryCheckpoint,
1998        target_checkpoint: GitRepositoryCheckpoint,
1999    ) -> BoxFuture<'_, Result<String>> {
2000        let working_directory = self.working_directory();
2001        let git_binary_path = self.any_git_binary_path.clone();
2002
2003        let executor = self.executor.clone();
2004        self.executor
2005            .spawn(async move {
2006                let working_directory = working_directory?;
2007                let git = GitBinary::new(git_binary_path, working_directory, executor);
2008                git.run(&[
2009                    "diff",
2010                    "--find-renames",
2011                    "--patch",
2012                    &base_checkpoint.commit_sha.to_string(),
2013                    &target_checkpoint.commit_sha.to_string(),
2014                ])
2015                .await
2016            })
2017            .boxed()
2018    }
2019
2020    fn default_branch(&self) -> BoxFuture<'_, Result<Option<SharedString>>> {
2021        let working_directory = self.working_directory();
2022        let git_binary_path = self.any_git_binary_path.clone();
2023
2024        let executor = self.executor.clone();
2025        self.executor
2026            .spawn(async move {
2027                let working_directory = working_directory?;
2028                let git = GitBinary::new(git_binary_path, working_directory, executor);
2029
2030                if let Ok(output) = git
2031                    .run(&["symbolic-ref", "refs/remotes/upstream/HEAD"])
2032                    .await
2033                {
2034                    let output = output
2035                        .strip_prefix("refs/remotes/upstream/")
2036                        .map(|s| SharedString::from(s.to_owned()));
2037                    return Ok(output);
2038                }
2039
2040                if let Ok(output) = git.run(&["symbolic-ref", "refs/remotes/origin/HEAD"]).await {
2041                    return Ok(output
2042                        .strip_prefix("refs/remotes/origin/")
2043                        .map(|s| SharedString::from(s.to_owned())));
2044                }
2045
2046                if let Ok(default_branch) = git.run(&["config", "init.defaultBranch"]).await {
2047                    if git.run(&["rev-parse", &default_branch]).await.is_ok() {
2048                        return Ok(Some(default_branch.into()));
2049                    }
2050                }
2051
2052                if git.run(&["rev-parse", "master"]).await.is_ok() {
2053                    return Ok(Some("master".into()));
2054                }
2055
2056                Ok(None)
2057            })
2058            .boxed()
2059    }
2060
2061    fn run_hook(
2062        &self,
2063        hook: RunHook,
2064        env: Arc<HashMap<String, String>>,
2065    ) -> BoxFuture<'_, Result<()>> {
2066        let working_directory = self.working_directory();
2067        let git_binary_path = self.any_git_binary_path.clone();
2068        let executor = self.executor.clone();
2069        self.executor
2070            .spawn(async move {
2071                let working_directory = working_directory?;
2072                let git = GitBinary::new(git_binary_path, working_directory, executor)
2073                    .envs(HashMap::clone(&env));
2074                git.run(&["hook", "run", "--ignore-missing", hook.as_str()])
2075                    .await?;
2076                Ok(())
2077            })
2078            .boxed()
2079    }
2080}
2081
2082fn git_status_args(path_prefixes: &[RepoPath]) -> Vec<OsString> {
2083    let mut args = vec![
2084        OsString::from("--no-optional-locks"),
2085        OsString::from("status"),
2086        OsString::from("--porcelain=v1"),
2087        OsString::from("--untracked-files=all"),
2088        OsString::from("--no-renames"),
2089        OsString::from("-z"),
2090    ];
2091    args.extend(
2092        path_prefixes
2093            .iter()
2094            .map(|path_prefix| path_prefix.as_std_path().into()),
2095    );
2096    args.extend(path_prefixes.iter().map(|path_prefix| {
2097        if path_prefix.is_empty() {
2098            Path::new(".").into()
2099        } else {
2100            path_prefix.as_std_path().into()
2101        }
2102    }));
2103    args
2104}
2105
2106/// Temporarily git-ignore commonly ignored files and files over 2MB
2107async fn exclude_files(git: &GitBinary) -> Result<GitExcludeOverride> {
2108    const MAX_SIZE: u64 = 2 * 1024 * 1024; // 2 MB
2109    let mut excludes = git.with_exclude_overrides().await?;
2110    excludes
2111        .add_excludes(include_str!("./checkpoint.gitignore"))
2112        .await?;
2113
2114    let working_directory = git.working_directory.clone();
2115    let untracked_files = git.list_untracked_files().await?;
2116    let excluded_paths = untracked_files.into_iter().map(|path| {
2117        let working_directory = working_directory.clone();
2118        smol::spawn(async move {
2119            let full_path = working_directory.join(path.clone());
2120            match smol::fs::metadata(&full_path).await {
2121                Ok(metadata) if metadata.is_file() && metadata.len() >= MAX_SIZE => {
2122                    Some(PathBuf::from("/").join(path.clone()))
2123                }
2124                _ => None,
2125            }
2126        })
2127    });
2128
2129    let excluded_paths = futures::future::join_all(excluded_paths).await;
2130    let excluded_paths = excluded_paths.into_iter().flatten().collect::<Vec<_>>();
2131
2132    if !excluded_paths.is_empty() {
2133        let exclude_patterns = excluded_paths
2134            .into_iter()
2135            .map(|path| path.to_string_lossy().into_owned())
2136            .collect::<Vec<_>>()
2137            .join("\n");
2138        excludes.add_excludes(&exclude_patterns).await?;
2139    }
2140
2141    Ok(excludes)
2142}
2143
2144struct GitBinary {
2145    git_binary_path: PathBuf,
2146    working_directory: PathBuf,
2147    executor: BackgroundExecutor,
2148    index_file_path: Option<PathBuf>,
2149    envs: HashMap<String, String>,
2150}
2151
2152impl GitBinary {
2153    fn new(
2154        git_binary_path: PathBuf,
2155        working_directory: PathBuf,
2156        executor: BackgroundExecutor,
2157    ) -> Self {
2158        Self {
2159            git_binary_path,
2160            working_directory,
2161            executor,
2162            index_file_path: None,
2163            envs: HashMap::default(),
2164        }
2165    }
2166
2167    async fn list_untracked_files(&self) -> Result<Vec<PathBuf>> {
2168        let status_output = self
2169            .run(&["status", "--porcelain=v1", "--untracked-files=all", "-z"])
2170            .await?;
2171
2172        let paths = status_output
2173            .split('\0')
2174            .filter(|entry| entry.len() >= 3 && entry.starts_with("?? "))
2175            .map(|entry| PathBuf::from(&entry[3..]))
2176            .collect::<Vec<_>>();
2177        Ok(paths)
2178    }
2179
2180    fn envs(mut self, envs: HashMap<String, String>) -> Self {
2181        self.envs = envs;
2182        self
2183    }
2184
2185    pub async fn with_temp_index<R>(
2186        &mut self,
2187        f: impl AsyncFnOnce(&Self) -> Result<R>,
2188    ) -> Result<R> {
2189        let index_file_path = self.path_for_index_id(Uuid::new_v4());
2190
2191        let delete_temp_index = util::defer({
2192            let index_file_path = index_file_path.clone();
2193            let executor = self.executor.clone();
2194            move || {
2195                executor
2196                    .spawn(async move {
2197                        smol::fs::remove_file(index_file_path).await.log_err();
2198                    })
2199                    .detach();
2200            }
2201        });
2202
2203        // Copy the default index file so that Git doesn't have to rebuild the
2204        // whole index from scratch. This might fail if this is an empty repository.
2205        smol::fs::copy(
2206            self.working_directory.join(".git").join("index"),
2207            &index_file_path,
2208        )
2209        .await
2210        .ok();
2211
2212        self.index_file_path = Some(index_file_path.clone());
2213        let result = f(self).await;
2214        self.index_file_path = None;
2215        let result = result?;
2216
2217        smol::fs::remove_file(index_file_path).await.ok();
2218        delete_temp_index.abort();
2219
2220        Ok(result)
2221    }
2222
2223    pub async fn with_exclude_overrides(&self) -> Result<GitExcludeOverride> {
2224        let path = self
2225            .working_directory
2226            .join(".git")
2227            .join("info")
2228            .join("exclude");
2229
2230        GitExcludeOverride::new(path).await
2231    }
2232
2233    fn path_for_index_id(&self, id: Uuid) -> PathBuf {
2234        self.working_directory
2235            .join(".git")
2236            .join(format!("index-{}.tmp", id))
2237    }
2238
2239    pub async fn run<S>(&self, args: impl IntoIterator<Item = S>) -> Result<String>
2240    where
2241        S: AsRef<OsStr>,
2242    {
2243        let mut stdout = self.run_raw(args).await?;
2244        if stdout.chars().last() == Some('\n') {
2245            stdout.pop();
2246        }
2247        Ok(stdout)
2248    }
2249
2250    /// Returns the result of the command without trimming the trailing newline.
2251    pub async fn run_raw<S>(&self, args: impl IntoIterator<Item = S>) -> Result<String>
2252    where
2253        S: AsRef<OsStr>,
2254    {
2255        let mut command = self.build_command(args);
2256        let output = command.output().await?;
2257        anyhow::ensure!(
2258            output.status.success(),
2259            GitBinaryCommandError {
2260                stdout: String::from_utf8_lossy(&output.stdout).to_string(),
2261                stderr: String::from_utf8_lossy(&output.stderr).to_string(),
2262                status: output.status,
2263            }
2264        );
2265        Ok(String::from_utf8(output.stdout)?)
2266    }
2267
2268    fn build_command<S>(&self, args: impl IntoIterator<Item = S>) -> smol::process::Command
2269    where
2270        S: AsRef<OsStr>,
2271    {
2272        let mut command = new_smol_command(&self.git_binary_path);
2273        command.current_dir(&self.working_directory);
2274        command.args(args);
2275        if let Some(index_file_path) = self.index_file_path.as_ref() {
2276            command.env("GIT_INDEX_FILE", index_file_path);
2277        }
2278        command.envs(&self.envs);
2279        command
2280    }
2281}
2282
2283#[derive(Error, Debug)]
2284#[error("Git command failed:\n{stdout}{stderr}\n")]
2285struct GitBinaryCommandError {
2286    stdout: String,
2287    stderr: String,
2288    status: ExitStatus,
2289}
2290
2291async fn run_git_command(
2292    env: Arc<HashMap<String, String>>,
2293    ask_pass: AskPassDelegate,
2294    mut command: smol::process::Command,
2295    executor: &BackgroundExecutor,
2296) -> Result<RemoteCommandOutput> {
2297    if env.contains_key("GIT_ASKPASS") {
2298        let git_process = command.spawn()?;
2299        let output = git_process.output().await?;
2300        anyhow::ensure!(
2301            output.status.success(),
2302            "{}",
2303            String::from_utf8_lossy(&output.stderr)
2304        );
2305        Ok(RemoteCommandOutput {
2306            stdout: String::from_utf8_lossy(&output.stdout).to_string(),
2307            stderr: String::from_utf8_lossy(&output.stderr).to_string(),
2308        })
2309    } else {
2310        let ask_pass = AskPassSession::new(executor, ask_pass).await?;
2311        command
2312            .env("GIT_ASKPASS", ask_pass.script_path())
2313            .env("SSH_ASKPASS", ask_pass.script_path())
2314            .env("SSH_ASKPASS_REQUIRE", "force");
2315        let git_process = command.spawn()?;
2316
2317        run_askpass_command(ask_pass, git_process).await
2318    }
2319}
2320
2321async fn run_askpass_command(
2322    mut ask_pass: AskPassSession,
2323    git_process: smol::process::Child,
2324) -> anyhow::Result<RemoteCommandOutput> {
2325    select_biased! {
2326        result = ask_pass.run().fuse() => {
2327            match result {
2328                AskPassResult::CancelledByUser => {
2329                    Err(anyhow!(REMOTE_CANCELLED_BY_USER))?
2330                }
2331                AskPassResult::Timedout => {
2332                    Err(anyhow!("Connecting to host timed out"))?
2333                }
2334            }
2335        }
2336        output = git_process.output().fuse() => {
2337            let output = output?;
2338            anyhow::ensure!(
2339                output.status.success(),
2340                "{}",
2341                String::from_utf8_lossy(&output.stderr)
2342            );
2343            Ok(RemoteCommandOutput {
2344                stdout: String::from_utf8_lossy(&output.stdout).to_string(),
2345                stderr: String::from_utf8_lossy(&output.stderr).to_string(),
2346            })
2347        }
2348    }
2349}
2350
2351#[derive(Clone, Ord, Hash, PartialOrd, Eq, PartialEq)]
2352pub struct RepoPath(Arc<RelPath>);
2353
2354impl std::fmt::Debug for RepoPath {
2355    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2356        self.0.fmt(f)
2357    }
2358}
2359
2360impl RepoPath {
2361    pub fn new<S: AsRef<str> + ?Sized>(s: &S) -> Result<Self> {
2362        let rel_path = RelPath::unix(s.as_ref())?;
2363        Ok(Self::from_rel_path(rel_path))
2364    }
2365
2366    pub fn from_std_path(path: &Path, path_style: PathStyle) -> Result<Self> {
2367        let rel_path = RelPath::new(path, path_style)?;
2368        Ok(Self::from_rel_path(&rel_path))
2369    }
2370
2371    pub fn from_proto(proto: &str) -> Result<Self> {
2372        let rel_path = RelPath::from_proto(proto)?;
2373        Ok(Self(rel_path))
2374    }
2375
2376    pub fn from_rel_path(path: &RelPath) -> RepoPath {
2377        Self(Arc::from(path))
2378    }
2379
2380    pub fn as_std_path(&self) -> &Path {
2381        // git2 does not like empty paths and our RelPath infra turns `.` into ``
2382        // so undo that here
2383        if self.is_empty() {
2384            Path::new(".")
2385        } else {
2386            self.0.as_std_path()
2387        }
2388    }
2389}
2390
2391#[cfg(any(test, feature = "test-support"))]
2392pub fn repo_path<S: AsRef<str> + ?Sized>(s: &S) -> RepoPath {
2393    RepoPath(RelPath::unix(s.as_ref()).unwrap().into())
2394}
2395
2396impl AsRef<Arc<RelPath>> for RepoPath {
2397    fn as_ref(&self) -> &Arc<RelPath> {
2398        &self.0
2399    }
2400}
2401
2402impl std::ops::Deref for RepoPath {
2403    type Target = RelPath;
2404
2405    fn deref(&self) -> &Self::Target {
2406        &self.0
2407    }
2408}
2409
2410#[derive(Debug)]
2411pub struct RepoPathDescendants<'a>(pub &'a RepoPath);
2412
2413impl MapSeekTarget<RepoPath> for RepoPathDescendants<'_> {
2414    fn cmp_cursor(&self, key: &RepoPath) -> Ordering {
2415        if key.starts_with(self.0) {
2416            Ordering::Greater
2417        } else {
2418            self.0.cmp(key)
2419        }
2420    }
2421}
2422
2423fn parse_branch_input(input: &str) -> Result<Vec<Branch>> {
2424    let mut branches = Vec::new();
2425    for line in input.split('\n') {
2426        if line.is_empty() {
2427            continue;
2428        }
2429        let mut fields = line.split('\x00');
2430        let Some(head) = fields.next() else {
2431            continue;
2432        };
2433        let Some(head_sha) = fields.next().map(|f| f.to_string().into()) else {
2434            continue;
2435        };
2436        let Some(parent_sha) = fields.next().map(|f| f.to_string()) else {
2437            continue;
2438        };
2439        let Some(ref_name) = fields.next().map(|f| f.to_string().into()) else {
2440            continue;
2441        };
2442        let Some(upstream_name) = fields.next().map(|f| f.to_string()) else {
2443            continue;
2444        };
2445        let Some(upstream_tracking) = fields.next().and_then(|f| parse_upstream_track(f).ok())
2446        else {
2447            continue;
2448        };
2449        let Some(commiterdate) = fields.next().and_then(|f| f.parse::<i64>().ok()) else {
2450            continue;
2451        };
2452        let Some(author_name) = fields.next().map(|f| f.to_string().into()) else {
2453            continue;
2454        };
2455        let Some(subject) = fields.next().map(|f| f.to_string().into()) else {
2456            continue;
2457        };
2458
2459        branches.push(Branch {
2460            is_head: head == "*",
2461            ref_name,
2462            most_recent_commit: Some(CommitSummary {
2463                sha: head_sha,
2464                subject,
2465                commit_timestamp: commiterdate,
2466                author_name: author_name,
2467                has_parent: !parent_sha.is_empty(),
2468            }),
2469            upstream: if upstream_name.is_empty() {
2470                None
2471            } else {
2472                Some(Upstream {
2473                    ref_name: upstream_name.into(),
2474                    tracking: upstream_tracking,
2475                })
2476            },
2477        })
2478    }
2479
2480    Ok(branches)
2481}
2482
2483fn parse_upstream_track(upstream_track: &str) -> Result<UpstreamTracking> {
2484    if upstream_track.is_empty() {
2485        return Ok(UpstreamTracking::Tracked(UpstreamTrackingStatus {
2486            ahead: 0,
2487            behind: 0,
2488        }));
2489    }
2490
2491    let upstream_track = upstream_track.strip_prefix("[").context("missing [")?;
2492    let upstream_track = upstream_track.strip_suffix("]").context("missing [")?;
2493    let mut ahead: u32 = 0;
2494    let mut behind: u32 = 0;
2495    for component in upstream_track.split(", ") {
2496        if component == "gone" {
2497            return Ok(UpstreamTracking::Gone);
2498        }
2499        if let Some(ahead_num) = component.strip_prefix("ahead ") {
2500            ahead = ahead_num.parse::<u32>()?;
2501        }
2502        if let Some(behind_num) = component.strip_prefix("behind ") {
2503            behind = behind_num.parse::<u32>()?;
2504        }
2505    }
2506    Ok(UpstreamTracking::Tracked(UpstreamTrackingStatus {
2507        ahead,
2508        behind,
2509    }))
2510}
2511
2512fn checkpoint_author_envs() -> HashMap<String, String> {
2513    HashMap::from_iter([
2514        ("GIT_AUTHOR_NAME".to_string(), "Zed".to_string()),
2515        ("GIT_AUTHOR_EMAIL".to_string(), "hi@zed.dev".to_string()),
2516        ("GIT_COMMITTER_NAME".to_string(), "Zed".to_string()),
2517        ("GIT_COMMITTER_EMAIL".to_string(), "hi@zed.dev".to_string()),
2518    ])
2519}
2520
2521#[cfg(test)]
2522mod tests {
2523    use super::*;
2524    use gpui::TestAppContext;
2525
2526    fn disable_git_global_config() {
2527        unsafe {
2528            std::env::set_var("GIT_CONFIG_GLOBAL", "");
2529            std::env::set_var("GIT_CONFIG_SYSTEM", "");
2530        }
2531    }
2532
2533    #[gpui::test]
2534    async fn test_checkpoint_basic(cx: &mut TestAppContext) {
2535        disable_git_global_config();
2536
2537        cx.executor().allow_parking();
2538
2539        let repo_dir = tempfile::tempdir().unwrap();
2540
2541        git2::Repository::init(repo_dir.path()).unwrap();
2542        let file_path = repo_dir.path().join("file");
2543        smol::fs::write(&file_path, "initial").await.unwrap();
2544
2545        let repo = RealGitRepository::new(
2546            &repo_dir.path().join(".git"),
2547            None,
2548            Some("git".into()),
2549            cx.executor(),
2550        )
2551        .unwrap();
2552
2553        repo.stage_paths(vec![repo_path("file")], Arc::new(HashMap::default()))
2554            .await
2555            .unwrap();
2556        repo.commit(
2557            "Initial commit".into(),
2558            None,
2559            CommitOptions::default(),
2560            AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}),
2561            Arc::new(checkpoint_author_envs()),
2562        )
2563        .await
2564        .unwrap();
2565
2566        smol::fs::write(&file_path, "modified before checkpoint")
2567            .await
2568            .unwrap();
2569        smol::fs::write(repo_dir.path().join("new_file_before_checkpoint"), "1")
2570            .await
2571            .unwrap();
2572        let checkpoint = repo.checkpoint().await.unwrap();
2573
2574        // Ensure the user can't see any branches after creating a checkpoint.
2575        assert_eq!(repo.branches().await.unwrap().len(), 1);
2576
2577        smol::fs::write(&file_path, "modified after checkpoint")
2578            .await
2579            .unwrap();
2580        repo.stage_paths(vec![repo_path("file")], Arc::new(HashMap::default()))
2581            .await
2582            .unwrap();
2583        repo.commit(
2584            "Commit after checkpoint".into(),
2585            None,
2586            CommitOptions::default(),
2587            AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}),
2588            Arc::new(checkpoint_author_envs()),
2589        )
2590        .await
2591        .unwrap();
2592
2593        smol::fs::remove_file(repo_dir.path().join("new_file_before_checkpoint"))
2594            .await
2595            .unwrap();
2596        smol::fs::write(repo_dir.path().join("new_file_after_checkpoint"), "2")
2597            .await
2598            .unwrap();
2599
2600        // Ensure checkpoint stays alive even after a Git GC.
2601        repo.gc().await.unwrap();
2602        repo.restore_checkpoint(checkpoint.clone()).await.unwrap();
2603
2604        assert_eq!(
2605            smol::fs::read_to_string(&file_path).await.unwrap(),
2606            "modified before checkpoint"
2607        );
2608        assert_eq!(
2609            smol::fs::read_to_string(repo_dir.path().join("new_file_before_checkpoint"))
2610                .await
2611                .unwrap(),
2612            "1"
2613        );
2614        // See TODO above
2615        // assert_eq!(
2616        //     smol::fs::read_to_string(repo_dir.path().join("new_file_after_checkpoint"))
2617        //         .await
2618        //         .ok(),
2619        //     None
2620        // );
2621    }
2622
2623    #[gpui::test]
2624    async fn test_checkpoint_empty_repo(cx: &mut TestAppContext) {
2625        disable_git_global_config();
2626
2627        cx.executor().allow_parking();
2628
2629        let repo_dir = tempfile::tempdir().unwrap();
2630        git2::Repository::init(repo_dir.path()).unwrap();
2631        let repo = RealGitRepository::new(
2632            &repo_dir.path().join(".git"),
2633            None,
2634            Some("git".into()),
2635            cx.executor(),
2636        )
2637        .unwrap();
2638
2639        smol::fs::write(repo_dir.path().join("foo"), "foo")
2640            .await
2641            .unwrap();
2642        let checkpoint_sha = repo.checkpoint().await.unwrap();
2643
2644        // Ensure the user can't see any branches after creating a checkpoint.
2645        assert_eq!(repo.branches().await.unwrap().len(), 1);
2646
2647        smol::fs::write(repo_dir.path().join("foo"), "bar")
2648            .await
2649            .unwrap();
2650        smol::fs::write(repo_dir.path().join("baz"), "qux")
2651            .await
2652            .unwrap();
2653        repo.restore_checkpoint(checkpoint_sha).await.unwrap();
2654        assert_eq!(
2655            smol::fs::read_to_string(repo_dir.path().join("foo"))
2656                .await
2657                .unwrap(),
2658            "foo"
2659        );
2660        // See TODOs above
2661        // assert_eq!(
2662        //     smol::fs::read_to_string(repo_dir.path().join("baz"))
2663        //         .await
2664        //         .ok(),
2665        //     None
2666        // );
2667    }
2668
2669    #[gpui::test]
2670    async fn test_compare_checkpoints(cx: &mut TestAppContext) {
2671        disable_git_global_config();
2672
2673        cx.executor().allow_parking();
2674
2675        let repo_dir = tempfile::tempdir().unwrap();
2676        git2::Repository::init(repo_dir.path()).unwrap();
2677        let repo = RealGitRepository::new(
2678            &repo_dir.path().join(".git"),
2679            None,
2680            Some("git".into()),
2681            cx.executor(),
2682        )
2683        .unwrap();
2684
2685        smol::fs::write(repo_dir.path().join("file1"), "content1")
2686            .await
2687            .unwrap();
2688        let checkpoint1 = repo.checkpoint().await.unwrap();
2689
2690        smol::fs::write(repo_dir.path().join("file2"), "content2")
2691            .await
2692            .unwrap();
2693        let checkpoint2 = repo.checkpoint().await.unwrap();
2694
2695        assert!(
2696            !repo
2697                .compare_checkpoints(checkpoint1, checkpoint2.clone())
2698                .await
2699                .unwrap()
2700        );
2701
2702        let checkpoint3 = repo.checkpoint().await.unwrap();
2703        assert!(
2704            repo.compare_checkpoints(checkpoint2, checkpoint3)
2705                .await
2706                .unwrap()
2707        );
2708    }
2709
2710    #[gpui::test]
2711    async fn test_checkpoint_exclude_binary_files(cx: &mut TestAppContext) {
2712        disable_git_global_config();
2713
2714        cx.executor().allow_parking();
2715
2716        let repo_dir = tempfile::tempdir().unwrap();
2717        let text_path = repo_dir.path().join("main.rs");
2718        let bin_path = repo_dir.path().join("binary.o");
2719
2720        git2::Repository::init(repo_dir.path()).unwrap();
2721
2722        smol::fs::write(&text_path, "fn main() {}").await.unwrap();
2723
2724        smol::fs::write(&bin_path, "some binary file here")
2725            .await
2726            .unwrap();
2727
2728        let repo = RealGitRepository::new(
2729            &repo_dir.path().join(".git"),
2730            None,
2731            Some("git".into()),
2732            cx.executor(),
2733        )
2734        .unwrap();
2735
2736        // initial commit
2737        repo.stage_paths(vec![repo_path("main.rs")], Arc::new(HashMap::default()))
2738            .await
2739            .unwrap();
2740        repo.commit(
2741            "Initial commit".into(),
2742            None,
2743            CommitOptions::default(),
2744            AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}),
2745            Arc::new(checkpoint_author_envs()),
2746        )
2747        .await
2748        .unwrap();
2749
2750        let checkpoint = repo.checkpoint().await.unwrap();
2751
2752        smol::fs::write(&text_path, "fn main() { println!(\"Modified\"); }")
2753            .await
2754            .unwrap();
2755        smol::fs::write(&bin_path, "Modified binary file")
2756            .await
2757            .unwrap();
2758
2759        repo.restore_checkpoint(checkpoint).await.unwrap();
2760
2761        // Text files should be restored to checkpoint state,
2762        // but binaries should not (they aren't tracked)
2763        assert_eq!(
2764            smol::fs::read_to_string(&text_path).await.unwrap(),
2765            "fn main() {}"
2766        );
2767
2768        assert_eq!(
2769            smol::fs::read_to_string(&bin_path).await.unwrap(),
2770            "Modified binary file"
2771        );
2772    }
2773
2774    #[test]
2775    fn test_branches_parsing() {
2776        // suppress "help: octal escapes are not supported, `\0` is always null"
2777        #[allow(clippy::octal_escapes)]
2778        let input = "*\0060964da10574cd9bf06463a53bf6e0769c5c45e\0\0refs/heads/zed-patches\0refs/remotes/origin/zed-patches\0\01733187470\0John Doe\0generated protobuf\n";
2779        assert_eq!(
2780            parse_branch_input(input).unwrap(),
2781            vec![Branch {
2782                is_head: true,
2783                ref_name: "refs/heads/zed-patches".into(),
2784                upstream: Some(Upstream {
2785                    ref_name: "refs/remotes/origin/zed-patches".into(),
2786                    tracking: UpstreamTracking::Tracked(UpstreamTrackingStatus {
2787                        ahead: 0,
2788                        behind: 0
2789                    })
2790                }),
2791                most_recent_commit: Some(CommitSummary {
2792                    sha: "060964da10574cd9bf06463a53bf6e0769c5c45e".into(),
2793                    subject: "generated protobuf".into(),
2794                    commit_timestamp: 1733187470,
2795                    author_name: SharedString::new("John Doe"),
2796                    has_parent: false,
2797                })
2798            }]
2799        )
2800    }
2801
2802    #[test]
2803    fn test_branches_parsing_containing_refs_with_missing_fields() {
2804        #[allow(clippy::octal_escapes)]
2805        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";
2806
2807        let branches = parse_branch_input(input).unwrap();
2808        assert_eq!(branches.len(), 2);
2809        assert_eq!(
2810            branches,
2811            vec![
2812                Branch {
2813                    is_head: false,
2814                    ref_name: "refs/heads/dev".into(),
2815                    upstream: None,
2816                    most_recent_commit: Some(CommitSummary {
2817                        sha: "eb0cae33272689bd11030822939dd2701c52f81e".into(),
2818                        subject: "Add feature".into(),
2819                        commit_timestamp: 1762948725,
2820                        author_name: SharedString::new("Zed"),
2821                        has_parent: true,
2822                    })
2823                },
2824                Branch {
2825                    is_head: true,
2826                    ref_name: "refs/heads/main".into(),
2827                    upstream: None,
2828                    most_recent_commit: Some(CommitSummary {
2829                        sha: "895951d681e5561478c0acdd6905e8aacdfd2249".into(),
2830                        subject: "Initial commit".into(),
2831                        commit_timestamp: 1762948695,
2832                        author_name: SharedString::new("Zed"),
2833                        has_parent: false,
2834                    })
2835                }
2836            ]
2837        )
2838    }
2839
2840    impl RealGitRepository {
2841        /// Force a Git garbage collection on the repository.
2842        fn gc(&self) -> BoxFuture<'_, Result<()>> {
2843            let working_directory = self.working_directory();
2844            let git_binary_path = self.any_git_binary_path.clone();
2845            let executor = self.executor.clone();
2846            self.executor
2847                .spawn(async move {
2848                    let git_binary_path = git_binary_path.clone();
2849                    let working_directory = working_directory?;
2850                    let git = GitBinary::new(git_binary_path, working_directory, executor);
2851                    git.run(&["gc", "--prune"]).await?;
2852                    Ok(())
2853                })
2854                .boxed()
2855        }
2856    }
2857}