repository.rs

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