repository.rs

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