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 output = 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                .output()?;
 366
 367            if !output.status.success() {
 368                return Err(anyhow!(
 369                    "Failed to stage:\n{}",
 370                    String::from_utf8_lossy(&output.stderr)
 371                ));
 372            }
 373        } else {
 374            let output = new_std_command(&self.git_binary_path)
 375                .current_dir(&working_directory)
 376                .args(["update-index", "--force-remove"])
 377                .arg(path.as_ref())
 378                .output()?;
 379
 380            if !output.status.success() {
 381                return Err(anyhow!(
 382                    "Failed to unstage:\n{}",
 383                    String::from_utf8_lossy(&output.stderr)
 384                ));
 385            }
 386        }
 387
 388        Ok(())
 389    }
 390
 391    fn remote_url(&self, name: &str) -> Option<String> {
 392        let repo = self.repository.lock();
 393        let remote = repo.find_remote(name).ok()?;
 394        remote.url().map(|url| url.to_string())
 395    }
 396
 397    fn head_sha(&self) -> Option<String> {
 398        Some(self.repository.lock().head().ok()?.target()?.to_string())
 399    }
 400
 401    fn merge_head_shas(&self) -> Vec<String> {
 402        let mut shas = Vec::default();
 403        self.repository
 404            .lock()
 405            .mergehead_foreach(|oid| {
 406                shas.push(oid.to_string());
 407                true
 408            })
 409            .ok();
 410        shas
 411    }
 412
 413    fn status(&self, path_prefixes: &[RepoPath]) -> Result<GitStatus> {
 414        let working_directory = self
 415            .repository
 416            .lock()
 417            .workdir()
 418            .context("failed to read git work directory")?
 419            .to_path_buf();
 420        GitStatus::new(&self.git_binary_path, &working_directory, path_prefixes)
 421    }
 422
 423    fn branch_exits(&self, name: &str) -> Result<bool> {
 424        let repo = self.repository.lock();
 425        let branch = repo.find_branch(name, BranchType::Local);
 426        match branch {
 427            Ok(_) => Ok(true),
 428            Err(e) => match e.code() {
 429                git2::ErrorCode::NotFound => Ok(false),
 430                _ => Err(anyhow!(e)),
 431            },
 432        }
 433    }
 434
 435    fn branches(&self) -> Result<Vec<Branch>> {
 436        let working_directory = self
 437            .repository
 438            .lock()
 439            .workdir()
 440            .context("failed to read git work directory")?
 441            .to_path_buf();
 442        let fields = [
 443            "%(HEAD)",
 444            "%(objectname)",
 445            "%(refname)",
 446            "%(upstream)",
 447            "%(upstream:track)",
 448            "%(committerdate:unix)",
 449            "%(contents:subject)",
 450        ]
 451        .join("%00");
 452        let args = vec!["for-each-ref", "refs/heads/**/*", "--format", &fields];
 453
 454        let output = new_std_command(&self.git_binary_path)
 455            .current_dir(&working_directory)
 456            .args(args)
 457            .output()?;
 458
 459        if !output.status.success() {
 460            return Err(anyhow!(
 461                "Failed to git git branches:\n{}",
 462                String::from_utf8_lossy(&output.stderr)
 463            ));
 464        }
 465
 466        let input = String::from_utf8_lossy(&output.stdout);
 467
 468        let mut branches = parse_branch_input(&input)?;
 469        if branches.is_empty() {
 470            let args = vec!["symbolic-ref", "--quiet", "--short", "HEAD"];
 471
 472            let output = new_std_command(&self.git_binary_path)
 473                .current_dir(&working_directory)
 474                .args(args)
 475                .output()?;
 476
 477            // git symbolic-ref returns a non-0 exit code if HEAD points
 478            // to something other than a branch
 479            if output.status.success() {
 480                let name = String::from_utf8_lossy(&output.stdout).trim().to_string();
 481
 482                branches.push(Branch {
 483                    name: name.into(),
 484                    is_head: true,
 485                    upstream: None,
 486                    most_recent_commit: None,
 487                });
 488            }
 489        }
 490
 491        Ok(branches)
 492    }
 493
 494    fn change_branch(&self, name: &str) -> Result<()> {
 495        let repo = self.repository.lock();
 496        let revision = repo.find_branch(name, BranchType::Local)?;
 497        let revision = revision.get();
 498        let as_tree = revision.peel_to_tree()?;
 499        repo.checkout_tree(as_tree.as_object(), None)?;
 500        repo.set_head(
 501            revision
 502                .name()
 503                .ok_or_else(|| anyhow!("Branch name could not be retrieved"))?,
 504        )?;
 505        Ok(())
 506    }
 507
 508    fn create_branch(&self, name: &str) -> Result<()> {
 509        let repo = self.repository.lock();
 510        let current_commit = repo.head()?.peel_to_commit()?;
 511        repo.branch(name, &current_commit, false)?;
 512        Ok(())
 513    }
 514
 515    fn blame(&self, path: &Path, content: Rope) -> Result<crate::blame::Blame> {
 516        let working_directory = self
 517            .repository
 518            .lock()
 519            .workdir()
 520            .with_context(|| format!("failed to get git working directory for file {:?}", path))?
 521            .to_path_buf();
 522
 523        const REMOTE_NAME: &str = "origin";
 524        let remote_url = self.remote_url(REMOTE_NAME);
 525
 526        crate::blame::Blame::for_path(
 527            &self.git_binary_path,
 528            &working_directory,
 529            path,
 530            &content,
 531            remote_url,
 532            self.hosting_provider_registry.clone(),
 533        )
 534    }
 535
 536    fn stage_paths(&self, paths: &[RepoPath]) -> Result<()> {
 537        let working_directory = self.working_directory()?;
 538
 539        if !paths.is_empty() {
 540            let output = new_std_command(&self.git_binary_path)
 541                .current_dir(&working_directory)
 542                .args(["update-index", "--add", "--remove", "--"])
 543                .args(paths.iter().map(|p| p.as_ref()))
 544                .output()?;
 545
 546            // TODO: Get remote response out of this and show it to the user
 547            if !output.status.success() {
 548                return Err(anyhow!(
 549                    "Failed to stage paths:\n{}",
 550                    String::from_utf8_lossy(&output.stderr)
 551                ));
 552            }
 553        }
 554        Ok(())
 555    }
 556
 557    fn unstage_paths(&self, paths: &[RepoPath]) -> Result<()> {
 558        let working_directory = self.working_directory()?;
 559
 560        if !paths.is_empty() {
 561            let output = new_std_command(&self.git_binary_path)
 562                .current_dir(&working_directory)
 563                .args(["reset", "--quiet", "--"])
 564                .args(paths.iter().map(|p| p.as_ref()))
 565                .output()?;
 566
 567            // TODO: Get remote response out of this and show it to the user
 568            if !output.status.success() {
 569                return Err(anyhow!(
 570                    "Failed to unstage:\n{}",
 571                    String::from_utf8_lossy(&output.stderr)
 572                ));
 573            }
 574        }
 575        Ok(())
 576    }
 577
 578    fn commit(&self, message: &str, name_and_email: Option<(&str, &str)>) -> Result<()> {
 579        let working_directory = self.working_directory()?;
 580
 581        let mut cmd = new_std_command(&self.git_binary_path);
 582        cmd.current_dir(&working_directory)
 583            .args(["commit", "--quiet", "-m"])
 584            .arg(message)
 585            .arg("--cleanup=strip");
 586
 587        if let Some((name, email)) = name_and_email {
 588            cmd.arg("--author").arg(&format!("{name} <{email}>"));
 589        }
 590
 591        let output = cmd.output()?;
 592
 593        // TODO: Get remote response out of this and show it to the user
 594        if !output.status.success() {
 595            return Err(anyhow!(
 596                "Failed to commit:\n{}",
 597                String::from_utf8_lossy(&output.stderr)
 598            ));
 599        }
 600        Ok(())
 601    }
 602
 603    fn push(
 604        &self,
 605        branch_name: &str,
 606        remote_name: &str,
 607        options: Option<PushOptions>,
 608    ) -> Result<()> {
 609        let working_directory = self.working_directory()?;
 610
 611        let output = new_std_command(&self.git_binary_path)
 612            .current_dir(&working_directory)
 613            .args(["push", "--quiet"])
 614            .args(options.map(|option| match option {
 615                PushOptions::SetUpstream => "--set-upstream",
 616                PushOptions::Force => "--force-with-lease",
 617            }))
 618            .arg(remote_name)
 619            .arg(format!("{}:{}", branch_name, branch_name))
 620            .output()?;
 621
 622        if !output.status.success() {
 623            return Err(anyhow!(
 624                "Failed to push:\n{}",
 625                String::from_utf8_lossy(&output.stderr)
 626            ));
 627        } else {
 628            Ok(())
 629        }
 630    }
 631
 632    fn pull(&self, branch_name: &str, remote_name: &str) -> Result<()> {
 633        let working_directory = self.working_directory()?;
 634
 635        let output = new_std_command(&self.git_binary_path)
 636            .current_dir(&working_directory)
 637            .args(["pull", "--quiet"])
 638            .arg(remote_name)
 639            .arg(branch_name)
 640            .output()?;
 641
 642        if !output.status.success() {
 643            return Err(anyhow!(
 644                "Failed to pull:\n{}",
 645                String::from_utf8_lossy(&output.stderr)
 646            ));
 647        } else {
 648            return Ok(());
 649        }
 650    }
 651
 652    fn fetch(&self) -> Result<()> {
 653        let working_directory = self.working_directory()?;
 654
 655        let output = new_std_command(&self.git_binary_path)
 656            .current_dir(&working_directory)
 657            .args(["fetch", "--quiet", "--all"])
 658            .output()?;
 659
 660        if !output.status.success() {
 661            return Err(anyhow!(
 662                "Failed to fetch:\n{}",
 663                String::from_utf8_lossy(&output.stderr)
 664            ));
 665        } else {
 666            return Ok(());
 667        }
 668    }
 669
 670    fn get_remotes(&self, branch_name: Option<&str>) -> Result<Vec<Remote>> {
 671        let working_directory = self.working_directory()?;
 672
 673        if let Some(branch_name) = branch_name {
 674            let output = new_std_command(&self.git_binary_path)
 675                .current_dir(&working_directory)
 676                .args(["config", "--get"])
 677                .arg(format!("branch.{}.remote", branch_name))
 678                .output()?;
 679
 680            if output.status.success() {
 681                let remote_name = String::from_utf8_lossy(&output.stdout);
 682
 683                return Ok(vec![Remote {
 684                    name: remote_name.trim().to_string().into(),
 685                }]);
 686            }
 687        }
 688
 689        let output = new_std_command(&self.git_binary_path)
 690            .current_dir(&working_directory)
 691            .args(["remote"])
 692            .output()?;
 693
 694        if output.status.success() {
 695            let remote_names = String::from_utf8_lossy(&output.stdout)
 696                .split('\n')
 697                .filter(|name| !name.is_empty())
 698                .map(|name| Remote {
 699                    name: name.trim().to_string().into(),
 700                })
 701                .collect();
 702
 703            return Ok(remote_names);
 704        } else {
 705            return Err(anyhow!(
 706                "Failed to get remotes:\n{}",
 707                String::from_utf8_lossy(&output.stderr)
 708            ));
 709        }
 710    }
 711}
 712
 713#[derive(Debug, Clone)]
 714pub struct FakeGitRepository {
 715    state: Arc<Mutex<FakeGitRepositoryState>>,
 716}
 717
 718#[derive(Debug, Clone)]
 719pub struct FakeGitRepositoryState {
 720    pub path: PathBuf,
 721    pub event_emitter: smol::channel::Sender<PathBuf>,
 722    pub head_contents: HashMap<RepoPath, String>,
 723    pub index_contents: HashMap<RepoPath, String>,
 724    pub blames: HashMap<RepoPath, Blame>,
 725    pub statuses: HashMap<RepoPath, FileStatus>,
 726    pub current_branch_name: Option<String>,
 727    pub branches: HashSet<String>,
 728}
 729
 730impl FakeGitRepository {
 731    pub fn open(state: Arc<Mutex<FakeGitRepositoryState>>) -> Arc<dyn GitRepository> {
 732        Arc::new(FakeGitRepository { state })
 733    }
 734}
 735
 736impl FakeGitRepositoryState {
 737    pub fn new(path: PathBuf, event_emitter: smol::channel::Sender<PathBuf>) -> Self {
 738        FakeGitRepositoryState {
 739            path,
 740            event_emitter,
 741            head_contents: Default::default(),
 742            index_contents: Default::default(),
 743            blames: Default::default(),
 744            statuses: Default::default(),
 745            current_branch_name: Default::default(),
 746            branches: Default::default(),
 747        }
 748    }
 749}
 750
 751impl GitRepository for FakeGitRepository {
 752    fn reload_index(&self) {}
 753
 754    fn load_index_text(&self, path: &RepoPath) -> Option<String> {
 755        let state = self.state.lock();
 756        state.index_contents.get(path.as_ref()).cloned()
 757    }
 758
 759    fn load_committed_text(&self, path: &RepoPath) -> Option<String> {
 760        let state = self.state.lock();
 761        state.head_contents.get(path.as_ref()).cloned()
 762    }
 763
 764    fn set_index_text(&self, path: &RepoPath, content: Option<String>) -> anyhow::Result<()> {
 765        let mut state = self.state.lock();
 766        if let Some(content) = content {
 767            state.index_contents.insert(path.clone(), content);
 768        } else {
 769            state.index_contents.remove(path);
 770        }
 771        state
 772            .event_emitter
 773            .try_send(state.path.clone())
 774            .expect("Dropped repo change event");
 775        Ok(())
 776    }
 777
 778    fn remote_url(&self, _name: &str) -> Option<String> {
 779        None
 780    }
 781
 782    fn head_sha(&self) -> Option<String> {
 783        None
 784    }
 785
 786    fn merge_head_shas(&self) -> Vec<String> {
 787        vec![]
 788    }
 789
 790    fn show(&self, _: &str) -> Result<CommitDetails> {
 791        unimplemented!()
 792    }
 793
 794    fn reset(&self, _: &str, _: ResetMode) -> Result<()> {
 795        unimplemented!()
 796    }
 797
 798    fn checkout_files(&self, _: &str, _: &[RepoPath]) -> Result<()> {
 799        unimplemented!()
 800    }
 801
 802    fn path(&self) -> PathBuf {
 803        let state = self.state.lock();
 804        state.path.clone()
 805    }
 806
 807    fn main_repository_path(&self) -> PathBuf {
 808        self.path()
 809    }
 810
 811    fn status(&self, path_prefixes: &[RepoPath]) -> Result<GitStatus> {
 812        let state = self.state.lock();
 813
 814        let mut entries = state
 815            .statuses
 816            .iter()
 817            .filter_map(|(repo_path, status)| {
 818                if path_prefixes
 819                    .iter()
 820                    .any(|path_prefix| repo_path.0.starts_with(path_prefix))
 821                {
 822                    Some((repo_path.to_owned(), *status))
 823                } else {
 824                    None
 825                }
 826            })
 827            .collect::<Vec<_>>();
 828        entries.sort_unstable_by(|(a, _), (b, _)| a.cmp(&b));
 829
 830        Ok(GitStatus {
 831            entries: entries.into(),
 832        })
 833    }
 834
 835    fn branches(&self) -> Result<Vec<Branch>> {
 836        let state = self.state.lock();
 837        let current_branch = &state.current_branch_name;
 838        Ok(state
 839            .branches
 840            .iter()
 841            .map(|branch_name| Branch {
 842                is_head: Some(branch_name) == current_branch.as_ref(),
 843                name: branch_name.into(),
 844                most_recent_commit: None,
 845                upstream: None,
 846            })
 847            .collect())
 848    }
 849
 850    fn branch_exits(&self, name: &str) -> Result<bool> {
 851        let state = self.state.lock();
 852        Ok(state.branches.contains(name))
 853    }
 854
 855    fn change_branch(&self, name: &str) -> Result<()> {
 856        let mut state = self.state.lock();
 857        state.current_branch_name = Some(name.to_owned());
 858        state
 859            .event_emitter
 860            .try_send(state.path.clone())
 861            .expect("Dropped repo change event");
 862        Ok(())
 863    }
 864
 865    fn create_branch(&self, name: &str) -> Result<()> {
 866        let mut state = self.state.lock();
 867        state.branches.insert(name.to_owned());
 868        state
 869            .event_emitter
 870            .try_send(state.path.clone())
 871            .expect("Dropped repo change event");
 872        Ok(())
 873    }
 874
 875    fn blame(&self, path: &Path, _content: Rope) -> Result<crate::blame::Blame> {
 876        let state = self.state.lock();
 877        state
 878            .blames
 879            .get(path)
 880            .with_context(|| format!("failed to get blame for {:?}", path))
 881            .cloned()
 882    }
 883
 884    fn stage_paths(&self, _paths: &[RepoPath]) -> Result<()> {
 885        unimplemented!()
 886    }
 887
 888    fn unstage_paths(&self, _paths: &[RepoPath]) -> Result<()> {
 889        unimplemented!()
 890    }
 891
 892    fn commit(&self, _message: &str, _name_and_email: Option<(&str, &str)>) -> Result<()> {
 893        unimplemented!()
 894    }
 895
 896    fn push(&self, _branch: &str, _remote: &str, _options: Option<PushOptions>) -> Result<()> {
 897        unimplemented!()
 898    }
 899
 900    fn pull(&self, _branch: &str, _remote: &str) -> Result<()> {
 901        unimplemented!()
 902    }
 903
 904    fn fetch(&self) -> Result<()> {
 905        unimplemented!()
 906    }
 907
 908    fn get_remotes(&self, _branch: Option<&str>) -> Result<Vec<Remote>> {
 909        unimplemented!()
 910    }
 911}
 912
 913fn check_path_to_repo_path_errors(relative_file_path: &Path) -> Result<()> {
 914    match relative_file_path.components().next() {
 915        None => anyhow::bail!("repo path should not be empty"),
 916        Some(Component::Prefix(_)) => anyhow::bail!(
 917            "repo path `{}` should be relative, not a windows prefix",
 918            relative_file_path.to_string_lossy()
 919        ),
 920        Some(Component::RootDir) => {
 921            anyhow::bail!(
 922                "repo path `{}` should be relative",
 923                relative_file_path.to_string_lossy()
 924            )
 925        }
 926        Some(Component::CurDir) => {
 927            anyhow::bail!(
 928                "repo path `{}` should not start with `.`",
 929                relative_file_path.to_string_lossy()
 930            )
 931        }
 932        Some(Component::ParentDir) => {
 933            anyhow::bail!(
 934                "repo path `{}` should not start with `..`",
 935                relative_file_path.to_string_lossy()
 936            )
 937        }
 938        _ => Ok(()),
 939    }
 940}
 941
 942pub static WORK_DIRECTORY_REPO_PATH: LazyLock<RepoPath> =
 943    LazyLock::new(|| RepoPath(Path::new("").into()));
 944
 945#[derive(Clone, Debug, Ord, Hash, PartialOrd, Eq, PartialEq)]
 946pub struct RepoPath(pub Arc<Path>);
 947
 948impl RepoPath {
 949    pub fn new(path: PathBuf) -> Self {
 950        debug_assert!(path.is_relative(), "Repo paths must be relative");
 951
 952        RepoPath(path.into())
 953    }
 954
 955    pub fn from_str(path: &str) -> Self {
 956        let path = Path::new(path);
 957        debug_assert!(path.is_relative(), "Repo paths must be relative");
 958
 959        RepoPath(path.into())
 960    }
 961}
 962
 963impl std::fmt::Display for RepoPath {
 964    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
 965        self.0.to_string_lossy().fmt(f)
 966    }
 967}
 968
 969impl From<&Path> for RepoPath {
 970    fn from(value: &Path) -> Self {
 971        RepoPath::new(value.into())
 972    }
 973}
 974
 975impl From<Arc<Path>> for RepoPath {
 976    fn from(value: Arc<Path>) -> Self {
 977        RepoPath(value)
 978    }
 979}
 980
 981impl From<PathBuf> for RepoPath {
 982    fn from(value: PathBuf) -> Self {
 983        RepoPath::new(value)
 984    }
 985}
 986
 987impl From<&str> for RepoPath {
 988    fn from(value: &str) -> Self {
 989        Self::from_str(value)
 990    }
 991}
 992
 993impl Default for RepoPath {
 994    fn default() -> Self {
 995        RepoPath(Path::new("").into())
 996    }
 997}
 998
 999impl AsRef<Path> for RepoPath {
1000    fn as_ref(&self) -> &Path {
1001        self.0.as_ref()
1002    }
1003}
1004
1005impl std::ops::Deref for RepoPath {
1006    type Target = Path;
1007
1008    fn deref(&self) -> &Self::Target {
1009        &self.0
1010    }
1011}
1012
1013impl Borrow<Path> for RepoPath {
1014    fn borrow(&self) -> &Path {
1015        self.0.as_ref()
1016    }
1017}
1018
1019#[derive(Debug)]
1020pub struct RepoPathDescendants<'a>(pub &'a Path);
1021
1022impl<'a> MapSeekTarget<RepoPath> for RepoPathDescendants<'a> {
1023    fn cmp_cursor(&self, key: &RepoPath) -> Ordering {
1024        if key.starts_with(self.0) {
1025            Ordering::Greater
1026        } else {
1027            self.0.cmp(key)
1028        }
1029    }
1030}
1031
1032fn parse_branch_input(input: &str) -> Result<Vec<Branch>> {
1033    let mut branches = Vec::new();
1034    for line in input.split('\n') {
1035        if line.is_empty() {
1036            continue;
1037        }
1038        let mut fields = line.split('\x00');
1039        let is_current_branch = fields.next().context("no HEAD")? == "*";
1040        let head_sha: SharedString = fields.next().context("no objectname")?.to_string().into();
1041        let ref_name: SharedString = fields
1042            .next()
1043            .context("no refname")?
1044            .strip_prefix("refs/heads/")
1045            .context("unexpected format for refname")?
1046            .to_string()
1047            .into();
1048        let upstream_name = fields.next().context("no upstream")?.to_string();
1049        let upstream_tracking = parse_upstream_track(fields.next().context("no upstream:track")?)?;
1050        let commiterdate = fields.next().context("no committerdate")?.parse::<i64>()?;
1051        let subject: SharedString = fields
1052            .next()
1053            .context("no contents:subject")?
1054            .to_string()
1055            .into();
1056
1057        branches.push(Branch {
1058            is_head: is_current_branch,
1059            name: ref_name,
1060            most_recent_commit: Some(CommitSummary {
1061                sha: head_sha,
1062                subject,
1063                commit_timestamp: commiterdate,
1064            }),
1065            upstream: if upstream_name.is_empty() {
1066                None
1067            } else {
1068                Some(Upstream {
1069                    ref_name: upstream_name.into(),
1070                    tracking: upstream_tracking,
1071                })
1072            },
1073        })
1074    }
1075
1076    Ok(branches)
1077}
1078
1079fn parse_upstream_track(upstream_track: &str) -> Result<UpstreamTracking> {
1080    if upstream_track == "" {
1081        return Ok(UpstreamTracking::Tracked(UpstreamTrackingStatus {
1082            ahead: 0,
1083            behind: 0,
1084        }));
1085    }
1086
1087    let upstream_track = upstream_track
1088        .strip_prefix("[")
1089        .ok_or_else(|| anyhow!("missing ["))?;
1090    let upstream_track = upstream_track
1091        .strip_suffix("]")
1092        .ok_or_else(|| anyhow!("missing ["))?;
1093    let mut ahead: u32 = 0;
1094    let mut behind: u32 = 0;
1095    for component in upstream_track.split(", ") {
1096        if component == "gone" {
1097            return Ok(UpstreamTracking::Gone);
1098        }
1099        if let Some(ahead_num) = component.strip_prefix("ahead ") {
1100            ahead = ahead_num.parse::<u32>()?;
1101        }
1102        if let Some(behind_num) = component.strip_prefix("behind ") {
1103            behind = behind_num.parse::<u32>()?;
1104        }
1105    }
1106    Ok(UpstreamTracking::Tracked(UpstreamTrackingStatus {
1107        ahead,
1108        behind,
1109    }))
1110}
1111
1112#[test]
1113fn test_branches_parsing() {
1114    // suppress "help: octal escapes are not supported, `\0` is always null"
1115    #[allow(clippy::octal_escapes)]
1116    let input = "*\0060964da10574cd9bf06463a53bf6e0769c5c45e\0refs/heads/zed-patches\0refs/remotes/origin/zed-patches\0\01733187470\0generated protobuf\n";
1117    assert_eq!(
1118        parse_branch_input(&input).unwrap(),
1119        vec![Branch {
1120            is_head: true,
1121            name: "zed-patches".into(),
1122            upstream: Some(Upstream {
1123                ref_name: "refs/remotes/origin/zed-patches".into(),
1124                tracking: UpstreamTracking::Tracked(UpstreamTrackingStatus {
1125                    ahead: 0,
1126                    behind: 0
1127                })
1128            }),
1129            most_recent_commit: Some(CommitSummary {
1130                sha: "060964da10574cd9bf06463a53bf6e0769c5c45e".into(),
1131                subject: "generated protobuf".into(),
1132                commit_timestamp: 1733187470,
1133            })
1134        }]
1135    )
1136}