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