repository.rs

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