repository.rs

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