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