repository.rs

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