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