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