repository.rs

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