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