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