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