repository.rs

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