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