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