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