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