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