repository.rs

   1use crate::status::FileStatus;
   2use crate::SHORT_SHA_LENGTH;
   3use crate::{blame::Blame, status::GitStatus};
   4use anyhow::{anyhow, Context, Result};
   5use askpass::{AskPassResult, AskPassSession};
   6use collections::{HashMap, HashSet};
   7use futures::future::BoxFuture;
   8use futures::{select_biased, AsyncWriteExt, FutureExt as _};
   9use git2::BranchType;
  10use gpui::{AppContext, AsyncApp, SharedString};
  11use parking_lot::Mutex;
  12use rope::Rope;
  13use schemars::JsonSchema;
  14use serde::Deserialize;
  15use std::borrow::Borrow;
  16use std::process::Stdio;
  17use std::sync::LazyLock;
  18use std::{
  19    cmp::Ordering,
  20    path::{Component, Path, PathBuf},
  21    sync::Arc,
  22};
  23use sum_tree::MapSeekTarget;
  24use util::command::new_smol_command;
  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: 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: 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        let git_binary_path = self.git_binary_path.clone();
 799        cx.background_spawn(async move {
 800            let mut cmd = new_smol_command(&git_binary_path);
 801            cmd.current_dir(&working_directory?)
 802                .envs(env)
 803                .args(["commit", "--quiet", "-m"])
 804                .arg(&message.to_string())
 805                .arg("--cleanup=strip");
 806
 807            if let Some((name, email)) = name_and_email {
 808                cmd.arg("--author").arg(&format!("{name} <{email}>"));
 809            }
 810
 811            let output = cmd.output().await?;
 812
 813            if !output.status.success() {
 814                return Err(anyhow!(
 815                    "Failed to commit:\n{}",
 816                    String::from_utf8_lossy(&output.stderr)
 817                ));
 818            }
 819            Ok(())
 820        })
 821        .boxed()
 822    }
 823
 824    fn push(
 825        &self,
 826        branch_name: String,
 827        remote_name: String,
 828        options: Option<PushOptions>,
 829        ask_pass: AskPassSession,
 830        env: HashMap<String, String>,
 831        // note: git push *must* be started on the main thread for
 832        // git-credentials manager to work (hence taking an AsyncApp)
 833        _cx: AsyncApp,
 834    ) -> BoxFuture<Result<RemoteCommandOutput>> {
 835        let working_directory = self.working_directory();
 836        async move {
 837            let working_directory = working_directory?;
 838
 839            let mut command = new_smol_command("git");
 840            command
 841                .envs(env)
 842                .env("GIT_ASKPASS", ask_pass.script_path())
 843                .env("SSH_ASKPASS", ask_pass.script_path())
 844                .env("SSH_ASKPASS_REQUIRE", "force")
 845                .env("GIT_HTTP_USER_AGENT", "Zed")
 846                .current_dir(&working_directory)
 847                .args(["push"])
 848                .args(options.map(|option| match option {
 849                    PushOptions::SetUpstream => "--set-upstream",
 850                    PushOptions::Force => "--force-with-lease",
 851                }))
 852                .arg(remote_name)
 853                .arg(format!("{}:{}", branch_name, branch_name))
 854                .stdin(smol::process::Stdio::null())
 855                .stdout(smol::process::Stdio::piped())
 856                .stderr(smol::process::Stdio::piped());
 857            let git_process = command.spawn()?;
 858
 859            run_remote_command(ask_pass, git_process).await
 860        }
 861        .boxed()
 862    }
 863
 864    fn pull(
 865        &self,
 866        branch_name: String,
 867        remote_name: String,
 868        ask_pass: AskPassSession,
 869        env: HashMap<String, String>,
 870        _cx: AsyncApp,
 871    ) -> BoxFuture<Result<RemoteCommandOutput>> {
 872        let working_directory = self.working_directory();
 873        async {
 874            let mut command = new_smol_command("git");
 875            command
 876                .envs(env)
 877                .env("GIT_ASKPASS", ask_pass.script_path())
 878                .env("SSH_ASKPASS", ask_pass.script_path())
 879                .env("SSH_ASKPASS_REQUIRE", "force")
 880                .current_dir(&working_directory?)
 881                .args(["pull"])
 882                .arg(remote_name)
 883                .arg(branch_name)
 884                .stdout(smol::process::Stdio::piped())
 885                .stderr(smol::process::Stdio::piped());
 886            let git_process = command.spawn()?;
 887
 888            run_remote_command(ask_pass, git_process).await
 889        }
 890        .boxed()
 891    }
 892
 893    fn fetch(
 894        &self,
 895        ask_pass: AskPassSession,
 896        env: HashMap<String, String>,
 897        _cx: AsyncApp,
 898    ) -> BoxFuture<Result<RemoteCommandOutput>> {
 899        let working_directory = self.working_directory();
 900        async {
 901            let mut command = new_smol_command("git");
 902            command
 903                .envs(env)
 904                .env("GIT_ASKPASS", ask_pass.script_path())
 905                .env("SSH_ASKPASS", ask_pass.script_path())
 906                .env("SSH_ASKPASS_REQUIRE", "force")
 907                .current_dir(&working_directory?)
 908                .args(["fetch", "--all"])
 909                .stdout(smol::process::Stdio::piped())
 910                .stderr(smol::process::Stdio::piped());
 911            let git_process = command.spawn()?;
 912
 913            run_remote_command(ask_pass, git_process).await
 914        }
 915        .boxed()
 916    }
 917
 918    fn get_remotes(
 919        &self,
 920        branch_name: Option<String>,
 921        cx: AsyncApp,
 922    ) -> BoxFuture<Result<Vec<Remote>>> {
 923        let working_directory = self.working_directory();
 924        let git_binary_path = self.git_binary_path.clone();
 925        cx.background_spawn(async move {
 926            let working_directory = working_directory?;
 927            if let Some(branch_name) = branch_name {
 928                let output = new_smol_command(&git_binary_path)
 929                    .current_dir(&working_directory)
 930                    .args(["config", "--get"])
 931                    .arg(format!("branch.{}.remote", branch_name))
 932                    .output()
 933                    .await?;
 934
 935                if output.status.success() {
 936                    let remote_name = String::from_utf8_lossy(&output.stdout);
 937
 938                    return Ok(vec![Remote {
 939                        name: remote_name.trim().to_string().into(),
 940                    }]);
 941                }
 942            }
 943
 944            let output = new_smol_command(&git_binary_path)
 945                .current_dir(&working_directory)
 946                .args(["remote"])
 947                .output()
 948                .await?;
 949
 950            if output.status.success() {
 951                let remote_names = String::from_utf8_lossy(&output.stdout)
 952                    .split('\n')
 953                    .filter(|name| !name.is_empty())
 954                    .map(|name| Remote {
 955                        name: name.trim().to_string().into(),
 956                    })
 957                    .collect();
 958
 959                return Ok(remote_names);
 960            } else {
 961                return Err(anyhow!(
 962                    "Failed to get remotes:\n{}",
 963                    String::from_utf8_lossy(&output.stderr)
 964                ));
 965            }
 966        })
 967        .boxed()
 968    }
 969
 970    fn check_for_pushed_commit(&self, cx: AsyncApp) -> BoxFuture<Result<Vec<SharedString>>> {
 971        let working_directory = self.working_directory();
 972        let git_binary_path = self.git_binary_path.clone();
 973        cx.background_spawn(async move {
 974            let working_directory = working_directory?;
 975            let git_cmd = async |args: &[&str]| -> Result<String> {
 976                let output = new_smol_command(&git_binary_path)
 977                    .current_dir(&working_directory)
 978                    .args(args)
 979                    .output()
 980                    .await?;
 981                if output.status.success() {
 982                    Ok(String::from_utf8(output.stdout)?)
 983                } else {
 984                    Err(anyhow!(String::from_utf8_lossy(&output.stderr).to_string()))
 985                }
 986            };
 987
 988            let head = git_cmd(&["rev-parse", "HEAD"])
 989                .await
 990                .context("Failed to get HEAD")?
 991                .trim()
 992                .to_owned();
 993
 994            let mut remote_branches = vec![];
 995            let mut add_if_matching = async |remote_head: &str| {
 996                if let Ok(merge_base) = git_cmd(&["merge-base", &head, remote_head]).await {
 997                    if merge_base.trim() == head {
 998                        if let Some(s) = remote_head.strip_prefix("refs/remotes/") {
 999                            remote_branches.push(s.to_owned().into());
1000                        }
1001                    }
1002                }
1003            };
1004
1005            // check the main branch of each remote
1006            let remotes = git_cmd(&["remote"])
1007                .await
1008                .context("Failed to get remotes")?;
1009            for remote in remotes.lines() {
1010                if let Ok(remote_head) =
1011                    git_cmd(&["symbolic-ref", &format!("refs/remotes/{remote}/HEAD")]).await
1012                {
1013                    add_if_matching(remote_head.trim()).await;
1014                }
1015            }
1016
1017            // ... and the remote branch that the checked-out one is tracking
1018            if let Ok(remote_head) = git_cmd(&["rev-parse", "--symbolic-full-name", "@{u}"]).await {
1019                add_if_matching(remote_head.trim()).await;
1020            }
1021
1022            Ok(remote_branches)
1023        })
1024        .boxed()
1025    }
1026}
1027
1028async fn run_remote_command(
1029    mut ask_pass: AskPassSession,
1030    git_process: smol::process::Child,
1031) -> std::result::Result<RemoteCommandOutput, anyhow::Error> {
1032    select_biased! {
1033        result = ask_pass.run().fuse() => {
1034            match result {
1035                AskPassResult::CancelledByUser => {
1036                    Err(anyhow!(REMOTE_CANCELLED_BY_USER))?
1037                }
1038                AskPassResult::Timedout => {
1039                    Err(anyhow!("Connecting to host timed out"))?
1040                }
1041            }
1042        }
1043        output = git_process.output().fuse() => {
1044            let output = output?;
1045            if !output.status.success() {
1046                Err(anyhow!(
1047                    "{}",
1048                    String::from_utf8_lossy(&output.stderr)
1049                ))
1050            } else {
1051                Ok(RemoteCommandOutput {
1052                    stdout: String::from_utf8_lossy(&output.stdout).to_string(),
1053                    stderr: String::from_utf8_lossy(&output.stderr).to_string(),
1054                })
1055            }
1056        }
1057    }
1058}
1059
1060#[derive(Debug, Clone)]
1061pub struct FakeGitRepository {
1062    state: Arc<Mutex<FakeGitRepositoryState>>,
1063}
1064
1065#[derive(Debug, Clone)]
1066pub struct FakeGitRepositoryState {
1067    pub path: PathBuf,
1068    pub event_emitter: smol::channel::Sender<PathBuf>,
1069    pub head_contents: HashMap<RepoPath, String>,
1070    pub index_contents: HashMap<RepoPath, String>,
1071    pub blames: HashMap<RepoPath, Blame>,
1072    pub statuses: HashMap<RepoPath, FileStatus>,
1073    pub current_branch_name: Option<String>,
1074    pub branches: HashSet<String>,
1075    pub simulated_index_write_error_message: Option<String>,
1076}
1077
1078impl FakeGitRepository {
1079    pub fn open(state: Arc<Mutex<FakeGitRepositoryState>>) -> Arc<dyn GitRepository> {
1080        Arc::new(FakeGitRepository { state })
1081    }
1082}
1083
1084impl FakeGitRepositoryState {
1085    pub fn new(path: PathBuf, event_emitter: smol::channel::Sender<PathBuf>) -> Self {
1086        FakeGitRepositoryState {
1087            path,
1088            event_emitter,
1089            head_contents: Default::default(),
1090            index_contents: Default::default(),
1091            blames: Default::default(),
1092            statuses: Default::default(),
1093            current_branch_name: Default::default(),
1094            branches: Default::default(),
1095            simulated_index_write_error_message: None,
1096        }
1097    }
1098}
1099
1100impl GitRepository for FakeGitRepository {
1101    fn reload_index(&self) {}
1102
1103    fn load_index_text(&self, path: RepoPath, _: AsyncApp) -> BoxFuture<Option<String>> {
1104        let state = self.state.lock();
1105        let content = state.index_contents.get(path.as_ref()).cloned();
1106        async { content }.boxed()
1107    }
1108
1109    fn load_committed_text(&self, path: RepoPath, _: AsyncApp) -> BoxFuture<Option<String>> {
1110        let state = self.state.lock();
1111        let content = state.head_contents.get(path.as_ref()).cloned();
1112        async { content }.boxed()
1113    }
1114
1115    fn set_index_text(
1116        &self,
1117        path: RepoPath,
1118        content: Option<String>,
1119        _env: HashMap<String, String>,
1120        _cx: AsyncApp,
1121    ) -> BoxFuture<anyhow::Result<()>> {
1122        let mut state = self.state.lock();
1123        if let Some(message) = state.simulated_index_write_error_message.clone() {
1124            return async { Err(anyhow::anyhow!(message)) }.boxed();
1125        }
1126        if let Some(content) = content {
1127            state.index_contents.insert(path.clone(), content);
1128        } else {
1129            state.index_contents.remove(&path);
1130        }
1131        state
1132            .event_emitter
1133            .try_send(state.path.clone())
1134            .expect("Dropped repo change event");
1135        async { Ok(()) }.boxed()
1136    }
1137
1138    fn remote_url(&self, _name: &str) -> Option<String> {
1139        None
1140    }
1141
1142    fn head_sha(&self) -> Option<String> {
1143        None
1144    }
1145
1146    fn merge_head_shas(&self) -> Vec<String> {
1147        vec![]
1148    }
1149
1150    fn show(&self, _: String, _: AsyncApp) -> BoxFuture<Result<CommitDetails>> {
1151        unimplemented!()
1152    }
1153
1154    fn reset(&self, _: String, _: ResetMode, _: HashMap<String, String>) -> BoxFuture<Result<()>> {
1155        unimplemented!()
1156    }
1157
1158    fn checkout_files(
1159        &self,
1160        _: String,
1161        _: Vec<RepoPath>,
1162        _: HashMap<String, String>,
1163    ) -> BoxFuture<Result<()>> {
1164        unimplemented!()
1165    }
1166
1167    fn path(&self) -> PathBuf {
1168        let state = self.state.lock();
1169        state.path.clone()
1170    }
1171
1172    fn main_repository_path(&self) -> PathBuf {
1173        self.path()
1174    }
1175
1176    fn status(&self, path_prefixes: &[RepoPath]) -> Result<GitStatus> {
1177        let state = self.state.lock();
1178
1179        let mut entries = state
1180            .statuses
1181            .iter()
1182            .filter_map(|(repo_path, status)| {
1183                if path_prefixes
1184                    .iter()
1185                    .any(|path_prefix| repo_path.0.starts_with(path_prefix))
1186                {
1187                    Some((repo_path.to_owned(), *status))
1188                } else {
1189                    None
1190                }
1191            })
1192            .collect::<Vec<_>>();
1193        entries.sort_unstable_by(|(a, _), (b, _)| a.cmp(&b));
1194
1195        Ok(GitStatus {
1196            entries: entries.into(),
1197        })
1198    }
1199
1200    fn branches(&self) -> BoxFuture<Result<Vec<Branch>>> {
1201        let state = self.state.lock();
1202        let current_branch = &state.current_branch_name;
1203        let result = Ok(state
1204            .branches
1205            .iter()
1206            .map(|branch_name| Branch {
1207                is_head: Some(branch_name) == current_branch.as_ref(),
1208                name: branch_name.into(),
1209                most_recent_commit: None,
1210                upstream: None,
1211            })
1212            .collect());
1213
1214        async { result }.boxed()
1215    }
1216
1217    fn change_branch(&self, name: String, _: AsyncApp) -> BoxFuture<Result<()>> {
1218        let mut state = self.state.lock();
1219        state.current_branch_name = Some(name.to_owned());
1220        state
1221            .event_emitter
1222            .try_send(state.path.clone())
1223            .expect("Dropped repo change event");
1224        async { Ok(()) }.boxed()
1225    }
1226
1227    fn create_branch(&self, name: String, _: AsyncApp) -> BoxFuture<Result<()>> {
1228        let mut state = self.state.lock();
1229        state.branches.insert(name.to_owned());
1230        state
1231            .event_emitter
1232            .try_send(state.path.clone())
1233            .expect("Dropped repo change event");
1234        async { Ok(()) }.boxed()
1235    }
1236
1237    fn blame(
1238        &self,
1239        path: RepoPath,
1240        _content: Rope,
1241        _cx: AsyncApp,
1242    ) -> BoxFuture<Result<crate::blame::Blame>> {
1243        let state = self.state.lock();
1244        let result = state
1245            .blames
1246            .get(&path)
1247            .with_context(|| format!("failed to get blame for {:?}", path.0))
1248            .cloned();
1249        async { result }.boxed()
1250    }
1251
1252    fn stage_paths(
1253        &self,
1254        _paths: Vec<RepoPath>,
1255        _env: HashMap<String, String>,
1256        _cx: AsyncApp,
1257    ) -> BoxFuture<Result<()>> {
1258        unimplemented!()
1259    }
1260
1261    fn unstage_paths(
1262        &self,
1263        _paths: Vec<RepoPath>,
1264        _env: HashMap<String, String>,
1265        _cx: AsyncApp,
1266    ) -> BoxFuture<Result<()>> {
1267        unimplemented!()
1268    }
1269
1270    fn commit(
1271        &self,
1272        _message: SharedString,
1273        _name_and_email: Option<(SharedString, SharedString)>,
1274        _env: HashMap<String, String>,
1275        _: AsyncApp,
1276    ) -> BoxFuture<Result<()>> {
1277        unimplemented!()
1278    }
1279
1280    fn push(
1281        &self,
1282        _branch: String,
1283        _remote: String,
1284        _options: Option<PushOptions>,
1285        _ask_pass: AskPassSession,
1286        _env: HashMap<String, String>,
1287        _cx: AsyncApp,
1288    ) -> BoxFuture<Result<RemoteCommandOutput>> {
1289        unimplemented!()
1290    }
1291
1292    fn pull(
1293        &self,
1294        _branch: String,
1295        _remote: String,
1296        _ask_pass: AskPassSession,
1297        _env: HashMap<String, String>,
1298        _cx: AsyncApp,
1299    ) -> BoxFuture<Result<RemoteCommandOutput>> {
1300        unimplemented!()
1301    }
1302
1303    fn fetch(
1304        &self,
1305        _ask_pass: AskPassSession,
1306        _env: HashMap<String, String>,
1307        _cx: AsyncApp,
1308    ) -> BoxFuture<Result<RemoteCommandOutput>> {
1309        unimplemented!()
1310    }
1311
1312    fn get_remotes(
1313        &self,
1314        _branch: Option<String>,
1315        _cx: AsyncApp,
1316    ) -> BoxFuture<Result<Vec<Remote>>> {
1317        unimplemented!()
1318    }
1319
1320    fn check_for_pushed_commit(&self, _cx: AsyncApp) -> BoxFuture<Result<Vec<SharedString>>> {
1321        unimplemented!()
1322    }
1323
1324    fn diff(&self, _diff: DiffType, _cx: AsyncApp) -> BoxFuture<Result<String>> {
1325        unimplemented!()
1326    }
1327}
1328
1329fn check_path_to_repo_path_errors(relative_file_path: &Path) -> Result<()> {
1330    match relative_file_path.components().next() {
1331        None => anyhow::bail!("repo path should not be empty"),
1332        Some(Component::Prefix(_)) => anyhow::bail!(
1333            "repo path `{}` should be relative, not a windows prefix",
1334            relative_file_path.to_string_lossy()
1335        ),
1336        Some(Component::RootDir) => {
1337            anyhow::bail!(
1338                "repo path `{}` should be relative",
1339                relative_file_path.to_string_lossy()
1340            )
1341        }
1342        Some(Component::CurDir) => {
1343            anyhow::bail!(
1344                "repo path `{}` should not start with `.`",
1345                relative_file_path.to_string_lossy()
1346            )
1347        }
1348        Some(Component::ParentDir) => {
1349            anyhow::bail!(
1350                "repo path `{}` should not start with `..`",
1351                relative_file_path.to_string_lossy()
1352            )
1353        }
1354        _ => Ok(()),
1355    }
1356}
1357
1358pub static WORK_DIRECTORY_REPO_PATH: LazyLock<RepoPath> =
1359    LazyLock::new(|| RepoPath(Path::new("").into()));
1360
1361#[derive(Clone, Debug, Ord, Hash, PartialOrd, Eq, PartialEq)]
1362pub struct RepoPath(pub Arc<Path>);
1363
1364impl RepoPath {
1365    pub fn new(path: PathBuf) -> Self {
1366        debug_assert!(path.is_relative(), "Repo paths must be relative");
1367
1368        RepoPath(path.into())
1369    }
1370
1371    pub fn from_str(path: &str) -> Self {
1372        let path = Path::new(path);
1373        debug_assert!(path.is_relative(), "Repo paths must be relative");
1374
1375        RepoPath(path.into())
1376    }
1377}
1378
1379impl std::fmt::Display for RepoPath {
1380    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1381        self.0.to_string_lossy().fmt(f)
1382    }
1383}
1384
1385impl From<&Path> for RepoPath {
1386    fn from(value: &Path) -> Self {
1387        RepoPath::new(value.into())
1388    }
1389}
1390
1391impl From<Arc<Path>> for RepoPath {
1392    fn from(value: Arc<Path>) -> Self {
1393        RepoPath(value)
1394    }
1395}
1396
1397impl From<PathBuf> for RepoPath {
1398    fn from(value: PathBuf) -> Self {
1399        RepoPath::new(value)
1400    }
1401}
1402
1403impl From<&str> for RepoPath {
1404    fn from(value: &str) -> Self {
1405        Self::from_str(value)
1406    }
1407}
1408
1409impl Default for RepoPath {
1410    fn default() -> Self {
1411        RepoPath(Path::new("").into())
1412    }
1413}
1414
1415impl AsRef<Path> for RepoPath {
1416    fn as_ref(&self) -> &Path {
1417        self.0.as_ref()
1418    }
1419}
1420
1421impl std::ops::Deref for RepoPath {
1422    type Target = Path;
1423
1424    fn deref(&self) -> &Self::Target {
1425        &self.0
1426    }
1427}
1428
1429impl Borrow<Path> for RepoPath {
1430    fn borrow(&self) -> &Path {
1431        self.0.as_ref()
1432    }
1433}
1434
1435#[derive(Debug)]
1436pub struct RepoPathDescendants<'a>(pub &'a Path);
1437
1438impl MapSeekTarget<RepoPath> for RepoPathDescendants<'_> {
1439    fn cmp_cursor(&self, key: &RepoPath) -> Ordering {
1440        if key.starts_with(self.0) {
1441            Ordering::Greater
1442        } else {
1443            self.0.cmp(key)
1444        }
1445    }
1446}
1447
1448fn parse_branch_input(input: &str) -> Result<Vec<Branch>> {
1449    let mut branches = Vec::new();
1450    for line in input.split('\n') {
1451        if line.is_empty() {
1452            continue;
1453        }
1454        let mut fields = line.split('\x00');
1455        let is_current_branch = fields.next().context("no HEAD")? == "*";
1456        let head_sha: SharedString = fields.next().context("no objectname")?.to_string().into();
1457        let parent_sha: SharedString = fields.next().context("no parent")?.to_string().into();
1458        let ref_name: SharedString = fields
1459            .next()
1460            .context("no refname")?
1461            .strip_prefix("refs/heads/")
1462            .context("unexpected format for refname")?
1463            .to_string()
1464            .into();
1465        let upstream_name = fields.next().context("no upstream")?.to_string();
1466        let upstream_tracking = parse_upstream_track(fields.next().context("no upstream:track")?)?;
1467        let commiterdate = fields.next().context("no committerdate")?.parse::<i64>()?;
1468        let subject: SharedString = fields
1469            .next()
1470            .context("no contents:subject")?
1471            .to_string()
1472            .into();
1473
1474        branches.push(Branch {
1475            is_head: is_current_branch,
1476            name: ref_name,
1477            most_recent_commit: Some(CommitSummary {
1478                sha: head_sha,
1479                subject,
1480                commit_timestamp: commiterdate,
1481                has_parent: !parent_sha.is_empty(),
1482            }),
1483            upstream: if upstream_name.is_empty() {
1484                None
1485            } else {
1486                Some(Upstream {
1487                    ref_name: upstream_name.into(),
1488                    tracking: upstream_tracking,
1489                })
1490            },
1491        })
1492    }
1493
1494    Ok(branches)
1495}
1496
1497fn parse_upstream_track(upstream_track: &str) -> Result<UpstreamTracking> {
1498    if upstream_track == "" {
1499        return Ok(UpstreamTracking::Tracked(UpstreamTrackingStatus {
1500            ahead: 0,
1501            behind: 0,
1502        }));
1503    }
1504
1505    let upstream_track = upstream_track
1506        .strip_prefix("[")
1507        .ok_or_else(|| anyhow!("missing ["))?;
1508    let upstream_track = upstream_track
1509        .strip_suffix("]")
1510        .ok_or_else(|| anyhow!("missing ["))?;
1511    let mut ahead: u32 = 0;
1512    let mut behind: u32 = 0;
1513    for component in upstream_track.split(", ") {
1514        if component == "gone" {
1515            return Ok(UpstreamTracking::Gone);
1516        }
1517        if let Some(ahead_num) = component.strip_prefix("ahead ") {
1518            ahead = ahead_num.parse::<u32>()?;
1519        }
1520        if let Some(behind_num) = component.strip_prefix("behind ") {
1521            behind = behind_num.parse::<u32>()?;
1522        }
1523    }
1524    Ok(UpstreamTracking::Tracked(UpstreamTrackingStatus {
1525        ahead,
1526        behind,
1527    }))
1528}
1529
1530#[test]
1531fn test_branches_parsing() {
1532    // suppress "help: octal escapes are not supported, `\0` is always null"
1533    #[allow(clippy::octal_escapes)]
1534    let input = "*\0060964da10574cd9bf06463a53bf6e0769c5c45e\0\0refs/heads/zed-patches\0refs/remotes/origin/zed-patches\0\01733187470\0generated protobuf\n";
1535    assert_eq!(
1536        parse_branch_input(&input).unwrap(),
1537        vec![Branch {
1538            is_head: true,
1539            name: "zed-patches".into(),
1540            upstream: Some(Upstream {
1541                ref_name: "refs/remotes/origin/zed-patches".into(),
1542                tracking: UpstreamTracking::Tracked(UpstreamTrackingStatus {
1543                    ahead: 0,
1544                    behind: 0
1545                })
1546            }),
1547            most_recent_commit: Some(CommitSummary {
1548                sha: "060964da10574cd9bf06463a53bf6e0769c5c45e".into(),
1549                subject: "generated protobuf".into(),
1550                commit_timestamp: 1733187470,
1551                has_parent: false,
1552            })
1553        }]
1554    )
1555}