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