repository.rs

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