repository.rs

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