repository.rs

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