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