repository.rs

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