repository.rs

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