repository.rs

   1use crate::commit::parse_git_diff_name_status;
   2use crate::status::{GitStatus, StatusCode};
   3use crate::{Oid, SHORT_SHA_LENGTH};
   4use anyhow::{Context as _, Result, anyhow, bail};
   5use collections::HashMap;
   6use futures::future::BoxFuture;
   7use futures::{AsyncWriteExt, FutureExt as _, select_biased};
   8use git2::BranchType;
   9use gpui::{AppContext as _, AsyncApp, BackgroundExecutor, SharedString, Task};
  10use parking_lot::Mutex;
  11use rope::Rope;
  12use schemars::JsonSchema;
  13use serde::Deserialize;
  14use std::borrow::{Borrow, Cow};
  15use std::ffi::{OsStr, OsString};
  16use std::io::prelude::*;
  17use std::path::Component;
  18use std::process::{ExitStatus, Stdio};
  19use std::sync::LazyLock;
  20use std::{
  21    cmp::Ordering,
  22    future,
  23    io::{BufRead, BufReader, BufWriter, Read},
  24    path::{Path, PathBuf},
  25    sync::Arc,
  26};
  27use sum_tree::MapSeekTarget;
  28use thiserror::Error;
  29use util::command::{new_smol_command, new_std_command};
  30use util::{ResultExt, paths};
  31use uuid::Uuid;
  32
  33pub use askpass::{AskPassDelegate, AskPassResult, AskPassSession};
  34
  35pub const REMOTE_CANCELLED_BY_USER: &str = "Operation cancelled by user";
  36
  37#[derive(Clone, Debug, Hash, PartialEq, Eq)]
  38pub struct Branch {
  39    pub is_head: bool,
  40    pub ref_name: SharedString,
  41    pub upstream: Option<Upstream>,
  42    pub most_recent_commit: Option<CommitSummary>,
  43}
  44
  45impl Branch {
  46    pub fn name(&self) -> &str {
  47        self.ref_name
  48            .as_ref()
  49            .strip_prefix("refs/heads/")
  50            .or_else(|| self.ref_name.as_ref().strip_prefix("refs/remotes/"))
  51            .unwrap_or(self.ref_name.as_ref())
  52    }
  53
  54    pub fn is_remote(&self) -> bool {
  55        self.ref_name.starts_with("refs/remotes/")
  56    }
  57
  58    pub fn tracking_status(&self) -> Option<UpstreamTrackingStatus> {
  59        self.upstream
  60            .as_ref()
  61            .and_then(|upstream| upstream.tracking.status())
  62    }
  63
  64    pub fn priority_key(&self) -> (bool, Option<i64>) {
  65        (
  66            self.is_head,
  67            self.most_recent_commit
  68                .as_ref()
  69                .map(|commit| commit.commit_timestamp),
  70        )
  71    }
  72}
  73
  74#[derive(Clone, Debug, Hash, PartialEq, Eq)]
  75pub struct Upstream {
  76    pub ref_name: SharedString,
  77    pub tracking: UpstreamTracking,
  78}
  79
  80impl Upstream {
  81    pub fn is_remote(&self) -> bool {
  82        self.remote_name().is_some()
  83    }
  84
  85    pub fn remote_name(&self) -> Option<&str> {
  86        self.ref_name
  87            .strip_prefix("refs/remotes/")
  88            .and_then(|stripped| stripped.split("/").next())
  89    }
  90
  91    pub fn stripped_ref_name(&self) -> Option<&str> {
  92        self.ref_name.strip_prefix("refs/remotes/")
  93    }
  94}
  95
  96#[derive(Clone, Copy, Default)]
  97pub struct CommitOptions {
  98    pub amend: bool,
  99    pub signoff: bool,
 100}
 101
 102#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
 103pub enum UpstreamTracking {
 104    /// Remote ref not present in local repository.
 105    Gone,
 106    /// Remote ref present in local repository (fetched from remote).
 107    Tracked(UpstreamTrackingStatus),
 108}
 109
 110impl From<UpstreamTrackingStatus> for UpstreamTracking {
 111    fn from(status: UpstreamTrackingStatus) -> Self {
 112        UpstreamTracking::Tracked(status)
 113    }
 114}
 115
 116impl UpstreamTracking {
 117    pub fn is_gone(&self) -> bool {
 118        matches!(self, UpstreamTracking::Gone)
 119    }
 120
 121    pub fn status(&self) -> Option<UpstreamTrackingStatus> {
 122        match self {
 123            UpstreamTracking::Gone => None,
 124            UpstreamTracking::Tracked(status) => Some(*status),
 125        }
 126    }
 127}
 128
 129#[derive(Debug, Clone)]
 130pub struct RemoteCommandOutput {
 131    pub stdout: String,
 132    pub stderr: String,
 133}
 134
 135impl RemoteCommandOutput {
 136    pub fn is_empty(&self) -> bool {
 137        self.stdout.is_empty() && self.stderr.is_empty()
 138    }
 139}
 140
 141#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
 142pub struct UpstreamTrackingStatus {
 143    pub ahead: u32,
 144    pub behind: u32,
 145}
 146
 147#[derive(Clone, Debug, Hash, PartialEq, Eq)]
 148pub struct CommitSummary {
 149    pub sha: SharedString,
 150    pub subject: SharedString,
 151    /// This is a unix timestamp
 152    pub commit_timestamp: i64,
 153    pub has_parent: bool,
 154}
 155
 156#[derive(Clone, Debug, Default, Hash, PartialEq, Eq)]
 157pub struct CommitDetails {
 158    pub sha: SharedString,
 159    pub message: SharedString,
 160    pub commit_timestamp: i64,
 161    pub author_email: SharedString,
 162    pub author_name: SharedString,
 163}
 164
 165#[derive(Debug)]
 166pub struct CommitDiff {
 167    pub files: Vec<CommitFile>,
 168}
 169
 170#[derive(Debug)]
 171pub struct CommitFile {
 172    pub path: RepoPath,
 173    pub old_text: Option<String>,
 174    pub new_text: Option<String>,
 175}
 176
 177impl CommitDetails {
 178    pub fn short_sha(&self) -> SharedString {
 179        self.sha[..SHORT_SHA_LENGTH].to_string().into()
 180    }
 181}
 182
 183#[derive(Debug, Clone, Hash, PartialEq, Eq)]
 184pub struct Remote {
 185    pub name: SharedString,
 186}
 187
 188pub enum ResetMode {
 189    /// Reset the branch pointer, leave index and worktree unchanged (this will make it look like things that were
 190    /// committed are now staged).
 191    Soft,
 192    /// Reset the branch pointer and index, leave worktree unchanged (this makes it look as though things that were
 193    /// committed are now unstaged).
 194    Mixed,
 195}
 196
 197#[derive(Debug, Clone, Hash, PartialEq, Eq)]
 198pub enum FetchOptions {
 199    All,
 200    Remote(Remote),
 201}
 202
 203impl FetchOptions {
 204    pub fn to_proto(&self) -> Option<String> {
 205        match self {
 206            FetchOptions::All => None,
 207            FetchOptions::Remote(remote) => Some(remote.clone().name.into()),
 208        }
 209    }
 210
 211    pub fn from_proto(remote_name: Option<String>) -> Self {
 212        match remote_name {
 213            Some(name) => FetchOptions::Remote(Remote { name: name.into() }),
 214            None => FetchOptions::All,
 215        }
 216    }
 217
 218    pub fn name(&self) -> SharedString {
 219        match self {
 220            Self::All => "Fetch all remotes".into(),
 221            Self::Remote(remote) => remote.name.clone(),
 222        }
 223    }
 224}
 225
 226impl std::fmt::Display for FetchOptions {
 227    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
 228        match self {
 229            FetchOptions::All => write!(f, "--all"),
 230            FetchOptions::Remote(remote) => write!(f, "{}", remote.name),
 231        }
 232    }
 233}
 234
 235/// Modifies .git/info/exclude temporarily
 236pub struct GitExcludeOverride {
 237    git_exclude_path: PathBuf,
 238    original_excludes: Option<String>,
 239    added_excludes: Option<String>,
 240}
 241
 242impl GitExcludeOverride {
 243    pub async fn new(git_exclude_path: PathBuf) -> Result<Self> {
 244        let original_excludes = smol::fs::read_to_string(&git_exclude_path).await.ok();
 245
 246        Ok(GitExcludeOverride {
 247            git_exclude_path,
 248            original_excludes,
 249            added_excludes: None,
 250        })
 251    }
 252
 253    pub async fn add_excludes(&mut self, excludes: &str) -> Result<()> {
 254        self.added_excludes = Some(if let Some(ref already_added) = self.added_excludes {
 255            format!("{already_added}\n{excludes}")
 256        } else {
 257            excludes.to_string()
 258        });
 259
 260        let mut content = self.original_excludes.clone().unwrap_or_default();
 261        content.push_str("\n\n#  ====== Auto-added by Zed: =======\n");
 262        content.push_str(self.added_excludes.as_ref().unwrap());
 263        content.push('\n');
 264
 265        smol::fs::write(&self.git_exclude_path, content).await?;
 266        Ok(())
 267    }
 268
 269    pub async fn restore_original(&mut self) -> Result<()> {
 270        if let Some(ref original) = self.original_excludes {
 271            smol::fs::write(&self.git_exclude_path, original).await?;
 272        } else if self.git_exclude_path.exists() {
 273            smol::fs::remove_file(&self.git_exclude_path).await?;
 274        }
 275
 276        self.added_excludes = None;
 277
 278        Ok(())
 279    }
 280}
 281
 282impl Drop for GitExcludeOverride {
 283    fn drop(&mut self) {
 284        if self.added_excludes.is_some() {
 285            let git_exclude_path = self.git_exclude_path.clone();
 286            let original_excludes = self.original_excludes.clone();
 287            smol::spawn(async move {
 288                if let Some(original) = original_excludes {
 289                    smol::fs::write(&git_exclude_path, original).await
 290                } else {
 291                    smol::fs::remove_file(&git_exclude_path).await
 292                }
 293            })
 294            .detach();
 295        }
 296    }
 297}
 298
 299pub trait GitRepository: Send + Sync {
 300    fn reload_index(&self);
 301
 302    /// Returns the contents of an entry in the repository's index, or None if there is no entry for the given path.
 303    ///
 304    /// Also returns `None` for symlinks.
 305    fn load_index_text(&self, path: RepoPath) -> BoxFuture<'_, Option<String>>;
 306
 307    /// 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.
 308    ///
 309    /// Also returns `None` for symlinks.
 310    fn load_committed_text(&self, path: RepoPath) -> BoxFuture<'_, Option<String>>;
 311
 312    fn set_index_text(
 313        &self,
 314        path: RepoPath,
 315        content: Option<String>,
 316        env: Arc<HashMap<String, String>>,
 317    ) -> BoxFuture<'_, anyhow::Result<()>>;
 318
 319    /// Returns the URL of the remote with the given name.
 320    fn remote_url(&self, name: &str) -> Option<String>;
 321
 322    /// Resolve a list of refs to SHAs.
 323    fn revparse_batch(&self, revs: Vec<String>) -> BoxFuture<'_, Result<Vec<Option<String>>>>;
 324
 325    fn head_sha(&self) -> BoxFuture<'_, Option<String>> {
 326        async move {
 327            self.revparse_batch(vec!["HEAD".into()])
 328                .await
 329                .unwrap_or_default()
 330                .into_iter()
 331                .next()
 332                .flatten()
 333        }
 334        .boxed()
 335    }
 336
 337    fn merge_message(&self) -> BoxFuture<'_, Option<String>>;
 338
 339    fn status(&self, path_prefixes: &[RepoPath]) -> Task<Result<GitStatus>>;
 340
 341    fn branches(&self) -> BoxFuture<'_, Result<Vec<Branch>>>;
 342
 343    fn change_branch(&self, name: String) -> BoxFuture<'_, Result<()>>;
 344    fn create_branch(&self, name: String) -> BoxFuture<'_, Result<()>>;
 345
 346    fn reset(
 347        &self,
 348        commit: String,
 349        mode: ResetMode,
 350        env: Arc<HashMap<String, String>>,
 351    ) -> BoxFuture<'_, Result<()>>;
 352
 353    fn checkout_files(
 354        &self,
 355        commit: String,
 356        paths: Vec<RepoPath>,
 357        env: Arc<HashMap<String, String>>,
 358    ) -> BoxFuture<'_, Result<()>>;
 359
 360    fn show(&self, commit: String) -> BoxFuture<'_, Result<CommitDetails>>;
 361
 362    fn load_commit(&self, commit: String, cx: AsyncApp) -> BoxFuture<'_, Result<CommitDiff>>;
 363    fn blame(&self, path: RepoPath, content: Rope) -> BoxFuture<'_, Result<crate::blame::Blame>>;
 364
 365    /// Returns the absolute path to the repository. For worktrees, this will be the path to the
 366    /// worktree's gitdir within the main repository (typically `.git/worktrees/<name>`).
 367    fn path(&self) -> PathBuf;
 368
 369    fn main_repository_path(&self) -> PathBuf;
 370
 371    /// Updates the index to match the worktree at the given paths.
 372    ///
 373    /// If any of the paths have been deleted from the worktree, they will be removed from the index if found there.
 374    fn stage_paths(
 375        &self,
 376        paths: Vec<RepoPath>,
 377        env: Arc<HashMap<String, String>>,
 378    ) -> BoxFuture<'_, Result<()>>;
 379    /// Updates the index to match HEAD at the given paths.
 380    ///
 381    /// If any of the paths were previously staged but do not exist in HEAD, they will be removed from the index.
 382    fn unstage_paths(
 383        &self,
 384        paths: Vec<RepoPath>,
 385        env: Arc<HashMap<String, String>>,
 386    ) -> BoxFuture<'_, Result<()>>;
 387
 388    fn commit(
 389        &self,
 390        message: SharedString,
 391        name_and_email: Option<(SharedString, SharedString)>,
 392        options: CommitOptions,
 393        env: Arc<HashMap<String, String>>,
 394    ) -> BoxFuture<'_, Result<()>>;
 395
 396    fn stash_paths(
 397        &self,
 398        paths: Vec<RepoPath>,
 399        env: Arc<HashMap<String, String>>,
 400    ) -> BoxFuture<'_, Result<()>>;
 401
 402    fn stash_pop(&self, env: Arc<HashMap<String, String>>) -> BoxFuture<'_, Result<()>>;
 403
 404    fn push(
 405        &self,
 406        branch_name: String,
 407        upstream_name: String,
 408        options: Option<PushOptions>,
 409        askpass: AskPassDelegate,
 410        env: Arc<HashMap<String, String>>,
 411        // This method takes an AsyncApp to ensure it's invoked on the main thread,
 412        // otherwise git-credentials-manager won't work.
 413        cx: AsyncApp,
 414    ) -> BoxFuture<'_, Result<RemoteCommandOutput>>;
 415
 416    fn pull(
 417        &self,
 418        branch_name: String,
 419        upstream_name: String,
 420        askpass: AskPassDelegate,
 421        env: Arc<HashMap<String, String>>,
 422        // This method takes an AsyncApp to ensure it's invoked on the main thread,
 423        // otherwise git-credentials-manager won't work.
 424        cx: AsyncApp,
 425    ) -> BoxFuture<'_, Result<RemoteCommandOutput>>;
 426
 427    fn fetch(
 428        &self,
 429        fetch_options: FetchOptions,
 430        askpass: AskPassDelegate,
 431        env: Arc<HashMap<String, String>>,
 432        // This method takes an AsyncApp to ensure it's invoked on the main thread,
 433        // otherwise git-credentials-manager won't work.
 434        cx: AsyncApp,
 435    ) -> BoxFuture<'_, Result<RemoteCommandOutput>>;
 436
 437    fn get_remotes(&self, branch_name: Option<String>) -> BoxFuture<'_, Result<Vec<Remote>>>;
 438
 439    /// returns a list of remote branches that contain HEAD
 440    fn check_for_pushed_commit(&self) -> BoxFuture<'_, Result<Vec<SharedString>>>;
 441
 442    /// Run git diff
 443    fn diff(&self, diff: DiffType) -> BoxFuture<'_, Result<String>>;
 444
 445    /// Creates a checkpoint for the repository.
 446    fn checkpoint(&self) -> BoxFuture<'static, Result<GitRepositoryCheckpoint>>;
 447
 448    /// Resets to a previously-created checkpoint.
 449    fn restore_checkpoint(&self, checkpoint: GitRepositoryCheckpoint) -> BoxFuture<'_, Result<()>>;
 450
 451    /// Compares two checkpoints, returning true if they are equal
 452    fn compare_checkpoints(
 453        &self,
 454        left: GitRepositoryCheckpoint,
 455        right: GitRepositoryCheckpoint,
 456    ) -> BoxFuture<'_, Result<bool>>;
 457
 458    /// Computes a diff between two checkpoints.
 459    fn diff_checkpoints(
 460        &self,
 461        base_checkpoint: GitRepositoryCheckpoint,
 462        target_checkpoint: GitRepositoryCheckpoint,
 463    ) -> BoxFuture<'_, Result<String>>;
 464
 465    fn default_branch(&self) -> BoxFuture<'_, Result<Option<SharedString>>>;
 466}
 467
 468pub enum DiffType {
 469    HeadToIndex,
 470    HeadToWorktree,
 471}
 472
 473#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, JsonSchema)]
 474pub enum PushOptions {
 475    SetUpstream,
 476    Force,
 477}
 478
 479impl std::fmt::Debug for dyn GitRepository {
 480    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
 481        f.debug_struct("dyn GitRepository<...>").finish()
 482    }
 483}
 484
 485pub struct RealGitRepository {
 486    pub repository: Arc<Mutex<git2::Repository>>,
 487    pub git_binary_path: PathBuf,
 488    executor: BackgroundExecutor,
 489}
 490
 491impl RealGitRepository {
 492    pub fn new(
 493        dotgit_path: &Path,
 494        git_binary_path: Option<PathBuf>,
 495        executor: BackgroundExecutor,
 496    ) -> Option<Self> {
 497        let workdir_root = dotgit_path.parent()?;
 498        let repository = git2::Repository::open(workdir_root).log_err()?;
 499        Some(Self {
 500            repository: Arc::new(Mutex::new(repository)),
 501            git_binary_path: git_binary_path.unwrap_or_else(|| PathBuf::from("git")),
 502            executor,
 503        })
 504    }
 505
 506    fn working_directory(&self) -> Result<PathBuf> {
 507        self.repository
 508            .lock()
 509            .workdir()
 510            .context("failed to read git work directory")
 511            .map(Path::to_path_buf)
 512    }
 513}
 514
 515#[derive(Clone, Debug)]
 516pub struct GitRepositoryCheckpoint {
 517    pub commit_sha: Oid,
 518}
 519
 520#[derive(Debug)]
 521pub struct GitCommitter {
 522    pub name: Option<String>,
 523    pub email: Option<String>,
 524}
 525
 526pub async fn get_git_committer(cx: &AsyncApp) -> GitCommitter {
 527    if cfg!(any(feature = "test-support", test)) {
 528        return GitCommitter {
 529            name: None,
 530            email: None,
 531        };
 532    }
 533
 534    let git_binary_path =
 535        if cfg!(target_os = "macos") && option_env!("ZED_BUNDLE").as_deref() == Some("true") {
 536            cx.update(|cx| {
 537                cx.path_for_auxiliary_executable("git")
 538                    .context("could not find git binary path")
 539                    .log_err()
 540            })
 541            .ok()
 542            .flatten()
 543        } else {
 544            None
 545        };
 546
 547    let git = GitBinary::new(
 548        git_binary_path.unwrap_or(PathBuf::from("git")),
 549        paths::home_dir().clone(),
 550        cx.background_executor().clone(),
 551    );
 552
 553    cx.background_spawn(async move {
 554        let name = git.run(["config", "--global", "user.name"]).await.log_err();
 555        let email = git
 556            .run(["config", "--global", "user.email"])
 557            .await
 558            .log_err();
 559        GitCommitter { name, email }
 560    })
 561    .await
 562}
 563
 564impl GitRepository for RealGitRepository {
 565    fn reload_index(&self) {
 566        if let Ok(mut index) = self.repository.lock().index() {
 567            _ = index.read(false);
 568        }
 569    }
 570
 571    fn path(&self) -> PathBuf {
 572        let repo = self.repository.lock();
 573        repo.path().into()
 574    }
 575
 576    fn main_repository_path(&self) -> PathBuf {
 577        let repo = self.repository.lock();
 578        repo.commondir().into()
 579    }
 580
 581    fn show(&self, commit: String) -> BoxFuture<'_, Result<CommitDetails>> {
 582        let working_directory = self.working_directory();
 583        self.executor
 584            .spawn(async move {
 585                let working_directory = working_directory?;
 586                let output = new_std_command("git")
 587                    .current_dir(&working_directory)
 588                    .args([
 589                        "--no-optional-locks",
 590                        "show",
 591                        "--no-patch",
 592                        "--format=%H%x00%B%x00%at%x00%ae%x00%an%x00",
 593                        &commit,
 594                    ])
 595                    .output()?;
 596                let output = std::str::from_utf8(&output.stdout)?;
 597                let fields = output.split('\0').collect::<Vec<_>>();
 598                if fields.len() != 6 {
 599                    bail!("unexpected git-show output for {commit:?}: {output:?}")
 600                }
 601                let sha = fields[0].to_string().into();
 602                let message = fields[1].to_string().into();
 603                let commit_timestamp = fields[2].parse()?;
 604                let author_email = fields[3].to_string().into();
 605                let author_name = fields[4].to_string().into();
 606                Ok(CommitDetails {
 607                    sha,
 608                    message,
 609                    commit_timestamp,
 610                    author_email,
 611                    author_name,
 612                })
 613            })
 614            .boxed()
 615    }
 616
 617    fn load_commit(&self, commit: String, cx: AsyncApp) -> BoxFuture<'_, Result<CommitDiff>> {
 618        let Some(working_directory) = self.repository.lock().workdir().map(ToOwned::to_owned)
 619        else {
 620            return future::ready(Err(anyhow!("no working directory"))).boxed();
 621        };
 622        cx.background_spawn(async move {
 623            let show_output = util::command::new_std_command("git")
 624                .current_dir(&working_directory)
 625                .args([
 626                    "--no-optional-locks",
 627                    "show",
 628                    "--format=%P",
 629                    "-z",
 630                    "--no-renames",
 631                    "--name-status",
 632                ])
 633                .arg(&commit)
 634                .stdin(Stdio::null())
 635                .stdout(Stdio::piped())
 636                .stderr(Stdio::piped())
 637                .output()
 638                .context("starting git show process")?;
 639
 640            let show_stdout = String::from_utf8_lossy(&show_output.stdout);
 641            let mut lines = show_stdout.split('\n');
 642            let parent_sha = lines.next().unwrap().trim().trim_end_matches('\0');
 643            let changes = parse_git_diff_name_status(lines.next().unwrap_or(""));
 644
 645            let mut cat_file_process = util::command::new_std_command("git")
 646                .current_dir(&working_directory)
 647                .args(["--no-optional-locks", "cat-file", "--batch=%(objectsize)"])
 648                .stdin(Stdio::piped())
 649                .stdout(Stdio::piped())
 650                .stderr(Stdio::piped())
 651                .spawn()
 652                .context("starting git cat-file process")?;
 653
 654            use std::io::Write as _;
 655            let mut files = Vec::<CommitFile>::new();
 656            let mut stdin = BufWriter::with_capacity(512, cat_file_process.stdin.take().unwrap());
 657            let mut stdout = BufReader::new(cat_file_process.stdout.take().unwrap());
 658            let mut info_line = String::new();
 659            let mut newline = [b'\0'];
 660            for (path, status_code) in changes {
 661                match status_code {
 662                    StatusCode::Modified => {
 663                        writeln!(&mut stdin, "{commit}:{}", path.display())?;
 664                        writeln!(&mut stdin, "{parent_sha}:{}", path.display())?;
 665                    }
 666                    StatusCode::Added => {
 667                        writeln!(&mut stdin, "{commit}:{}", path.display())?;
 668                    }
 669                    StatusCode::Deleted => {
 670                        writeln!(&mut stdin, "{parent_sha}:{}", path.display())?;
 671                    }
 672                    _ => continue,
 673                }
 674                stdin.flush()?;
 675
 676                info_line.clear();
 677                stdout.read_line(&mut info_line)?;
 678
 679                let len = info_line.trim_end().parse().with_context(|| {
 680                    format!("invalid object size output from cat-file {info_line}")
 681                })?;
 682                let mut text = vec![0; len];
 683                stdout.read_exact(&mut text)?;
 684                stdout.read_exact(&mut newline)?;
 685                let text = String::from_utf8_lossy(&text).to_string();
 686
 687                let mut old_text = None;
 688                let mut new_text = None;
 689                match status_code {
 690                    StatusCode::Modified => {
 691                        info_line.clear();
 692                        stdout.read_line(&mut info_line)?;
 693                        let len = info_line.trim_end().parse().with_context(|| {
 694                            format!("invalid object size output from cat-file {}", info_line)
 695                        })?;
 696                        let mut parent_text = vec![0; len];
 697                        stdout.read_exact(&mut parent_text)?;
 698                        stdout.read_exact(&mut newline)?;
 699                        old_text = Some(String::from_utf8_lossy(&parent_text).to_string());
 700                        new_text = Some(text);
 701                    }
 702                    StatusCode::Added => new_text = Some(text),
 703                    StatusCode::Deleted => old_text = Some(text),
 704                    _ => continue,
 705                }
 706
 707                files.push(CommitFile {
 708                    path: path.into(),
 709                    old_text,
 710                    new_text,
 711                })
 712            }
 713
 714            Ok(CommitDiff { files })
 715        })
 716        .boxed()
 717    }
 718
 719    fn reset(
 720        &self,
 721        commit: String,
 722        mode: ResetMode,
 723        env: Arc<HashMap<String, String>>,
 724    ) -> BoxFuture<'_, Result<()>> {
 725        async move {
 726            let working_directory = self.working_directory();
 727
 728            let mode_flag = match mode {
 729                ResetMode::Mixed => "--mixed",
 730                ResetMode::Soft => "--soft",
 731            };
 732
 733            let output = new_smol_command(&self.git_binary_path)
 734                .envs(env.iter())
 735                .current_dir(&working_directory?)
 736                .args(["reset", mode_flag, &commit])
 737                .output()
 738                .await?;
 739            anyhow::ensure!(
 740                output.status.success(),
 741                "Failed to reset:\n{}",
 742                String::from_utf8_lossy(&output.stderr),
 743            );
 744            Ok(())
 745        }
 746        .boxed()
 747    }
 748
 749    fn checkout_files(
 750        &self,
 751        commit: String,
 752        paths: Vec<RepoPath>,
 753        env: Arc<HashMap<String, String>>,
 754    ) -> BoxFuture<'_, Result<()>> {
 755        let working_directory = self.working_directory();
 756        let git_binary_path = self.git_binary_path.clone();
 757        async move {
 758            if paths.is_empty() {
 759                return Ok(());
 760            }
 761
 762            let output = new_smol_command(&git_binary_path)
 763                .current_dir(&working_directory?)
 764                .envs(env.iter())
 765                .args(["checkout", &commit, "--"])
 766                .args(paths.iter().map(|path| path.as_ref()))
 767                .output()
 768                .await?;
 769            anyhow::ensure!(
 770                output.status.success(),
 771                "Failed to checkout files:\n{}",
 772                String::from_utf8_lossy(&output.stderr),
 773            );
 774            Ok(())
 775        }
 776        .boxed()
 777    }
 778
 779    fn load_index_text(&self, path: RepoPath) -> BoxFuture<'_, Option<String>> {
 780        // https://git-scm.com/book/en/v2/Git-Internals-Git-Objects
 781        const GIT_MODE_SYMLINK: u32 = 0o120000;
 782
 783        let repo = self.repository.clone();
 784        self.executor
 785            .spawn(async move {
 786                fn logic(repo: &git2::Repository, path: &RepoPath) -> Result<Option<String>> {
 787                    // This check is required because index.get_path() unwraps internally :(
 788                    check_path_to_repo_path_errors(path)?;
 789
 790                    let mut index = repo.index()?;
 791                    index.read(false)?;
 792
 793                    const STAGE_NORMAL: i32 = 0;
 794                    let oid = match index.get_path(path, STAGE_NORMAL) {
 795                        Some(entry) if entry.mode != GIT_MODE_SYMLINK => entry.id,
 796                        _ => return Ok(None),
 797                    };
 798
 799                    let content = repo.find_blob(oid)?.content().to_owned();
 800                    Ok(String::from_utf8(content).ok())
 801                }
 802
 803                match logic(&repo.lock(), &path) {
 804                    Ok(value) => return value,
 805                    Err(err) => log::error!("Error loading index text: {:?}", err),
 806                }
 807                None
 808            })
 809            .boxed()
 810    }
 811
 812    fn load_committed_text(&self, path: RepoPath) -> BoxFuture<'_, Option<String>> {
 813        let repo = self.repository.clone();
 814        self.executor
 815            .spawn(async move {
 816                let repo = repo.lock();
 817                let head = repo.head().ok()?.peel_to_tree().log_err()?;
 818                let entry = head.get_path(&path).ok()?;
 819                if entry.filemode() == i32::from(git2::FileMode::Link) {
 820                    return None;
 821                }
 822                let content = repo.find_blob(entry.id()).log_err()?.content().to_owned();
 823                String::from_utf8(content).ok()
 824            })
 825            .boxed()
 826    }
 827
 828    fn set_index_text(
 829        &self,
 830        path: RepoPath,
 831        content: Option<String>,
 832        env: Arc<HashMap<String, String>>,
 833    ) -> BoxFuture<'_, anyhow::Result<()>> {
 834        let working_directory = self.working_directory();
 835        let git_binary_path = self.git_binary_path.clone();
 836        self.executor
 837            .spawn(async move {
 838                let working_directory = working_directory?;
 839                if let Some(content) = content {
 840                    let mut child = new_smol_command(&git_binary_path)
 841                        .current_dir(&working_directory)
 842                        .envs(env.iter())
 843                        .args(["hash-object", "-w", "--stdin"])
 844                        .stdin(Stdio::piped())
 845                        .stdout(Stdio::piped())
 846                        .spawn()?;
 847                    let mut stdin = child.stdin.take().unwrap();
 848                    stdin.write_all(content.as_bytes()).await?;
 849                    stdin.flush().await?;
 850                    drop(stdin);
 851                    let output = child.output().await?.stdout;
 852                    let sha = str::from_utf8(&output)?.trim();
 853
 854                    log::debug!("indexing SHA: {sha}, path {path:?}");
 855
 856                    let output = new_smol_command(&git_binary_path)
 857                        .current_dir(&working_directory)
 858                        .envs(env.iter())
 859                        .args(["update-index", "--add", "--cacheinfo", "100644", sha])
 860                        .arg(path.to_unix_style())
 861                        .output()
 862                        .await?;
 863
 864                    anyhow::ensure!(
 865                        output.status.success(),
 866                        "Failed to stage:\n{}",
 867                        String::from_utf8_lossy(&output.stderr)
 868                    );
 869                } else {
 870                    log::debug!("removing path {path:?} from the index");
 871                    let output = new_smol_command(&git_binary_path)
 872                        .current_dir(&working_directory)
 873                        .envs(env.iter())
 874                        .args(["update-index", "--force-remove"])
 875                        .arg(path.to_unix_style())
 876                        .output()
 877                        .await?;
 878                    anyhow::ensure!(
 879                        output.status.success(),
 880                        "Failed to unstage:\n{}",
 881                        String::from_utf8_lossy(&output.stderr)
 882                    );
 883                }
 884
 885                Ok(())
 886            })
 887            .boxed()
 888    }
 889
 890    fn remote_url(&self, name: &str) -> Option<String> {
 891        let repo = self.repository.lock();
 892        let remote = repo.find_remote(name).ok()?;
 893        remote.url().map(|url| url.to_string())
 894    }
 895
 896    fn revparse_batch(&self, revs: Vec<String>) -> BoxFuture<'_, Result<Vec<Option<String>>>> {
 897        let working_directory = self.working_directory();
 898        self.executor
 899            .spawn(async move {
 900                let working_directory = working_directory?;
 901                let mut process = new_std_command("git")
 902                    .current_dir(&working_directory)
 903                    .args([
 904                        "--no-optional-locks",
 905                        "cat-file",
 906                        "--batch-check=%(objectname)",
 907                    ])
 908                    .stdin(Stdio::piped())
 909                    .stdout(Stdio::piped())
 910                    .stderr(Stdio::piped())
 911                    .spawn()?;
 912
 913                let stdin = process
 914                    .stdin
 915                    .take()
 916                    .context("no stdin for git cat-file subprocess")?;
 917                let mut stdin = BufWriter::new(stdin);
 918                for rev in &revs {
 919                    writeln!(&mut stdin, "{rev}")?;
 920                }
 921                stdin.flush()?;
 922                drop(stdin);
 923
 924                let output = process.wait_with_output()?;
 925                let output = std::str::from_utf8(&output.stdout)?;
 926                let shas = output
 927                    .lines()
 928                    .map(|line| {
 929                        if line.ends_with("missing") {
 930                            None
 931                        } else {
 932                            Some(line.to_string())
 933                        }
 934                    })
 935                    .collect::<Vec<_>>();
 936
 937                if shas.len() != revs.len() {
 938                    // In an octopus merge, git cat-file still only outputs the first sha from MERGE_HEAD.
 939                    bail!("unexpected number of shas")
 940                }
 941
 942                Ok(shas)
 943            })
 944            .boxed()
 945    }
 946
 947    fn merge_message(&self) -> BoxFuture<'_, Option<String>> {
 948        let path = self.path().join("MERGE_MSG");
 949        self.executor
 950            .spawn(async move { std::fs::read_to_string(&path).ok() })
 951            .boxed()
 952    }
 953
 954    fn status(&self, path_prefixes: &[RepoPath]) -> Task<Result<GitStatus>> {
 955        let git_binary_path = self.git_binary_path.clone();
 956        let working_directory = match self.working_directory() {
 957            Ok(working_directory) => working_directory,
 958            Err(e) => return Task::ready(Err(e)),
 959        };
 960        let args = git_status_args(path_prefixes);
 961        log::debug!("Checking for git status in {path_prefixes:?}");
 962        self.executor.spawn(async move {
 963            let output = new_std_command(&git_binary_path)
 964                .current_dir(working_directory)
 965                .args(args)
 966                .output()?;
 967            if output.status.success() {
 968                let stdout = String::from_utf8_lossy(&output.stdout);
 969                stdout.parse()
 970            } else {
 971                let stderr = String::from_utf8_lossy(&output.stderr);
 972                anyhow::bail!("git status failed: {stderr}");
 973            }
 974        })
 975    }
 976
 977    fn branches(&self) -> BoxFuture<'_, Result<Vec<Branch>>> {
 978        let working_directory = self.working_directory();
 979        let git_binary_path = self.git_binary_path.clone();
 980        self.executor
 981            .spawn(async move {
 982                let fields = [
 983                    "%(HEAD)",
 984                    "%(objectname)",
 985                    "%(parent)",
 986                    "%(refname)",
 987                    "%(upstream)",
 988                    "%(upstream:track)",
 989                    "%(committerdate:unix)",
 990                    "%(contents:subject)",
 991                ]
 992                .join("%00");
 993                let args = vec![
 994                    "for-each-ref",
 995                    "refs/heads/**/*",
 996                    "refs/remotes/**/*",
 997                    "--format",
 998                    &fields,
 999                ];
1000                let working_directory = working_directory?;
1001                let output = new_smol_command(&git_binary_path)
1002                    .current_dir(&working_directory)
1003                    .args(args)
1004                    .output()
1005                    .await?;
1006
1007                anyhow::ensure!(
1008                    output.status.success(),
1009                    "Failed to git git branches:\n{}",
1010                    String::from_utf8_lossy(&output.stderr)
1011                );
1012
1013                let input = String::from_utf8_lossy(&output.stdout);
1014
1015                let mut branches = parse_branch_input(&input)?;
1016                if branches.is_empty() {
1017                    let args = vec!["symbolic-ref", "--quiet", "HEAD"];
1018
1019                    let output = new_smol_command(&git_binary_path)
1020                        .current_dir(&working_directory)
1021                        .args(args)
1022                        .output()
1023                        .await?;
1024
1025                    // git symbolic-ref returns a non-0 exit code if HEAD points
1026                    // to something other than a branch
1027                    if output.status.success() {
1028                        let name = String::from_utf8_lossy(&output.stdout).trim().to_string();
1029
1030                        branches.push(Branch {
1031                            ref_name: name.into(),
1032                            is_head: true,
1033                            upstream: None,
1034                            most_recent_commit: None,
1035                        });
1036                    }
1037                }
1038
1039                Ok(branches)
1040            })
1041            .boxed()
1042    }
1043
1044    fn change_branch(&self, name: String) -> BoxFuture<'_, Result<()>> {
1045        let repo = self.repository.clone();
1046        let working_directory = self.working_directory();
1047        let git_binary_path = self.git_binary_path.clone();
1048        let executor = self.executor.clone();
1049        let branch = self.executor.spawn(async move {
1050            let repo = repo.lock();
1051            let branch = if let Ok(branch) = repo.find_branch(&name, BranchType::Local) {
1052                branch
1053            } else if let Ok(revision) = repo.find_branch(&name, BranchType::Remote) {
1054                let (_, branch_name) = name.split_once("/").context("Unexpected branch format")?;
1055                let revision = revision.get();
1056                let branch_commit = revision.peel_to_commit()?;
1057                let mut branch = repo.branch(branch_name, &branch_commit, false)?;
1058                branch.set_upstream(Some(&name))?;
1059                branch
1060            } else {
1061                anyhow::bail!("Branch not found");
1062            };
1063
1064            Ok(branch
1065                .name()?
1066                .context("cannot checkout anonymous branch")?
1067                .to_string())
1068        });
1069
1070        self.executor
1071            .spawn(async move {
1072                let branch = branch.await?;
1073
1074                GitBinary::new(git_binary_path, working_directory?, executor)
1075                    .run(&["checkout", &branch])
1076                    .await?;
1077
1078                anyhow::Ok(())
1079            })
1080            .boxed()
1081    }
1082
1083    fn create_branch(&self, name: String) -> BoxFuture<'_, Result<()>> {
1084        let repo = self.repository.clone();
1085        self.executor
1086            .spawn(async move {
1087                let repo = repo.lock();
1088                let current_commit = repo.head()?.peel_to_commit()?;
1089                repo.branch(&name, &current_commit, false)?;
1090                Ok(())
1091            })
1092            .boxed()
1093    }
1094
1095    fn blame(&self, path: RepoPath, content: Rope) -> BoxFuture<'_, Result<crate::blame::Blame>> {
1096        let working_directory = self.working_directory();
1097        let git_binary_path = self.git_binary_path.clone();
1098
1099        let remote_url = self
1100            .remote_url("upstream")
1101            .or_else(|| self.remote_url("origin"));
1102
1103        self.executor
1104            .spawn(async move {
1105                crate::blame::Blame::for_path(
1106                    &git_binary_path,
1107                    &working_directory?,
1108                    &path,
1109                    &content,
1110                    remote_url,
1111                )
1112                .await
1113            })
1114            .boxed()
1115    }
1116
1117    fn diff(&self, diff: DiffType) -> BoxFuture<'_, Result<String>> {
1118        let working_directory = self.working_directory();
1119        let git_binary_path = self.git_binary_path.clone();
1120        self.executor
1121            .spawn(async move {
1122                let args = match diff {
1123                    DiffType::HeadToIndex => Some("--staged"),
1124                    DiffType::HeadToWorktree => None,
1125                };
1126
1127                let output = new_smol_command(&git_binary_path)
1128                    .current_dir(&working_directory?)
1129                    .args(["diff"])
1130                    .args(args)
1131                    .output()
1132                    .await?;
1133
1134                anyhow::ensure!(
1135                    output.status.success(),
1136                    "Failed to run git diff:\n{}",
1137                    String::from_utf8_lossy(&output.stderr)
1138                );
1139                Ok(String::from_utf8_lossy(&output.stdout).to_string())
1140            })
1141            .boxed()
1142    }
1143
1144    fn stage_paths(
1145        &self,
1146        paths: Vec<RepoPath>,
1147        env: Arc<HashMap<String, String>>,
1148    ) -> BoxFuture<'_, Result<()>> {
1149        let working_directory = self.working_directory();
1150        let git_binary_path = self.git_binary_path.clone();
1151        self.executor
1152            .spawn(async move {
1153                if !paths.is_empty() {
1154                    let output = new_smol_command(&git_binary_path)
1155                        .current_dir(&working_directory?)
1156                        .envs(env.iter())
1157                        .args(["update-index", "--add", "--remove", "--"])
1158                        .args(paths.iter().map(|p| p.to_unix_style()))
1159                        .output()
1160                        .await?;
1161                    anyhow::ensure!(
1162                        output.status.success(),
1163                        "Failed to stage paths:\n{}",
1164                        String::from_utf8_lossy(&output.stderr),
1165                    );
1166                }
1167                Ok(())
1168            })
1169            .boxed()
1170    }
1171
1172    fn unstage_paths(
1173        &self,
1174        paths: Vec<RepoPath>,
1175        env: Arc<HashMap<String, String>>,
1176    ) -> BoxFuture<'_, Result<()>> {
1177        let working_directory = self.working_directory();
1178        let git_binary_path = self.git_binary_path.clone();
1179
1180        self.executor
1181            .spawn(async move {
1182                if !paths.is_empty() {
1183                    let output = new_smol_command(&git_binary_path)
1184                        .current_dir(&working_directory?)
1185                        .envs(env.iter())
1186                        .args(["reset", "--quiet", "--"])
1187                        .args(paths.iter().map(|p| p.as_ref()))
1188                        .output()
1189                        .await?;
1190
1191                    anyhow::ensure!(
1192                        output.status.success(),
1193                        "Failed to unstage:\n{}",
1194                        String::from_utf8_lossy(&output.stderr),
1195                    );
1196                }
1197                Ok(())
1198            })
1199            .boxed()
1200    }
1201
1202    fn stash_paths(
1203        &self,
1204        paths: Vec<RepoPath>,
1205        env: Arc<HashMap<String, String>>,
1206    ) -> BoxFuture<'_, Result<()>> {
1207        let working_directory = self.working_directory();
1208        let git_binary_path = self.git_binary_path.clone();
1209        self.executor
1210            .spawn(async move {
1211                let mut cmd = new_smol_command(&git_binary_path);
1212                cmd.current_dir(&working_directory?)
1213                    .envs(env.iter())
1214                    .args(["stash", "push", "--quiet"])
1215                    .arg("--include-untracked");
1216
1217                cmd.args(paths.iter().map(|p| p.as_ref()));
1218
1219                let output = cmd.output().await?;
1220
1221                anyhow::ensure!(
1222                    output.status.success(),
1223                    "Failed to stash:\n{}",
1224                    String::from_utf8_lossy(&output.stderr)
1225                );
1226                Ok(())
1227            })
1228            .boxed()
1229    }
1230
1231    fn stash_pop(&self, env: Arc<HashMap<String, String>>) -> BoxFuture<'_, Result<()>> {
1232        let working_directory = self.working_directory();
1233        let git_binary_path = self.git_binary_path.clone();
1234        self.executor
1235            .spawn(async move {
1236                let mut cmd = new_smol_command(&git_binary_path);
1237                cmd.current_dir(&working_directory?)
1238                    .envs(env.iter())
1239                    .args(["stash", "pop"]);
1240
1241                let output = cmd.output().await?;
1242
1243                anyhow::ensure!(
1244                    output.status.success(),
1245                    "Failed to stash pop:\n{}",
1246                    String::from_utf8_lossy(&output.stderr)
1247                );
1248                Ok(())
1249            })
1250            .boxed()
1251    }
1252
1253    fn commit(
1254        &self,
1255        message: SharedString,
1256        name_and_email: Option<(SharedString, SharedString)>,
1257        options: CommitOptions,
1258        env: Arc<HashMap<String, String>>,
1259    ) -> BoxFuture<'_, Result<()>> {
1260        let working_directory = self.working_directory();
1261        let git_binary_path = self.git_binary_path.clone();
1262        self.executor
1263            .spawn(async move {
1264                let mut cmd = new_smol_command(&git_binary_path);
1265                cmd.current_dir(&working_directory?)
1266                    .envs(env.iter())
1267                    .args(["commit", "--quiet", "-m"])
1268                    .arg(&message.to_string())
1269                    .arg("--cleanup=strip");
1270
1271                if options.amend {
1272                    cmd.arg("--amend");
1273                }
1274
1275                if options.signoff {
1276                    cmd.arg("--signoff");
1277                }
1278
1279                if let Some((name, email)) = name_and_email {
1280                    cmd.arg("--author").arg(&format!("{name} <{email}>"));
1281                }
1282
1283                let output = cmd.output().await?;
1284
1285                anyhow::ensure!(
1286                    output.status.success(),
1287                    "Failed to commit:\n{}",
1288                    String::from_utf8_lossy(&output.stderr)
1289                );
1290                Ok(())
1291            })
1292            .boxed()
1293    }
1294
1295    fn push(
1296        &self,
1297        branch_name: String,
1298        remote_name: String,
1299        options: Option<PushOptions>,
1300        ask_pass: AskPassDelegate,
1301        env: Arc<HashMap<String, String>>,
1302        cx: AsyncApp,
1303    ) -> BoxFuture<'_, Result<RemoteCommandOutput>> {
1304        let working_directory = self.working_directory();
1305        let executor = cx.background_executor().clone();
1306        async move {
1307            let working_directory = working_directory?;
1308            let mut command = new_smol_command(&self.git_binary_path);
1309            command
1310                .envs(env.iter())
1311                .current_dir(&working_directory)
1312                .args(["push"])
1313                .args(options.map(|option| match option {
1314                    PushOptions::SetUpstream => "--set-upstream",
1315                    PushOptions::Force => "--force-with-lease",
1316                }))
1317                .arg(remote_name)
1318                .arg(format!("{}:{}", branch_name, branch_name))
1319                .stdin(smol::process::Stdio::null())
1320                .stdout(smol::process::Stdio::piped())
1321                .stderr(smol::process::Stdio::piped());
1322
1323            run_git_command(env, ask_pass, command, &executor).await
1324        }
1325        .boxed()
1326    }
1327
1328    fn pull(
1329        &self,
1330        branch_name: String,
1331        remote_name: String,
1332        ask_pass: AskPassDelegate,
1333        env: Arc<HashMap<String, String>>,
1334        cx: AsyncApp,
1335    ) -> BoxFuture<'_, Result<RemoteCommandOutput>> {
1336        let working_directory = self.working_directory();
1337        let executor = cx.background_executor().clone();
1338        async move {
1339            let mut command = new_smol_command(&self.git_binary_path);
1340            command
1341                .envs(env.iter())
1342                .current_dir(&working_directory?)
1343                .args(["pull"])
1344                .arg(remote_name)
1345                .arg(branch_name)
1346                .stdout(smol::process::Stdio::piped())
1347                .stderr(smol::process::Stdio::piped());
1348
1349            run_git_command(env, ask_pass, command, &executor).await
1350        }
1351        .boxed()
1352    }
1353
1354    fn fetch(
1355        &self,
1356        fetch_options: FetchOptions,
1357        ask_pass: AskPassDelegate,
1358        env: Arc<HashMap<String, String>>,
1359        cx: AsyncApp,
1360    ) -> BoxFuture<'_, Result<RemoteCommandOutput>> {
1361        let working_directory = self.working_directory();
1362        let remote_name = format!("{}", fetch_options);
1363        let executor = cx.background_executor().clone();
1364        async move {
1365            let mut command = new_smol_command(&self.git_binary_path);
1366            command
1367                .envs(env.iter())
1368                .current_dir(&working_directory?)
1369                .args(["fetch", &remote_name])
1370                .stdout(smol::process::Stdio::piped())
1371                .stderr(smol::process::Stdio::piped());
1372
1373            run_git_command(env, ask_pass, command, &executor).await
1374        }
1375        .boxed()
1376    }
1377
1378    fn get_remotes(&self, branch_name: Option<String>) -> BoxFuture<'_, Result<Vec<Remote>>> {
1379        let working_directory = self.working_directory();
1380        let git_binary_path = self.git_binary_path.clone();
1381        self.executor
1382            .spawn(async move {
1383                let working_directory = working_directory?;
1384                if let Some(branch_name) = branch_name {
1385                    let output = new_smol_command(&git_binary_path)
1386                        .current_dir(&working_directory)
1387                        .args(["config", "--get"])
1388                        .arg(format!("branch.{}.remote", branch_name))
1389                        .output()
1390                        .await?;
1391
1392                    if output.status.success() {
1393                        let remote_name = String::from_utf8_lossy(&output.stdout);
1394
1395                        return Ok(vec![Remote {
1396                            name: remote_name.trim().to_string().into(),
1397                        }]);
1398                    }
1399                }
1400
1401                let output = new_smol_command(&git_binary_path)
1402                    .current_dir(&working_directory)
1403                    .args(["remote"])
1404                    .output()
1405                    .await?;
1406
1407                anyhow::ensure!(
1408                    output.status.success(),
1409                    "Failed to get remotes:\n{}",
1410                    String::from_utf8_lossy(&output.stderr)
1411                );
1412                let remote_names = String::from_utf8_lossy(&output.stdout)
1413                    .split('\n')
1414                    .filter(|name| !name.is_empty())
1415                    .map(|name| Remote {
1416                        name: name.trim().to_string().into(),
1417                    })
1418                    .collect();
1419                Ok(remote_names)
1420            })
1421            .boxed()
1422    }
1423
1424    fn check_for_pushed_commit(&self) -> BoxFuture<'_, Result<Vec<SharedString>>> {
1425        let working_directory = self.working_directory();
1426        let git_binary_path = self.git_binary_path.clone();
1427        self.executor
1428            .spawn(async move {
1429                let working_directory = working_directory?;
1430                let git_cmd = async |args: &[&str]| -> Result<String> {
1431                    let output = new_smol_command(&git_binary_path)
1432                        .current_dir(&working_directory)
1433                        .args(args)
1434                        .output()
1435                        .await?;
1436                    anyhow::ensure!(
1437                        output.status.success(),
1438                        String::from_utf8_lossy(&output.stderr).to_string()
1439                    );
1440                    Ok(String::from_utf8(output.stdout)?)
1441                };
1442
1443                let head = git_cmd(&["rev-parse", "HEAD"])
1444                    .await
1445                    .context("Failed to get HEAD")?
1446                    .trim()
1447                    .to_owned();
1448
1449                let mut remote_branches = vec![];
1450                let mut add_if_matching = async |remote_head: &str| {
1451                    if let Ok(merge_base) = git_cmd(&["merge-base", &head, remote_head]).await
1452                        && merge_base.trim() == head
1453                        && let Some(s) = remote_head.strip_prefix("refs/remotes/")
1454                    {
1455                        remote_branches.push(s.to_owned().into());
1456                    }
1457                };
1458
1459                // check the main branch of each remote
1460                let remotes = git_cmd(&["remote"])
1461                    .await
1462                    .context("Failed to get remotes")?;
1463                for remote in remotes.lines() {
1464                    if let Ok(remote_head) =
1465                        git_cmd(&["symbolic-ref", &format!("refs/remotes/{remote}/HEAD")]).await
1466                    {
1467                        add_if_matching(remote_head.trim()).await;
1468                    }
1469                }
1470
1471                // ... and the remote branch that the checked-out one is tracking
1472                if let Ok(remote_head) =
1473                    git_cmd(&["rev-parse", "--symbolic-full-name", "@{u}"]).await
1474                {
1475                    add_if_matching(remote_head.trim()).await;
1476                }
1477
1478                Ok(remote_branches)
1479            })
1480            .boxed()
1481    }
1482
1483    fn checkpoint(&self) -> BoxFuture<'static, Result<GitRepositoryCheckpoint>> {
1484        let working_directory = self.working_directory();
1485        let git_binary_path = self.git_binary_path.clone();
1486        let executor = self.executor.clone();
1487        self.executor
1488            .spawn(async move {
1489                let working_directory = working_directory?;
1490                let mut git = GitBinary::new(git_binary_path, working_directory.clone(), executor)
1491                    .envs(checkpoint_author_envs());
1492                git.with_temp_index(async |git| {
1493                    let head_sha = git.run(&["rev-parse", "HEAD"]).await.ok();
1494                    let mut excludes = exclude_files(git).await?;
1495
1496                    git.run(&["add", "--all"]).await?;
1497                    let tree = git.run(&["write-tree"]).await?;
1498                    let checkpoint_sha = if let Some(head_sha) = head_sha.as_deref() {
1499                        git.run(&["commit-tree", &tree, "-p", head_sha, "-m", "Checkpoint"])
1500                            .await?
1501                    } else {
1502                        git.run(&["commit-tree", &tree, "-m", "Checkpoint"]).await?
1503                    };
1504
1505                    excludes.restore_original().await?;
1506
1507                    Ok(GitRepositoryCheckpoint {
1508                        commit_sha: checkpoint_sha.parse()?,
1509                    })
1510                })
1511                .await
1512            })
1513            .boxed()
1514    }
1515
1516    fn restore_checkpoint(&self, checkpoint: GitRepositoryCheckpoint) -> BoxFuture<'_, Result<()>> {
1517        let working_directory = self.working_directory();
1518        let git_binary_path = self.git_binary_path.clone();
1519
1520        let executor = self.executor.clone();
1521        self.executor
1522            .spawn(async move {
1523                let working_directory = working_directory?;
1524
1525                let git = GitBinary::new(git_binary_path, working_directory, executor);
1526                git.run(&[
1527                    "restore",
1528                    "--source",
1529                    &checkpoint.commit_sha.to_string(),
1530                    "--worktree",
1531                    ".",
1532                ])
1533                .await?;
1534
1535                // TODO: We don't track binary and large files anymore,
1536                //       so the following call would delete them.
1537                //       Implement an alternative way to track files added by agent.
1538                //
1539                // git.with_temp_index(async move |git| {
1540                //     git.run(&["read-tree", &checkpoint.commit_sha.to_string()])
1541                //         .await?;
1542                //     git.run(&["clean", "-d", "--force"]).await
1543                // })
1544                // .await?;
1545
1546                Ok(())
1547            })
1548            .boxed()
1549    }
1550
1551    fn compare_checkpoints(
1552        &self,
1553        left: GitRepositoryCheckpoint,
1554        right: GitRepositoryCheckpoint,
1555    ) -> BoxFuture<'_, Result<bool>> {
1556        let working_directory = self.working_directory();
1557        let git_binary_path = self.git_binary_path.clone();
1558
1559        let executor = self.executor.clone();
1560        self.executor
1561            .spawn(async move {
1562                let working_directory = working_directory?;
1563                let git = GitBinary::new(git_binary_path, working_directory, executor);
1564                let result = git
1565                    .run(&[
1566                        "diff-tree",
1567                        "--quiet",
1568                        &left.commit_sha.to_string(),
1569                        &right.commit_sha.to_string(),
1570                    ])
1571                    .await;
1572                match result {
1573                    Ok(_) => Ok(true),
1574                    Err(error) => {
1575                        if let Some(GitBinaryCommandError { status, .. }) =
1576                            error.downcast_ref::<GitBinaryCommandError>()
1577                            && status.code() == Some(1)
1578                        {
1579                            return Ok(false);
1580                        }
1581
1582                        Err(error)
1583                    }
1584                }
1585            })
1586            .boxed()
1587    }
1588
1589    fn diff_checkpoints(
1590        &self,
1591        base_checkpoint: GitRepositoryCheckpoint,
1592        target_checkpoint: GitRepositoryCheckpoint,
1593    ) -> BoxFuture<'_, Result<String>> {
1594        let working_directory = self.working_directory();
1595        let git_binary_path = self.git_binary_path.clone();
1596
1597        let executor = self.executor.clone();
1598        self.executor
1599            .spawn(async move {
1600                let working_directory = working_directory?;
1601                let git = GitBinary::new(git_binary_path, working_directory, executor);
1602                git.run(&[
1603                    "diff",
1604                    "--find-renames",
1605                    "--patch",
1606                    &base_checkpoint.commit_sha.to_string(),
1607                    &target_checkpoint.commit_sha.to_string(),
1608                ])
1609                .await
1610            })
1611            .boxed()
1612    }
1613
1614    fn default_branch(&self) -> BoxFuture<'_, Result<Option<SharedString>>> {
1615        let working_directory = self.working_directory();
1616        let git_binary_path = self.git_binary_path.clone();
1617
1618        let executor = self.executor.clone();
1619        self.executor
1620            .spawn(async move {
1621                let working_directory = working_directory?;
1622                let git = GitBinary::new(git_binary_path, working_directory, executor);
1623
1624                if let Ok(output) = git
1625                    .run(&["symbolic-ref", "refs/remotes/upstream/HEAD"])
1626                    .await
1627                {
1628                    let output = output
1629                        .strip_prefix("refs/remotes/upstream/")
1630                        .map(|s| SharedString::from(s.to_owned()));
1631                    return Ok(output);
1632                }
1633
1634                let output = git
1635                    .run(&["symbolic-ref", "refs/remotes/origin/HEAD"])
1636                    .await?;
1637
1638                Ok(output
1639                    .strip_prefix("refs/remotes/origin/")
1640                    .map(|s| SharedString::from(s.to_owned())))
1641            })
1642            .boxed()
1643    }
1644}
1645
1646fn git_status_args(path_prefixes: &[RepoPath]) -> Vec<OsString> {
1647    let mut args = vec![
1648        OsString::from("--no-optional-locks"),
1649        OsString::from("status"),
1650        OsString::from("--porcelain=v1"),
1651        OsString::from("--untracked-files=all"),
1652        OsString::from("--no-renames"),
1653        OsString::from("-z"),
1654    ];
1655    args.extend(path_prefixes.iter().map(|path_prefix| {
1656        if path_prefix.0.as_ref() == Path::new("") {
1657            Path::new(".").into()
1658        } else {
1659            path_prefix.as_os_str().into()
1660        }
1661    }));
1662    args
1663}
1664
1665/// Temporarily git-ignore commonly ignored files and files over 2MB
1666async fn exclude_files(git: &GitBinary) -> Result<GitExcludeOverride> {
1667    const MAX_SIZE: u64 = 2 * 1024 * 1024; // 2 MB
1668    let mut excludes = git.with_exclude_overrides().await?;
1669    excludes
1670        .add_excludes(include_str!("./checkpoint.gitignore"))
1671        .await?;
1672
1673    let working_directory = git.working_directory.clone();
1674    let untracked_files = git.list_untracked_files().await?;
1675    let excluded_paths = untracked_files.into_iter().map(|path| {
1676        let working_directory = working_directory.clone();
1677        smol::spawn(async move {
1678            let full_path = working_directory.join(path.clone());
1679            match smol::fs::metadata(&full_path).await {
1680                Ok(metadata) if metadata.is_file() && metadata.len() >= MAX_SIZE => {
1681                    Some(PathBuf::from("/").join(path.clone()))
1682                }
1683                _ => None,
1684            }
1685        })
1686    });
1687
1688    let excluded_paths = futures::future::join_all(excluded_paths).await;
1689    let excluded_paths = excluded_paths.into_iter().flatten().collect::<Vec<_>>();
1690
1691    if !excluded_paths.is_empty() {
1692        let exclude_patterns = excluded_paths
1693            .into_iter()
1694            .map(|path| path.to_string_lossy().to_string())
1695            .collect::<Vec<_>>()
1696            .join("\n");
1697        excludes.add_excludes(&exclude_patterns).await?;
1698    }
1699
1700    Ok(excludes)
1701}
1702
1703struct GitBinary {
1704    git_binary_path: PathBuf,
1705    working_directory: PathBuf,
1706    executor: BackgroundExecutor,
1707    index_file_path: Option<PathBuf>,
1708    envs: HashMap<String, String>,
1709}
1710
1711impl GitBinary {
1712    fn new(
1713        git_binary_path: PathBuf,
1714        working_directory: PathBuf,
1715        executor: BackgroundExecutor,
1716    ) -> Self {
1717        Self {
1718            git_binary_path,
1719            working_directory,
1720            executor,
1721            index_file_path: None,
1722            envs: HashMap::default(),
1723        }
1724    }
1725
1726    async fn list_untracked_files(&self) -> Result<Vec<PathBuf>> {
1727        let status_output = self
1728            .run(&["status", "--porcelain=v1", "--untracked-files=all", "-z"])
1729            .await?;
1730
1731        let paths = status_output
1732            .split('\0')
1733            .filter(|entry| entry.len() >= 3 && entry.starts_with("?? "))
1734            .map(|entry| PathBuf::from(&entry[3..]))
1735            .collect::<Vec<_>>();
1736        Ok(paths)
1737    }
1738
1739    fn envs(mut self, envs: HashMap<String, String>) -> Self {
1740        self.envs = envs;
1741        self
1742    }
1743
1744    pub async fn with_temp_index<R>(
1745        &mut self,
1746        f: impl AsyncFnOnce(&Self) -> Result<R>,
1747    ) -> Result<R> {
1748        let index_file_path = self.path_for_index_id(Uuid::new_v4());
1749
1750        let delete_temp_index = util::defer({
1751            let index_file_path = index_file_path.clone();
1752            let executor = self.executor.clone();
1753            move || {
1754                executor
1755                    .spawn(async move {
1756                        smol::fs::remove_file(index_file_path).await.log_err();
1757                    })
1758                    .detach();
1759            }
1760        });
1761
1762        // Copy the default index file so that Git doesn't have to rebuild the
1763        // whole index from scratch. This might fail if this is an empty repository.
1764        smol::fs::copy(
1765            self.working_directory.join(".git").join("index"),
1766            &index_file_path,
1767        )
1768        .await
1769        .ok();
1770
1771        self.index_file_path = Some(index_file_path.clone());
1772        let result = f(self).await;
1773        self.index_file_path = None;
1774        let result = result?;
1775
1776        smol::fs::remove_file(index_file_path).await.ok();
1777        delete_temp_index.abort();
1778
1779        Ok(result)
1780    }
1781
1782    pub async fn with_exclude_overrides(&self) -> Result<GitExcludeOverride> {
1783        let path = self
1784            .working_directory
1785            .join(".git")
1786            .join("info")
1787            .join("exclude");
1788
1789        GitExcludeOverride::new(path).await
1790    }
1791
1792    fn path_for_index_id(&self, id: Uuid) -> PathBuf {
1793        self.working_directory
1794            .join(".git")
1795            .join(format!("index-{}.tmp", id))
1796    }
1797
1798    pub async fn run<S>(&self, args: impl IntoIterator<Item = S>) -> Result<String>
1799    where
1800        S: AsRef<OsStr>,
1801    {
1802        let mut stdout = self.run_raw(args).await?;
1803        if stdout.chars().last() == Some('\n') {
1804            stdout.pop();
1805        }
1806        Ok(stdout)
1807    }
1808
1809    /// Returns the result of the command without trimming the trailing newline.
1810    pub async fn run_raw<S>(&self, args: impl IntoIterator<Item = S>) -> Result<String>
1811    where
1812        S: AsRef<OsStr>,
1813    {
1814        let mut command = self.build_command(args);
1815        let output = command.output().await?;
1816        anyhow::ensure!(
1817            output.status.success(),
1818            GitBinaryCommandError {
1819                stdout: String::from_utf8_lossy(&output.stdout).to_string(),
1820                status: output.status,
1821            }
1822        );
1823        Ok(String::from_utf8(output.stdout)?)
1824    }
1825
1826    fn build_command<S>(&self, args: impl IntoIterator<Item = S>) -> smol::process::Command
1827    where
1828        S: AsRef<OsStr>,
1829    {
1830        let mut command = new_smol_command(&self.git_binary_path);
1831        command.current_dir(&self.working_directory);
1832        command.args(args);
1833        if let Some(index_file_path) = self.index_file_path.as_ref() {
1834            command.env("GIT_INDEX_FILE", index_file_path);
1835        }
1836        command.envs(&self.envs);
1837        command
1838    }
1839}
1840
1841#[derive(Error, Debug)]
1842#[error("Git command failed: {stdout}")]
1843struct GitBinaryCommandError {
1844    stdout: String,
1845    status: ExitStatus,
1846}
1847
1848async fn run_git_command(
1849    env: Arc<HashMap<String, String>>,
1850    ask_pass: AskPassDelegate,
1851    mut command: smol::process::Command,
1852    executor: &BackgroundExecutor,
1853) -> Result<RemoteCommandOutput> {
1854    if env.contains_key("GIT_ASKPASS") {
1855        let git_process = command.spawn()?;
1856        let output = git_process.output().await?;
1857        anyhow::ensure!(
1858            output.status.success(),
1859            "{}",
1860            String::from_utf8_lossy(&output.stderr)
1861        );
1862        Ok(RemoteCommandOutput {
1863            stdout: String::from_utf8_lossy(&output.stdout).to_string(),
1864            stderr: String::from_utf8_lossy(&output.stderr).to_string(),
1865        })
1866    } else {
1867        let ask_pass = AskPassSession::new(executor, ask_pass).await?;
1868        command
1869            .env("GIT_ASKPASS", ask_pass.script_path())
1870            .env("SSH_ASKPASS", ask_pass.script_path())
1871            .env("SSH_ASKPASS_REQUIRE", "force");
1872        let git_process = command.spawn()?;
1873
1874        run_askpass_command(ask_pass, git_process).await
1875    }
1876}
1877
1878async fn run_askpass_command(
1879    mut ask_pass: AskPassSession,
1880    git_process: smol::process::Child,
1881) -> anyhow::Result<RemoteCommandOutput> {
1882    select_biased! {
1883        result = ask_pass.run().fuse() => {
1884            match result {
1885                AskPassResult::CancelledByUser => {
1886                    Err(anyhow!(REMOTE_CANCELLED_BY_USER))?
1887                }
1888                AskPassResult::Timedout => {
1889                    Err(anyhow!("Connecting to host timed out"))?
1890                }
1891            }
1892        }
1893        output = git_process.output().fuse() => {
1894            let output = output?;
1895            anyhow::ensure!(
1896                output.status.success(),
1897                "{}",
1898                String::from_utf8_lossy(&output.stderr)
1899            );
1900            Ok(RemoteCommandOutput {
1901                stdout: String::from_utf8_lossy(&output.stdout).to_string(),
1902                stderr: String::from_utf8_lossy(&output.stderr).to_string(),
1903            })
1904        }
1905    }
1906}
1907
1908pub static WORK_DIRECTORY_REPO_PATH: LazyLock<RepoPath> =
1909    LazyLock::new(|| RepoPath(Path::new("").into()));
1910
1911#[derive(Clone, Debug, Ord, Hash, PartialOrd, Eq, PartialEq)]
1912pub struct RepoPath(pub Arc<Path>);
1913
1914impl RepoPath {
1915    pub fn new(path: PathBuf) -> Self {
1916        debug_assert!(path.is_relative(), "Repo paths must be relative");
1917
1918        RepoPath(path.into())
1919    }
1920
1921    pub fn from_str(path: &str) -> Self {
1922        let path = Path::new(path);
1923        debug_assert!(path.is_relative(), "Repo paths must be relative");
1924
1925        RepoPath(path.into())
1926    }
1927
1928    pub fn to_unix_style(&self) -> Cow<'_, OsStr> {
1929        #[cfg(target_os = "windows")]
1930        {
1931            use std::ffi::OsString;
1932
1933            let path = self.0.as_os_str().to_string_lossy().replace("\\", "/");
1934            Cow::Owned(OsString::from(path))
1935        }
1936        #[cfg(not(target_os = "windows"))]
1937        {
1938            Cow::Borrowed(self.0.as_os_str())
1939        }
1940    }
1941}
1942
1943impl std::fmt::Display for RepoPath {
1944    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1945        self.0.to_string_lossy().fmt(f)
1946    }
1947}
1948
1949impl From<&Path> for RepoPath {
1950    fn from(value: &Path) -> Self {
1951        RepoPath::new(value.into())
1952    }
1953}
1954
1955impl From<Arc<Path>> for RepoPath {
1956    fn from(value: Arc<Path>) -> Self {
1957        RepoPath(value)
1958    }
1959}
1960
1961impl From<PathBuf> for RepoPath {
1962    fn from(value: PathBuf) -> Self {
1963        RepoPath::new(value)
1964    }
1965}
1966
1967impl From<&str> for RepoPath {
1968    fn from(value: &str) -> Self {
1969        Self::from_str(value)
1970    }
1971}
1972
1973impl Default for RepoPath {
1974    fn default() -> Self {
1975        RepoPath(Path::new("").into())
1976    }
1977}
1978
1979impl AsRef<Path> for RepoPath {
1980    fn as_ref(&self) -> &Path {
1981        self.0.as_ref()
1982    }
1983}
1984
1985impl std::ops::Deref for RepoPath {
1986    type Target = Path;
1987
1988    fn deref(&self) -> &Self::Target {
1989        &self.0
1990    }
1991}
1992
1993impl Borrow<Path> for RepoPath {
1994    fn borrow(&self) -> &Path {
1995        self.0.as_ref()
1996    }
1997}
1998
1999#[derive(Debug)]
2000pub struct RepoPathDescendants<'a>(pub &'a Path);
2001
2002impl MapSeekTarget<RepoPath> for RepoPathDescendants<'_> {
2003    fn cmp_cursor(&self, key: &RepoPath) -> Ordering {
2004        if key.starts_with(self.0) {
2005            Ordering::Greater
2006        } else {
2007            self.0.cmp(key)
2008        }
2009    }
2010}
2011
2012fn parse_branch_input(input: &str) -> Result<Vec<Branch>> {
2013    let mut branches = Vec::new();
2014    for line in input.split('\n') {
2015        if line.is_empty() {
2016            continue;
2017        }
2018        let mut fields = line.split('\x00');
2019        let is_current_branch = fields.next().context("no HEAD")? == "*";
2020        let head_sha: SharedString = fields.next().context("no objectname")?.to_string().into();
2021        let parent_sha: SharedString = fields.next().context("no parent")?.to_string().into();
2022        let ref_name = fields.next().context("no refname")?.to_string().into();
2023        let upstream_name = fields.next().context("no upstream")?.to_string();
2024        let upstream_tracking = parse_upstream_track(fields.next().context("no upstream:track")?)?;
2025        let commiterdate = fields.next().context("no committerdate")?.parse::<i64>()?;
2026        let subject: SharedString = fields
2027            .next()
2028            .context("no contents:subject")?
2029            .to_string()
2030            .into();
2031
2032        branches.push(Branch {
2033            is_head: is_current_branch,
2034            ref_name,
2035            most_recent_commit: Some(CommitSummary {
2036                sha: head_sha,
2037                subject,
2038                commit_timestamp: commiterdate,
2039                has_parent: !parent_sha.is_empty(),
2040            }),
2041            upstream: if upstream_name.is_empty() {
2042                None
2043            } else {
2044                Some(Upstream {
2045                    ref_name: upstream_name.into(),
2046                    tracking: upstream_tracking,
2047                })
2048            },
2049        })
2050    }
2051
2052    Ok(branches)
2053}
2054
2055fn parse_upstream_track(upstream_track: &str) -> Result<UpstreamTracking> {
2056    if upstream_track.is_empty() {
2057        return Ok(UpstreamTracking::Tracked(UpstreamTrackingStatus {
2058            ahead: 0,
2059            behind: 0,
2060        }));
2061    }
2062
2063    let upstream_track = upstream_track.strip_prefix("[").context("missing [")?;
2064    let upstream_track = upstream_track.strip_suffix("]").context("missing [")?;
2065    let mut ahead: u32 = 0;
2066    let mut behind: u32 = 0;
2067    for component in upstream_track.split(", ") {
2068        if component == "gone" {
2069            return Ok(UpstreamTracking::Gone);
2070        }
2071        if let Some(ahead_num) = component.strip_prefix("ahead ") {
2072            ahead = ahead_num.parse::<u32>()?;
2073        }
2074        if let Some(behind_num) = component.strip_prefix("behind ") {
2075            behind = behind_num.parse::<u32>()?;
2076        }
2077    }
2078    Ok(UpstreamTracking::Tracked(UpstreamTrackingStatus {
2079        ahead,
2080        behind,
2081    }))
2082}
2083
2084fn check_path_to_repo_path_errors(relative_file_path: &Path) -> Result<()> {
2085    match relative_file_path.components().next() {
2086        None => anyhow::bail!("repo path should not be empty"),
2087        Some(Component::Prefix(_)) => anyhow::bail!(
2088            "repo path `{}` should be relative, not a windows prefix",
2089            relative_file_path.to_string_lossy()
2090        ),
2091        Some(Component::RootDir) => {
2092            anyhow::bail!(
2093                "repo path `{}` should be relative",
2094                relative_file_path.to_string_lossy()
2095            )
2096        }
2097        Some(Component::CurDir) => {
2098            anyhow::bail!(
2099                "repo path `{}` should not start with `.`",
2100                relative_file_path.to_string_lossy()
2101            )
2102        }
2103        Some(Component::ParentDir) => {
2104            anyhow::bail!(
2105                "repo path `{}` should not start with `..`",
2106                relative_file_path.to_string_lossy()
2107            )
2108        }
2109        _ => Ok(()),
2110    }
2111}
2112
2113fn checkpoint_author_envs() -> HashMap<String, String> {
2114    HashMap::from_iter([
2115        ("GIT_AUTHOR_NAME".to_string(), "Zed".to_string()),
2116        ("GIT_AUTHOR_EMAIL".to_string(), "hi@zed.dev".to_string()),
2117        ("GIT_COMMITTER_NAME".to_string(), "Zed".to_string()),
2118        ("GIT_COMMITTER_EMAIL".to_string(), "hi@zed.dev".to_string()),
2119    ])
2120}
2121
2122#[cfg(test)]
2123mod tests {
2124    use super::*;
2125    use gpui::TestAppContext;
2126
2127    #[gpui::test]
2128    async fn test_checkpoint_basic(cx: &mut TestAppContext) {
2129        cx.executor().allow_parking();
2130
2131        let repo_dir = tempfile::tempdir().unwrap();
2132
2133        git2::Repository::init(repo_dir.path()).unwrap();
2134        let file_path = repo_dir.path().join("file");
2135        smol::fs::write(&file_path, "initial").await.unwrap();
2136
2137        let repo =
2138            RealGitRepository::new(&repo_dir.path().join(".git"), None, cx.executor()).unwrap();
2139        repo.stage_paths(
2140            vec![RepoPath::from_str("file")],
2141            Arc::new(HashMap::default()),
2142        )
2143        .await
2144        .unwrap();
2145        repo.commit(
2146            "Initial commit".into(),
2147            None,
2148            CommitOptions::default(),
2149            Arc::new(checkpoint_author_envs()),
2150        )
2151        .await
2152        .unwrap();
2153
2154        smol::fs::write(&file_path, "modified before checkpoint")
2155            .await
2156            .unwrap();
2157        smol::fs::write(repo_dir.path().join("new_file_before_checkpoint"), "1")
2158            .await
2159            .unwrap();
2160        let checkpoint = repo.checkpoint().await.unwrap();
2161
2162        // Ensure the user can't see any branches after creating a checkpoint.
2163        assert_eq!(repo.branches().await.unwrap().len(), 1);
2164
2165        smol::fs::write(&file_path, "modified after checkpoint")
2166            .await
2167            .unwrap();
2168        repo.stage_paths(
2169            vec![RepoPath::from_str("file")],
2170            Arc::new(HashMap::default()),
2171        )
2172        .await
2173        .unwrap();
2174        repo.commit(
2175            "Commit after checkpoint".into(),
2176            None,
2177            CommitOptions::default(),
2178            Arc::new(checkpoint_author_envs()),
2179        )
2180        .await
2181        .unwrap();
2182
2183        smol::fs::remove_file(repo_dir.path().join("new_file_before_checkpoint"))
2184            .await
2185            .unwrap();
2186        smol::fs::write(repo_dir.path().join("new_file_after_checkpoint"), "2")
2187            .await
2188            .unwrap();
2189
2190        // Ensure checkpoint stays alive even after a Git GC.
2191        repo.gc().await.unwrap();
2192        repo.restore_checkpoint(checkpoint.clone()).await.unwrap();
2193
2194        assert_eq!(
2195            smol::fs::read_to_string(&file_path).await.unwrap(),
2196            "modified before checkpoint"
2197        );
2198        assert_eq!(
2199            smol::fs::read_to_string(repo_dir.path().join("new_file_before_checkpoint"))
2200                .await
2201                .unwrap(),
2202            "1"
2203        );
2204        // See TODO above
2205        // assert_eq!(
2206        //     smol::fs::read_to_string(repo_dir.path().join("new_file_after_checkpoint"))
2207        //         .await
2208        //         .ok(),
2209        //     None
2210        // );
2211    }
2212
2213    #[gpui::test]
2214    async fn test_checkpoint_empty_repo(cx: &mut TestAppContext) {
2215        cx.executor().allow_parking();
2216
2217        let repo_dir = tempfile::tempdir().unwrap();
2218        git2::Repository::init(repo_dir.path()).unwrap();
2219        let repo =
2220            RealGitRepository::new(&repo_dir.path().join(".git"), None, cx.executor()).unwrap();
2221
2222        smol::fs::write(repo_dir.path().join("foo"), "foo")
2223            .await
2224            .unwrap();
2225        let checkpoint_sha = repo.checkpoint().await.unwrap();
2226
2227        // Ensure the user can't see any branches after creating a checkpoint.
2228        assert_eq!(repo.branches().await.unwrap().len(), 1);
2229
2230        smol::fs::write(repo_dir.path().join("foo"), "bar")
2231            .await
2232            .unwrap();
2233        smol::fs::write(repo_dir.path().join("baz"), "qux")
2234            .await
2235            .unwrap();
2236        repo.restore_checkpoint(checkpoint_sha).await.unwrap();
2237        assert_eq!(
2238            smol::fs::read_to_string(repo_dir.path().join("foo"))
2239                .await
2240                .unwrap(),
2241            "foo"
2242        );
2243        // See TODOs above
2244        // assert_eq!(
2245        //     smol::fs::read_to_string(repo_dir.path().join("baz"))
2246        //         .await
2247        //         .ok(),
2248        //     None
2249        // );
2250    }
2251
2252    #[gpui::test]
2253    async fn test_compare_checkpoints(cx: &mut TestAppContext) {
2254        cx.executor().allow_parking();
2255
2256        let repo_dir = tempfile::tempdir().unwrap();
2257        git2::Repository::init(repo_dir.path()).unwrap();
2258        let repo =
2259            RealGitRepository::new(&repo_dir.path().join(".git"), None, cx.executor()).unwrap();
2260
2261        smol::fs::write(repo_dir.path().join("file1"), "content1")
2262            .await
2263            .unwrap();
2264        let checkpoint1 = repo.checkpoint().await.unwrap();
2265
2266        smol::fs::write(repo_dir.path().join("file2"), "content2")
2267            .await
2268            .unwrap();
2269        let checkpoint2 = repo.checkpoint().await.unwrap();
2270
2271        assert!(
2272            !repo
2273                .compare_checkpoints(checkpoint1, checkpoint2.clone())
2274                .await
2275                .unwrap()
2276        );
2277
2278        let checkpoint3 = repo.checkpoint().await.unwrap();
2279        assert!(
2280            repo.compare_checkpoints(checkpoint2, checkpoint3)
2281                .await
2282                .unwrap()
2283        );
2284    }
2285
2286    #[gpui::test]
2287    async fn test_checkpoint_exclude_binary_files(cx: &mut TestAppContext) {
2288        cx.executor().allow_parking();
2289
2290        let repo_dir = tempfile::tempdir().unwrap();
2291        let text_path = repo_dir.path().join("main.rs");
2292        let bin_path = repo_dir.path().join("binary.o");
2293
2294        git2::Repository::init(repo_dir.path()).unwrap();
2295
2296        smol::fs::write(&text_path, "fn main() {}").await.unwrap();
2297
2298        smol::fs::write(&bin_path, "some binary file here")
2299            .await
2300            .unwrap();
2301
2302        let repo =
2303            RealGitRepository::new(&repo_dir.path().join(".git"), None, cx.executor()).unwrap();
2304
2305        // initial commit
2306        repo.stage_paths(
2307            vec![RepoPath::from_str("main.rs")],
2308            Arc::new(HashMap::default()),
2309        )
2310        .await
2311        .unwrap();
2312        repo.commit(
2313            "Initial commit".into(),
2314            None,
2315            CommitOptions::default(),
2316            Arc::new(checkpoint_author_envs()),
2317        )
2318        .await
2319        .unwrap();
2320
2321        let checkpoint = repo.checkpoint().await.unwrap();
2322
2323        smol::fs::write(&text_path, "fn main() { println!(\"Modified\"); }")
2324            .await
2325            .unwrap();
2326        smol::fs::write(&bin_path, "Modified binary file")
2327            .await
2328            .unwrap();
2329
2330        repo.restore_checkpoint(checkpoint).await.unwrap();
2331
2332        // Text files should be restored to checkpoint state,
2333        // but binaries should not (they aren't tracked)
2334        assert_eq!(
2335            smol::fs::read_to_string(&text_path).await.unwrap(),
2336            "fn main() {}"
2337        );
2338
2339        assert_eq!(
2340            smol::fs::read_to_string(&bin_path).await.unwrap(),
2341            "Modified binary file"
2342        );
2343    }
2344
2345    #[test]
2346    fn test_branches_parsing() {
2347        // suppress "help: octal escapes are not supported, `\0` is always null"
2348        #[allow(clippy::octal_escapes)]
2349        let input = "*\0060964da10574cd9bf06463a53bf6e0769c5c45e\0\0refs/heads/zed-patches\0refs/remotes/origin/zed-patches\0\01733187470\0generated protobuf\n";
2350        assert_eq!(
2351            parse_branch_input(input).unwrap(),
2352            vec![Branch {
2353                is_head: true,
2354                ref_name: "refs/heads/zed-patches".into(),
2355                upstream: Some(Upstream {
2356                    ref_name: "refs/remotes/origin/zed-patches".into(),
2357                    tracking: UpstreamTracking::Tracked(UpstreamTrackingStatus {
2358                        ahead: 0,
2359                        behind: 0
2360                    })
2361                }),
2362                most_recent_commit: Some(CommitSummary {
2363                    sha: "060964da10574cd9bf06463a53bf6e0769c5c45e".into(),
2364                    subject: "generated protobuf".into(),
2365                    commit_timestamp: 1733187470,
2366                    has_parent: false,
2367                })
2368            }]
2369        )
2370    }
2371
2372    impl RealGitRepository {
2373        /// Force a Git garbage collection on the repository.
2374        fn gc(&self) -> BoxFuture<'_, Result<()>> {
2375            let working_directory = self.working_directory();
2376            let git_binary_path = self.git_binary_path.clone();
2377            let executor = self.executor.clone();
2378            self.executor
2379                .spawn(async move {
2380                    let git_binary_path = git_binary_path.clone();
2381                    let working_directory = working_directory?;
2382                    let git = GitBinary::new(git_binary_path, working_directory, executor);
2383                    git.run(&["gc", "--prune"]).await?;
2384                    Ok(())
2385                })
2386                .boxed()
2387        }
2388    }
2389}