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