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
 313pub enum DiffType {
 314    HeadToIndex,
 315    HeadToWorktree,
 316}
 317
 318#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, JsonSchema)]
 319pub enum PushOptions {
 320    SetUpstream,
 321    Force,
 322}
 323
 324impl std::fmt::Debug for dyn GitRepository {
 325    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
 326        f.debug_struct("dyn GitRepository<...>").finish()
 327    }
 328}
 329
 330pub struct RealGitRepository {
 331    pub repository: Arc<Mutex<git2::Repository>>,
 332    pub git_binary_path: PathBuf,
 333}
 334
 335impl RealGitRepository {
 336    pub fn new(dotgit_path: &Path, git_binary_path: Option<PathBuf>) -> Option<Self> {
 337        let workdir_root = dotgit_path.parent()?;
 338        let repository = git2::Repository::open(workdir_root).log_err()?;
 339        Some(Self {
 340            repository: Arc::new(Mutex::new(repository)),
 341            git_binary_path: git_binary_path.unwrap_or_else(|| PathBuf::from("git")),
 342        })
 343    }
 344
 345    fn working_directory(&self) -> Result<PathBuf> {
 346        self.repository
 347            .lock()
 348            .workdir()
 349            .context("failed to read git work directory")
 350            .map(Path::to_path_buf)
 351    }
 352}
 353
 354#[derive(Copy, Clone)]
 355pub struct GitRepositoryCheckpoint {
 356    head_sha: Option<Oid>,
 357    sha: Oid,
 358}
 359
 360// https://git-scm.com/book/en/v2/Git-Internals-Git-Objects
 361const GIT_MODE_SYMLINK: u32 = 0o120000;
 362
 363impl GitRepository for RealGitRepository {
 364    fn reload_index(&self) {
 365        if let Ok(mut index) = self.repository.lock().index() {
 366            _ = index.read(false);
 367        }
 368    }
 369
 370    fn path(&self) -> PathBuf {
 371        let repo = self.repository.lock();
 372        repo.path().into()
 373    }
 374
 375    fn main_repository_path(&self) -> PathBuf {
 376        let repo = self.repository.lock();
 377        repo.commondir().into()
 378    }
 379
 380    fn show(&self, commit: String, cx: AsyncApp) -> BoxFuture<Result<CommitDetails>> {
 381        let repo = self.repository.clone();
 382        cx.background_spawn(async move {
 383            let repo = repo.lock();
 384            let Ok(commit) = repo.revparse_single(&commit)?.into_commit() else {
 385                anyhow::bail!("{} is not a commit", commit);
 386            };
 387            let details = CommitDetails {
 388                sha: commit.id().to_string().into(),
 389                message: String::from_utf8_lossy(commit.message_raw_bytes())
 390                    .to_string()
 391                    .into(),
 392                commit_timestamp: commit.time().seconds(),
 393                committer_email: String::from_utf8_lossy(commit.committer().email_bytes())
 394                    .to_string()
 395                    .into(),
 396                committer_name: String::from_utf8_lossy(commit.committer().name_bytes())
 397                    .to_string()
 398                    .into(),
 399            };
 400            Ok(details)
 401        })
 402        .boxed()
 403    }
 404
 405    fn reset(
 406        &self,
 407        commit: String,
 408        mode: ResetMode,
 409        env: HashMap<String, String>,
 410    ) -> BoxFuture<Result<()>> {
 411        async move {
 412            let working_directory = self.working_directory();
 413
 414            let mode_flag = match mode {
 415                ResetMode::Mixed => "--mixed",
 416                ResetMode::Soft => "--soft",
 417            };
 418
 419            let output = new_smol_command(&self.git_binary_path)
 420                .envs(env)
 421                .current_dir(&working_directory?)
 422                .args(["reset", mode_flag, &commit])
 423                .output()
 424                .await?;
 425            if !output.status.success() {
 426                return Err(anyhow!(
 427                    "Failed to reset:\n{}",
 428                    String::from_utf8_lossy(&output.stderr)
 429                ));
 430            }
 431            Ok(())
 432        }
 433        .boxed()
 434    }
 435
 436    fn checkout_files(
 437        &self,
 438        commit: String,
 439        paths: Vec<RepoPath>,
 440        env: HashMap<String, String>,
 441    ) -> BoxFuture<Result<()>> {
 442        let working_directory = self.working_directory();
 443        let git_binary_path = self.git_binary_path.clone();
 444        async move {
 445            if paths.is_empty() {
 446                return Ok(());
 447            }
 448
 449            let output = new_smol_command(&git_binary_path)
 450                .current_dir(&working_directory?)
 451                .envs(env)
 452                .args(["checkout", &commit, "--"])
 453                .args(paths.iter().map(|path| path.as_ref()))
 454                .output()
 455                .await?;
 456            if !output.status.success() {
 457                return Err(anyhow!(
 458                    "Failed to checkout files:\n{}",
 459                    String::from_utf8_lossy(&output.stderr)
 460                ));
 461            }
 462            Ok(())
 463        }
 464        .boxed()
 465    }
 466
 467    fn load_index_text(&self, path: RepoPath, cx: AsyncApp) -> BoxFuture<Option<String>> {
 468        let repo = self.repository.clone();
 469        cx.background_spawn(async move {
 470            fn logic(repo: &git2::Repository, path: &RepoPath) -> Result<Option<String>> {
 471                const STAGE_NORMAL: i32 = 0;
 472                let index = repo.index()?;
 473
 474                // This check is required because index.get_path() unwraps internally :(
 475                check_path_to_repo_path_errors(path)?;
 476
 477                let oid = match index.get_path(path, STAGE_NORMAL) {
 478                    Some(entry) if entry.mode != GIT_MODE_SYMLINK => entry.id,
 479                    _ => return Ok(None),
 480                };
 481
 482                let content = repo.find_blob(oid)?.content().to_owned();
 483                Ok(Some(String::from_utf8(content)?))
 484            }
 485            match logic(&repo.lock(), &path) {
 486                Ok(value) => return value,
 487                Err(err) => log::error!("Error loading index text: {:?}", err),
 488            }
 489            None
 490        })
 491        .boxed()
 492    }
 493
 494    fn load_committed_text(&self, path: RepoPath, cx: AsyncApp) -> BoxFuture<Option<String>> {
 495        let repo = self.repository.clone();
 496        cx.background_spawn(async move {
 497            let repo = repo.lock();
 498            let head = repo.head().ok()?.peel_to_tree().log_err()?;
 499            let entry = head.get_path(&path).ok()?;
 500            if entry.filemode() == i32::from(git2::FileMode::Link) {
 501                return None;
 502            }
 503            let content = repo.find_blob(entry.id()).log_err()?.content().to_owned();
 504            let content = String::from_utf8(content).log_err()?;
 505            Some(content)
 506        })
 507        .boxed()
 508    }
 509
 510    fn set_index_text(
 511        &self,
 512        path: RepoPath,
 513        content: Option<String>,
 514        env: HashMap<String, String>,
 515        cx: AsyncApp,
 516    ) -> BoxFuture<anyhow::Result<()>> {
 517        let working_directory = self.working_directory();
 518        let git_binary_path = self.git_binary_path.clone();
 519        cx.background_spawn(async move {
 520            let working_directory = working_directory?;
 521            if let Some(content) = content {
 522                let mut child = new_smol_command(&git_binary_path)
 523                    .current_dir(&working_directory)
 524                    .envs(&env)
 525                    .args(["hash-object", "-w", "--stdin"])
 526                    .stdin(Stdio::piped())
 527                    .stdout(Stdio::piped())
 528                    .spawn()?;
 529                child
 530                    .stdin
 531                    .take()
 532                    .unwrap()
 533                    .write_all(content.as_bytes())
 534                    .await?;
 535                let output = child.output().await?.stdout;
 536                let sha = String::from_utf8(output)?;
 537
 538                log::debug!("indexing SHA: {sha}, path {path:?}");
 539
 540                let output = new_smol_command(&git_binary_path)
 541                    .current_dir(&working_directory)
 542                    .envs(env)
 543                    .args(["update-index", "--add", "--cacheinfo", "100644", &sha])
 544                    .arg(path.as_ref())
 545                    .output()
 546                    .await?;
 547
 548                if !output.status.success() {
 549                    return Err(anyhow!(
 550                        "Failed to stage:\n{}",
 551                        String::from_utf8_lossy(&output.stderr)
 552                    ));
 553                }
 554            } else {
 555                let output = new_smol_command(&git_binary_path)
 556                    .current_dir(&working_directory)
 557                    .envs(env)
 558                    .args(["update-index", "--force-remove"])
 559                    .arg(path.as_ref())
 560                    .output()
 561                    .await?;
 562
 563                if !output.status.success() {
 564                    return Err(anyhow!(
 565                        "Failed to unstage:\n{}",
 566                        String::from_utf8_lossy(&output.stderr)
 567                    ));
 568                }
 569            }
 570
 571            Ok(())
 572        })
 573        .boxed()
 574    }
 575
 576    fn remote_url(&self, name: &str) -> Option<String> {
 577        let repo = self.repository.lock();
 578        let remote = repo.find_remote(name).ok()?;
 579        remote.url().map(|url| url.to_string())
 580    }
 581
 582    fn head_sha(&self) -> Option<String> {
 583        Some(self.repository.lock().head().ok()?.target()?.to_string())
 584    }
 585
 586    fn merge_head_shas(&self) -> Vec<String> {
 587        let mut shas = Vec::default();
 588        self.repository
 589            .lock()
 590            .mergehead_foreach(|oid| {
 591                shas.push(oid.to_string());
 592                true
 593            })
 594            .ok();
 595        if let Some(oid) = self
 596            .repository
 597            .lock()
 598            .find_reference("CHERRY_PICK_HEAD")
 599            .ok()
 600            .and_then(|reference| reference.target())
 601        {
 602            shas.push(oid.to_string())
 603        }
 604        shas
 605    }
 606
 607    fn status(&self, path_prefixes: &[RepoPath]) -> Result<GitStatus> {
 608        let working_directory = self
 609            .repository
 610            .lock()
 611            .workdir()
 612            .context("failed to read git work directory")?
 613            .to_path_buf();
 614        GitStatus::new(&self.git_binary_path, &working_directory, path_prefixes)
 615    }
 616
 617    fn branches(&self) -> BoxFuture<Result<Vec<Branch>>> {
 618        let working_directory = self.working_directory();
 619        let git_binary_path = self.git_binary_path.clone();
 620        async move {
 621            let fields = [
 622                "%(HEAD)",
 623                "%(objectname)",
 624                "%(parent)",
 625                "%(refname)",
 626                "%(upstream)",
 627                "%(upstream:track)",
 628                "%(committerdate:unix)",
 629                "%(contents:subject)",
 630            ]
 631            .join("%00");
 632            let args = vec!["for-each-ref", "refs/heads/**/*", "--format", &fields];
 633            let working_directory = working_directory?;
 634            let output = new_smol_command(&git_binary_path)
 635                .current_dir(&working_directory)
 636                .args(args)
 637                .output()
 638                .await?;
 639
 640            if !output.status.success() {
 641                return Err(anyhow!(
 642                    "Failed to git git branches:\n{}",
 643                    String::from_utf8_lossy(&output.stderr)
 644                ));
 645            }
 646
 647            let input = String::from_utf8_lossy(&output.stdout);
 648
 649            let mut branches = parse_branch_input(&input)?;
 650            if branches.is_empty() {
 651                let args = vec!["symbolic-ref", "--quiet", "--short", "HEAD"];
 652
 653                let output = new_smol_command(&git_binary_path)
 654                    .current_dir(&working_directory)
 655                    .args(args)
 656                    .output()
 657                    .await?;
 658
 659                // git symbolic-ref returns a non-0 exit code if HEAD points
 660                // to something other than a branch
 661                if output.status.success() {
 662                    let name = String::from_utf8_lossy(&output.stdout).trim().to_string();
 663
 664                    branches.push(Branch {
 665                        name: name.into(),
 666                        is_head: true,
 667                        upstream: None,
 668                        most_recent_commit: None,
 669                    });
 670                }
 671            }
 672
 673            Ok(branches)
 674        }
 675        .boxed()
 676    }
 677
 678    fn change_branch(&self, name: String, cx: AsyncApp) -> BoxFuture<Result<()>> {
 679        let repo = self.repository.clone();
 680        cx.background_spawn(async move {
 681            let repo = repo.lock();
 682            let revision = repo.find_branch(&name, BranchType::Local)?;
 683            let revision = revision.get();
 684            let as_tree = revision.peel_to_tree()?;
 685            repo.checkout_tree(as_tree.as_object(), None)?;
 686            repo.set_head(
 687                revision
 688                    .name()
 689                    .ok_or_else(|| anyhow!("Branch name could not be retrieved"))?,
 690            )?;
 691            Ok(())
 692        })
 693        .boxed()
 694    }
 695
 696    fn create_branch(&self, name: String, cx: AsyncApp) -> BoxFuture<Result<()>> {
 697        let repo = self.repository.clone();
 698        cx.background_spawn(async move {
 699            let repo = repo.lock();
 700            let current_commit = repo.head()?.peel_to_commit()?;
 701            repo.branch(&name, &current_commit, false)?;
 702            Ok(())
 703        })
 704        .boxed()
 705    }
 706
 707    fn blame(
 708        &self,
 709        path: RepoPath,
 710        content: Rope,
 711        cx: &mut AsyncApp,
 712    ) -> BoxFuture<Result<crate::blame::Blame>> {
 713        let working_directory = self.working_directory();
 714        let git_binary_path = self.git_binary_path.clone();
 715
 716        const REMOTE_NAME: &str = "origin";
 717        let remote_url = self.remote_url(REMOTE_NAME);
 718
 719        cx.background_spawn(async move {
 720            crate::blame::Blame::for_path(
 721                &git_binary_path,
 722                &working_directory?,
 723                &path,
 724                &content,
 725                remote_url,
 726            )
 727            .await
 728        })
 729        .boxed()
 730    }
 731
 732    fn diff(&self, diff: DiffType, cx: AsyncApp) -> BoxFuture<Result<String>> {
 733        let working_directory = self.working_directory();
 734        let git_binary_path = self.git_binary_path.clone();
 735        cx.background_spawn(async move {
 736            let args = match diff {
 737                DiffType::HeadToIndex => Some("--staged"),
 738                DiffType::HeadToWorktree => None,
 739            };
 740
 741            let output = new_smol_command(&git_binary_path)
 742                .current_dir(&working_directory?)
 743                .args(["diff"])
 744                .args(args)
 745                .output()
 746                .await?;
 747
 748            if !output.status.success() {
 749                return Err(anyhow!(
 750                    "Failed to run git diff:\n{}",
 751                    String::from_utf8_lossy(&output.stderr)
 752                ));
 753            }
 754            Ok(String::from_utf8_lossy(&output.stdout).to_string())
 755        })
 756        .boxed()
 757    }
 758
 759    fn stage_paths(
 760        &self,
 761        paths: Vec<RepoPath>,
 762        env: HashMap<String, String>,
 763        cx: AsyncApp,
 764    ) -> BoxFuture<Result<()>> {
 765        let working_directory = self.working_directory();
 766        let git_binary_path = self.git_binary_path.clone();
 767        cx.background_spawn(async move {
 768            if !paths.is_empty() {
 769                let output = new_smol_command(&git_binary_path)
 770                    .current_dir(&working_directory?)
 771                    .envs(env)
 772                    .args(["update-index", "--add", "--remove", "--"])
 773                    .args(paths.iter().map(|p| p.as_ref()))
 774                    .output()
 775                    .await?;
 776
 777                if !output.status.success() {
 778                    return Err(anyhow!(
 779                        "Failed to stage paths:\n{}",
 780                        String::from_utf8_lossy(&output.stderr)
 781                    ));
 782                }
 783            }
 784            Ok(())
 785        })
 786        .boxed()
 787    }
 788
 789    fn unstage_paths(
 790        &self,
 791        paths: Vec<RepoPath>,
 792        env: HashMap<String, String>,
 793        cx: AsyncApp,
 794    ) -> BoxFuture<Result<()>> {
 795        let working_directory = self.working_directory();
 796        let git_binary_path = self.git_binary_path.clone();
 797
 798        cx.background_spawn(async move {
 799            if !paths.is_empty() {
 800                let output = new_smol_command(&git_binary_path)
 801                    .current_dir(&working_directory?)
 802                    .envs(env)
 803                    .args(["reset", "--quiet", "--"])
 804                    .args(paths.iter().map(|p| p.as_ref()))
 805                    .output()
 806                    .await?;
 807
 808                if !output.status.success() {
 809                    return Err(anyhow!(
 810                        "Failed to unstage:\n{}",
 811                        String::from_utf8_lossy(&output.stderr)
 812                    ));
 813                }
 814            }
 815            Ok(())
 816        })
 817        .boxed()
 818    }
 819
 820    fn commit(
 821        &self,
 822        message: SharedString,
 823        name_and_email: Option<(SharedString, SharedString)>,
 824        env: HashMap<String, String>,
 825        cx: AsyncApp,
 826    ) -> BoxFuture<Result<()>> {
 827        let working_directory = self.working_directory();
 828        cx.background_spawn(async move {
 829            let mut cmd = new_smol_command("git");
 830            cmd.current_dir(&working_directory?)
 831                .envs(env)
 832                .args(["commit", "--quiet", "-m"])
 833                .arg(&message.to_string())
 834                .arg("--cleanup=strip");
 835
 836            if let Some((name, email)) = name_and_email {
 837                cmd.arg("--author").arg(&format!("{name} <{email}>"));
 838            }
 839
 840            let output = cmd.output().await?;
 841
 842            if !output.status.success() {
 843                return Err(anyhow!(
 844                    "Failed to commit:\n{}",
 845                    String::from_utf8_lossy(&output.stderr)
 846                ));
 847            }
 848            Ok(())
 849        })
 850        .boxed()
 851    }
 852
 853    fn push(
 854        &self,
 855        branch_name: String,
 856        remote_name: String,
 857        options: Option<PushOptions>,
 858        ask_pass: AskPassSession,
 859        env: HashMap<String, String>,
 860        // note: git push *must* be started on the main thread for
 861        // git-credentials manager to work (hence taking an AsyncApp)
 862        _cx: AsyncApp,
 863    ) -> BoxFuture<Result<RemoteCommandOutput>> {
 864        let working_directory = self.working_directory();
 865        async move {
 866            let working_directory = working_directory?;
 867
 868            let mut command = new_smol_command("git");
 869            command
 870                .envs(env)
 871                .env("GIT_ASKPASS", ask_pass.script_path())
 872                .env("SSH_ASKPASS", ask_pass.script_path())
 873                .env("SSH_ASKPASS_REQUIRE", "force")
 874                .env("GIT_HTTP_USER_AGENT", "Zed")
 875                .current_dir(&working_directory)
 876                .args(["push"])
 877                .args(options.map(|option| match option {
 878                    PushOptions::SetUpstream => "--set-upstream",
 879                    PushOptions::Force => "--force-with-lease",
 880                }))
 881                .arg(remote_name)
 882                .arg(format!("{}:{}", branch_name, branch_name))
 883                .stdin(smol::process::Stdio::null())
 884                .stdout(smol::process::Stdio::piped())
 885                .stderr(smol::process::Stdio::piped());
 886            let git_process = command.spawn()?;
 887
 888            run_remote_command(ask_pass, git_process).await
 889        }
 890        .boxed()
 891    }
 892
 893    fn pull(
 894        &self,
 895        branch_name: String,
 896        remote_name: String,
 897        ask_pass: AskPassSession,
 898        env: HashMap<String, String>,
 899        _cx: AsyncApp,
 900    ) -> BoxFuture<Result<RemoteCommandOutput>> {
 901        let working_directory = self.working_directory();
 902        async {
 903            let mut command = new_smol_command("git");
 904            command
 905                .envs(env)
 906                .env("GIT_ASKPASS", ask_pass.script_path())
 907                .env("SSH_ASKPASS", ask_pass.script_path())
 908                .env("SSH_ASKPASS_REQUIRE", "force")
 909                .current_dir(&working_directory?)
 910                .args(["pull"])
 911                .arg(remote_name)
 912                .arg(branch_name)
 913                .stdout(smol::process::Stdio::piped())
 914                .stderr(smol::process::Stdio::piped());
 915            let git_process = command.spawn()?;
 916
 917            run_remote_command(ask_pass, git_process).await
 918        }
 919        .boxed()
 920    }
 921
 922    fn fetch(
 923        &self,
 924        ask_pass: AskPassSession,
 925        env: HashMap<String, String>,
 926        _cx: AsyncApp,
 927    ) -> BoxFuture<Result<RemoteCommandOutput>> {
 928        let working_directory = self.working_directory();
 929        async {
 930            let mut command = new_smol_command("git");
 931            command
 932                .envs(env)
 933                .env("GIT_ASKPASS", ask_pass.script_path())
 934                .env("SSH_ASKPASS", ask_pass.script_path())
 935                .env("SSH_ASKPASS_REQUIRE", "force")
 936                .current_dir(&working_directory?)
 937                .args(["fetch", "--all"])
 938                .stdout(smol::process::Stdio::piped())
 939                .stderr(smol::process::Stdio::piped());
 940            let git_process = command.spawn()?;
 941
 942            run_remote_command(ask_pass, git_process).await
 943        }
 944        .boxed()
 945    }
 946
 947    fn get_remotes(
 948        &self,
 949        branch_name: Option<String>,
 950        cx: AsyncApp,
 951    ) -> BoxFuture<Result<Vec<Remote>>> {
 952        let working_directory = self.working_directory();
 953        let git_binary_path = self.git_binary_path.clone();
 954        cx.background_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, cx: AsyncApp) -> BoxFuture<Result<Vec<SharedString>>> {
1000        let working_directory = self.working_directory();
1001        let git_binary_path = self.git_binary_path.clone();
1002        cx.background_spawn(async move {
1003            let working_directory = working_directory?;
1004            let git_cmd = async |args: &[&str]| -> Result<String> {
1005                let output = new_smol_command(&git_binary_path)
1006                    .current_dir(&working_directory)
1007                    .args(args)
1008                    .output()
1009                    .await?;
1010                if output.status.success() {
1011                    Ok(String::from_utf8(output.stdout)?)
1012                } else {
1013                    Err(anyhow!(String::from_utf8_lossy(&output.stderr).to_string()))
1014                }
1015            };
1016
1017            let head = git_cmd(&["rev-parse", "HEAD"])
1018                .await
1019                .context("Failed to get HEAD")?
1020                .trim()
1021                .to_owned();
1022
1023            let mut remote_branches = vec![];
1024            let mut add_if_matching = async |remote_head: &str| {
1025                if let Ok(merge_base) = git_cmd(&["merge-base", &head, remote_head]).await {
1026                    if merge_base.trim() == head {
1027                        if let Some(s) = remote_head.strip_prefix("refs/remotes/") {
1028                            remote_branches.push(s.to_owned().into());
1029                        }
1030                    }
1031                }
1032            };
1033
1034            // check the main branch of each remote
1035            let remotes = git_cmd(&["remote"])
1036                .await
1037                .context("Failed to get remotes")?;
1038            for remote in remotes.lines() {
1039                if let Ok(remote_head) =
1040                    git_cmd(&["symbolic-ref", &format!("refs/remotes/{remote}/HEAD")]).await
1041                {
1042                    add_if_matching(remote_head.trim()).await;
1043                }
1044            }
1045
1046            // ... and the remote branch that the checked-out one is tracking
1047            if let Ok(remote_head) = git_cmd(&["rev-parse", "--symbolic-full-name", "@{u}"]).await {
1048                add_if_matching(remote_head.trim()).await;
1049            }
1050
1051            Ok(remote_branches)
1052        })
1053        .boxed()
1054    }
1055
1056    fn checkpoint(&self, cx: AsyncApp) -> BoxFuture<Result<GitRepositoryCheckpoint>> {
1057        let working_directory = self.working_directory();
1058        let git_binary_path = self.git_binary_path.clone();
1059        let executor = cx.background_executor().clone();
1060        cx.background_spawn(async move {
1061            let working_directory = working_directory?;
1062            let mut git = GitBinary::new(git_binary_path, working_directory, executor)
1063                .envs(checkpoint_author_envs());
1064            git.with_temp_index(async |git| {
1065                let head_sha = git.run(&["rev-parse", "HEAD"]).await.ok();
1066                git.run(&["add", "--all"]).await?;
1067                let tree = git.run(&["write-tree"]).await?;
1068                let checkpoint_sha = if let Some(head_sha) = head_sha.as_deref() {
1069                    git.run(&["commit-tree", &tree, "-p", head_sha, "-m", "Checkpoint"])
1070                        .await?
1071                } else {
1072                    git.run(&["commit-tree", &tree, "-m", "Checkpoint"]).await?
1073                };
1074                let ref_name = Uuid::new_v4().to_string();
1075                git.run(&[
1076                    "update-ref",
1077                    &format!("refs/zed/{ref_name}"),
1078                    &checkpoint_sha,
1079                ])
1080                .await?;
1081
1082                Ok(GitRepositoryCheckpoint {
1083                    head_sha: if let Some(head_sha) = head_sha {
1084                        Some(head_sha.parse()?)
1085                    } else {
1086                        None
1087                    },
1088                    sha: checkpoint_sha.parse()?,
1089                })
1090            })
1091            .await
1092        })
1093        .boxed()
1094    }
1095
1096    fn restore_checkpoint(
1097        &self,
1098        checkpoint: GitRepositoryCheckpoint,
1099        cx: AsyncApp,
1100    ) -> BoxFuture<Result<()>> {
1101        let working_directory = self.working_directory();
1102        let git_binary_path = self.git_binary_path.clone();
1103
1104        let executor = cx.background_executor().clone();
1105        cx.background_spawn(async move {
1106            let working_directory = working_directory?;
1107
1108            let mut git = GitBinary::new(git_binary_path, working_directory, executor);
1109            git.run(&[
1110                "restore",
1111                "--source",
1112                &checkpoint.sha.to_string(),
1113                "--worktree",
1114                ".",
1115            ])
1116            .await?;
1117
1118            git.with_temp_index(async move |git| {
1119                git.run(&["read-tree", &checkpoint.sha.to_string()]).await?;
1120                git.run(&["clean", "-d", "--force"]).await
1121            })
1122            .await?;
1123
1124            if let Some(head_sha) = checkpoint.head_sha {
1125                git.run(&["reset", "--mixed", &head_sha.to_string()])
1126                    .await?;
1127            } else {
1128                git.run(&["update-ref", "-d", "HEAD"]).await?;
1129            }
1130
1131            Ok(())
1132        })
1133        .boxed()
1134    }
1135
1136    fn compare_checkpoints(
1137        &self,
1138        left: GitRepositoryCheckpoint,
1139        right: GitRepositoryCheckpoint,
1140        cx: AsyncApp,
1141    ) -> BoxFuture<Result<bool>> {
1142        if left.head_sha != right.head_sha {
1143            return future::ready(Ok(false)).boxed();
1144        }
1145
1146        let working_directory = self.working_directory();
1147        let git_binary_path = self.git_binary_path.clone();
1148
1149        let executor = cx.background_executor().clone();
1150        cx.background_spawn(async move {
1151            let working_directory = working_directory?;
1152            let git = GitBinary::new(git_binary_path, working_directory, executor);
1153            let result = git
1154                .run(&[
1155                    "diff-tree",
1156                    "--quiet",
1157                    &left.sha.to_string(),
1158                    &right.sha.to_string(),
1159                ])
1160                .await;
1161            match result {
1162                Ok(_) => Ok(true),
1163                Err(error) => {
1164                    if let Some(GitBinaryCommandError { status, .. }) =
1165                        error.downcast_ref::<GitBinaryCommandError>()
1166                    {
1167                        if status.code() == Some(1) {
1168                            return Ok(false);
1169                        }
1170                    }
1171
1172                    Err(error)
1173                }
1174            }
1175        })
1176        .boxed()
1177    }
1178}
1179
1180struct GitBinary {
1181    git_binary_path: PathBuf,
1182    working_directory: PathBuf,
1183    executor: BackgroundExecutor,
1184    index_file_path: Option<PathBuf>,
1185    envs: HashMap<String, String>,
1186}
1187
1188impl GitBinary {
1189    fn new(
1190        git_binary_path: PathBuf,
1191        working_directory: PathBuf,
1192        executor: BackgroundExecutor,
1193    ) -> Self {
1194        Self {
1195            git_binary_path,
1196            working_directory,
1197            executor,
1198            index_file_path: None,
1199            envs: HashMap::default(),
1200        }
1201    }
1202
1203    fn envs(mut self, envs: HashMap<String, String>) -> Self {
1204        self.envs = envs;
1205        self
1206    }
1207
1208    pub async fn with_temp_index<R>(
1209        &mut self,
1210        f: impl AsyncFnOnce(&Self) -> Result<R>,
1211    ) -> Result<R> {
1212        let index_file_path = self.working_directory.join(".git/index.tmp");
1213
1214        let delete_temp_index = util::defer({
1215            let index_file_path = index_file_path.clone();
1216            let executor = self.executor.clone();
1217            move || {
1218                executor
1219                    .spawn(async move {
1220                        smol::fs::remove_file(index_file_path).await.log_err();
1221                    })
1222                    .detach();
1223            }
1224        });
1225
1226        self.index_file_path = Some(index_file_path.clone());
1227        let result = f(self).await;
1228        self.index_file_path = None;
1229        let result = result?;
1230
1231        smol::fs::remove_file(index_file_path).await.ok();
1232        delete_temp_index.abort();
1233
1234        Ok(result)
1235    }
1236
1237    pub async fn run(&self, args: &[&str]) -> Result<String> {
1238        let mut command = new_smol_command(&self.git_binary_path);
1239        command.current_dir(&self.working_directory);
1240        command.args(args);
1241        if let Some(index_file_path) = self.index_file_path.as_ref() {
1242            command.env("GIT_INDEX_FILE", index_file_path);
1243        }
1244        command.envs(&self.envs);
1245        let output = command.output().await?;
1246        if output.status.success() {
1247            anyhow::Ok(String::from_utf8(output.stdout)?.trim_end().to_string())
1248        } else {
1249            Err(anyhow!(GitBinaryCommandError {
1250                stdout: String::from_utf8_lossy(&output.stdout).to_string(),
1251                status: output.status,
1252            }))
1253        }
1254    }
1255}
1256
1257#[derive(Error, Debug)]
1258#[error("Git command failed: {stdout}")]
1259struct GitBinaryCommandError {
1260    stdout: String,
1261    status: ExitStatus,
1262}
1263
1264async fn run_remote_command(
1265    mut ask_pass: AskPassSession,
1266    git_process: smol::process::Child,
1267) -> std::result::Result<RemoteCommandOutput, anyhow::Error> {
1268    select_biased! {
1269        result = ask_pass.run().fuse() => {
1270            match result {
1271                AskPassResult::CancelledByUser => {
1272                    Err(anyhow!(REMOTE_CANCELLED_BY_USER))?
1273                }
1274                AskPassResult::Timedout => {
1275                    Err(anyhow!("Connecting to host timed out"))?
1276                }
1277            }
1278        }
1279        output = git_process.output().fuse() => {
1280            let output = output?;
1281            if !output.status.success() {
1282                Err(anyhow!(
1283                    "{}",
1284                    String::from_utf8_lossy(&output.stderr)
1285                ))
1286            } else {
1287                Ok(RemoteCommandOutput {
1288                    stdout: String::from_utf8_lossy(&output.stdout).to_string(),
1289                    stderr: String::from_utf8_lossy(&output.stderr).to_string(),
1290                })
1291            }
1292        }
1293    }
1294}
1295
1296pub static WORK_DIRECTORY_REPO_PATH: LazyLock<RepoPath> =
1297    LazyLock::new(|| RepoPath(Path::new("").into()));
1298
1299#[derive(Clone, Debug, Ord, Hash, PartialOrd, Eq, PartialEq)]
1300pub struct RepoPath(pub Arc<Path>);
1301
1302impl RepoPath {
1303    pub fn new(path: PathBuf) -> Self {
1304        debug_assert!(path.is_relative(), "Repo paths must be relative");
1305
1306        RepoPath(path.into())
1307    }
1308
1309    pub fn from_str(path: &str) -> Self {
1310        let path = Path::new(path);
1311        debug_assert!(path.is_relative(), "Repo paths must be relative");
1312
1313        RepoPath(path.into())
1314    }
1315}
1316
1317impl std::fmt::Display for RepoPath {
1318    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1319        self.0.to_string_lossy().fmt(f)
1320    }
1321}
1322
1323impl From<&Path> for RepoPath {
1324    fn from(value: &Path) -> Self {
1325        RepoPath::new(value.into())
1326    }
1327}
1328
1329impl From<Arc<Path>> for RepoPath {
1330    fn from(value: Arc<Path>) -> Self {
1331        RepoPath(value)
1332    }
1333}
1334
1335impl From<PathBuf> for RepoPath {
1336    fn from(value: PathBuf) -> Self {
1337        RepoPath::new(value)
1338    }
1339}
1340
1341impl From<&str> for RepoPath {
1342    fn from(value: &str) -> Self {
1343        Self::from_str(value)
1344    }
1345}
1346
1347impl Default for RepoPath {
1348    fn default() -> Self {
1349        RepoPath(Path::new("").into())
1350    }
1351}
1352
1353impl AsRef<Path> for RepoPath {
1354    fn as_ref(&self) -> &Path {
1355        self.0.as_ref()
1356    }
1357}
1358
1359impl std::ops::Deref for RepoPath {
1360    type Target = Path;
1361
1362    fn deref(&self) -> &Self::Target {
1363        &self.0
1364    }
1365}
1366
1367impl Borrow<Path> for RepoPath {
1368    fn borrow(&self) -> &Path {
1369        self.0.as_ref()
1370    }
1371}
1372
1373#[derive(Debug)]
1374pub struct RepoPathDescendants<'a>(pub &'a Path);
1375
1376impl MapSeekTarget<RepoPath> for RepoPathDescendants<'_> {
1377    fn cmp_cursor(&self, key: &RepoPath) -> Ordering {
1378        if key.starts_with(self.0) {
1379            Ordering::Greater
1380        } else {
1381            self.0.cmp(key)
1382        }
1383    }
1384}
1385
1386fn parse_branch_input(input: &str) -> Result<Vec<Branch>> {
1387    let mut branches = Vec::new();
1388    for line in input.split('\n') {
1389        if line.is_empty() {
1390            continue;
1391        }
1392        let mut fields = line.split('\x00');
1393        let is_current_branch = fields.next().context("no HEAD")? == "*";
1394        let head_sha: SharedString = fields.next().context("no objectname")?.to_string().into();
1395        let parent_sha: SharedString = fields.next().context("no parent")?.to_string().into();
1396        let ref_name: SharedString = fields
1397            .next()
1398            .context("no refname")?
1399            .strip_prefix("refs/heads/")
1400            .context("unexpected format for refname")?
1401            .to_string()
1402            .into();
1403        let upstream_name = fields.next().context("no upstream")?.to_string();
1404        let upstream_tracking = parse_upstream_track(fields.next().context("no upstream:track")?)?;
1405        let commiterdate = fields.next().context("no committerdate")?.parse::<i64>()?;
1406        let subject: SharedString = fields
1407            .next()
1408            .context("no contents:subject")?
1409            .to_string()
1410            .into();
1411
1412        branches.push(Branch {
1413            is_head: is_current_branch,
1414            name: ref_name,
1415            most_recent_commit: Some(CommitSummary {
1416                sha: head_sha,
1417                subject,
1418                commit_timestamp: commiterdate,
1419                has_parent: !parent_sha.is_empty(),
1420            }),
1421            upstream: if upstream_name.is_empty() {
1422                None
1423            } else {
1424                Some(Upstream {
1425                    ref_name: upstream_name.into(),
1426                    tracking: upstream_tracking,
1427                })
1428            },
1429        })
1430    }
1431
1432    Ok(branches)
1433}
1434
1435fn parse_upstream_track(upstream_track: &str) -> Result<UpstreamTracking> {
1436    if upstream_track == "" {
1437        return Ok(UpstreamTracking::Tracked(UpstreamTrackingStatus {
1438            ahead: 0,
1439            behind: 0,
1440        }));
1441    }
1442
1443    let upstream_track = upstream_track
1444        .strip_prefix("[")
1445        .ok_or_else(|| anyhow!("missing ["))?;
1446    let upstream_track = upstream_track
1447        .strip_suffix("]")
1448        .ok_or_else(|| anyhow!("missing ["))?;
1449    let mut ahead: u32 = 0;
1450    let mut behind: u32 = 0;
1451    for component in upstream_track.split(", ") {
1452        if component == "gone" {
1453            return Ok(UpstreamTracking::Gone);
1454        }
1455        if let Some(ahead_num) = component.strip_prefix("ahead ") {
1456            ahead = ahead_num.parse::<u32>()?;
1457        }
1458        if let Some(behind_num) = component.strip_prefix("behind ") {
1459            behind = behind_num.parse::<u32>()?;
1460        }
1461    }
1462    Ok(UpstreamTracking::Tracked(UpstreamTrackingStatus {
1463        ahead,
1464        behind,
1465    }))
1466}
1467
1468fn check_path_to_repo_path_errors(relative_file_path: &Path) -> Result<()> {
1469    match relative_file_path.components().next() {
1470        None => anyhow::bail!("repo path should not be empty"),
1471        Some(Component::Prefix(_)) => anyhow::bail!(
1472            "repo path `{}` should be relative, not a windows prefix",
1473            relative_file_path.to_string_lossy()
1474        ),
1475        Some(Component::RootDir) => {
1476            anyhow::bail!(
1477                "repo path `{}` should be relative",
1478                relative_file_path.to_string_lossy()
1479            )
1480        }
1481        Some(Component::CurDir) => {
1482            anyhow::bail!(
1483                "repo path `{}` should not start with `.`",
1484                relative_file_path.to_string_lossy()
1485            )
1486        }
1487        Some(Component::ParentDir) => {
1488            anyhow::bail!(
1489                "repo path `{}` should not start with `..`",
1490                relative_file_path.to_string_lossy()
1491            )
1492        }
1493        _ => Ok(()),
1494    }
1495}
1496
1497fn checkpoint_author_envs() -> HashMap<String, String> {
1498    HashMap::from_iter([
1499        ("GIT_AUTHOR_NAME".to_string(), "Zed".to_string()),
1500        ("GIT_AUTHOR_EMAIL".to_string(), "hi@zed.dev".to_string()),
1501        ("GIT_COMMITTER_NAME".to_string(), "Zed".to_string()),
1502        ("GIT_COMMITTER_EMAIL".to_string(), "hi@zed.dev".to_string()),
1503    ])
1504}
1505
1506#[cfg(test)]
1507mod tests {
1508    use super::*;
1509    use crate::status::FileStatus;
1510    use gpui::TestAppContext;
1511
1512    #[gpui::test]
1513    async fn test_checkpoint_basic(cx: &mut TestAppContext) {
1514        cx.executor().allow_parking();
1515
1516        let repo_dir = tempfile::tempdir().unwrap();
1517
1518        git2::Repository::init(repo_dir.path()).unwrap();
1519        let file_path = repo_dir.path().join("file");
1520        smol::fs::write(&file_path, "initial").await.unwrap();
1521
1522        let repo = RealGitRepository::new(&repo_dir.path().join(".git"), None).unwrap();
1523        repo.stage_paths(
1524            vec![RepoPath::from_str("file")],
1525            HashMap::default(),
1526            cx.to_async(),
1527        )
1528        .await
1529        .unwrap();
1530        repo.commit(
1531            "Initial commit".into(),
1532            None,
1533            checkpoint_author_envs(),
1534            cx.to_async(),
1535        )
1536        .await
1537        .unwrap();
1538
1539        smol::fs::write(&file_path, "modified before checkpoint")
1540            .await
1541            .unwrap();
1542        smol::fs::write(repo_dir.path().join("new_file_before_checkpoint"), "1")
1543            .await
1544            .unwrap();
1545        let sha_before_checkpoint = repo.head_sha().unwrap();
1546        let checkpoint = repo.checkpoint(cx.to_async()).await.unwrap();
1547
1548        // Ensure the user can't see any branches after creating a checkpoint.
1549        assert_eq!(repo.branches().await.unwrap().len(), 1);
1550
1551        smol::fs::write(&file_path, "modified after checkpoint")
1552            .await
1553            .unwrap();
1554        repo.stage_paths(
1555            vec![RepoPath::from_str("file")],
1556            HashMap::default(),
1557            cx.to_async(),
1558        )
1559        .await
1560        .unwrap();
1561        repo.commit(
1562            "Commit after checkpoint".into(),
1563            None,
1564            checkpoint_author_envs(),
1565            cx.to_async(),
1566        )
1567        .await
1568        .unwrap();
1569
1570        smol::fs::remove_file(repo_dir.path().join("new_file_before_checkpoint"))
1571            .await
1572            .unwrap();
1573        smol::fs::write(repo_dir.path().join("new_file_after_checkpoint"), "2")
1574            .await
1575            .unwrap();
1576
1577        repo.restore_checkpoint(checkpoint, cx.to_async())
1578            .await
1579            .unwrap();
1580
1581        assert_eq!(repo.head_sha().unwrap(), sha_before_checkpoint);
1582        assert_eq!(
1583            smol::fs::read_to_string(&file_path).await.unwrap(),
1584            "modified before checkpoint"
1585        );
1586        assert_eq!(
1587            smol::fs::read_to_string(repo_dir.path().join("new_file_before_checkpoint"))
1588                .await
1589                .unwrap(),
1590            "1"
1591        );
1592        assert_eq!(
1593            smol::fs::read_to_string(repo_dir.path().join("new_file_after_checkpoint"))
1594                .await
1595                .ok(),
1596            None
1597        );
1598    }
1599
1600    #[gpui::test]
1601    async fn test_checkpoint_empty_repo(cx: &mut TestAppContext) {
1602        cx.executor().allow_parking();
1603
1604        let repo_dir = tempfile::tempdir().unwrap();
1605        git2::Repository::init(repo_dir.path()).unwrap();
1606        let repo = RealGitRepository::new(&repo_dir.path().join(".git"), None).unwrap();
1607
1608        smol::fs::write(repo_dir.path().join("foo"), "foo")
1609            .await
1610            .unwrap();
1611        let checkpoint_sha = repo.checkpoint(cx.to_async()).await.unwrap();
1612
1613        // Ensure the user can't see any branches after creating a checkpoint.
1614        assert_eq!(repo.branches().await.unwrap().len(), 1);
1615
1616        smol::fs::write(repo_dir.path().join("foo"), "bar")
1617            .await
1618            .unwrap();
1619        smol::fs::write(repo_dir.path().join("baz"), "qux")
1620            .await
1621            .unwrap();
1622        repo.restore_checkpoint(checkpoint_sha, cx.to_async())
1623            .await
1624            .unwrap();
1625        assert_eq!(
1626            smol::fs::read_to_string(repo_dir.path().join("foo"))
1627                .await
1628                .unwrap(),
1629            "foo"
1630        );
1631        assert_eq!(
1632            smol::fs::read_to_string(repo_dir.path().join("baz"))
1633                .await
1634                .ok(),
1635            None
1636        );
1637    }
1638
1639    #[gpui::test]
1640    async fn test_undoing_commit_via_checkpoint(cx: &mut TestAppContext) {
1641        cx.executor().allow_parking();
1642
1643        let repo_dir = tempfile::tempdir().unwrap();
1644
1645        git2::Repository::init(repo_dir.path()).unwrap();
1646        let file_path = repo_dir.path().join("file");
1647        smol::fs::write(&file_path, "initial").await.unwrap();
1648
1649        let repo = RealGitRepository::new(&repo_dir.path().join(".git"), None).unwrap();
1650        repo.stage_paths(
1651            vec![RepoPath::from_str("file")],
1652            HashMap::default(),
1653            cx.to_async(),
1654        )
1655        .await
1656        .unwrap();
1657        repo.commit(
1658            "Initial commit".into(),
1659            None,
1660            checkpoint_author_envs(),
1661            cx.to_async(),
1662        )
1663        .await
1664        .unwrap();
1665
1666        let initial_commit_sha = repo.head_sha().unwrap();
1667
1668        smol::fs::write(repo_dir.path().join("new_file1"), "content1")
1669            .await
1670            .unwrap();
1671        smol::fs::write(repo_dir.path().join("new_file2"), "content2")
1672            .await
1673            .unwrap();
1674
1675        let checkpoint = repo.checkpoint(cx.to_async()).await.unwrap();
1676
1677        repo.stage_paths(
1678            vec![
1679                RepoPath::from_str("new_file1"),
1680                RepoPath::from_str("new_file2"),
1681            ],
1682            HashMap::default(),
1683            cx.to_async(),
1684        )
1685        .await
1686        .unwrap();
1687        repo.commit(
1688            "Commit new files".into(),
1689            None,
1690            checkpoint_author_envs(),
1691            cx.to_async(),
1692        )
1693        .await
1694        .unwrap();
1695
1696        repo.restore_checkpoint(checkpoint, cx.to_async())
1697            .await
1698            .unwrap();
1699        assert_eq!(repo.head_sha().unwrap(), initial_commit_sha);
1700        assert_eq!(
1701            smol::fs::read_to_string(repo_dir.path().join("new_file1"))
1702                .await
1703                .unwrap(),
1704            "content1"
1705        );
1706        assert_eq!(
1707            smol::fs::read_to_string(repo_dir.path().join("new_file2"))
1708                .await
1709                .unwrap(),
1710            "content2"
1711        );
1712        assert_eq!(
1713            repo.status(&[]).unwrap().entries.as_ref(),
1714            &[
1715                (RepoPath::from_str("new_file1"), FileStatus::Untracked),
1716                (RepoPath::from_str("new_file2"), FileStatus::Untracked)
1717            ]
1718        );
1719    }
1720
1721    #[gpui::test]
1722    async fn test_compare_checkpoints(cx: &mut TestAppContext) {
1723        cx.executor().allow_parking();
1724
1725        let repo_dir = tempfile::tempdir().unwrap();
1726        git2::Repository::init(repo_dir.path()).unwrap();
1727        let repo = RealGitRepository::new(&repo_dir.path().join(".git"), None).unwrap();
1728
1729        smol::fs::write(repo_dir.path().join("file1"), "content1")
1730            .await
1731            .unwrap();
1732        let checkpoint1 = repo.checkpoint(cx.to_async()).await.unwrap();
1733
1734        smol::fs::write(repo_dir.path().join("file2"), "content2")
1735            .await
1736            .unwrap();
1737        let checkpoint2 = repo.checkpoint(cx.to_async()).await.unwrap();
1738
1739        assert!(!repo
1740            .compare_checkpoints(checkpoint1, checkpoint2, cx.to_async())
1741            .await
1742            .unwrap());
1743
1744        let checkpoint3 = repo.checkpoint(cx.to_async()).await.unwrap();
1745        assert!(repo
1746            .compare_checkpoints(checkpoint2, checkpoint3, cx.to_async())
1747            .await
1748            .unwrap());
1749    }
1750
1751    #[test]
1752    fn test_branches_parsing() {
1753        // suppress "help: octal escapes are not supported, `\0` is always null"
1754        #[allow(clippy::octal_escapes)]
1755        let input = "*\0060964da10574cd9bf06463a53bf6e0769c5c45e\0\0refs/heads/zed-patches\0refs/remotes/origin/zed-patches\0\01733187470\0generated protobuf\n";
1756        assert_eq!(
1757            parse_branch_input(&input).unwrap(),
1758            vec![Branch {
1759                is_head: true,
1760                name: "zed-patches".into(),
1761                upstream: Some(Upstream {
1762                    ref_name: "refs/remotes/origin/zed-patches".into(),
1763                    tracking: UpstreamTracking::Tracked(UpstreamTrackingStatus {
1764                        ahead: 0,
1765                        behind: 0
1766                    })
1767                }),
1768                most_recent_commit: Some(CommitSummary {
1769                    sha: "060964da10574cd9bf06463a53bf6e0769c5c45e".into(),
1770                    subject: "generated protobuf".into(),
1771                    commit_timestamp: 1733187470,
1772                    has_parent: false,
1773                })
1774            }]
1775        )
1776    }
1777}