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