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