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