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