repository.rs

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