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