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