repository.rs

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