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