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