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