repository.rs

   1use crate::status::FileStatus;
   2use crate::GitHostingProviderRegistry;
   3use crate::{blame::Blame, status::GitStatus};
   4use anyhow::{anyhow, Context, Result};
   5use askpass::{AskPassResult, AskPassSession};
   6use collections::{HashMap, HashSet};
   7use futures::{select_biased, FutureExt as _};
   8use git2::BranchType;
   9use gpui::SharedString;
  10use parking_lot::Mutex;
  11use rope::Rope;
  12use schemars::JsonSchema;
  13use serde::Deserialize;
  14use std::borrow::Borrow;
  15use std::io::Write as _;
  16use std::process::Stdio;
  17use std::sync::LazyLock;
  18use std::{
  19    cmp::Ordering,
  20    path::{Component, Path, PathBuf},
  21    sync::Arc,
  22};
  23use sum_tree::MapSeekTarget;
  24use util::command::{new_smol_command, new_std_command};
  25use util::ResultExt;
  26
  27pub const REMOTE_CANCELLED_BY_USER: &str = "Operation cancelled by user";
  28
  29#[derive(Clone, Debug, Hash, PartialEq, Eq)]
  30pub struct Branch {
  31    pub is_head: bool,
  32    pub name: SharedString,
  33    pub upstream: Option<Upstream>,
  34    pub most_recent_commit: Option<CommitSummary>,
  35}
  36
  37impl Branch {
  38    pub fn tracking_status(&self) -> Option<UpstreamTrackingStatus> {
  39        self.upstream
  40            .as_ref()
  41            .and_then(|upstream| upstream.tracking.status())
  42    }
  43
  44    pub fn priority_key(&self) -> (bool, Option<i64>) {
  45        (
  46            self.is_head,
  47            self.most_recent_commit
  48                .as_ref()
  49                .map(|commit| commit.commit_timestamp),
  50        )
  51    }
  52}
  53
  54#[derive(Clone, Debug, Hash, PartialEq, Eq)]
  55pub struct Upstream {
  56    pub ref_name: SharedString,
  57    pub tracking: UpstreamTracking,
  58}
  59
  60#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
  61pub enum UpstreamTracking {
  62    /// Remote ref not present in local repository.
  63    Gone,
  64    /// Remote ref present in local repository (fetched from remote).
  65    Tracked(UpstreamTrackingStatus),
  66}
  67
  68impl From<UpstreamTrackingStatus> for UpstreamTracking {
  69    fn from(status: UpstreamTrackingStatus) -> Self {
  70        UpstreamTracking::Tracked(status)
  71    }
  72}
  73
  74impl UpstreamTracking {
  75    pub fn is_gone(&self) -> bool {
  76        matches!(self, UpstreamTracking::Gone)
  77    }
  78
  79    pub fn status(&self) -> Option<UpstreamTrackingStatus> {
  80        match self {
  81            UpstreamTracking::Gone => None,
  82            UpstreamTracking::Tracked(status) => Some(*status),
  83        }
  84    }
  85}
  86
  87#[derive(Debug)]
  88pub struct RemoteCommandOutput {
  89    pub stdout: String,
  90    pub stderr: String,
  91}
  92
  93impl RemoteCommandOutput {
  94    pub fn is_empty(&self) -> bool {
  95        self.stdout.is_empty() && self.stderr.is_empty()
  96    }
  97}
  98
  99#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
 100pub struct UpstreamTrackingStatus {
 101    pub ahead: u32,
 102    pub behind: u32,
 103}
 104
 105#[derive(Clone, Debug, Hash, PartialEq, Eq)]
 106pub struct CommitSummary {
 107    pub sha: SharedString,
 108    pub subject: SharedString,
 109    /// This is a unix timestamp
 110    pub commit_timestamp: i64,
 111}
 112
 113#[derive(Clone, Debug, Hash, PartialEq, Eq)]
 114pub struct CommitDetails {
 115    pub sha: SharedString,
 116    pub message: SharedString,
 117    pub commit_timestamp: i64,
 118    pub committer_email: SharedString,
 119    pub committer_name: SharedString,
 120}
 121
 122#[derive(Debug, Clone, Hash, PartialEq, Eq)]
 123pub struct Remote {
 124    pub name: SharedString,
 125}
 126
 127pub enum ResetMode {
 128    // reset the branch pointer, leave index and worktree unchanged
 129    // (this will make it look like things that were committed are now
 130    // staged)
 131    Soft,
 132    // reset the branch pointer and index, leave worktree unchanged
 133    // (this makes it look as though things that were committed are now
 134    // unstaged)
 135    Mixed,
 136}
 137
 138pub trait GitRepository: Send + Sync {
 139    fn reload_index(&self);
 140
 141    /// Returns the contents of an entry in the repository's index, or None if there is no entry for the given path.
 142    ///
 143    /// Also returns `None` for symlinks.
 144    fn load_index_text(&self, path: &RepoPath) -> Option<String>;
 145
 146    /// 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.
 147    ///
 148    /// Also returns `None` for symlinks.
 149    fn load_committed_text(&self, path: &RepoPath) -> Option<String>;
 150
 151    fn set_index_text(&self, path: &RepoPath, content: Option<String>) -> anyhow::Result<()>;
 152
 153    /// Returns the URL of the remote with the given name.
 154    fn remote_url(&self, name: &str) -> Option<String>;
 155
 156    /// Returns the SHA of the current HEAD.
 157    fn head_sha(&self) -> Option<String>;
 158
 159    fn merge_head_shas(&self) -> Vec<String>;
 160
 161    /// Returns the list of git statuses, sorted by path
 162    fn status(&self, path_prefixes: &[RepoPath]) -> Result<GitStatus>;
 163
 164    fn branches(&self) -> Result<Vec<Branch>>;
 165    fn change_branch(&self, _: &str) -> Result<()>;
 166    fn create_branch(&self, _: &str) -> Result<()>;
 167    fn branch_exits(&self, _: &str) -> Result<bool>;
 168
 169    fn reset(&self, commit: &str, mode: ResetMode) -> Result<()>;
 170    fn checkout_files(&self, commit: &str, paths: &[RepoPath]) -> Result<()>;
 171
 172    fn show(&self, commit: &str) -> Result<CommitDetails>;
 173
 174    fn blame(&self, path: &Path, content: Rope) -> Result<crate::blame::Blame>;
 175
 176    /// Returns the absolute path to the repository. For worktrees, this will be the path to the
 177    /// worktree's gitdir within the main repository (typically `.git/worktrees/<name>`).
 178    fn path(&self) -> PathBuf;
 179
 180    /// Returns the absolute path to the ".git" dir for the main repository, typically a `.git`
 181    /// folder. For worktrees, this will be the path to the repository the worktree was created
 182    /// from. Otherwise, this is the same value as `path()`.
 183    ///
 184    /// Git documentation calls this the "commondir", and for git CLI is overridden by
 185    /// `GIT_COMMON_DIR`.
 186    fn main_repository_path(&self) -> PathBuf;
 187
 188    /// Updates the index to match the worktree at the given paths.
 189    ///
 190    /// If any of the paths have been deleted from the worktree, they will be removed from the index if found there.
 191    fn stage_paths(&self, paths: &[RepoPath]) -> Result<()>;
 192    /// Updates the index to match HEAD at the given paths.
 193    ///
 194    /// If any of the paths were previously staged but do not exist in HEAD, they will be removed from the index.
 195    fn unstage_paths(&self, paths: &[RepoPath]) -> Result<()>;
 196
 197    fn commit(&self, message: &str, name_and_email: Option<(&str, &str)>) -> Result<()>;
 198
 199    fn push(
 200        &self,
 201        branch_name: &str,
 202        upstream_name: &str,
 203        options: Option<PushOptions>,
 204        askpass: AskPassSession,
 205    ) -> Result<RemoteCommandOutput>;
 206
 207    fn pull(
 208        &self,
 209        branch_name: &str,
 210        upstream_name: &str,
 211        askpass: AskPassSession,
 212    ) -> Result<RemoteCommandOutput>;
 213    fn fetch(&self, askpass: AskPassSession) -> Result<RemoteCommandOutput>;
 214
 215    fn get_remotes(&self, branch_name: Option<&str>) -> Result<Vec<Remote>>;
 216
 217    /// returns a list of remote branches that contain HEAD
 218    fn check_for_pushed_commit(&self) -> Result<Vec<SharedString>>;
 219
 220    /// Run git diff
 221    fn diff(&self, diff: DiffType) -> Result<String>;
 222}
 223
 224pub enum DiffType {
 225    HeadToIndex,
 226    HeadToWorktree,
 227}
 228
 229#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, JsonSchema)]
 230pub enum PushOptions {
 231    SetUpstream,
 232    Force,
 233}
 234
 235impl std::fmt::Debug for dyn GitRepository {
 236    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
 237        f.debug_struct("dyn GitRepository<...>").finish()
 238    }
 239}
 240
 241pub struct RealGitRepository {
 242    pub repository: Mutex<git2::Repository>,
 243    pub git_binary_path: PathBuf,
 244    hosting_provider_registry: Arc<GitHostingProviderRegistry>,
 245}
 246
 247impl RealGitRepository {
 248    pub fn new(
 249        repository: git2::Repository,
 250        git_binary_path: Option<PathBuf>,
 251        hosting_provider_registry: Arc<GitHostingProviderRegistry>,
 252    ) -> Self {
 253        Self {
 254            repository: Mutex::new(repository),
 255            git_binary_path: git_binary_path.unwrap_or_else(|| PathBuf::from("git")),
 256            hosting_provider_registry,
 257        }
 258    }
 259
 260    fn working_directory(&self) -> Result<PathBuf> {
 261        self.repository
 262            .lock()
 263            .workdir()
 264            .context("failed to read git work directory")
 265            .map(Path::to_path_buf)
 266    }
 267}
 268
 269// https://git-scm.com/book/en/v2/Git-Internals-Git-Objects
 270const GIT_MODE_SYMLINK: u32 = 0o120000;
 271
 272impl GitRepository for RealGitRepository {
 273    fn reload_index(&self) {
 274        if let Ok(mut index) = self.repository.lock().index() {
 275            _ = index.read(false);
 276        }
 277    }
 278
 279    fn path(&self) -> PathBuf {
 280        let repo = self.repository.lock();
 281        repo.path().into()
 282    }
 283
 284    fn main_repository_path(&self) -> PathBuf {
 285        let repo = self.repository.lock();
 286        repo.commondir().into()
 287    }
 288
 289    fn show(&self, commit: &str) -> Result<CommitDetails> {
 290        let repo = self.repository.lock();
 291        let Ok(commit) = repo.revparse_single(commit)?.into_commit() else {
 292            anyhow::bail!("{} is not a commit", commit);
 293        };
 294        let details = CommitDetails {
 295            sha: commit.id().to_string().into(),
 296            message: String::from_utf8_lossy(commit.message_raw_bytes())
 297                .to_string()
 298                .into(),
 299            commit_timestamp: commit.time().seconds(),
 300            committer_email: String::from_utf8_lossy(commit.committer().email_bytes())
 301                .to_string()
 302                .into(),
 303            committer_name: String::from_utf8_lossy(commit.committer().name_bytes())
 304                .to_string()
 305                .into(),
 306        };
 307        Ok(details)
 308    }
 309
 310    fn reset(&self, commit: &str, mode: ResetMode) -> Result<()> {
 311        let working_directory = self.working_directory()?;
 312
 313        let mode_flag = match mode {
 314            ResetMode::Mixed => "--mixed",
 315            ResetMode::Soft => "--soft",
 316        };
 317
 318        let output = new_std_command(&self.git_binary_path)
 319            .current_dir(&working_directory)
 320            .args(["reset", mode_flag, commit])
 321            .output()?;
 322        if !output.status.success() {
 323            return Err(anyhow!(
 324                "Failed to reset:\n{}",
 325                String::from_utf8_lossy(&output.stderr)
 326            ));
 327        }
 328        Ok(())
 329    }
 330
 331    fn checkout_files(&self, commit: &str, paths: &[RepoPath]) -> Result<()> {
 332        if paths.is_empty() {
 333            return Ok(());
 334        }
 335        let working_directory = self.working_directory()?;
 336
 337        let output = new_std_command(&self.git_binary_path)
 338            .current_dir(&working_directory)
 339            .args(["checkout", commit, "--"])
 340            .args(paths.iter().map(|path| path.as_ref()))
 341            .output()?;
 342        if !output.status.success() {
 343            return Err(anyhow!(
 344                "Failed to checkout files:\n{}",
 345                String::from_utf8_lossy(&output.stderr)
 346            ));
 347        }
 348        Ok(())
 349    }
 350
 351    fn load_index_text(&self, path: &RepoPath) -> Option<String> {
 352        fn logic(repo: &git2::Repository, path: &RepoPath) -> Result<Option<String>> {
 353            const STAGE_NORMAL: i32 = 0;
 354            let index = repo.index()?;
 355
 356            // This check is required because index.get_path() unwraps internally :(
 357            check_path_to_repo_path_errors(path)?;
 358
 359            let oid = match index.get_path(path, STAGE_NORMAL) {
 360                Some(entry) if entry.mode != GIT_MODE_SYMLINK => entry.id,
 361                _ => return Ok(None),
 362            };
 363
 364            let content = repo.find_blob(oid)?.content().to_owned();
 365            Ok(Some(String::from_utf8(content)?))
 366        }
 367
 368        match logic(&self.repository.lock(), path) {
 369            Ok(value) => return value,
 370            Err(err) => log::error!("Error loading index text: {:?}", err),
 371        }
 372        None
 373    }
 374
 375    fn load_committed_text(&self, path: &RepoPath) -> Option<String> {
 376        let repo = self.repository.lock();
 377        let head = repo.head().ok()?.peel_to_tree().log_err()?;
 378        let entry = head.get_path(path).ok()?;
 379        if entry.filemode() == i32::from(git2::FileMode::Link) {
 380            return None;
 381        }
 382        let content = repo.find_blob(entry.id()).log_err()?.content().to_owned();
 383        let content = String::from_utf8(content).log_err()?;
 384        Some(content)
 385    }
 386
 387    fn set_index_text(&self, path: &RepoPath, content: Option<String>) -> anyhow::Result<()> {
 388        let working_directory = self.working_directory()?;
 389        if let Some(content) = content {
 390            let mut child = new_std_command(&self.git_binary_path)
 391                .current_dir(&working_directory)
 392                .args(["hash-object", "-w", "--stdin"])
 393                .stdin(Stdio::piped())
 394                .stdout(Stdio::piped())
 395                .spawn()?;
 396            child.stdin.take().unwrap().write_all(content.as_bytes())?;
 397            let output = child.wait_with_output()?.stdout;
 398            let sha = String::from_utf8(output)?;
 399
 400            log::debug!("indexing SHA: {sha}, path {path:?}");
 401
 402            let output = new_std_command(&self.git_binary_path)
 403                .current_dir(&working_directory)
 404                .args(["update-index", "--add", "--cacheinfo", "100644", &sha])
 405                .arg(path.as_ref())
 406                .output()?;
 407
 408            if !output.status.success() {
 409                return Err(anyhow!(
 410                    "Failed to stage:\n{}",
 411                    String::from_utf8_lossy(&output.stderr)
 412                ));
 413            }
 414        } else {
 415            let output = new_std_command(&self.git_binary_path)
 416                .current_dir(&working_directory)
 417                .args(["update-index", "--force-remove"])
 418                .arg(path.as_ref())
 419                .output()?;
 420
 421            if !output.status.success() {
 422                return Err(anyhow!(
 423                    "Failed to unstage:\n{}",
 424                    String::from_utf8_lossy(&output.stderr)
 425                ));
 426            }
 427        }
 428
 429        Ok(())
 430    }
 431
 432    fn remote_url(&self, name: &str) -> Option<String> {
 433        let repo = self.repository.lock();
 434        let remote = repo.find_remote(name).ok()?;
 435        remote.url().map(|url| url.to_string())
 436    }
 437
 438    fn head_sha(&self) -> Option<String> {
 439        Some(self.repository.lock().head().ok()?.target()?.to_string())
 440    }
 441
 442    fn merge_head_shas(&self) -> Vec<String> {
 443        let mut shas = Vec::default();
 444        self.repository
 445            .lock()
 446            .mergehead_foreach(|oid| {
 447                shas.push(oid.to_string());
 448                true
 449            })
 450            .ok();
 451        if let Some(oid) = self
 452            .repository
 453            .lock()
 454            .find_reference("CHERRY_PICK_HEAD")
 455            .ok()
 456            .and_then(|reference| reference.target())
 457        {
 458            shas.push(oid.to_string())
 459        }
 460        shas
 461    }
 462
 463    fn status(&self, path_prefixes: &[RepoPath]) -> Result<GitStatus> {
 464        let working_directory = self
 465            .repository
 466            .lock()
 467            .workdir()
 468            .context("failed to read git work directory")?
 469            .to_path_buf();
 470        GitStatus::new(&self.git_binary_path, &working_directory, path_prefixes)
 471    }
 472
 473    fn branch_exits(&self, name: &str) -> Result<bool> {
 474        let repo = self.repository.lock();
 475        let branch = repo.find_branch(name, BranchType::Local);
 476        match branch {
 477            Ok(_) => Ok(true),
 478            Err(e) => match e.code() {
 479                git2::ErrorCode::NotFound => Ok(false),
 480                _ => Err(anyhow!(e)),
 481            },
 482        }
 483    }
 484
 485    fn branches(&self) -> Result<Vec<Branch>> {
 486        let working_directory = self
 487            .repository
 488            .lock()
 489            .workdir()
 490            .context("failed to read git work directory")?
 491            .to_path_buf();
 492        let fields = [
 493            "%(HEAD)",
 494            "%(objectname)",
 495            "%(refname)",
 496            "%(upstream)",
 497            "%(upstream:track)",
 498            "%(committerdate:unix)",
 499            "%(contents:subject)",
 500        ]
 501        .join("%00");
 502        let args = vec!["for-each-ref", "refs/heads/**/*", "--format", &fields];
 503
 504        let output = new_std_command(&self.git_binary_path)
 505            .current_dir(&working_directory)
 506            .args(args)
 507            .output()?;
 508
 509        if !output.status.success() {
 510            return Err(anyhow!(
 511                "Failed to git git branches:\n{}",
 512                String::from_utf8_lossy(&output.stderr)
 513            ));
 514        }
 515
 516        let input = String::from_utf8_lossy(&output.stdout);
 517
 518        let mut branches = parse_branch_input(&input)?;
 519        if branches.is_empty() {
 520            let args = vec!["symbolic-ref", "--quiet", "--short", "HEAD"];
 521
 522            let output = new_std_command(&self.git_binary_path)
 523                .current_dir(&working_directory)
 524                .args(args)
 525                .output()?;
 526
 527            // git symbolic-ref returns a non-0 exit code if HEAD points
 528            // to something other than a branch
 529            if output.status.success() {
 530                let name = String::from_utf8_lossy(&output.stdout).trim().to_string();
 531
 532                branches.push(Branch {
 533                    name: name.into(),
 534                    is_head: true,
 535                    upstream: None,
 536                    most_recent_commit: None,
 537                });
 538            }
 539        }
 540
 541        Ok(branches)
 542    }
 543
 544    fn change_branch(&self, name: &str) -> Result<()> {
 545        let repo = self.repository.lock();
 546        let revision = repo.find_branch(name, BranchType::Local)?;
 547        let revision = revision.get();
 548        let as_tree = revision.peel_to_tree()?;
 549        repo.checkout_tree(as_tree.as_object(), None)?;
 550        repo.set_head(
 551            revision
 552                .name()
 553                .ok_or_else(|| anyhow!("Branch name could not be retrieved"))?,
 554        )?;
 555        Ok(())
 556    }
 557
 558    fn create_branch(&self, name: &str) -> Result<()> {
 559        let repo = self.repository.lock();
 560        let current_commit = repo.head()?.peel_to_commit()?;
 561        repo.branch(name, &current_commit, false)?;
 562        Ok(())
 563    }
 564
 565    fn blame(&self, path: &Path, content: Rope) -> Result<crate::blame::Blame> {
 566        let working_directory = self
 567            .repository
 568            .lock()
 569            .workdir()
 570            .with_context(|| format!("failed to get git working directory for file {:?}", path))?
 571            .to_path_buf();
 572
 573        const REMOTE_NAME: &str = "origin";
 574        let remote_url = self.remote_url(REMOTE_NAME);
 575
 576        crate::blame::Blame::for_path(
 577            &self.git_binary_path,
 578            &working_directory,
 579            path,
 580            &content,
 581            remote_url,
 582            self.hosting_provider_registry.clone(),
 583        )
 584    }
 585
 586    fn diff(&self, diff: DiffType) -> Result<String> {
 587        let working_directory = self.working_directory()?;
 588        let args = match diff {
 589            DiffType::HeadToIndex => Some("--staged"),
 590            DiffType::HeadToWorktree => None,
 591        };
 592
 593        let output = new_std_command(&self.git_binary_path)
 594            .current_dir(&working_directory)
 595            .args(["diff"])
 596            .args(args)
 597            .output()?;
 598
 599        if !output.status.success() {
 600            return Err(anyhow!(
 601                "Failed to run git diff:\n{}",
 602                String::from_utf8_lossy(&output.stderr)
 603            ));
 604        }
 605        Ok(String::from_utf8_lossy(&output.stdout).to_string())
 606    }
 607
 608    fn stage_paths(&self, paths: &[RepoPath]) -> Result<()> {
 609        let working_directory = self.working_directory()?;
 610
 611        if !paths.is_empty() {
 612            let output = new_std_command(&self.git_binary_path)
 613                .current_dir(&working_directory)
 614                .args(["update-index", "--add", "--remove", "--"])
 615                .args(paths.iter().map(|p| p.as_ref()))
 616                .output()?;
 617
 618            if !output.status.success() {
 619                return Err(anyhow!(
 620                    "Failed to stage paths:\n{}",
 621                    String::from_utf8_lossy(&output.stderr)
 622                ));
 623            }
 624        }
 625        Ok(())
 626    }
 627
 628    fn unstage_paths(&self, paths: &[RepoPath]) -> Result<()> {
 629        let working_directory = self.working_directory()?;
 630
 631        if !paths.is_empty() {
 632            let output = new_std_command(&self.git_binary_path)
 633                .current_dir(&working_directory)
 634                .args(["reset", "--quiet", "--"])
 635                .args(paths.iter().map(|p| p.as_ref()))
 636                .output()?;
 637
 638            if !output.status.success() {
 639                return Err(anyhow!(
 640                    "Failed to unstage:\n{}",
 641                    String::from_utf8_lossy(&output.stderr)
 642                ));
 643            }
 644        }
 645        Ok(())
 646    }
 647
 648    fn commit(&self, message: &str, name_and_email: Option<(&str, &str)>) -> Result<()> {
 649        let working_directory = self.working_directory()?;
 650
 651        let mut cmd = new_std_command(&self.git_binary_path);
 652        cmd.current_dir(&working_directory)
 653            .args(["commit", "--quiet", "-m"])
 654            .arg(message)
 655            .arg("--cleanup=strip");
 656
 657        if let Some((name, email)) = name_and_email {
 658            cmd.arg("--author").arg(&format!("{name} <{email}>"));
 659        }
 660
 661        let output = cmd.output()?;
 662
 663        if !output.status.success() {
 664            return Err(anyhow!(
 665                "Failed to commit:\n{}",
 666                String::from_utf8_lossy(&output.stderr)
 667            ));
 668        }
 669        Ok(())
 670    }
 671
 672    fn push(
 673        &self,
 674        branch_name: &str,
 675        remote_name: &str,
 676        options: Option<PushOptions>,
 677        ask_pass: AskPassSession,
 678    ) -> Result<RemoteCommandOutput> {
 679        let working_directory = self.working_directory()?;
 680
 681        let mut command = new_smol_command("git");
 682        command
 683            .env("GIT_ASKPASS", ask_pass.script_path())
 684            .env("SSH_ASKPASS", ask_pass.script_path())
 685            .env("SSH_ASKPASS_REQUIRE", "force")
 686            .current_dir(&working_directory)
 687            .args(["push"])
 688            .args(options.map(|option| match option {
 689                PushOptions::SetUpstream => "--set-upstream",
 690                PushOptions::Force => "--force-with-lease",
 691            }))
 692            .arg(remote_name)
 693            .arg(format!("{}:{}", branch_name, branch_name))
 694            .stdout(smol::process::Stdio::piped())
 695            .stderr(smol::process::Stdio::piped());
 696        let git_process = command.spawn()?;
 697
 698        run_remote_command(ask_pass, git_process)
 699    }
 700
 701    fn pull(
 702        &self,
 703        branch_name: &str,
 704        remote_name: &str,
 705        ask_pass: AskPassSession,
 706    ) -> Result<RemoteCommandOutput> {
 707        let working_directory = self.working_directory()?;
 708
 709        let mut command = new_smol_command("git");
 710        command
 711            .env("GIT_ASKPASS", ask_pass.script_path())
 712            .env("SSH_ASKPASS", ask_pass.script_path())
 713            .env("SSH_ASKPASS_REQUIRE", "force")
 714            .current_dir(&working_directory)
 715            .args(["pull"])
 716            .arg(remote_name)
 717            .arg(branch_name)
 718            .stdout(smol::process::Stdio::piped())
 719            .stderr(smol::process::Stdio::piped());
 720        let git_process = command.spawn()?;
 721
 722        run_remote_command(ask_pass, git_process)
 723    }
 724
 725    fn fetch(&self, ask_pass: AskPassSession) -> Result<RemoteCommandOutput> {
 726        let working_directory = self.working_directory()?;
 727
 728        let mut command = new_smol_command("git");
 729        command
 730            .env("GIT_ASKPASS", ask_pass.script_path())
 731            .env("SSH_ASKPASS", ask_pass.script_path())
 732            .env("SSH_ASKPASS_REQUIRE", "force")
 733            .current_dir(&working_directory)
 734            .args(["fetch", "--all"])
 735            .stdout(smol::process::Stdio::piped())
 736            .stderr(smol::process::Stdio::piped());
 737        let git_process = command.spawn()?;
 738
 739        run_remote_command(ask_pass, git_process)
 740    }
 741
 742    fn get_remotes(&self, branch_name: Option<&str>) -> Result<Vec<Remote>> {
 743        let working_directory = self.working_directory()?;
 744
 745        if let Some(branch_name) = branch_name {
 746            let output = new_std_command(&self.git_binary_path)
 747                .current_dir(&working_directory)
 748                .args(["config", "--get"])
 749                .arg(format!("branch.{}.remote", branch_name))
 750                .output()?;
 751
 752            if output.status.success() {
 753                let remote_name = String::from_utf8_lossy(&output.stdout);
 754
 755                return Ok(vec![Remote {
 756                    name: remote_name.trim().to_string().into(),
 757                }]);
 758            }
 759        }
 760
 761        let output = new_std_command(&self.git_binary_path)
 762            .current_dir(&working_directory)
 763            .args(["remote"])
 764            .output()?;
 765
 766        if output.status.success() {
 767            let remote_names = String::from_utf8_lossy(&output.stdout)
 768                .split('\n')
 769                .filter(|name| !name.is_empty())
 770                .map(|name| Remote {
 771                    name: name.trim().to_string().into(),
 772                })
 773                .collect();
 774
 775            return Ok(remote_names);
 776        } else {
 777            return Err(anyhow!(
 778                "Failed to get remotes:\n{}",
 779                String::from_utf8_lossy(&output.stderr)
 780            ));
 781        }
 782    }
 783
 784    fn check_for_pushed_commit(&self) -> Result<Vec<SharedString>> {
 785        let working_directory = self.working_directory()?;
 786        let git_cmd = |args: &[&str]| -> Result<String> {
 787            let output = new_std_command(&self.git_binary_path)
 788                .current_dir(&working_directory)
 789                .args(args)
 790                .output()?;
 791            if output.status.success() {
 792                Ok(String::from_utf8(output.stdout)?)
 793            } else {
 794                Err(anyhow!(String::from_utf8_lossy(&output.stderr).to_string()))
 795            }
 796        };
 797
 798        let head = git_cmd(&["rev-parse", "HEAD"])
 799            .context("Failed to get HEAD")?
 800            .trim()
 801            .to_owned();
 802
 803        let mut remote_branches = vec![];
 804        let mut add_if_matching = |remote_head: &str| {
 805            if let Ok(merge_base) = git_cmd(&["merge-base", &head, remote_head]) {
 806                if merge_base.trim() == head {
 807                    if let Some(s) = remote_head.strip_prefix("refs/remotes/") {
 808                        remote_branches.push(s.to_owned().into());
 809                    }
 810                }
 811            }
 812        };
 813
 814        // check the main branch of each remote
 815        let remotes = git_cmd(&["remote"]).context("Failed to get remotes")?;
 816        for remote in remotes.lines() {
 817            if let Ok(remote_head) =
 818                git_cmd(&["symbolic-ref", &format!("refs/remotes/{remote}/HEAD")])
 819            {
 820                add_if_matching(remote_head.trim());
 821            }
 822        }
 823
 824        // ... and the remote branch that the checked-out one is tracking
 825        if let Ok(remote_head) = git_cmd(&["rev-parse", "--symbolic-full-name", "@{u}"]) {
 826            add_if_matching(remote_head.trim());
 827        }
 828
 829        Ok(remote_branches)
 830    }
 831}
 832
 833fn run_remote_command(
 834    mut ask_pass: AskPassSession,
 835    git_process: smol::process::Child,
 836) -> std::result::Result<RemoteCommandOutput, anyhow::Error> {
 837    smol::block_on(async {
 838        select_biased! {
 839            result = ask_pass.run().fuse() => {
 840                match result {
 841                    AskPassResult::CancelledByUser => {
 842                        Err(anyhow!(REMOTE_CANCELLED_BY_USER))?
 843                    }
 844                    AskPassResult::Timedout => {
 845                        Err(anyhow!("Connecting to host timed out"))?
 846                    }
 847                }
 848            }
 849            output = git_process.output().fuse() => {
 850                let output = output?;
 851                if !output.status.success() {
 852                    Err(anyhow!(
 853                        "Operation failed:\n{}",
 854                        String::from_utf8_lossy(&output.stderr)
 855                    ))
 856                } else {
 857                    Ok(RemoteCommandOutput {
 858                        stdout: String::from_utf8_lossy(&output.stdout).to_string(),
 859                        stderr: String::from_utf8_lossy(&output.stderr).to_string(),
 860                    })
 861                }
 862            }
 863        }
 864    })
 865}
 866
 867#[derive(Debug, Clone)]
 868pub struct FakeGitRepository {
 869    state: Arc<Mutex<FakeGitRepositoryState>>,
 870}
 871
 872#[derive(Debug, Clone)]
 873pub struct FakeGitRepositoryState {
 874    pub path: PathBuf,
 875    pub event_emitter: smol::channel::Sender<PathBuf>,
 876    pub head_contents: HashMap<RepoPath, String>,
 877    pub index_contents: HashMap<RepoPath, String>,
 878    pub blames: HashMap<RepoPath, Blame>,
 879    pub statuses: HashMap<RepoPath, FileStatus>,
 880    pub current_branch_name: Option<String>,
 881    pub branches: HashSet<String>,
 882    pub simulated_index_write_error_message: Option<String>,
 883}
 884
 885impl FakeGitRepository {
 886    pub fn open(state: Arc<Mutex<FakeGitRepositoryState>>) -> Arc<dyn GitRepository> {
 887        Arc::new(FakeGitRepository { state })
 888    }
 889}
 890
 891impl FakeGitRepositoryState {
 892    pub fn new(path: PathBuf, event_emitter: smol::channel::Sender<PathBuf>) -> Self {
 893        FakeGitRepositoryState {
 894            path,
 895            event_emitter,
 896            head_contents: Default::default(),
 897            index_contents: Default::default(),
 898            blames: Default::default(),
 899            statuses: Default::default(),
 900            current_branch_name: Default::default(),
 901            branches: Default::default(),
 902            simulated_index_write_error_message: None,
 903        }
 904    }
 905}
 906
 907impl GitRepository for FakeGitRepository {
 908    fn reload_index(&self) {}
 909
 910    fn load_index_text(&self, path: &RepoPath) -> Option<String> {
 911        let state = self.state.lock();
 912        state.index_contents.get(path.as_ref()).cloned()
 913    }
 914
 915    fn load_committed_text(&self, path: &RepoPath) -> Option<String> {
 916        let state = self.state.lock();
 917        state.head_contents.get(path.as_ref()).cloned()
 918    }
 919
 920    fn set_index_text(&self, path: &RepoPath, content: Option<String>) -> anyhow::Result<()> {
 921        let mut state = self.state.lock();
 922        if let Some(message) = state.simulated_index_write_error_message.clone() {
 923            return Err(anyhow::anyhow!(message));
 924        }
 925        if let Some(content) = content {
 926            state.index_contents.insert(path.clone(), content);
 927        } else {
 928            state.index_contents.remove(path);
 929        }
 930        state
 931            .event_emitter
 932            .try_send(state.path.clone())
 933            .expect("Dropped repo change event");
 934        Ok(())
 935    }
 936
 937    fn remote_url(&self, _name: &str) -> Option<String> {
 938        None
 939    }
 940
 941    fn head_sha(&self) -> Option<String> {
 942        None
 943    }
 944
 945    fn merge_head_shas(&self) -> Vec<String> {
 946        vec![]
 947    }
 948
 949    fn show(&self, _: &str) -> Result<CommitDetails> {
 950        unimplemented!()
 951    }
 952
 953    fn reset(&self, _: &str, _: ResetMode) -> Result<()> {
 954        unimplemented!()
 955    }
 956
 957    fn checkout_files(&self, _: &str, _: &[RepoPath]) -> Result<()> {
 958        unimplemented!()
 959    }
 960
 961    fn path(&self) -> PathBuf {
 962        let state = self.state.lock();
 963        state.path.clone()
 964    }
 965
 966    fn main_repository_path(&self) -> PathBuf {
 967        self.path()
 968    }
 969
 970    fn status(&self, path_prefixes: &[RepoPath]) -> Result<GitStatus> {
 971        let state = self.state.lock();
 972
 973        let mut entries = state
 974            .statuses
 975            .iter()
 976            .filter_map(|(repo_path, status)| {
 977                if path_prefixes
 978                    .iter()
 979                    .any(|path_prefix| repo_path.0.starts_with(path_prefix))
 980                {
 981                    Some((repo_path.to_owned(), *status))
 982                } else {
 983                    None
 984                }
 985            })
 986            .collect::<Vec<_>>();
 987        entries.sort_unstable_by(|(a, _), (b, _)| a.cmp(&b));
 988
 989        Ok(GitStatus {
 990            entries: entries.into(),
 991        })
 992    }
 993
 994    fn branches(&self) -> Result<Vec<Branch>> {
 995        let state = self.state.lock();
 996        let current_branch = &state.current_branch_name;
 997        Ok(state
 998            .branches
 999            .iter()
1000            .map(|branch_name| Branch {
1001                is_head: Some(branch_name) == current_branch.as_ref(),
1002                name: branch_name.into(),
1003                most_recent_commit: None,
1004                upstream: None,
1005            })
1006            .collect())
1007    }
1008
1009    fn branch_exits(&self, name: &str) -> Result<bool> {
1010        let state = self.state.lock();
1011        Ok(state.branches.contains(name))
1012    }
1013
1014    fn change_branch(&self, name: &str) -> Result<()> {
1015        let mut state = self.state.lock();
1016        state.current_branch_name = Some(name.to_owned());
1017        state
1018            .event_emitter
1019            .try_send(state.path.clone())
1020            .expect("Dropped repo change event");
1021        Ok(())
1022    }
1023
1024    fn create_branch(&self, name: &str) -> Result<()> {
1025        let mut state = self.state.lock();
1026        state.branches.insert(name.to_owned());
1027        state
1028            .event_emitter
1029            .try_send(state.path.clone())
1030            .expect("Dropped repo change event");
1031        Ok(())
1032    }
1033
1034    fn blame(&self, path: &Path, _content: Rope) -> Result<crate::blame::Blame> {
1035        let state = self.state.lock();
1036        state
1037            .blames
1038            .get(path)
1039            .with_context(|| format!("failed to get blame for {:?}", path))
1040            .cloned()
1041    }
1042
1043    fn stage_paths(&self, _paths: &[RepoPath]) -> Result<()> {
1044        unimplemented!()
1045    }
1046
1047    fn unstage_paths(&self, _paths: &[RepoPath]) -> Result<()> {
1048        unimplemented!()
1049    }
1050
1051    fn commit(&self, _message: &str, _name_and_email: Option<(&str, &str)>) -> Result<()> {
1052        unimplemented!()
1053    }
1054
1055    fn push(
1056        &self,
1057        _branch: &str,
1058        _remote: &str,
1059        _options: Option<PushOptions>,
1060        _ask_pass: AskPassSession,
1061    ) -> Result<RemoteCommandOutput> {
1062        unimplemented!()
1063    }
1064
1065    fn pull(
1066        &self,
1067        _branch: &str,
1068        _remote: &str,
1069        _ask_pass: AskPassSession,
1070    ) -> Result<RemoteCommandOutput> {
1071        unimplemented!()
1072    }
1073
1074    fn fetch(&self, _ask_pass: AskPassSession) -> Result<RemoteCommandOutput> {
1075        unimplemented!()
1076    }
1077
1078    fn get_remotes(&self, _branch: Option<&str>) -> Result<Vec<Remote>> {
1079        unimplemented!()
1080    }
1081
1082    fn check_for_pushed_commit(&self) -> Result<Vec<SharedString>> {
1083        unimplemented!()
1084    }
1085
1086    fn diff(&self, _diff: DiffType) -> Result<String> {
1087        unimplemented!()
1088    }
1089}
1090
1091fn check_path_to_repo_path_errors(relative_file_path: &Path) -> Result<()> {
1092    match relative_file_path.components().next() {
1093        None => anyhow::bail!("repo path should not be empty"),
1094        Some(Component::Prefix(_)) => anyhow::bail!(
1095            "repo path `{}` should be relative, not a windows prefix",
1096            relative_file_path.to_string_lossy()
1097        ),
1098        Some(Component::RootDir) => {
1099            anyhow::bail!(
1100                "repo path `{}` should be relative",
1101                relative_file_path.to_string_lossy()
1102            )
1103        }
1104        Some(Component::CurDir) => {
1105            anyhow::bail!(
1106                "repo path `{}` should not start with `.`",
1107                relative_file_path.to_string_lossy()
1108            )
1109        }
1110        Some(Component::ParentDir) => {
1111            anyhow::bail!(
1112                "repo path `{}` should not start with `..`",
1113                relative_file_path.to_string_lossy()
1114            )
1115        }
1116        _ => Ok(()),
1117    }
1118}
1119
1120pub static WORK_DIRECTORY_REPO_PATH: LazyLock<RepoPath> =
1121    LazyLock::new(|| RepoPath(Path::new("").into()));
1122
1123#[derive(Clone, Debug, Ord, Hash, PartialOrd, Eq, PartialEq)]
1124pub struct RepoPath(pub Arc<Path>);
1125
1126impl RepoPath {
1127    pub fn new(path: PathBuf) -> Self {
1128        debug_assert!(path.is_relative(), "Repo paths must be relative");
1129
1130        RepoPath(path.into())
1131    }
1132
1133    pub fn from_str(path: &str) -> Self {
1134        let path = Path::new(path);
1135        debug_assert!(path.is_relative(), "Repo paths must be relative");
1136
1137        RepoPath(path.into())
1138    }
1139}
1140
1141impl std::fmt::Display for RepoPath {
1142    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1143        self.0.to_string_lossy().fmt(f)
1144    }
1145}
1146
1147impl From<&Path> for RepoPath {
1148    fn from(value: &Path) -> Self {
1149        RepoPath::new(value.into())
1150    }
1151}
1152
1153impl From<Arc<Path>> for RepoPath {
1154    fn from(value: Arc<Path>) -> Self {
1155        RepoPath(value)
1156    }
1157}
1158
1159impl From<PathBuf> for RepoPath {
1160    fn from(value: PathBuf) -> Self {
1161        RepoPath::new(value)
1162    }
1163}
1164
1165impl From<&str> for RepoPath {
1166    fn from(value: &str) -> Self {
1167        Self::from_str(value)
1168    }
1169}
1170
1171impl Default for RepoPath {
1172    fn default() -> Self {
1173        RepoPath(Path::new("").into())
1174    }
1175}
1176
1177impl AsRef<Path> for RepoPath {
1178    fn as_ref(&self) -> &Path {
1179        self.0.as_ref()
1180    }
1181}
1182
1183impl std::ops::Deref for RepoPath {
1184    type Target = Path;
1185
1186    fn deref(&self) -> &Self::Target {
1187        &self.0
1188    }
1189}
1190
1191impl Borrow<Path> for RepoPath {
1192    fn borrow(&self) -> &Path {
1193        self.0.as_ref()
1194    }
1195}
1196
1197#[derive(Debug)]
1198pub struct RepoPathDescendants<'a>(pub &'a Path);
1199
1200impl MapSeekTarget<RepoPath> for RepoPathDescendants<'_> {
1201    fn cmp_cursor(&self, key: &RepoPath) -> Ordering {
1202        if key.starts_with(self.0) {
1203            Ordering::Greater
1204        } else {
1205            self.0.cmp(key)
1206        }
1207    }
1208}
1209
1210fn parse_branch_input(input: &str) -> Result<Vec<Branch>> {
1211    let mut branches = Vec::new();
1212    for line in input.split('\n') {
1213        if line.is_empty() {
1214            continue;
1215        }
1216        let mut fields = line.split('\x00');
1217        let is_current_branch = fields.next().context("no HEAD")? == "*";
1218        let head_sha: SharedString = fields.next().context("no objectname")?.to_string().into();
1219        let ref_name: SharedString = fields
1220            .next()
1221            .context("no refname")?
1222            .strip_prefix("refs/heads/")
1223            .context("unexpected format for refname")?
1224            .to_string()
1225            .into();
1226        let upstream_name = fields.next().context("no upstream")?.to_string();
1227        let upstream_tracking = parse_upstream_track(fields.next().context("no upstream:track")?)?;
1228        let commiterdate = fields.next().context("no committerdate")?.parse::<i64>()?;
1229        let subject: SharedString = fields
1230            .next()
1231            .context("no contents:subject")?
1232            .to_string()
1233            .into();
1234
1235        branches.push(Branch {
1236            is_head: is_current_branch,
1237            name: ref_name,
1238            most_recent_commit: Some(CommitSummary {
1239                sha: head_sha,
1240                subject,
1241                commit_timestamp: commiterdate,
1242            }),
1243            upstream: if upstream_name.is_empty() {
1244                None
1245            } else {
1246                Some(Upstream {
1247                    ref_name: upstream_name.into(),
1248                    tracking: upstream_tracking,
1249                })
1250            },
1251        })
1252    }
1253
1254    Ok(branches)
1255}
1256
1257fn parse_upstream_track(upstream_track: &str) -> Result<UpstreamTracking> {
1258    if upstream_track == "" {
1259        return Ok(UpstreamTracking::Tracked(UpstreamTrackingStatus {
1260            ahead: 0,
1261            behind: 0,
1262        }));
1263    }
1264
1265    let upstream_track = upstream_track
1266        .strip_prefix("[")
1267        .ok_or_else(|| anyhow!("missing ["))?;
1268    let upstream_track = upstream_track
1269        .strip_suffix("]")
1270        .ok_or_else(|| anyhow!("missing ["))?;
1271    let mut ahead: u32 = 0;
1272    let mut behind: u32 = 0;
1273    for component in upstream_track.split(", ") {
1274        if component == "gone" {
1275            return Ok(UpstreamTracking::Gone);
1276        }
1277        if let Some(ahead_num) = component.strip_prefix("ahead ") {
1278            ahead = ahead_num.parse::<u32>()?;
1279        }
1280        if let Some(behind_num) = component.strip_prefix("behind ") {
1281            behind = behind_num.parse::<u32>()?;
1282        }
1283    }
1284    Ok(UpstreamTracking::Tracked(UpstreamTrackingStatus {
1285        ahead,
1286        behind,
1287    }))
1288}
1289
1290#[test]
1291fn test_branches_parsing() {
1292    // suppress "help: octal escapes are not supported, `\0` is always null"
1293    #[allow(clippy::octal_escapes)]
1294    let input = "*\0060964da10574cd9bf06463a53bf6e0769c5c45e\0refs/heads/zed-patches\0refs/remotes/origin/zed-patches\0\01733187470\0generated protobuf\n";
1295    assert_eq!(
1296        parse_branch_input(&input).unwrap(),
1297        vec![Branch {
1298            is_head: true,
1299            name: "zed-patches".into(),
1300            upstream: Some(Upstream {
1301                ref_name: "refs/remotes/origin/zed-patches".into(),
1302                tracking: UpstreamTracking::Tracked(UpstreamTrackingStatus {
1303                    ahead: 0,
1304                    behind: 0
1305                })
1306            }),
1307            most_recent_commit: Some(CommitSummary {
1308                sha: "060964da10574cd9bf06463a53bf6e0769c5c45e".into(),
1309                subject: "generated protobuf".into(),
1310                commit_timestamp: 1733187470,
1311            })
1312        }]
1313    )
1314}