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