repository.rs

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