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