repository.rs

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