repository.rs

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