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