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