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