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