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