repository.rs

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