repository.rs

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