repository.rs

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