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