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