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