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