repository.rs

   1use crate::commit::parse_git_diff_name_status;
   2use crate::stash::GitStash;
   3use crate::status::{DiffTreeType, GitStatus, StatusCode, TreeDiff};
   4use crate::{Oid, RunHook, SHORT_SHA_LENGTH};
   5use anyhow::{Context as _, Result, anyhow, bail};
   6use collections::HashMap;
   7use futures::channel::oneshot;
   8use futures::future::BoxFuture;
   9use futures::io::BufWriter;
  10use futures::{AsyncWriteExt, FutureExt as _, select_biased};
  11use git2::{BranchType, ErrorCode};
  12use gpui::{AppContext as _, AsyncApp, BackgroundExecutor, SharedString, Task};
  13use parking_lot::Mutex;
  14use rope::Rope;
  15use schemars::JsonSchema;
  16use serde::Deserialize;
  17use smallvec::SmallVec;
  18use smol::channel::Sender;
  19use smol::io::{AsyncBufReadExt, AsyncReadExt, BufReader};
  20use text::LineEnding;
  21
  22use std::collections::HashSet;
  23use std::ffi::{OsStr, OsString};
  24use std::process::{ExitStatus, Stdio};
  25use std::str::FromStr;
  26use std::{
  27    cmp::Ordering,
  28    future,
  29    path::{Path, PathBuf},
  30    sync::Arc,
  31};
  32use sum_tree::MapSeekTarget;
  33use thiserror::Error;
  34use util::command::new_smol_command;
  35use util::paths::PathStyle;
  36use util::rel_path::RelPath;
  37use util::{ResultExt, paths};
  38use uuid::Uuid;
  39
  40pub use askpass::{AskPassDelegate, AskPassResult, AskPassSession};
  41
  42pub const REMOTE_CANCELLED_BY_USER: &str = "Operation cancelled by user";
  43
  44/// Format string used in graph log to get initial data for the git graph
  45/// %H - Full commit hash
  46/// %P - Parent hashes
  47/// %D - Ref names
  48/// %x00 - Null byte separator, used to split up commit data
  49static GRAPH_COMMIT_FORMAT: &str = "--format=%H%x00%P%x00%D";
  50
  51/// Number of commits to load per chunk for the git graph.
  52pub const GRAPH_CHUNK_SIZE: usize = 1000;
  53
  54/// Commit data needed for the git graph visualization.
  55#[derive(Debug, Clone)]
  56pub struct GraphCommitData {
  57    pub sha: Oid,
  58    /// Most commits have a single parent, so we use a SmallVec to avoid allocations.
  59    pub parents: SmallVec<[Oid; 1]>,
  60    pub author_name: SharedString,
  61    pub author_email: SharedString,
  62    pub commit_timestamp: i64,
  63    pub subject: SharedString,
  64}
  65
  66#[derive(Debug)]
  67pub struct InitialGraphCommitData {
  68    pub sha: Oid,
  69    pub parents: SmallVec<[Oid; 1]>,
  70    pub ref_names: Vec<SharedString>,
  71}
  72
  73struct CommitDataRequest {
  74    sha: Oid,
  75    response_tx: oneshot::Sender<Result<GraphCommitData>>,
  76}
  77
  78pub struct CommitDataReader {
  79    request_tx: smol::channel::Sender<CommitDataRequest>,
  80    _task: Task<()>,
  81}
  82
  83impl CommitDataReader {
  84    pub async fn read(&self, sha: Oid) -> Result<GraphCommitData> {
  85        let (response_tx, response_rx) = oneshot::channel();
  86        self.request_tx
  87            .send(CommitDataRequest { sha, response_tx })
  88            .await
  89            .map_err(|_| anyhow!("commit data reader task closed"))?;
  90        response_rx
  91            .await
  92            .map_err(|_| anyhow!("commit data reader task dropped response"))?
  93    }
  94}
  95
  96fn parse_cat_file_commit(sha: Oid, content: &str) -> Option<GraphCommitData> {
  97    let mut parents = SmallVec::new();
  98    let mut author_name = SharedString::default();
  99    let mut author_email = SharedString::default();
 100    let mut commit_timestamp = 0i64;
 101    let mut in_headers = true;
 102    let mut subject = None;
 103
 104    for line in content.lines() {
 105        if in_headers {
 106            if line.is_empty() {
 107                in_headers = false;
 108                continue;
 109            }
 110
 111            if let Some(parent_sha) = line.strip_prefix("parent ") {
 112                if let Ok(oid) = Oid::from_str(parent_sha.trim()) {
 113                    parents.push(oid);
 114                }
 115            } else if let Some(author_line) = line.strip_prefix("author ") {
 116                if let Some((name_email, _timestamp_tz)) = author_line.rsplit_once(' ') {
 117                    if let Some((name_email, timestamp_str)) = name_email.rsplit_once(' ') {
 118                        if let Ok(ts) = timestamp_str.parse::<i64>() {
 119                            commit_timestamp = ts;
 120                        }
 121                        if let Some((name, email)) = name_email.rsplit_once(" <") {
 122                            author_name = SharedString::from(name.to_string());
 123                            author_email =
 124                                SharedString::from(email.trim_end_matches('>').to_string());
 125                        }
 126                    }
 127                }
 128            }
 129        } else if subject.is_none() {
 130            subject = Some(SharedString::from(line.to_string()));
 131        }
 132    }
 133
 134    Some(GraphCommitData {
 135        sha,
 136        parents,
 137        author_name,
 138        author_email,
 139        commit_timestamp,
 140        subject: subject.unwrap_or_default(),
 141    })
 142}
 143
 144#[derive(Clone, Debug, Hash, PartialEq, Eq)]
 145pub struct Branch {
 146    pub is_head: bool,
 147    pub ref_name: SharedString,
 148    pub upstream: Option<Upstream>,
 149    pub most_recent_commit: Option<CommitSummary>,
 150}
 151
 152impl Branch {
 153    pub fn name(&self) -> &str {
 154        self.ref_name
 155            .as_ref()
 156            .strip_prefix("refs/heads/")
 157            .or_else(|| self.ref_name.as_ref().strip_prefix("refs/remotes/"))
 158            .unwrap_or(self.ref_name.as_ref())
 159    }
 160
 161    pub fn is_remote(&self) -> bool {
 162        self.ref_name.starts_with("refs/remotes/")
 163    }
 164
 165    pub fn remote_name(&self) -> Option<&str> {
 166        self.ref_name
 167            .strip_prefix("refs/remotes/")
 168            .and_then(|stripped| stripped.split("/").next())
 169    }
 170
 171    pub fn tracking_status(&self) -> Option<UpstreamTrackingStatus> {
 172        self.upstream
 173            .as_ref()
 174            .and_then(|upstream| upstream.tracking.status())
 175    }
 176
 177    pub fn priority_key(&self) -> (bool, Option<i64>) {
 178        (
 179            self.is_head,
 180            self.most_recent_commit
 181                .as_ref()
 182                .map(|commit| commit.commit_timestamp),
 183        )
 184    }
 185}
 186
 187#[derive(Clone, Debug, Hash, PartialEq, Eq)]
 188pub struct Worktree {
 189    pub path: PathBuf,
 190    pub ref_name: SharedString,
 191    pub sha: SharedString,
 192}
 193
 194impl Worktree {
 195    pub fn branch(&self) -> &str {
 196        self.ref_name
 197            .as_ref()
 198            .strip_prefix("refs/heads/")
 199            .or_else(|| self.ref_name.as_ref().strip_prefix("refs/remotes/"))
 200            .unwrap_or(self.ref_name.as_ref())
 201    }
 202}
 203
 204pub fn parse_worktrees_from_str<T: AsRef<str>>(raw_worktrees: T) -> Vec<Worktree> {
 205    let mut worktrees = Vec::new();
 206    let entries = raw_worktrees.as_ref().split("\n\n");
 207    for entry in entries {
 208        let mut parts = entry.splitn(3, '\n');
 209        let path = parts
 210            .next()
 211            .and_then(|p| p.split_once(' ').map(|(_, path)| path.to_string()));
 212        let sha = parts
 213            .next()
 214            .and_then(|p| p.split_once(' ').map(|(_, sha)| sha.to_string()));
 215        let ref_name = parts
 216            .next()
 217            .and_then(|p| p.split_once(' ').map(|(_, ref_name)| ref_name.to_string()));
 218
 219        if let (Some(path), Some(sha), Some(ref_name)) = (path, sha, ref_name) {
 220            worktrees.push(Worktree {
 221                path: PathBuf::from(path),
 222                ref_name: ref_name.into(),
 223                sha: sha.into(),
 224            })
 225        }
 226    }
 227
 228    worktrees
 229}
 230
 231#[derive(Clone, Debug, Hash, PartialEq, Eq)]
 232pub struct Upstream {
 233    pub ref_name: SharedString,
 234    pub tracking: UpstreamTracking,
 235}
 236
 237impl Upstream {
 238    pub fn is_remote(&self) -> bool {
 239        self.remote_name().is_some()
 240    }
 241
 242    pub fn remote_name(&self) -> Option<&str> {
 243        self.ref_name
 244            .strip_prefix("refs/remotes/")
 245            .and_then(|stripped| stripped.split("/").next())
 246    }
 247
 248    pub fn stripped_ref_name(&self) -> Option<&str> {
 249        self.ref_name.strip_prefix("refs/remotes/")
 250    }
 251
 252    pub fn branch_name(&self) -> Option<&str> {
 253        self.ref_name
 254            .strip_prefix("refs/remotes/")
 255            .and_then(|stripped| stripped.split_once('/').map(|(_, name)| name))
 256    }
 257}
 258
 259#[derive(Clone, Copy, Default)]
 260pub struct CommitOptions {
 261    pub amend: bool,
 262    pub signoff: bool,
 263}
 264
 265#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
 266pub enum UpstreamTracking {
 267    /// Remote ref not present in local repository.
 268    Gone,
 269    /// Remote ref present in local repository (fetched from remote).
 270    Tracked(UpstreamTrackingStatus),
 271}
 272
 273impl From<UpstreamTrackingStatus> for UpstreamTracking {
 274    fn from(status: UpstreamTrackingStatus) -> Self {
 275        UpstreamTracking::Tracked(status)
 276    }
 277}
 278
 279impl UpstreamTracking {
 280    pub fn is_gone(&self) -> bool {
 281        matches!(self, UpstreamTracking::Gone)
 282    }
 283
 284    pub fn status(&self) -> Option<UpstreamTrackingStatus> {
 285        match self {
 286            UpstreamTracking::Gone => None,
 287            UpstreamTracking::Tracked(status) => Some(*status),
 288        }
 289    }
 290}
 291
 292#[derive(Debug, Clone)]
 293pub struct RemoteCommandOutput {
 294    pub stdout: String,
 295    pub stderr: String,
 296}
 297
 298impl RemoteCommandOutput {
 299    pub fn is_empty(&self) -> bool {
 300        self.stdout.is_empty() && self.stderr.is_empty()
 301    }
 302}
 303
 304#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
 305pub struct UpstreamTrackingStatus {
 306    pub ahead: u32,
 307    pub behind: u32,
 308}
 309
 310#[derive(Clone, Debug, Hash, PartialEq, Eq)]
 311pub struct CommitSummary {
 312    pub sha: SharedString,
 313    pub subject: SharedString,
 314    /// This is a unix timestamp
 315    pub commit_timestamp: i64,
 316    pub author_name: SharedString,
 317    pub has_parent: bool,
 318}
 319
 320#[derive(Clone, Debug, Default, Hash, PartialEq, Eq)]
 321pub struct CommitDetails {
 322    pub sha: SharedString,
 323    pub message: SharedString,
 324    pub commit_timestamp: i64,
 325    pub author_email: SharedString,
 326    pub author_name: SharedString,
 327}
 328
 329#[derive(Clone, Debug, Hash, PartialEq, Eq)]
 330pub struct FileHistoryEntry {
 331    pub sha: SharedString,
 332    pub subject: SharedString,
 333    pub message: SharedString,
 334    pub commit_timestamp: i64,
 335    pub author_name: SharedString,
 336    pub author_email: SharedString,
 337}
 338
 339#[derive(Debug, Clone)]
 340pub struct FileHistory {
 341    pub entries: Vec<FileHistoryEntry>,
 342    pub path: RepoPath,
 343}
 344
 345#[derive(Debug)]
 346pub struct CommitDiff {
 347    pub files: Vec<CommitFile>,
 348}
 349
 350#[derive(Debug)]
 351pub struct CommitFile {
 352    pub path: RepoPath,
 353    pub old_text: Option<String>,
 354    pub new_text: Option<String>,
 355    pub is_binary: bool,
 356}
 357
 358impl CommitDetails {
 359    pub fn short_sha(&self) -> SharedString {
 360        self.sha[..SHORT_SHA_LENGTH].to_string().into()
 361    }
 362}
 363
 364/// Detects if content is binary by checking for NUL bytes in the first 8000 bytes.
 365/// This matches git's binary detection heuristic.
 366pub fn is_binary_content(content: &[u8]) -> bool {
 367    let check_len = content.len().min(8000);
 368    content[..check_len].contains(&0)
 369}
 370
 371#[derive(Debug, Clone, Hash, PartialEq, Eq)]
 372pub struct Remote {
 373    pub name: SharedString,
 374}
 375
 376pub enum ResetMode {
 377    /// Reset the branch pointer, leave index and worktree unchanged (this will make it look like things that were
 378    /// committed are now staged).
 379    Soft,
 380    /// Reset the branch pointer and index, leave worktree unchanged (this makes it look as though things that were
 381    /// committed are now unstaged).
 382    Mixed,
 383}
 384
 385#[derive(Debug, Clone, Hash, PartialEq, Eq)]
 386pub enum FetchOptions {
 387    All,
 388    Remote(Remote),
 389}
 390
 391impl FetchOptions {
 392    pub fn to_proto(&self) -> Option<String> {
 393        match self {
 394            FetchOptions::All => None,
 395            FetchOptions::Remote(remote) => Some(remote.clone().name.into()),
 396        }
 397    }
 398
 399    pub fn from_proto(remote_name: Option<String>) -> Self {
 400        match remote_name {
 401            Some(name) => FetchOptions::Remote(Remote { name: name.into() }),
 402            None => FetchOptions::All,
 403        }
 404    }
 405
 406    pub fn name(&self) -> SharedString {
 407        match self {
 408            Self::All => "Fetch all remotes".into(),
 409            Self::Remote(remote) => remote.name.clone(),
 410        }
 411    }
 412}
 413
 414impl std::fmt::Display for FetchOptions {
 415    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
 416        match self {
 417            FetchOptions::All => write!(f, "--all"),
 418            FetchOptions::Remote(remote) => write!(f, "{}", remote.name),
 419        }
 420    }
 421}
 422
 423/// Modifies .git/info/exclude temporarily
 424pub struct GitExcludeOverride {
 425    git_exclude_path: PathBuf,
 426    original_excludes: Option<String>,
 427    added_excludes: Option<String>,
 428}
 429
 430impl GitExcludeOverride {
 431    const START_BLOCK_MARKER: &str = "\n\n#  ====== Auto-added by Zed: =======\n";
 432    const END_BLOCK_MARKER: &str = "\n#  ====== End of auto-added by Zed =======\n";
 433
 434    pub async fn new(git_exclude_path: PathBuf) -> Result<Self> {
 435        let original_excludes =
 436            smol::fs::read_to_string(&git_exclude_path)
 437                .await
 438                .ok()
 439                .map(|content| {
 440                    // Auto-generated lines are normally cleaned up in
 441                    // `restore_original()` or `drop()`, but may stuck in rare cases.
 442                    // Make sure to remove them.
 443                    Self::remove_auto_generated_block(&content)
 444                });
 445
 446        Ok(GitExcludeOverride {
 447            git_exclude_path,
 448            original_excludes,
 449            added_excludes: None,
 450        })
 451    }
 452
 453    pub async fn add_excludes(&mut self, excludes: &str) -> Result<()> {
 454        self.added_excludes = Some(if let Some(ref already_added) = self.added_excludes {
 455            format!("{already_added}\n{excludes}")
 456        } else {
 457            excludes.to_string()
 458        });
 459
 460        let mut content = self.original_excludes.clone().unwrap_or_default();
 461
 462        content.push_str(Self::START_BLOCK_MARKER);
 463        content.push_str(self.added_excludes.as_ref().unwrap());
 464        content.push_str(Self::END_BLOCK_MARKER);
 465
 466        smol::fs::write(&self.git_exclude_path, content).await?;
 467        Ok(())
 468    }
 469
 470    pub async fn restore_original(&mut self) -> Result<()> {
 471        if let Some(ref original) = self.original_excludes {
 472            smol::fs::write(&self.git_exclude_path, original).await?;
 473        } else if self.git_exclude_path.exists() {
 474            smol::fs::remove_file(&self.git_exclude_path).await?;
 475        }
 476
 477        self.added_excludes = None;
 478
 479        Ok(())
 480    }
 481
 482    fn remove_auto_generated_block(content: &str) -> String {
 483        let start_marker = Self::START_BLOCK_MARKER;
 484        let end_marker = Self::END_BLOCK_MARKER;
 485        let mut content = content.to_string();
 486
 487        let start_index = content.find(start_marker);
 488        let end_index = content.rfind(end_marker);
 489
 490        if let (Some(start), Some(end)) = (start_index, end_index) {
 491            if end > start {
 492                content.replace_range(start..end + end_marker.len(), "");
 493            }
 494        }
 495
 496        // Older versions of Zed didn't have end-of-block markers,
 497        // so it's impossible to determine auto-generated lines.
 498        // Conservatively remove the standard list of excludes
 499        let standard_excludes = format!(
 500            "{}{}",
 501            Self::START_BLOCK_MARKER,
 502            include_str!("./checkpoint.gitignore")
 503        );
 504        content = content.replace(&standard_excludes, "");
 505
 506        content
 507    }
 508}
 509
 510impl Drop for GitExcludeOverride {
 511    fn drop(&mut self) {
 512        if self.added_excludes.is_some() {
 513            let git_exclude_path = self.git_exclude_path.clone();
 514            let original_excludes = self.original_excludes.clone();
 515            smol::spawn(async move {
 516                if let Some(original) = original_excludes {
 517                    smol::fs::write(&git_exclude_path, original).await
 518                } else {
 519                    smol::fs::remove_file(&git_exclude_path).await
 520                }
 521            })
 522            .detach();
 523        }
 524    }
 525}
 526
 527#[derive(Clone, Debug, Default, PartialEq, Eq, Hash, Copy)]
 528pub enum LogOrder {
 529    #[default]
 530    DateOrder,
 531    TopoOrder,
 532    AuthorDateOrder,
 533    ReverseChronological,
 534}
 535
 536impl LogOrder {
 537    pub fn as_arg(&self) -> &'static str {
 538        match self {
 539            LogOrder::DateOrder => "--date-order",
 540            LogOrder::TopoOrder => "--topo-order",
 541            LogOrder::AuthorDateOrder => "--author-date-order",
 542            LogOrder::ReverseChronological => "--reverse",
 543        }
 544    }
 545}
 546
 547#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)]
 548pub enum LogSource {
 549    #[default]
 550    All,
 551    Branch(SharedString),
 552    Sha(Oid),
 553}
 554
 555impl LogSource {
 556    fn get_arg(&self) -> Result<&str> {
 557        match self {
 558            LogSource::All => Ok("--all"),
 559            LogSource::Branch(branch) => Ok(branch.as_str()),
 560            LogSource::Sha(oid) => {
 561                str::from_utf8(oid.as_bytes()).context("Failed to build str from sha")
 562            }
 563        }
 564    }
 565}
 566
 567pub trait GitRepository: Send + Sync {
 568    fn reload_index(&self);
 569
 570    /// Returns the contents of an entry in the repository's index, or None if there is no entry for the given path.
 571    ///
 572    /// Also returns `None` for symlinks.
 573    fn load_index_text(&self, path: RepoPath) -> BoxFuture<'_, Option<String>>;
 574
 575    /// 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.
 576    ///
 577    /// Also returns `None` for symlinks.
 578    fn load_committed_text(&self, path: RepoPath) -> BoxFuture<'_, Option<String>>;
 579    fn load_blob_content(&self, oid: Oid) -> BoxFuture<'_, Result<String>>;
 580
 581    fn set_index_text(
 582        &self,
 583        path: RepoPath,
 584        content: Option<String>,
 585        env: Arc<HashMap<String, String>>,
 586        is_executable: bool,
 587    ) -> BoxFuture<'_, anyhow::Result<()>>;
 588
 589    /// Returns the URL of the remote with the given name.
 590    fn remote_url(&self, name: &str) -> BoxFuture<'_, Option<String>>;
 591
 592    /// Resolve a list of refs to SHAs.
 593    fn revparse_batch(&self, revs: Vec<String>) -> BoxFuture<'_, Result<Vec<Option<String>>>>;
 594
 595    fn head_sha(&self) -> BoxFuture<'_, Option<String>> {
 596        async move {
 597            self.revparse_batch(vec!["HEAD".into()])
 598                .await
 599                .unwrap_or_default()
 600                .into_iter()
 601                .next()
 602                .flatten()
 603        }
 604        .boxed()
 605    }
 606
 607    fn merge_message(&self) -> BoxFuture<'_, Option<String>>;
 608
 609    fn status(&self, path_prefixes: &[RepoPath]) -> Task<Result<GitStatus>>;
 610    fn diff_tree(&self, request: DiffTreeType) -> BoxFuture<'_, Result<TreeDiff>>;
 611
 612    fn stash_entries(&self) -> BoxFuture<'_, Result<GitStash>>;
 613
 614    fn branches(&self) -> BoxFuture<'_, Result<Vec<Branch>>>;
 615
 616    fn change_branch(&self, name: String) -> BoxFuture<'_, Result<()>>;
 617    fn create_branch(&self, name: String, base_branch: Option<String>)
 618    -> BoxFuture<'_, Result<()>>;
 619    fn rename_branch(&self, branch: String, new_name: String) -> BoxFuture<'_, Result<()>>;
 620
 621    fn delete_branch(&self, name: String) -> BoxFuture<'_, Result<()>>;
 622
 623    fn worktrees(&self) -> BoxFuture<'_, Result<Vec<Worktree>>>;
 624
 625    fn create_worktree(
 626        &self,
 627        name: String,
 628        directory: PathBuf,
 629        from_commit: Option<String>,
 630    ) -> BoxFuture<'_, Result<()>>;
 631
 632    fn reset(
 633        &self,
 634        commit: String,
 635        mode: ResetMode,
 636        env: Arc<HashMap<String, String>>,
 637    ) -> BoxFuture<'_, Result<()>>;
 638
 639    fn checkout_files(
 640        &self,
 641        commit: String,
 642        paths: Vec<RepoPath>,
 643        env: Arc<HashMap<String, String>>,
 644    ) -> BoxFuture<'_, Result<()>>;
 645
 646    fn show(&self, commit: String) -> BoxFuture<'_, Result<CommitDetails>>;
 647
 648    fn load_commit(&self, commit: String, cx: AsyncApp) -> BoxFuture<'_, Result<CommitDiff>>;
 649    fn blame(
 650        &self,
 651        path: RepoPath,
 652        content: Rope,
 653        line_ending: LineEnding,
 654    ) -> BoxFuture<'_, Result<crate::blame::Blame>>;
 655    fn file_history(&self, path: RepoPath) -> BoxFuture<'_, Result<FileHistory>>;
 656    fn file_history_paginated(
 657        &self,
 658        path: RepoPath,
 659        skip: usize,
 660        limit: Option<usize>,
 661    ) -> BoxFuture<'_, Result<FileHistory>>;
 662
 663    /// Returns the absolute path to the repository. For worktrees, this will be the path to the
 664    /// worktree's gitdir within the main repository (typically `.git/worktrees/<name>`).
 665    fn path(&self) -> PathBuf;
 666
 667    fn main_repository_path(&self) -> PathBuf;
 668
 669    /// Updates the index to match the worktree at the given paths.
 670    ///
 671    /// If any of the paths have been deleted from the worktree, they will be removed from the index if found there.
 672    fn stage_paths(
 673        &self,
 674        paths: Vec<RepoPath>,
 675        env: Arc<HashMap<String, String>>,
 676    ) -> BoxFuture<'_, Result<()>>;
 677    /// Updates the index to match HEAD at the given paths.
 678    ///
 679    /// If any of the paths were previously staged but do not exist in HEAD, they will be removed from the index.
 680    fn unstage_paths(
 681        &self,
 682        paths: Vec<RepoPath>,
 683        env: Arc<HashMap<String, String>>,
 684    ) -> BoxFuture<'_, Result<()>>;
 685
 686    fn run_hook(
 687        &self,
 688        hook: RunHook,
 689        env: Arc<HashMap<String, String>>,
 690    ) -> BoxFuture<'_, Result<()>>;
 691
 692    fn commit(
 693        &self,
 694        message: SharedString,
 695        name_and_email: Option<(SharedString, SharedString)>,
 696        options: CommitOptions,
 697        askpass: AskPassDelegate,
 698        env: Arc<HashMap<String, String>>,
 699    ) -> BoxFuture<'_, Result<()>>;
 700
 701    fn stash_paths(
 702        &self,
 703        paths: Vec<RepoPath>,
 704        env: Arc<HashMap<String, String>>,
 705    ) -> BoxFuture<'_, Result<()>>;
 706
 707    fn stash_pop(
 708        &self,
 709        index: Option<usize>,
 710        env: Arc<HashMap<String, String>>,
 711    ) -> BoxFuture<'_, Result<()>>;
 712
 713    fn stash_apply(
 714        &self,
 715        index: Option<usize>,
 716        env: Arc<HashMap<String, String>>,
 717    ) -> BoxFuture<'_, Result<()>>;
 718
 719    fn stash_drop(
 720        &self,
 721        index: Option<usize>,
 722        env: Arc<HashMap<String, String>>,
 723    ) -> BoxFuture<'_, Result<()>>;
 724
 725    fn push(
 726        &self,
 727        branch_name: String,
 728        remote_branch_name: String,
 729        upstream_name: String,
 730        options: Option<PushOptions>,
 731        askpass: AskPassDelegate,
 732        env: Arc<HashMap<String, String>>,
 733        // This method takes an AsyncApp to ensure it's invoked on the main thread,
 734        // otherwise git-credentials-manager won't work.
 735        cx: AsyncApp,
 736    ) -> BoxFuture<'_, Result<RemoteCommandOutput>>;
 737
 738    fn pull(
 739        &self,
 740        branch_name: Option<String>,
 741        upstream_name: String,
 742        rebase: bool,
 743        askpass: AskPassDelegate,
 744        env: Arc<HashMap<String, String>>,
 745        // This method takes an AsyncApp to ensure it's invoked on the main thread,
 746        // otherwise git-credentials-manager won't work.
 747        cx: AsyncApp,
 748    ) -> BoxFuture<'_, Result<RemoteCommandOutput>>;
 749
 750    fn fetch(
 751        &self,
 752        fetch_options: FetchOptions,
 753        askpass: AskPassDelegate,
 754        env: Arc<HashMap<String, String>>,
 755        // This method takes an AsyncApp to ensure it's invoked on the main thread,
 756        // otherwise git-credentials-manager won't work.
 757        cx: AsyncApp,
 758    ) -> BoxFuture<'_, Result<RemoteCommandOutput>>;
 759
 760    fn get_push_remote(&self, branch: String) -> BoxFuture<'_, Result<Option<Remote>>>;
 761
 762    fn get_branch_remote(&self, branch: String) -> BoxFuture<'_, Result<Option<Remote>>>;
 763
 764    fn get_all_remotes(&self) -> BoxFuture<'_, Result<Vec<Remote>>>;
 765
 766    fn remove_remote(&self, name: String) -> BoxFuture<'_, Result<()>>;
 767
 768    fn create_remote(&self, name: String, url: String) -> BoxFuture<'_, Result<()>>;
 769
 770    /// returns a list of remote branches that contain HEAD
 771    fn check_for_pushed_commit(&self) -> BoxFuture<'_, Result<Vec<SharedString>>>;
 772
 773    /// Run git diff
 774    fn diff(&self, diff: DiffType) -> BoxFuture<'_, Result<String>>;
 775
 776    /// Creates a checkpoint for the repository.
 777    fn checkpoint(&self) -> BoxFuture<'static, Result<GitRepositoryCheckpoint>>;
 778
 779    /// Resets to a previously-created checkpoint.
 780    fn restore_checkpoint(&self, checkpoint: GitRepositoryCheckpoint) -> BoxFuture<'_, Result<()>>;
 781
 782    /// Compares two checkpoints, returning true if they are equal
 783    fn compare_checkpoints(
 784        &self,
 785        left: GitRepositoryCheckpoint,
 786        right: GitRepositoryCheckpoint,
 787    ) -> BoxFuture<'_, Result<bool>>;
 788
 789    /// Computes a diff between two checkpoints.
 790    fn diff_checkpoints(
 791        &self,
 792        base_checkpoint: GitRepositoryCheckpoint,
 793        target_checkpoint: GitRepositoryCheckpoint,
 794    ) -> BoxFuture<'_, Result<String>>;
 795
 796    fn default_branch(
 797        &self,
 798        include_remote_name: bool,
 799    ) -> BoxFuture<'_, Result<Option<SharedString>>>;
 800
 801    /// Runs `git rev-list --parents` to get the commit graph structure.
 802    /// Returns commit SHAs and their parent SHAs for building the graph visualization.
 803    fn initial_graph_data(
 804        &self,
 805        log_source: LogSource,
 806        log_order: LogOrder,
 807        request_tx: Sender<Vec<Arc<InitialGraphCommitData>>>,
 808    ) -> BoxFuture<'_, Result<()>>;
 809
 810    fn commit_data_reader(&self) -> Result<CommitDataReader>;
 811}
 812
 813pub enum DiffType {
 814    HeadToIndex,
 815    HeadToWorktree,
 816}
 817
 818#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, JsonSchema)]
 819pub enum PushOptions {
 820    SetUpstream,
 821    Force,
 822}
 823
 824impl std::fmt::Debug for dyn GitRepository {
 825    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
 826        f.debug_struct("dyn GitRepository<...>").finish()
 827    }
 828}
 829
 830pub struct RealGitRepository {
 831    pub repository: Arc<Mutex<git2::Repository>>,
 832    pub system_git_binary_path: Option<PathBuf>,
 833    pub any_git_binary_path: PathBuf,
 834    any_git_binary_help_output: Arc<Mutex<Option<SharedString>>>,
 835    executor: BackgroundExecutor,
 836}
 837
 838impl RealGitRepository {
 839    pub fn new(
 840        dotgit_path: &Path,
 841        bundled_git_binary_path: Option<PathBuf>,
 842        system_git_binary_path: Option<PathBuf>,
 843        executor: BackgroundExecutor,
 844    ) -> Option<Self> {
 845        let any_git_binary_path = system_git_binary_path.clone().or(bundled_git_binary_path)?;
 846        let workdir_root = dotgit_path.parent()?;
 847        let repository = git2::Repository::open(workdir_root).log_err()?;
 848        Some(Self {
 849            repository: Arc::new(Mutex::new(repository)),
 850            system_git_binary_path,
 851            any_git_binary_path,
 852            executor,
 853            any_git_binary_help_output: Arc::new(Mutex::new(None)),
 854        })
 855    }
 856
 857    fn working_directory(&self) -> Result<PathBuf> {
 858        self.repository
 859            .lock()
 860            .workdir()
 861            .context("failed to read git work directory")
 862            .map(Path::to_path_buf)
 863    }
 864
 865    async fn any_git_binary_help_output(&self) -> SharedString {
 866        if let Some(output) = self.any_git_binary_help_output.lock().clone() {
 867            return output;
 868        }
 869        let git_binary_path = self.any_git_binary_path.clone();
 870        let executor = self.executor.clone();
 871        let working_directory = self.working_directory();
 872        let output: SharedString = self
 873            .executor
 874            .spawn(async move {
 875                GitBinary::new(git_binary_path, working_directory?, executor)
 876                    .run(["help", "-a"])
 877                    .await
 878            })
 879            .await
 880            .unwrap_or_default()
 881            .into();
 882        *self.any_git_binary_help_output.lock() = Some(output.clone());
 883        output
 884    }
 885}
 886
 887#[derive(Clone, Debug)]
 888pub struct GitRepositoryCheckpoint {
 889    pub commit_sha: Oid,
 890}
 891
 892#[derive(Debug)]
 893pub struct GitCommitter {
 894    pub name: Option<String>,
 895    pub email: Option<String>,
 896}
 897
 898pub async fn get_git_committer(cx: &AsyncApp) -> GitCommitter {
 899    if cfg!(any(feature = "test-support", test)) {
 900        return GitCommitter {
 901            name: None,
 902            email: None,
 903        };
 904    }
 905
 906    let git_binary_path =
 907        if cfg!(target_os = "macos") && option_env!("ZED_BUNDLE").as_deref() == Some("true") {
 908            cx.update(|cx| {
 909                cx.path_for_auxiliary_executable("git")
 910                    .context("could not find git binary path")
 911                    .log_err()
 912            })
 913        } else {
 914            None
 915        };
 916
 917    let git = GitBinary::new(
 918        git_binary_path.unwrap_or(PathBuf::from("git")),
 919        paths::home_dir().clone(),
 920        cx.background_executor().clone(),
 921    );
 922
 923    cx.background_spawn(async move {
 924        let name = git.run(["config", "--global", "user.name"]).await.log_err();
 925        let email = git
 926            .run(["config", "--global", "user.email"])
 927            .await
 928            .log_err();
 929        GitCommitter { name, email }
 930    })
 931    .await
 932}
 933
 934impl GitRepository for RealGitRepository {
 935    fn reload_index(&self) {
 936        if let Ok(mut index) = self.repository.lock().index() {
 937            _ = index.read(false);
 938        }
 939    }
 940
 941    fn path(&self) -> PathBuf {
 942        let repo = self.repository.lock();
 943        repo.path().into()
 944    }
 945
 946    fn main_repository_path(&self) -> PathBuf {
 947        let repo = self.repository.lock();
 948        repo.commondir().into()
 949    }
 950
 951    fn show(&self, commit: String) -> BoxFuture<'_, Result<CommitDetails>> {
 952        let git_binary_path = self.any_git_binary_path.clone();
 953        let working_directory = self.working_directory();
 954        self.executor
 955            .spawn(async move {
 956                let working_directory = working_directory?;
 957                let output = new_smol_command(git_binary_path)
 958                    .current_dir(&working_directory)
 959                    .args([
 960                        "--no-optional-locks",
 961                        "show",
 962                        "--no-patch",
 963                        "--format=%H%x00%B%x00%at%x00%ae%x00%an%x00",
 964                        &commit,
 965                    ])
 966                    .output()
 967                    .await?;
 968                let output = std::str::from_utf8(&output.stdout)?;
 969                let fields = output.split('\0').collect::<Vec<_>>();
 970                if fields.len() != 6 {
 971                    bail!("unexpected git-show output for {commit:?}: {output:?}")
 972                }
 973                let sha = fields[0].to_string().into();
 974                let message = fields[1].to_string().into();
 975                let commit_timestamp = fields[2].parse()?;
 976                let author_email = fields[3].to_string().into();
 977                let author_name = fields[4].to_string().into();
 978                Ok(CommitDetails {
 979                    sha,
 980                    message,
 981                    commit_timestamp,
 982                    author_email,
 983                    author_name,
 984                })
 985            })
 986            .boxed()
 987    }
 988
 989    fn load_commit(&self, commit: String, cx: AsyncApp) -> BoxFuture<'_, Result<CommitDiff>> {
 990        let Some(working_directory) = self.repository.lock().workdir().map(ToOwned::to_owned)
 991        else {
 992            return future::ready(Err(anyhow!("no working directory"))).boxed();
 993        };
 994        let git_binary_path = self.any_git_binary_path.clone();
 995        cx.background_spawn(async move {
 996            let show_output = util::command::new_smol_command(&git_binary_path)
 997                .current_dir(&working_directory)
 998                .args([
 999                    "--no-optional-locks",
1000                    "show",
1001                    "--format=",
1002                    "-z",
1003                    "--no-renames",
1004                    "--name-status",
1005                    "--first-parent",
1006                ])
1007                .arg(&commit)
1008                .stdin(Stdio::null())
1009                .stdout(Stdio::piped())
1010                .stderr(Stdio::piped())
1011                .output()
1012                .await
1013                .context("starting git show process")?;
1014
1015            let show_stdout = String::from_utf8_lossy(&show_output.stdout);
1016            let changes = parse_git_diff_name_status(&show_stdout);
1017            let parent_sha = format!("{}^", commit);
1018
1019            let mut cat_file_process = util::command::new_smol_command(&git_binary_path)
1020                .current_dir(&working_directory)
1021                .args(["--no-optional-locks", "cat-file", "--batch=%(objectsize)"])
1022                .stdin(Stdio::piped())
1023                .stdout(Stdio::piped())
1024                .stderr(Stdio::piped())
1025                .spawn()
1026                .context("starting git cat-file process")?;
1027
1028            let mut files = Vec::<CommitFile>::new();
1029            let mut stdin = BufWriter::with_capacity(512, cat_file_process.stdin.take().unwrap());
1030            let mut stdout = BufReader::new(cat_file_process.stdout.take().unwrap());
1031            let mut info_line = String::new();
1032            let mut newline = [b'\0'];
1033            for (path, status_code) in changes {
1034                // git-show outputs `/`-delimited paths even on Windows.
1035                let Some(rel_path) = RelPath::unix(path).log_err() else {
1036                    continue;
1037                };
1038
1039                match status_code {
1040                    StatusCode::Modified => {
1041                        stdin.write_all(commit.as_bytes()).await?;
1042                        stdin.write_all(b":").await?;
1043                        stdin.write_all(path.as_bytes()).await?;
1044                        stdin.write_all(b"\n").await?;
1045                        stdin.write_all(parent_sha.as_bytes()).await?;
1046                        stdin.write_all(b":").await?;
1047                        stdin.write_all(path.as_bytes()).await?;
1048                        stdin.write_all(b"\n").await?;
1049                    }
1050                    StatusCode::Added => {
1051                        stdin.write_all(commit.as_bytes()).await?;
1052                        stdin.write_all(b":").await?;
1053                        stdin.write_all(path.as_bytes()).await?;
1054                        stdin.write_all(b"\n").await?;
1055                    }
1056                    StatusCode::Deleted => {
1057                        stdin.write_all(parent_sha.as_bytes()).await?;
1058                        stdin.write_all(b":").await?;
1059                        stdin.write_all(path.as_bytes()).await?;
1060                        stdin.write_all(b"\n").await?;
1061                    }
1062                    _ => continue,
1063                }
1064                stdin.flush().await?;
1065
1066                info_line.clear();
1067                stdout.read_line(&mut info_line).await?;
1068
1069                let len = info_line.trim_end().parse().with_context(|| {
1070                    format!("invalid object size output from cat-file {info_line}")
1071                })?;
1072                let mut text_bytes = vec![0; len];
1073                stdout.read_exact(&mut text_bytes).await?;
1074                stdout.read_exact(&mut newline).await?;
1075
1076                let mut old_text = None;
1077                let mut new_text = None;
1078                let mut is_binary = is_binary_content(&text_bytes);
1079                let text = if is_binary {
1080                    String::new()
1081                } else {
1082                    String::from_utf8_lossy(&text_bytes).to_string()
1083                };
1084
1085                match status_code {
1086                    StatusCode::Modified => {
1087                        info_line.clear();
1088                        stdout.read_line(&mut info_line).await?;
1089                        let len = info_line.trim_end().parse().with_context(|| {
1090                            format!("invalid object size output from cat-file {}", info_line)
1091                        })?;
1092                        let mut parent_bytes = vec![0; len];
1093                        stdout.read_exact(&mut parent_bytes).await?;
1094                        stdout.read_exact(&mut newline).await?;
1095                        is_binary = is_binary || is_binary_content(&parent_bytes);
1096                        if is_binary {
1097                            old_text = Some(String::new());
1098                            new_text = Some(String::new());
1099                        } else {
1100                            old_text = Some(String::from_utf8_lossy(&parent_bytes).to_string());
1101                            new_text = Some(text);
1102                        }
1103                    }
1104                    StatusCode::Added => new_text = Some(text),
1105                    StatusCode::Deleted => old_text = Some(text),
1106                    _ => continue,
1107                }
1108
1109                files.push(CommitFile {
1110                    path: RepoPath(Arc::from(rel_path)),
1111                    old_text,
1112                    new_text,
1113                    is_binary,
1114                })
1115            }
1116
1117            Ok(CommitDiff { files })
1118        })
1119        .boxed()
1120    }
1121
1122    fn reset(
1123        &self,
1124        commit: String,
1125        mode: ResetMode,
1126        env: Arc<HashMap<String, String>>,
1127    ) -> BoxFuture<'_, Result<()>> {
1128        async move {
1129            let working_directory = self.working_directory();
1130
1131            let mode_flag = match mode {
1132                ResetMode::Mixed => "--mixed",
1133                ResetMode::Soft => "--soft",
1134            };
1135
1136            let output = new_smol_command(&self.any_git_binary_path)
1137                .envs(env.iter())
1138                .current_dir(&working_directory?)
1139                .args(["reset", mode_flag, &commit])
1140                .output()
1141                .await?;
1142            anyhow::ensure!(
1143                output.status.success(),
1144                "Failed to reset:\n{}",
1145                String::from_utf8_lossy(&output.stderr),
1146            );
1147            Ok(())
1148        }
1149        .boxed()
1150    }
1151
1152    fn checkout_files(
1153        &self,
1154        commit: String,
1155        paths: Vec<RepoPath>,
1156        env: Arc<HashMap<String, String>>,
1157    ) -> BoxFuture<'_, Result<()>> {
1158        let working_directory = self.working_directory();
1159        let git_binary_path = self.any_git_binary_path.clone();
1160        async move {
1161            if paths.is_empty() {
1162                return Ok(());
1163            }
1164
1165            let working_directory = working_directory?;
1166            let mut child = new_smol_command(&git_binary_path)
1167                .current_dir(&working_directory)
1168                .envs(env.iter())
1169                .args([
1170                    "checkout",
1171                    &commit,
1172                    "--pathspec-from-file=-",
1173                    "--pathspec-file-nul",
1174                ])
1175                .stdin(Stdio::piped())
1176                .stdout(Stdio::null())
1177                .stderr(Stdio::piped())
1178                .spawn()
1179                .context("failed to spawn git checkout")?;
1180
1181            let mut stdin = child.stdin.take().context("failed to get stdin")?;
1182            for path in &paths {
1183                stdin.write_all(path.as_unix_str().as_bytes()).await?;
1184                stdin.write_all(b"\0").await?;
1185            }
1186            drop(stdin);
1187
1188            let output = child.output().await?;
1189            if output.status.success() {
1190                return Ok(());
1191            }
1192
1193            let stderr = String::from_utf8_lossy(&output.stderr);
1194            if !stderr.contains("pathspec-from-file") {
1195                anyhow::bail!("Failed to checkout files:\n{}", stderr);
1196            }
1197
1198            // Fallback for older git versions: pass paths as command-line arguments
1199            let output = new_smol_command(&git_binary_path)
1200                .current_dir(&working_directory)
1201                .envs(env.iter())
1202                .args(["checkout", &commit, "--"])
1203                .args(paths.iter().map(|path| path.as_unix_str()))
1204                .output()
1205                .await?;
1206            anyhow::ensure!(
1207                output.status.success(),
1208                "Failed to checkout files:\n{}",
1209                String::from_utf8_lossy(&output.stderr),
1210            );
1211            Ok(())
1212        }
1213        .boxed()
1214    }
1215
1216    fn load_index_text(&self, path: RepoPath) -> BoxFuture<'_, Option<String>> {
1217        // https://git-scm.com/book/en/v2/Git-Internals-Git-Objects
1218        const GIT_MODE_SYMLINK: u32 = 0o120000;
1219
1220        let repo = self.repository.clone();
1221        self.executor
1222            .spawn(async move {
1223                fn logic(repo: &git2::Repository, path: &RepoPath) -> Result<Option<String>> {
1224                    // This check is required because index.get_path() unwraps internally :(
1225                    let mut index = repo.index()?;
1226                    index.read(false)?;
1227
1228                    const STAGE_NORMAL: i32 = 0;
1229                    let path = path.as_std_path();
1230                    // `RepoPath` contains a `RelPath` which normalizes `.` into an empty path
1231                    // `get_path` unwraps on empty paths though, so undo that normalization here
1232                    let path = if path.components().next().is_none() {
1233                        ".".as_ref()
1234                    } else {
1235                        path
1236                    };
1237                    let oid = match index.get_path(path, STAGE_NORMAL) {
1238                        Some(entry) if entry.mode != GIT_MODE_SYMLINK => entry.id,
1239                        _ => return Ok(None),
1240                    };
1241
1242                    let content = repo.find_blob(oid)?.content().to_owned();
1243                    Ok(String::from_utf8(content).ok())
1244                }
1245
1246                match logic(&repo.lock(), &path) {
1247                    Ok(value) => return value,
1248                    Err(err) => log::error!("Error loading index text: {:?}", err),
1249                }
1250                None
1251            })
1252            .boxed()
1253    }
1254
1255    fn load_committed_text(&self, path: RepoPath) -> BoxFuture<'_, Option<String>> {
1256        let repo = self.repository.clone();
1257        self.executor
1258            .spawn(async move {
1259                let repo = repo.lock();
1260                let head = repo.head().ok()?.peel_to_tree().log_err()?;
1261                let entry = head.get_path(path.as_std_path()).ok()?;
1262                if entry.filemode() == i32::from(git2::FileMode::Link) {
1263                    return None;
1264                }
1265                let content = repo.find_blob(entry.id()).log_err()?.content().to_owned();
1266                String::from_utf8(content).ok()
1267            })
1268            .boxed()
1269    }
1270
1271    fn load_blob_content(&self, oid: Oid) -> BoxFuture<'_, Result<String>> {
1272        let repo = self.repository.clone();
1273        self.executor
1274            .spawn(async move {
1275                let repo = repo.lock();
1276                let content = repo.find_blob(oid.0)?.content().to_owned();
1277                Ok(String::from_utf8(content)?)
1278            })
1279            .boxed()
1280    }
1281
1282    fn set_index_text(
1283        &self,
1284        path: RepoPath,
1285        content: Option<String>,
1286        env: Arc<HashMap<String, String>>,
1287        is_executable: bool,
1288    ) -> BoxFuture<'_, anyhow::Result<()>> {
1289        let working_directory = self.working_directory();
1290        let git_binary_path = self.any_git_binary_path.clone();
1291        self.executor
1292            .spawn(async move {
1293                let working_directory = working_directory?;
1294                let mode = if is_executable { "100755" } else { "100644" };
1295
1296                if let Some(content) = content {
1297                    let mut child = new_smol_command(&git_binary_path)
1298                        .current_dir(&working_directory)
1299                        .envs(env.iter())
1300                        .args(["hash-object", "-w", "--stdin"])
1301                        .stdin(Stdio::piped())
1302                        .stdout(Stdio::piped())
1303                        .spawn()?;
1304                    let mut stdin = child.stdin.take().unwrap();
1305                    stdin.write_all(content.as_bytes()).await?;
1306                    stdin.flush().await?;
1307                    drop(stdin);
1308                    let output = child.output().await?.stdout;
1309                    let sha = str::from_utf8(&output)?.trim();
1310
1311                    log::debug!("indexing SHA: {sha}, path {path:?}");
1312
1313                    let output = new_smol_command(&git_binary_path)
1314                        .current_dir(&working_directory)
1315                        .envs(env.iter())
1316                        .args(["update-index", "--add", "--cacheinfo", mode, sha])
1317                        .arg(path.as_unix_str())
1318                        .output()
1319                        .await?;
1320
1321                    anyhow::ensure!(
1322                        output.status.success(),
1323                        "Failed to stage:\n{}",
1324                        String::from_utf8_lossy(&output.stderr)
1325                    );
1326                } else {
1327                    log::debug!("removing path {path:?} from the index");
1328                    let output = new_smol_command(&git_binary_path)
1329                        .current_dir(&working_directory)
1330                        .envs(env.iter())
1331                        .args(["update-index", "--force-remove"])
1332                        .arg(path.as_unix_str())
1333                        .output()
1334                        .await?;
1335                    anyhow::ensure!(
1336                        output.status.success(),
1337                        "Failed to unstage:\n{}",
1338                        String::from_utf8_lossy(&output.stderr)
1339                    );
1340                }
1341
1342                Ok(())
1343            })
1344            .boxed()
1345    }
1346
1347    fn remote_url(&self, name: &str) -> BoxFuture<'_, Option<String>> {
1348        let repo = self.repository.clone();
1349        let name = name.to_owned();
1350        self.executor
1351            .spawn(async move {
1352                let repo = repo.lock();
1353                let remote = repo.find_remote(&name).ok()?;
1354                remote.url().map(|url| url.to_string())
1355            })
1356            .boxed()
1357    }
1358
1359    fn revparse_batch(&self, revs: Vec<String>) -> BoxFuture<'_, Result<Vec<Option<String>>>> {
1360        let working_directory = self.working_directory();
1361        let git_binary_path = self.any_git_binary_path.clone();
1362        self.executor
1363            .spawn(async move {
1364                let working_directory = working_directory?;
1365                let mut process = new_smol_command(&git_binary_path)
1366                    .current_dir(&working_directory)
1367                    .args([
1368                        "--no-optional-locks",
1369                        "cat-file",
1370                        "--batch-check=%(objectname)",
1371                    ])
1372                    .stdin(Stdio::piped())
1373                    .stdout(Stdio::piped())
1374                    .stderr(Stdio::piped())
1375                    .spawn()?;
1376
1377                let stdin = process
1378                    .stdin
1379                    .take()
1380                    .context("no stdin for git cat-file subprocess")?;
1381                let mut stdin = BufWriter::new(stdin);
1382                for rev in &revs {
1383                    stdin.write_all(rev.as_bytes()).await?;
1384                    stdin.write_all(b"\n").await?;
1385                }
1386                stdin.flush().await?;
1387                drop(stdin);
1388
1389                let output = process.output().await?;
1390                let output = std::str::from_utf8(&output.stdout)?;
1391                let shas = output
1392                    .lines()
1393                    .map(|line| {
1394                        if line.ends_with("missing") {
1395                            None
1396                        } else {
1397                            Some(line.to_string())
1398                        }
1399                    })
1400                    .collect::<Vec<_>>();
1401
1402                if shas.len() != revs.len() {
1403                    // In an octopus merge, git cat-file still only outputs the first sha from MERGE_HEAD.
1404                    bail!("unexpected number of shas")
1405                }
1406
1407                Ok(shas)
1408            })
1409            .boxed()
1410    }
1411
1412    fn merge_message(&self) -> BoxFuture<'_, Option<String>> {
1413        let path = self.path().join("MERGE_MSG");
1414        self.executor
1415            .spawn(async move { std::fs::read_to_string(&path).ok() })
1416            .boxed()
1417    }
1418
1419    fn status(&self, path_prefixes: &[RepoPath]) -> Task<Result<GitStatus>> {
1420        let git_binary_path = self.any_git_binary_path.clone();
1421        let working_directory = match self.working_directory() {
1422            Ok(working_directory) => working_directory,
1423            Err(e) => return Task::ready(Err(e)),
1424        };
1425        let args = git_status_args(path_prefixes);
1426        log::debug!("Checking for git status in {path_prefixes:?}");
1427        self.executor.spawn(async move {
1428            let output = new_smol_command(&git_binary_path)
1429                .current_dir(working_directory)
1430                .args(args)
1431                .output()
1432                .await?;
1433            if output.status.success() {
1434                let stdout = String::from_utf8_lossy(&output.stdout);
1435                stdout.parse()
1436            } else {
1437                let stderr = String::from_utf8_lossy(&output.stderr);
1438                anyhow::bail!("git status failed: {stderr}");
1439            }
1440        })
1441    }
1442
1443    fn diff_tree(&self, request: DiffTreeType) -> BoxFuture<'_, Result<TreeDiff>> {
1444        let git_binary_path = self.any_git_binary_path.clone();
1445        let working_directory = match self.working_directory() {
1446            Ok(working_directory) => working_directory,
1447            Err(e) => return Task::ready(Err(e)).boxed(),
1448        };
1449
1450        let mut args = vec![
1451            OsString::from("--no-optional-locks"),
1452            OsString::from("diff-tree"),
1453            OsString::from("-r"),
1454            OsString::from("-z"),
1455            OsString::from("--no-renames"),
1456        ];
1457        match request {
1458            DiffTreeType::MergeBase { base, head } => {
1459                args.push("--merge-base".into());
1460                args.push(OsString::from(base.as_str()));
1461                args.push(OsString::from(head.as_str()));
1462            }
1463            DiffTreeType::Since { base, head } => {
1464                args.push(OsString::from(base.as_str()));
1465                args.push(OsString::from(head.as_str()));
1466            }
1467        }
1468
1469        self.executor
1470            .spawn(async move {
1471                let output = new_smol_command(&git_binary_path)
1472                    .current_dir(working_directory)
1473                    .args(args)
1474                    .output()
1475                    .await?;
1476                if output.status.success() {
1477                    let stdout = String::from_utf8_lossy(&output.stdout);
1478                    stdout.parse()
1479                } else {
1480                    let stderr = String::from_utf8_lossy(&output.stderr);
1481                    anyhow::bail!("git status failed: {stderr}");
1482                }
1483            })
1484            .boxed()
1485    }
1486
1487    fn stash_entries(&self) -> BoxFuture<'_, Result<GitStash>> {
1488        let git_binary_path = self.any_git_binary_path.clone();
1489        let working_directory = self.working_directory();
1490        self.executor
1491            .spawn(async move {
1492                let output = new_smol_command(&git_binary_path)
1493                    .current_dir(working_directory?)
1494                    .args(&["stash", "list", "--pretty=format:%gd%x00%H%x00%ct%x00%s"])
1495                    .output()
1496                    .await?;
1497                if output.status.success() {
1498                    let stdout = String::from_utf8_lossy(&output.stdout);
1499                    stdout.parse()
1500                } else {
1501                    let stderr = String::from_utf8_lossy(&output.stderr);
1502                    anyhow::bail!("git status failed: {stderr}");
1503                }
1504            })
1505            .boxed()
1506    }
1507
1508    fn branches(&self) -> BoxFuture<'_, Result<Vec<Branch>>> {
1509        let working_directory = self.working_directory();
1510        let git_binary_path = self.any_git_binary_path.clone();
1511        self.executor
1512            .spawn(async move {
1513                let fields = [
1514                    "%(HEAD)",
1515                    "%(objectname)",
1516                    "%(parent)",
1517                    "%(refname)",
1518                    "%(upstream)",
1519                    "%(upstream:track)",
1520                    "%(committerdate:unix)",
1521                    "%(authorname)",
1522                    "%(contents:subject)",
1523                ]
1524                .join("%00");
1525                let args = vec![
1526                    "for-each-ref",
1527                    "refs/heads/**/*",
1528                    "refs/remotes/**/*",
1529                    "--format",
1530                    &fields,
1531                ];
1532                let working_directory = working_directory?;
1533                let output = new_smol_command(&git_binary_path)
1534                    .current_dir(&working_directory)
1535                    .args(args)
1536                    .output()
1537                    .await?;
1538
1539                anyhow::ensure!(
1540                    output.status.success(),
1541                    "Failed to git git branches:\n{}",
1542                    String::from_utf8_lossy(&output.stderr)
1543                );
1544
1545                let input = String::from_utf8_lossy(&output.stdout);
1546
1547                let mut branches = parse_branch_input(&input)?;
1548                if branches.is_empty() {
1549                    let args = vec!["symbolic-ref", "--quiet", "HEAD"];
1550
1551                    let output = new_smol_command(&git_binary_path)
1552                        .current_dir(&working_directory)
1553                        .args(args)
1554                        .output()
1555                        .await?;
1556
1557                    // git symbolic-ref returns a non-0 exit code if HEAD points
1558                    // to something other than a branch
1559                    if output.status.success() {
1560                        let name = String::from_utf8_lossy(&output.stdout).trim().to_string();
1561
1562                        branches.push(Branch {
1563                            ref_name: name.into(),
1564                            is_head: true,
1565                            upstream: None,
1566                            most_recent_commit: None,
1567                        });
1568                    }
1569                }
1570
1571                Ok(branches)
1572            })
1573            .boxed()
1574    }
1575
1576    fn worktrees(&self) -> BoxFuture<'_, Result<Vec<Worktree>>> {
1577        let git_binary_path = self.any_git_binary_path.clone();
1578        let working_directory = self.working_directory();
1579        self.executor
1580            .spawn(async move {
1581                let output = new_smol_command(&git_binary_path)
1582                    .current_dir(working_directory?)
1583                    .args(&["--no-optional-locks", "worktree", "list", "--porcelain"])
1584                    .output()
1585                    .await?;
1586                if output.status.success() {
1587                    let stdout = String::from_utf8_lossy(&output.stdout);
1588                    Ok(parse_worktrees_from_str(&stdout))
1589                } else {
1590                    let stderr = String::from_utf8_lossy(&output.stderr);
1591                    anyhow::bail!("git worktree list failed: {stderr}");
1592                }
1593            })
1594            .boxed()
1595    }
1596
1597    fn create_worktree(
1598        &self,
1599        name: String,
1600        directory: PathBuf,
1601        from_commit: Option<String>,
1602    ) -> BoxFuture<'_, Result<()>> {
1603        let git_binary_path = self.any_git_binary_path.clone();
1604        let working_directory = self.working_directory();
1605        let final_path = directory.join(&name);
1606        let mut args = vec![
1607            OsString::from("--no-optional-locks"),
1608            OsString::from("worktree"),
1609            OsString::from("add"),
1610            OsString::from(final_path.as_os_str()),
1611        ];
1612        if let Some(from_commit) = from_commit {
1613            args.extend([
1614                OsString::from("-b"),
1615                OsString::from(name.as_str()),
1616                OsString::from(from_commit),
1617            ]);
1618        }
1619        self.executor
1620            .spawn(async move {
1621                let output = new_smol_command(&git_binary_path)
1622                    .current_dir(working_directory?)
1623                    .args(args)
1624                    .output()
1625                    .await?;
1626                if output.status.success() {
1627                    Ok(())
1628                } else {
1629                    let stderr = String::from_utf8_lossy(&output.stderr);
1630                    anyhow::bail!("git worktree list failed: {stderr}");
1631                }
1632            })
1633            .boxed()
1634    }
1635
1636    fn change_branch(&self, name: String) -> BoxFuture<'_, Result<()>> {
1637        let repo = self.repository.clone();
1638        let working_directory = self.working_directory();
1639        let git_binary_path = self.any_git_binary_path.clone();
1640        let executor = self.executor.clone();
1641        let branch = self.executor.spawn(async move {
1642            let repo = repo.lock();
1643            let branch = if let Ok(branch) = repo.find_branch(&name, BranchType::Local) {
1644                branch
1645            } else if let Ok(revision) = repo.find_branch(&name, BranchType::Remote) {
1646                let (_, branch_name) = name.split_once("/").context("Unexpected branch format")?;
1647
1648                let revision = revision.get();
1649                let branch_commit = revision.peel_to_commit()?;
1650                let mut branch = match repo.branch(&branch_name, &branch_commit, false) {
1651                    Ok(branch) => branch,
1652                    Err(err) if err.code() == ErrorCode::Exists => {
1653                        repo.find_branch(&branch_name, BranchType::Local)?
1654                    }
1655                    Err(err) => {
1656                        return Err(err.into());
1657                    }
1658                };
1659
1660                branch.set_upstream(Some(&name))?;
1661                branch
1662            } else {
1663                anyhow::bail!("Branch '{}' not found", name);
1664            };
1665
1666            Ok(branch
1667                .name()?
1668                .context("cannot checkout anonymous branch")?
1669                .to_string())
1670        });
1671
1672        self.executor
1673            .spawn(async move {
1674                let branch = branch.await?;
1675                GitBinary::new(git_binary_path, working_directory?, executor)
1676                    .run(&["checkout", &branch])
1677                    .await?;
1678                anyhow::Ok(())
1679            })
1680            .boxed()
1681    }
1682
1683    fn create_branch(
1684        &self,
1685        name: String,
1686        base_branch: Option<String>,
1687    ) -> BoxFuture<'_, Result<()>> {
1688        let git_binary_path = self.any_git_binary_path.clone();
1689        let working_directory = self.working_directory();
1690        let executor = self.executor.clone();
1691
1692        self.executor
1693            .spawn(async move {
1694                let mut args = vec!["switch", "-c", &name];
1695                let base_branch_str;
1696                if let Some(ref base) = base_branch {
1697                    base_branch_str = base.clone();
1698                    args.push(&base_branch_str);
1699                }
1700
1701                GitBinary::new(git_binary_path, working_directory?, executor)
1702                    .run(&args)
1703                    .await?;
1704                anyhow::Ok(())
1705            })
1706            .boxed()
1707    }
1708
1709    fn rename_branch(&self, branch: String, new_name: String) -> BoxFuture<'_, Result<()>> {
1710        let git_binary_path = self.any_git_binary_path.clone();
1711        let working_directory = self.working_directory();
1712        let executor = self.executor.clone();
1713
1714        self.executor
1715            .spawn(async move {
1716                GitBinary::new(git_binary_path, working_directory?, executor)
1717                    .run(&["branch", "-m", &branch, &new_name])
1718                    .await?;
1719                anyhow::Ok(())
1720            })
1721            .boxed()
1722    }
1723
1724    fn delete_branch(&self, name: String) -> BoxFuture<'_, Result<()>> {
1725        let git_binary_path = self.any_git_binary_path.clone();
1726        let working_directory = self.working_directory();
1727        let executor = self.executor.clone();
1728
1729        self.executor
1730            .spawn(async move {
1731                GitBinary::new(git_binary_path, working_directory?, executor)
1732                    .run(&["branch", "-d", &name])
1733                    .await?;
1734                anyhow::Ok(())
1735            })
1736            .boxed()
1737    }
1738
1739    fn blame(
1740        &self,
1741        path: RepoPath,
1742        content: Rope,
1743        line_ending: LineEnding,
1744    ) -> BoxFuture<'_, Result<crate::blame::Blame>> {
1745        let working_directory = self.working_directory();
1746        let git_binary_path = self.any_git_binary_path.clone();
1747        let executor = self.executor.clone();
1748
1749        executor
1750            .spawn(async move {
1751                crate::blame::Blame::for_path(
1752                    &git_binary_path,
1753                    &working_directory?,
1754                    &path,
1755                    &content,
1756                    line_ending,
1757                )
1758                .await
1759            })
1760            .boxed()
1761    }
1762
1763    fn file_history(&self, path: RepoPath) -> BoxFuture<'_, Result<FileHistory>> {
1764        self.file_history_paginated(path, 0, None)
1765    }
1766
1767    fn file_history_paginated(
1768        &self,
1769        path: RepoPath,
1770        skip: usize,
1771        limit: Option<usize>,
1772    ) -> BoxFuture<'_, Result<FileHistory>> {
1773        let working_directory = self.working_directory();
1774        let git_binary_path = self.any_git_binary_path.clone();
1775        self.executor
1776            .spawn(async move {
1777                let working_directory = working_directory?;
1778                // Use a unique delimiter with a hardcoded UUID to separate commits
1779                // This essentially eliminates any chance of encountering the delimiter in actual commit data
1780                let commit_delimiter =
1781                    concat!("<<COMMIT_END-", "3f8a9c2e-7d4b-4e1a-9f6c-8b5d2a1e4c3f>>",);
1782
1783                let format_string = format!(
1784                    "--pretty=format:%H%x00%s%x00%B%x00%at%x00%an%x00%ae{}",
1785                    commit_delimiter
1786                );
1787
1788                let mut args = vec!["--no-optional-locks", "log", "--follow", &format_string];
1789
1790                let skip_str;
1791                let limit_str;
1792                if skip > 0 {
1793                    skip_str = skip.to_string();
1794                    args.push("--skip");
1795                    args.push(&skip_str);
1796                }
1797                if let Some(n) = limit {
1798                    limit_str = n.to_string();
1799                    args.push("-n");
1800                    args.push(&limit_str);
1801                }
1802
1803                args.push("--");
1804
1805                let output = new_smol_command(&git_binary_path)
1806                    .current_dir(&working_directory)
1807                    .args(&args)
1808                    .arg(path.as_unix_str())
1809                    .output()
1810                    .await?;
1811
1812                if !output.status.success() {
1813                    let stderr = String::from_utf8_lossy(&output.stderr);
1814                    bail!("git log failed: {stderr}");
1815                }
1816
1817                let stdout = std::str::from_utf8(&output.stdout)?;
1818                let mut entries = Vec::new();
1819
1820                for commit_block in stdout.split(commit_delimiter) {
1821                    let commit_block = commit_block.trim();
1822                    if commit_block.is_empty() {
1823                        continue;
1824                    }
1825
1826                    let fields: Vec<&str> = commit_block.split('\0').collect();
1827                    if fields.len() >= 6 {
1828                        let sha = fields[0].trim().to_string().into();
1829                        let subject = fields[1].trim().to_string().into();
1830                        let message = fields[2].trim().to_string().into();
1831                        let commit_timestamp = fields[3].trim().parse().unwrap_or(0);
1832                        let author_name = fields[4].trim().to_string().into();
1833                        let author_email = fields[5].trim().to_string().into();
1834
1835                        entries.push(FileHistoryEntry {
1836                            sha,
1837                            subject,
1838                            message,
1839                            commit_timestamp,
1840                            author_name,
1841                            author_email,
1842                        });
1843                    }
1844                }
1845
1846                Ok(FileHistory { entries, path })
1847            })
1848            .boxed()
1849    }
1850
1851    fn diff(&self, diff: DiffType) -> BoxFuture<'_, Result<String>> {
1852        let working_directory = self.working_directory();
1853        let git_binary_path = self.any_git_binary_path.clone();
1854        self.executor
1855            .spawn(async move {
1856                let args = match diff {
1857                    DiffType::HeadToIndex => Some("--staged"),
1858                    DiffType::HeadToWorktree => None,
1859                };
1860
1861                let output = new_smol_command(&git_binary_path)
1862                    .current_dir(&working_directory?)
1863                    .args(["diff"])
1864                    .args(args)
1865                    .output()
1866                    .await?;
1867
1868                anyhow::ensure!(
1869                    output.status.success(),
1870                    "Failed to run git diff:\n{}",
1871                    String::from_utf8_lossy(&output.stderr)
1872                );
1873                Ok(String::from_utf8_lossy(&output.stdout).to_string())
1874            })
1875            .boxed()
1876    }
1877
1878    fn stage_paths(
1879        &self,
1880        paths: Vec<RepoPath>,
1881        env: Arc<HashMap<String, String>>,
1882    ) -> BoxFuture<'_, Result<()>> {
1883        let working_directory = self.working_directory();
1884        let git_binary_path = self.any_git_binary_path.clone();
1885        self.executor
1886            .spawn(async move {
1887                if paths.is_empty() {
1888                    return Ok(());
1889                }
1890
1891                let mut child = new_smol_command(&git_binary_path)
1892                    .current_dir(&working_directory?)
1893                    .envs(env.iter())
1894                    .args(["update-index", "--add", "--remove", "-z", "--stdin"])
1895                    .stdin(Stdio::piped())
1896                    .stdout(Stdio::null())
1897                    .stderr(Stdio::piped())
1898                    .spawn()
1899                    .context("failed to spawn git update-index")?;
1900
1901                let mut stdin = child.stdin.take().context("failed to get stdin")?;
1902                for path in &paths {
1903                    stdin.write_all(path.as_unix_str().as_bytes()).await?;
1904                    stdin.write_all(b"\0").await?;
1905                }
1906                drop(stdin);
1907
1908                let output = child.output().await?;
1909                anyhow::ensure!(
1910                    output.status.success(),
1911                    "Failed to stage paths:\n{}",
1912                    String::from_utf8_lossy(&output.stderr),
1913                );
1914                Ok(())
1915            })
1916            .boxed()
1917    }
1918
1919    fn unstage_paths(
1920        &self,
1921        paths: Vec<RepoPath>,
1922        env: Arc<HashMap<String, String>>,
1923    ) -> BoxFuture<'_, Result<()>> {
1924        let working_directory = self.working_directory();
1925        let git_binary_path = self.any_git_binary_path.clone();
1926
1927        self.executor
1928            .spawn(async move {
1929                if paths.is_empty() {
1930                    return Ok(());
1931                }
1932
1933                let working_directory = working_directory?;
1934                let mut child = new_smol_command(&git_binary_path)
1935                    .current_dir(&working_directory)
1936                    .envs(env.iter())
1937                    .args([
1938                        "reset",
1939                        "--quiet",
1940                        "--pathspec-from-file=-",
1941                        "--pathspec-file-nul",
1942                    ])
1943                    .stdin(Stdio::piped())
1944                    .stdout(Stdio::null())
1945                    .stderr(Stdio::piped())
1946                    .spawn()
1947                    .context("failed to spawn git reset")?;
1948
1949                let mut stdin = child.stdin.take().context("failed to get stdin")?;
1950                for path in &paths {
1951                    stdin.write_all(path.as_unix_str().as_bytes()).await?;
1952                    stdin.write_all(b"\0").await?;
1953                }
1954                drop(stdin);
1955
1956                let output = child.output().await?;
1957                if output.status.success() {
1958                    return Ok(());
1959                }
1960
1961                let stderr = String::from_utf8_lossy(&output.stderr);
1962                if !stderr.contains("pathspec-from-file") {
1963                    anyhow::bail!("Failed to unstage:\n{}", stderr);
1964                }
1965
1966                // Fallback for older git versions: pass paths as command-line arguments
1967                let output = new_smol_command(&git_binary_path)
1968                    .current_dir(&working_directory)
1969                    .envs(env.iter())
1970                    .args(["reset", "--quiet", "--"])
1971                    .args(paths.iter().map(|p| p.as_std_path()))
1972                    .output()
1973                    .await?;
1974
1975                anyhow::ensure!(
1976                    output.status.success(),
1977                    "Failed to unstage:\n{}",
1978                    String::from_utf8_lossy(&output.stderr),
1979                );
1980                Ok(())
1981            })
1982            .boxed()
1983    }
1984
1985    fn stash_paths(
1986        &self,
1987        paths: Vec<RepoPath>,
1988        env: Arc<HashMap<String, String>>,
1989    ) -> BoxFuture<'_, Result<()>> {
1990        let working_directory = self.working_directory();
1991        let git_binary_path = self.any_git_binary_path.clone();
1992        self.executor
1993            .spawn(async move {
1994                let mut cmd = new_smol_command(&git_binary_path);
1995                cmd.current_dir(&working_directory?)
1996                    .envs(env.iter())
1997                    .args(["stash", "push", "--quiet"])
1998                    .arg("--include-untracked");
1999
2000                cmd.args(paths.iter().map(|p| p.as_unix_str()));
2001
2002                let output = cmd.output().await?;
2003
2004                anyhow::ensure!(
2005                    output.status.success(),
2006                    "Failed to stash:\n{}",
2007                    String::from_utf8_lossy(&output.stderr)
2008                );
2009                Ok(())
2010            })
2011            .boxed()
2012    }
2013
2014    fn stash_pop(
2015        &self,
2016        index: Option<usize>,
2017        env: Arc<HashMap<String, String>>,
2018    ) -> BoxFuture<'_, Result<()>> {
2019        let working_directory = self.working_directory();
2020        let git_binary_path = self.any_git_binary_path.clone();
2021        self.executor
2022            .spawn(async move {
2023                let mut cmd = new_smol_command(git_binary_path);
2024                let mut args = vec!["stash".to_string(), "pop".to_string()];
2025                if let Some(index) = index {
2026                    args.push(format!("stash@{{{}}}", index));
2027                }
2028                cmd.current_dir(&working_directory?)
2029                    .envs(env.iter())
2030                    .args(args);
2031
2032                let output = cmd.output().await?;
2033
2034                anyhow::ensure!(
2035                    output.status.success(),
2036                    "Failed to stash pop:\n{}",
2037                    String::from_utf8_lossy(&output.stderr)
2038                );
2039                Ok(())
2040            })
2041            .boxed()
2042    }
2043
2044    fn stash_apply(
2045        &self,
2046        index: Option<usize>,
2047        env: Arc<HashMap<String, String>>,
2048    ) -> BoxFuture<'_, Result<()>> {
2049        let working_directory = self.working_directory();
2050        let git_binary_path = self.any_git_binary_path.clone();
2051        self.executor
2052            .spawn(async move {
2053                let mut cmd = new_smol_command(git_binary_path);
2054                let mut args = vec!["stash".to_string(), "apply".to_string()];
2055                if let Some(index) = index {
2056                    args.push(format!("stash@{{{}}}", index));
2057                }
2058                cmd.current_dir(&working_directory?)
2059                    .envs(env.iter())
2060                    .args(args);
2061
2062                let output = cmd.output().await?;
2063
2064                anyhow::ensure!(
2065                    output.status.success(),
2066                    "Failed to apply stash:\n{}",
2067                    String::from_utf8_lossy(&output.stderr)
2068                );
2069                Ok(())
2070            })
2071            .boxed()
2072    }
2073
2074    fn stash_drop(
2075        &self,
2076        index: Option<usize>,
2077        env: Arc<HashMap<String, String>>,
2078    ) -> BoxFuture<'_, Result<()>> {
2079        let working_directory = self.working_directory();
2080        let git_binary_path = self.any_git_binary_path.clone();
2081        self.executor
2082            .spawn(async move {
2083                let mut cmd = new_smol_command(git_binary_path);
2084                let mut args = vec!["stash".to_string(), "drop".to_string()];
2085                if let Some(index) = index {
2086                    args.push(format!("stash@{{{}}}", index));
2087                }
2088                cmd.current_dir(&working_directory?)
2089                    .envs(env.iter())
2090                    .args(args);
2091
2092                let output = cmd.output().await?;
2093
2094                anyhow::ensure!(
2095                    output.status.success(),
2096                    "Failed to stash drop:\n{}",
2097                    String::from_utf8_lossy(&output.stderr)
2098                );
2099                Ok(())
2100            })
2101            .boxed()
2102    }
2103
2104    fn commit(
2105        &self,
2106        message: SharedString,
2107        name_and_email: Option<(SharedString, SharedString)>,
2108        options: CommitOptions,
2109        ask_pass: AskPassDelegate,
2110        env: Arc<HashMap<String, String>>,
2111    ) -> BoxFuture<'_, Result<()>> {
2112        let working_directory = self.working_directory();
2113        let git_binary_path = self.any_git_binary_path.clone();
2114        let executor = self.executor.clone();
2115        // Note: Do not spawn this command on the background thread, it might pop open the credential helper
2116        // which we want to block on.
2117        async move {
2118            let mut cmd = new_smol_command(git_binary_path);
2119            cmd.current_dir(&working_directory?)
2120                .envs(env.iter())
2121                .args(["commit", "--quiet", "-m"])
2122                .arg(&message.to_string())
2123                .arg("--cleanup=strip")
2124                .arg("--no-verify")
2125                .stdout(smol::process::Stdio::piped())
2126                .stderr(smol::process::Stdio::piped());
2127
2128            if options.amend {
2129                cmd.arg("--amend");
2130            }
2131
2132            if options.signoff {
2133                cmd.arg("--signoff");
2134            }
2135
2136            if let Some((name, email)) = name_and_email {
2137                cmd.arg("--author").arg(&format!("{name} <{email}>"));
2138            }
2139
2140            run_git_command(env, ask_pass, cmd, executor).await?;
2141
2142            Ok(())
2143        }
2144        .boxed()
2145    }
2146
2147    fn push(
2148        &self,
2149        branch_name: String,
2150        remote_branch_name: String,
2151        remote_name: String,
2152        options: Option<PushOptions>,
2153        ask_pass: AskPassDelegate,
2154        env: Arc<HashMap<String, String>>,
2155        cx: AsyncApp,
2156    ) -> BoxFuture<'_, Result<RemoteCommandOutput>> {
2157        let working_directory = self.working_directory();
2158        let executor = cx.background_executor().clone();
2159        let git_binary_path = self.system_git_binary_path.clone();
2160        // Note: Do not spawn this command on the background thread, it might pop open the credential helper
2161        // which we want to block on.
2162        async move {
2163            let git_binary_path = git_binary_path.context("git not found on $PATH, can't push")?;
2164            let working_directory = working_directory?;
2165            let mut command = new_smol_command(git_binary_path);
2166            command
2167                .envs(env.iter())
2168                .current_dir(&working_directory)
2169                .args(["push"])
2170                .args(options.map(|option| match option {
2171                    PushOptions::SetUpstream => "--set-upstream",
2172                    PushOptions::Force => "--force-with-lease",
2173                }))
2174                .arg(remote_name)
2175                .arg(format!("{}:{}", branch_name, remote_branch_name))
2176                .stdin(smol::process::Stdio::null())
2177                .stdout(smol::process::Stdio::piped())
2178                .stderr(smol::process::Stdio::piped());
2179
2180            run_git_command(env, ask_pass, command, executor).await
2181        }
2182        .boxed()
2183    }
2184
2185    fn pull(
2186        &self,
2187        branch_name: Option<String>,
2188        remote_name: String,
2189        rebase: bool,
2190        ask_pass: AskPassDelegate,
2191        env: Arc<HashMap<String, String>>,
2192        cx: AsyncApp,
2193    ) -> BoxFuture<'_, Result<RemoteCommandOutput>> {
2194        let working_directory = self.working_directory();
2195        let executor = cx.background_executor().clone();
2196        let git_binary_path = self.system_git_binary_path.clone();
2197        // Note: Do not spawn this command on the background thread, it might pop open the credential helper
2198        // which we want to block on.
2199        async move {
2200            let git_binary_path = git_binary_path.context("git not found on $PATH, can't pull")?;
2201            let mut command = new_smol_command(git_binary_path);
2202            command
2203                .envs(env.iter())
2204                .current_dir(&working_directory?)
2205                .arg("pull");
2206
2207            if rebase {
2208                command.arg("--rebase");
2209            }
2210
2211            command
2212                .arg(remote_name)
2213                .args(branch_name)
2214                .stdout(smol::process::Stdio::piped())
2215                .stderr(smol::process::Stdio::piped());
2216
2217            run_git_command(env, ask_pass, command, executor).await
2218        }
2219        .boxed()
2220    }
2221
2222    fn fetch(
2223        &self,
2224        fetch_options: FetchOptions,
2225        ask_pass: AskPassDelegate,
2226        env: Arc<HashMap<String, String>>,
2227        cx: AsyncApp,
2228    ) -> BoxFuture<'_, Result<RemoteCommandOutput>> {
2229        let working_directory = self.working_directory();
2230        let remote_name = format!("{}", fetch_options);
2231        let git_binary_path = self.system_git_binary_path.clone();
2232        let executor = cx.background_executor().clone();
2233        // Note: Do not spawn this command on the background thread, it might pop open the credential helper
2234        // which we want to block on.
2235        async move {
2236            let git_binary_path = git_binary_path.context("git not found on $PATH, can't fetch")?;
2237            let mut command = new_smol_command(git_binary_path);
2238            command
2239                .envs(env.iter())
2240                .current_dir(&working_directory?)
2241                .args(["fetch", &remote_name])
2242                .stdout(smol::process::Stdio::piped())
2243                .stderr(smol::process::Stdio::piped());
2244
2245            run_git_command(env, ask_pass, command, executor).await
2246        }
2247        .boxed()
2248    }
2249
2250    fn get_push_remote(&self, branch: String) -> BoxFuture<'_, Result<Option<Remote>>> {
2251        let working_directory = self.working_directory();
2252        let git_binary_path = self.any_git_binary_path.clone();
2253        self.executor
2254            .spawn(async move {
2255                let working_directory = working_directory?;
2256                let output = new_smol_command(&git_binary_path)
2257                    .current_dir(&working_directory)
2258                    .args(["rev-parse", "--abbrev-ref"])
2259                    .arg(format!("{branch}@{{push}}"))
2260                    .output()
2261                    .await?;
2262                if !output.status.success() {
2263                    return Ok(None);
2264                }
2265                let remote_name = String::from_utf8_lossy(&output.stdout)
2266                    .split('/')
2267                    .next()
2268                    .map(|name| Remote {
2269                        name: name.trim().to_string().into(),
2270                    });
2271
2272                Ok(remote_name)
2273            })
2274            .boxed()
2275    }
2276
2277    fn get_branch_remote(&self, branch: String) -> BoxFuture<'_, Result<Option<Remote>>> {
2278        let working_directory = self.working_directory();
2279        let git_binary_path = self.any_git_binary_path.clone();
2280        self.executor
2281            .spawn(async move {
2282                let working_directory = working_directory?;
2283                let output = new_smol_command(&git_binary_path)
2284                    .current_dir(&working_directory)
2285                    .args(["config", "--get"])
2286                    .arg(format!("branch.{branch}.remote"))
2287                    .output()
2288                    .await?;
2289                if !output.status.success() {
2290                    return Ok(None);
2291                }
2292
2293                let remote_name = String::from_utf8_lossy(&output.stdout);
2294                return Ok(Some(Remote {
2295                    name: remote_name.trim().to_string().into(),
2296                }));
2297            })
2298            .boxed()
2299    }
2300
2301    fn get_all_remotes(&self) -> BoxFuture<'_, Result<Vec<Remote>>> {
2302        let working_directory = self.working_directory();
2303        let git_binary_path = self.any_git_binary_path.clone();
2304        self.executor
2305            .spawn(async move {
2306                let working_directory = working_directory?;
2307                let output = new_smol_command(&git_binary_path)
2308                    .current_dir(&working_directory)
2309                    .args(["remote", "-v"])
2310                    .output()
2311                    .await?;
2312
2313                anyhow::ensure!(
2314                    output.status.success(),
2315                    "Failed to get all remotes:\n{}",
2316                    String::from_utf8_lossy(&output.stderr)
2317                );
2318                let remote_names: HashSet<Remote> = String::from_utf8_lossy(&output.stdout)
2319                    .lines()
2320                    .filter(|line| !line.is_empty())
2321                    .filter_map(|line| {
2322                        let mut split_line = line.split_whitespace();
2323                        let remote_name = split_line.next()?;
2324
2325                        Some(Remote {
2326                            name: remote_name.trim().to_string().into(),
2327                        })
2328                    })
2329                    .collect();
2330
2331                Ok(remote_names.into_iter().collect())
2332            })
2333            .boxed()
2334    }
2335
2336    fn remove_remote(&self, name: String) -> BoxFuture<'_, Result<()>> {
2337        let repo = self.repository.clone();
2338        self.executor
2339            .spawn(async move {
2340                let repo = repo.lock();
2341                repo.remote_delete(&name)?;
2342
2343                Ok(())
2344            })
2345            .boxed()
2346    }
2347
2348    fn create_remote(&self, name: String, url: String) -> BoxFuture<'_, Result<()>> {
2349        let repo = self.repository.clone();
2350        self.executor
2351            .spawn(async move {
2352                let repo = repo.lock();
2353                repo.remote(&name, url.as_ref())?;
2354                Ok(())
2355            })
2356            .boxed()
2357    }
2358
2359    fn check_for_pushed_commit(&self) -> BoxFuture<'_, Result<Vec<SharedString>>> {
2360        let working_directory = self.working_directory();
2361        let git_binary_path = self.any_git_binary_path.clone();
2362        self.executor
2363            .spawn(async move {
2364                let working_directory = working_directory?;
2365                let git_cmd = async |args: &[&str]| -> Result<String> {
2366                    let output = new_smol_command(&git_binary_path)
2367                        .current_dir(&working_directory)
2368                        .args(args)
2369                        .output()
2370                        .await?;
2371                    anyhow::ensure!(
2372                        output.status.success(),
2373                        String::from_utf8_lossy(&output.stderr).to_string()
2374                    );
2375                    Ok(String::from_utf8(output.stdout)?)
2376                };
2377
2378                let head = git_cmd(&["rev-parse", "HEAD"])
2379                    .await
2380                    .context("Failed to get HEAD")?
2381                    .trim()
2382                    .to_owned();
2383
2384                let mut remote_branches = vec![];
2385                let mut add_if_matching = async |remote_head: &str| {
2386                    if let Ok(merge_base) = git_cmd(&["merge-base", &head, remote_head]).await
2387                        && merge_base.trim() == head
2388                        && let Some(s) = remote_head.strip_prefix("refs/remotes/")
2389                    {
2390                        remote_branches.push(s.to_owned().into());
2391                    }
2392                };
2393
2394                // check the main branch of each remote
2395                let remotes = git_cmd(&["remote"])
2396                    .await
2397                    .context("Failed to get remotes")?;
2398                for remote in remotes.lines() {
2399                    if let Ok(remote_head) =
2400                        git_cmd(&["symbolic-ref", &format!("refs/remotes/{remote}/HEAD")]).await
2401                    {
2402                        add_if_matching(remote_head.trim()).await;
2403                    }
2404                }
2405
2406                // ... and the remote branch that the checked-out one is tracking
2407                if let Ok(remote_head) =
2408                    git_cmd(&["rev-parse", "--symbolic-full-name", "@{u}"]).await
2409                {
2410                    add_if_matching(remote_head.trim()).await;
2411                }
2412
2413                Ok(remote_branches)
2414            })
2415            .boxed()
2416    }
2417
2418    fn checkpoint(&self) -> BoxFuture<'static, Result<GitRepositoryCheckpoint>> {
2419        let working_directory = self.working_directory();
2420        let git_binary_path = self.any_git_binary_path.clone();
2421        let executor = self.executor.clone();
2422        self.executor
2423            .spawn(async move {
2424                let working_directory = working_directory?;
2425                let mut git = GitBinary::new(git_binary_path, working_directory.clone(), executor)
2426                    .envs(checkpoint_author_envs());
2427                git.with_temp_index(async |git| {
2428                    let head_sha = git.run(&["rev-parse", "HEAD"]).await.ok();
2429                    let mut excludes = exclude_files(git).await?;
2430
2431                    git.run(&["add", "--all"]).await?;
2432                    let tree = git.run(&["write-tree"]).await?;
2433                    let checkpoint_sha = if let Some(head_sha) = head_sha.as_deref() {
2434                        git.run(&["commit-tree", &tree, "-p", head_sha, "-m", "Checkpoint"])
2435                            .await?
2436                    } else {
2437                        git.run(&["commit-tree", &tree, "-m", "Checkpoint"]).await?
2438                    };
2439
2440                    excludes.restore_original().await?;
2441
2442                    Ok(GitRepositoryCheckpoint {
2443                        commit_sha: checkpoint_sha.parse()?,
2444                    })
2445                })
2446                .await
2447            })
2448            .boxed()
2449    }
2450
2451    fn restore_checkpoint(&self, checkpoint: GitRepositoryCheckpoint) -> BoxFuture<'_, Result<()>> {
2452        let working_directory = self.working_directory();
2453        let git_binary_path = self.any_git_binary_path.clone();
2454
2455        let executor = self.executor.clone();
2456        self.executor
2457            .spawn(async move {
2458                let working_directory = working_directory?;
2459
2460                let git = GitBinary::new(git_binary_path, working_directory, executor);
2461                git.run(&[
2462                    "restore",
2463                    "--source",
2464                    &checkpoint.commit_sha.to_string(),
2465                    "--worktree",
2466                    ".",
2467                ])
2468                .await?;
2469
2470                // TODO: We don't track binary and large files anymore,
2471                //       so the following call would delete them.
2472                //       Implement an alternative way to track files added by agent.
2473                //
2474                // git.with_temp_index(async move |git| {
2475                //     git.run(&["read-tree", &checkpoint.commit_sha.to_string()])
2476                //         .await?;
2477                //     git.run(&["clean", "-d", "--force"]).await
2478                // })
2479                // .await?;
2480
2481                Ok(())
2482            })
2483            .boxed()
2484    }
2485
2486    fn compare_checkpoints(
2487        &self,
2488        left: GitRepositoryCheckpoint,
2489        right: GitRepositoryCheckpoint,
2490    ) -> BoxFuture<'_, Result<bool>> {
2491        let working_directory = self.working_directory();
2492        let git_binary_path = self.any_git_binary_path.clone();
2493
2494        let executor = self.executor.clone();
2495        self.executor
2496            .spawn(async move {
2497                let working_directory = working_directory?;
2498                let git = GitBinary::new(git_binary_path, working_directory, executor);
2499                let result = git
2500                    .run(&[
2501                        "diff-tree",
2502                        "--quiet",
2503                        &left.commit_sha.to_string(),
2504                        &right.commit_sha.to_string(),
2505                    ])
2506                    .await;
2507                match result {
2508                    Ok(_) => Ok(true),
2509                    Err(error) => {
2510                        if let Some(GitBinaryCommandError { status, .. }) =
2511                            error.downcast_ref::<GitBinaryCommandError>()
2512                            && status.code() == Some(1)
2513                        {
2514                            return Ok(false);
2515                        }
2516
2517                        Err(error)
2518                    }
2519                }
2520            })
2521            .boxed()
2522    }
2523
2524    fn diff_checkpoints(
2525        &self,
2526        base_checkpoint: GitRepositoryCheckpoint,
2527        target_checkpoint: GitRepositoryCheckpoint,
2528    ) -> BoxFuture<'_, Result<String>> {
2529        let working_directory = self.working_directory();
2530        let git_binary_path = self.any_git_binary_path.clone();
2531
2532        let executor = self.executor.clone();
2533        self.executor
2534            .spawn(async move {
2535                let working_directory = working_directory?;
2536                let git = GitBinary::new(git_binary_path, working_directory, executor);
2537                git.run(&[
2538                    "diff",
2539                    "--find-renames",
2540                    "--patch",
2541                    &base_checkpoint.commit_sha.to_string(),
2542                    &target_checkpoint.commit_sha.to_string(),
2543                ])
2544                .await
2545            })
2546            .boxed()
2547    }
2548
2549    fn default_branch(
2550        &self,
2551        include_remote_name: bool,
2552    ) -> BoxFuture<'_, Result<Option<SharedString>>> {
2553        let working_directory = self.working_directory();
2554        let git_binary_path = self.any_git_binary_path.clone();
2555
2556        let executor = self.executor.clone();
2557        self.executor
2558            .spawn(async move {
2559                let working_directory = working_directory?;
2560                let git = GitBinary::new(git_binary_path, working_directory, executor);
2561
2562                let strip_prefix = if include_remote_name {
2563                    "refs/remotes/"
2564                } else {
2565                    "refs/remotes/upstream/"
2566                };
2567
2568                if let Ok(output) = git
2569                    .run(&["symbolic-ref", "refs/remotes/upstream/HEAD"])
2570                    .await
2571                {
2572                    let output = output
2573                        .strip_prefix(strip_prefix)
2574                        .map(|s| SharedString::from(s.to_owned()));
2575                    return Ok(output);
2576                }
2577
2578                let strip_prefix = if include_remote_name {
2579                    "refs/remotes/"
2580                } else {
2581                    "refs/remotes/origin/"
2582                };
2583
2584                if let Ok(output) = git.run(&["symbolic-ref", "refs/remotes/origin/HEAD"]).await {
2585                    return Ok(output
2586                        .strip_prefix(strip_prefix)
2587                        .map(|s| SharedString::from(s.to_owned())));
2588                }
2589
2590                if let Ok(default_branch) = git.run(&["config", "init.defaultBranch"]).await {
2591                    if git.run(&["rev-parse", &default_branch]).await.is_ok() {
2592                        return Ok(Some(default_branch.into()));
2593                    }
2594                }
2595
2596                if git.run(&["rev-parse", "master"]).await.is_ok() {
2597                    return Ok(Some("master".into()));
2598                }
2599
2600                Ok(None)
2601            })
2602            .boxed()
2603    }
2604
2605    fn run_hook(
2606        &self,
2607        hook: RunHook,
2608        env: Arc<HashMap<String, String>>,
2609    ) -> BoxFuture<'_, Result<()>> {
2610        let working_directory = self.working_directory();
2611        let repository = self.repository.clone();
2612        let git_binary_path = self.any_git_binary_path.clone();
2613        let executor = self.executor.clone();
2614        let help_output = self.any_git_binary_help_output();
2615
2616        // Note: Do not spawn these commands on the background thread, as this causes some git hooks to hang.
2617        async move {
2618            let working_directory = working_directory?;
2619            if !help_output
2620                .await
2621                .lines()
2622                .any(|line| line.trim().starts_with("hook "))
2623            {
2624                let hook_abs_path = repository.lock().path().join("hooks").join(hook.as_str());
2625                if hook_abs_path.is_file() {
2626                    let output = new_smol_command(&hook_abs_path)
2627                        .envs(env.iter())
2628                        .current_dir(&working_directory)
2629                        .output()
2630                        .await?;
2631
2632                    if !output.status.success() {
2633                        return Err(GitBinaryCommandError {
2634                            stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
2635                            stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
2636                            status: output.status,
2637                        }
2638                        .into());
2639                    }
2640                }
2641
2642                return Ok(());
2643            }
2644
2645            let git = GitBinary::new(git_binary_path, working_directory, executor)
2646                .envs(HashMap::clone(&env));
2647            git.run(&["hook", "run", "--ignore-missing", hook.as_str()])
2648                .await?;
2649            Ok(())
2650        }
2651        .boxed()
2652    }
2653
2654    fn initial_graph_data(
2655        &self,
2656        log_source: LogSource,
2657        log_order: LogOrder,
2658        request_tx: Sender<Vec<Arc<InitialGraphCommitData>>>,
2659    ) -> BoxFuture<'_, Result<()>> {
2660        let git_binary_path = self.any_git_binary_path.clone();
2661        let working_directory = self.working_directory();
2662        let executor = self.executor.clone();
2663
2664        async move {
2665            let working_directory = working_directory?;
2666            let git = GitBinary::new(git_binary_path, working_directory, executor);
2667
2668            let mut command = git.build_command([
2669                "log",
2670                GRAPH_COMMIT_FORMAT,
2671                log_order.as_arg(),
2672                log_source.get_arg()?,
2673            ]);
2674            command.stdout(Stdio::piped());
2675            command.stderr(Stdio::null());
2676
2677            let mut child = command.spawn()?;
2678            let stdout = child.stdout.take().context("failed to get stdout")?;
2679            let mut reader = BufReader::new(stdout);
2680
2681            let mut line_buffer = String::new();
2682            let mut lines: Vec<String> = Vec::with_capacity(GRAPH_CHUNK_SIZE);
2683
2684            loop {
2685                line_buffer.clear();
2686                let bytes_read = reader.read_line(&mut line_buffer).await?;
2687
2688                if bytes_read == 0 {
2689                    if !lines.is_empty() {
2690                        let commits = parse_initial_graph_output(lines.iter().map(|s| s.as_str()));
2691                        if request_tx.send(commits).await.is_err() {
2692                            log::warn!(
2693                                "initial_graph_data: receiver dropped while sending commits"
2694                            );
2695                        }
2696                    }
2697                    break;
2698                }
2699
2700                let line = line_buffer.trim_end_matches('\n').to_string();
2701                lines.push(line);
2702
2703                if lines.len() >= GRAPH_CHUNK_SIZE {
2704                    let commits = parse_initial_graph_output(lines.iter().map(|s| s.as_str()));
2705                    if request_tx.send(commits).await.is_err() {
2706                        log::warn!("initial_graph_data: receiver dropped while streaming commits");
2707                        break;
2708                    }
2709                    lines.clear();
2710                }
2711            }
2712
2713            child.status().await?;
2714            Ok(())
2715        }
2716        .boxed()
2717    }
2718
2719    fn commit_data_reader(&self) -> Result<CommitDataReader> {
2720        let git_binary_path = self.any_git_binary_path.clone();
2721        let working_directory = self
2722            .working_directory()
2723            .map_err(|_| anyhow!("no working directory"))?;
2724        let executor = self.executor.clone();
2725
2726        let (request_tx, request_rx) = smol::channel::bounded::<CommitDataRequest>(64);
2727
2728        let task = self.executor.spawn(async move {
2729            if let Err(error) =
2730                run_commit_data_reader(git_binary_path, working_directory, executor, request_rx)
2731                    .await
2732            {
2733                log::error!("commit data reader failed: {error:?}");
2734            }
2735        });
2736
2737        Ok(CommitDataReader {
2738            request_tx,
2739            _task: task,
2740        })
2741    }
2742}
2743
2744async fn run_commit_data_reader(
2745    git_binary_path: PathBuf,
2746    working_directory: PathBuf,
2747    executor: BackgroundExecutor,
2748    request_rx: smol::channel::Receiver<CommitDataRequest>,
2749) -> Result<()> {
2750    let git = GitBinary::new(git_binary_path, working_directory, executor);
2751    let mut process = git
2752        .build_command(["--no-optional-locks", "cat-file", "--batch"])
2753        .stdin(Stdio::piped())
2754        .stdout(Stdio::piped())
2755        .stderr(Stdio::piped())
2756        .spawn()
2757        .context("starting git cat-file --batch process")?;
2758
2759    let mut stdin = BufWriter::new(process.stdin.take().context("no stdin")?);
2760    let mut stdout = BufReader::new(process.stdout.take().context("no stdout")?);
2761
2762    const MAX_BATCH_SIZE: usize = 64;
2763
2764    while let Ok(first_request) = request_rx.recv().await {
2765        let mut pending_requests = vec![first_request];
2766
2767        while pending_requests.len() < MAX_BATCH_SIZE {
2768            match request_rx.try_recv() {
2769                Ok(request) => pending_requests.push(request),
2770                Err(_) => break,
2771            }
2772        }
2773
2774        for request in &pending_requests {
2775            stdin.write_all(request.sha.to_string().as_bytes()).await?;
2776            stdin.write_all(b"\n").await?;
2777        }
2778        stdin.flush().await?;
2779
2780        for request in pending_requests {
2781            let result = read_single_commit_response(&mut stdout, &request.sha).await;
2782            request.response_tx.send(result).ok();
2783        }
2784    }
2785
2786    drop(stdin);
2787    process.kill().ok();
2788
2789    Ok(())
2790}
2791
2792async fn read_single_commit_response(
2793    stdout: &mut BufReader<smol::process::ChildStdout>,
2794    sha: &Oid,
2795) -> Result<GraphCommitData> {
2796    let mut header_bytes = Vec::new();
2797    stdout.read_until(b'\n', &mut header_bytes).await?;
2798    let header_line = String::from_utf8_lossy(&header_bytes);
2799
2800    let parts: Vec<&str> = header_line.trim().split(' ').collect();
2801    if parts.len() < 3 {
2802        bail!("invalid cat-file header: {header_line}");
2803    }
2804
2805    let object_type = parts[1];
2806    if object_type == "missing" {
2807        bail!("object not found: {}", sha);
2808    }
2809
2810    if object_type != "commit" {
2811        bail!("expected commit object, got {object_type}");
2812    }
2813
2814    let size: usize = parts[2]
2815        .parse()
2816        .with_context(|| format!("invalid object size: {}", parts[2]))?;
2817
2818    let mut content = vec![0u8; size];
2819    stdout.read_exact(&mut content).await?;
2820
2821    let mut newline = [0u8; 1];
2822    stdout.read_exact(&mut newline).await?;
2823
2824    let content_str = String::from_utf8_lossy(&content);
2825    parse_cat_file_commit(*sha, &content_str)
2826        .ok_or_else(|| anyhow!("failed to parse commit {}", sha))
2827}
2828
2829fn parse_initial_graph_output<'a>(
2830    lines: impl Iterator<Item = &'a str>,
2831) -> Vec<Arc<InitialGraphCommitData>> {
2832    lines
2833        .filter(|line| !line.is_empty())
2834        .filter_map(|line| {
2835            // Format: "SHA\x00PARENT1 PARENT2...\x00REF1, REF2, ..."
2836            let mut parts = line.split('\x00');
2837
2838            let sha = Oid::from_str(parts.next()?).ok()?;
2839            let parents_str = parts.next()?;
2840            let parents = parents_str
2841                .split_whitespace()
2842                .filter_map(|p| Oid::from_str(p).ok())
2843                .collect();
2844
2845            let ref_names_str = parts.next().unwrap_or("");
2846            let ref_names = if ref_names_str.is_empty() {
2847                Vec::new()
2848            } else {
2849                ref_names_str
2850                    .split(", ")
2851                    .map(|s| SharedString::from(s.to_string()))
2852                    .collect()
2853            };
2854
2855            Some(Arc::new(InitialGraphCommitData {
2856                sha,
2857                parents,
2858                ref_names,
2859            }))
2860        })
2861        .collect()
2862}
2863
2864fn git_status_args(path_prefixes: &[RepoPath]) -> Vec<OsString> {
2865    let mut args = vec![
2866        OsString::from("--no-optional-locks"),
2867        OsString::from("status"),
2868        OsString::from("--porcelain=v1"),
2869        OsString::from("--untracked-files=all"),
2870        OsString::from("--no-renames"),
2871        OsString::from("-z"),
2872    ];
2873    args.extend(
2874        path_prefixes
2875            .iter()
2876            .map(|path_prefix| path_prefix.as_std_path().into()),
2877    );
2878    args.extend(path_prefixes.iter().map(|path_prefix| {
2879        if path_prefix.is_empty() {
2880            Path::new(".").into()
2881        } else {
2882            path_prefix.as_std_path().into()
2883        }
2884    }));
2885    args
2886}
2887
2888/// Temporarily git-ignore commonly ignored files and files over 2MB
2889async fn exclude_files(git: &GitBinary) -> Result<GitExcludeOverride> {
2890    const MAX_SIZE: u64 = 2 * 1024 * 1024; // 2 MB
2891    let mut excludes = git.with_exclude_overrides().await?;
2892    excludes
2893        .add_excludes(include_str!("./checkpoint.gitignore"))
2894        .await?;
2895
2896    let working_directory = git.working_directory.clone();
2897    let untracked_files = git.list_untracked_files().await?;
2898    let excluded_paths = untracked_files.into_iter().map(|path| {
2899        let working_directory = working_directory.clone();
2900        smol::spawn(async move {
2901            let full_path = working_directory.join(path.clone());
2902            match smol::fs::metadata(&full_path).await {
2903                Ok(metadata) if metadata.is_file() && metadata.len() >= MAX_SIZE => {
2904                    Some(PathBuf::from("/").join(path.clone()))
2905                }
2906                _ => None,
2907            }
2908        })
2909    });
2910
2911    let excluded_paths = futures::future::join_all(excluded_paths).await;
2912    let excluded_paths = excluded_paths.into_iter().flatten().collect::<Vec<_>>();
2913
2914    if !excluded_paths.is_empty() {
2915        let exclude_patterns = excluded_paths
2916            .into_iter()
2917            .map(|path| path.to_string_lossy().into_owned())
2918            .collect::<Vec<_>>()
2919            .join("\n");
2920        excludes.add_excludes(&exclude_patterns).await?;
2921    }
2922
2923    Ok(excludes)
2924}
2925
2926struct GitBinary {
2927    git_binary_path: PathBuf,
2928    working_directory: PathBuf,
2929    executor: BackgroundExecutor,
2930    index_file_path: Option<PathBuf>,
2931    envs: HashMap<String, String>,
2932}
2933
2934impl GitBinary {
2935    fn new(
2936        git_binary_path: PathBuf,
2937        working_directory: PathBuf,
2938        executor: BackgroundExecutor,
2939    ) -> Self {
2940        Self {
2941            git_binary_path,
2942            working_directory,
2943            executor,
2944            index_file_path: None,
2945            envs: HashMap::default(),
2946        }
2947    }
2948
2949    async fn list_untracked_files(&self) -> Result<Vec<PathBuf>> {
2950        let status_output = self
2951            .run(&["status", "--porcelain=v1", "--untracked-files=all", "-z"])
2952            .await?;
2953
2954        let paths = status_output
2955            .split('\0')
2956            .filter(|entry| entry.len() >= 3 && entry.starts_with("?? "))
2957            .map(|entry| PathBuf::from(&entry[3..]))
2958            .collect::<Vec<_>>();
2959        Ok(paths)
2960    }
2961
2962    fn envs(mut self, envs: HashMap<String, String>) -> Self {
2963        self.envs = envs;
2964        self
2965    }
2966
2967    pub async fn with_temp_index<R>(
2968        &mut self,
2969        f: impl AsyncFnOnce(&Self) -> Result<R>,
2970    ) -> Result<R> {
2971        let index_file_path = self.path_for_index_id(Uuid::new_v4());
2972
2973        let delete_temp_index = util::defer({
2974            let index_file_path = index_file_path.clone();
2975            let executor = self.executor.clone();
2976            move || {
2977                executor
2978                    .spawn(async move {
2979                        smol::fs::remove_file(index_file_path).await.log_err();
2980                    })
2981                    .detach();
2982            }
2983        });
2984
2985        // Copy the default index file so that Git doesn't have to rebuild the
2986        // whole index from scratch. This might fail if this is an empty repository.
2987        smol::fs::copy(
2988            self.working_directory.join(".git").join("index"),
2989            &index_file_path,
2990        )
2991        .await
2992        .ok();
2993
2994        self.index_file_path = Some(index_file_path.clone());
2995        let result = f(self).await;
2996        self.index_file_path = None;
2997        let result = result?;
2998
2999        smol::fs::remove_file(index_file_path).await.ok();
3000        delete_temp_index.abort();
3001
3002        Ok(result)
3003    }
3004
3005    pub async fn with_exclude_overrides(&self) -> Result<GitExcludeOverride> {
3006        let path = self
3007            .working_directory
3008            .join(".git")
3009            .join("info")
3010            .join("exclude");
3011
3012        GitExcludeOverride::new(path).await
3013    }
3014
3015    fn path_for_index_id(&self, id: Uuid) -> PathBuf {
3016        self.working_directory
3017            .join(".git")
3018            .join(format!("index-{}.tmp", id))
3019    }
3020
3021    pub async fn run<S>(&self, args: impl IntoIterator<Item = S>) -> Result<String>
3022    where
3023        S: AsRef<OsStr>,
3024    {
3025        let mut stdout = self.run_raw(args).await?;
3026        if stdout.chars().last() == Some('\n') {
3027            stdout.pop();
3028        }
3029        Ok(stdout)
3030    }
3031
3032    /// Returns the result of the command without trimming the trailing newline.
3033    pub async fn run_raw<S>(&self, args: impl IntoIterator<Item = S>) -> Result<String>
3034    where
3035        S: AsRef<OsStr>,
3036    {
3037        let mut command = self.build_command(args);
3038        let output = command.output().await?;
3039        anyhow::ensure!(
3040            output.status.success(),
3041            GitBinaryCommandError {
3042                stdout: String::from_utf8_lossy(&output.stdout).to_string(),
3043                stderr: String::from_utf8_lossy(&output.stderr).to_string(),
3044                status: output.status,
3045            }
3046        );
3047        Ok(String::from_utf8(output.stdout)?)
3048    }
3049
3050    fn build_command<S>(&self, args: impl IntoIterator<Item = S>) -> smol::process::Command
3051    where
3052        S: AsRef<OsStr>,
3053    {
3054        let mut command = new_smol_command(&self.git_binary_path);
3055        command.current_dir(&self.working_directory);
3056        command.args(args);
3057        if let Some(index_file_path) = self.index_file_path.as_ref() {
3058            command.env("GIT_INDEX_FILE", index_file_path);
3059        }
3060        command.envs(&self.envs);
3061        command
3062    }
3063}
3064
3065#[derive(Error, Debug)]
3066#[error("Git command failed:\n{stdout}{stderr}\n")]
3067struct GitBinaryCommandError {
3068    stdout: String,
3069    stderr: String,
3070    status: ExitStatus,
3071}
3072
3073async fn run_git_command(
3074    env: Arc<HashMap<String, String>>,
3075    ask_pass: AskPassDelegate,
3076    mut command: smol::process::Command,
3077    executor: BackgroundExecutor,
3078) -> Result<RemoteCommandOutput> {
3079    if env.contains_key("GIT_ASKPASS") {
3080        let git_process = command.spawn()?;
3081        let output = git_process.output().await?;
3082        anyhow::ensure!(
3083            output.status.success(),
3084            "{}",
3085            String::from_utf8_lossy(&output.stderr)
3086        );
3087        Ok(RemoteCommandOutput {
3088            stdout: String::from_utf8_lossy(&output.stdout).to_string(),
3089            stderr: String::from_utf8_lossy(&output.stderr).to_string(),
3090        })
3091    } else {
3092        let ask_pass = AskPassSession::new(executor, ask_pass).await?;
3093        command
3094            .env("GIT_ASKPASS", ask_pass.script_path())
3095            .env("SSH_ASKPASS", ask_pass.script_path())
3096            .env("SSH_ASKPASS_REQUIRE", "force");
3097        let git_process = command.spawn()?;
3098
3099        run_askpass_command(ask_pass, git_process).await
3100    }
3101}
3102
3103async fn run_askpass_command(
3104    mut ask_pass: AskPassSession,
3105    git_process: smol::process::Child,
3106) -> anyhow::Result<RemoteCommandOutput> {
3107    select_biased! {
3108        result = ask_pass.run().fuse() => {
3109            match result {
3110                AskPassResult::CancelledByUser => {
3111                    Err(anyhow!(REMOTE_CANCELLED_BY_USER))?
3112                }
3113                AskPassResult::Timedout => {
3114                    Err(anyhow!("Connecting to host timed out"))?
3115                }
3116            }
3117        }
3118        output = git_process.output().fuse() => {
3119            let output = output?;
3120            anyhow::ensure!(
3121                output.status.success(),
3122                "{}",
3123                String::from_utf8_lossy(&output.stderr)
3124            );
3125            Ok(RemoteCommandOutput {
3126                stdout: String::from_utf8_lossy(&output.stdout).to_string(),
3127                stderr: String::from_utf8_lossy(&output.stderr).to_string(),
3128            })
3129        }
3130    }
3131}
3132
3133#[derive(Clone, Ord, Hash, PartialOrd, Eq, PartialEq)]
3134pub struct RepoPath(Arc<RelPath>);
3135
3136impl std::fmt::Debug for RepoPath {
3137    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3138        self.0.fmt(f)
3139    }
3140}
3141
3142impl RepoPath {
3143    pub fn new<S: AsRef<str> + ?Sized>(s: &S) -> Result<Self> {
3144        let rel_path = RelPath::unix(s.as_ref())?;
3145        Ok(Self::from_rel_path(rel_path))
3146    }
3147
3148    pub fn from_std_path(path: &Path, path_style: PathStyle) -> Result<Self> {
3149        let rel_path = RelPath::new(path, path_style)?;
3150        Ok(Self::from_rel_path(&rel_path))
3151    }
3152
3153    pub fn from_proto(proto: &str) -> Result<Self> {
3154        let rel_path = RelPath::from_proto(proto)?;
3155        Ok(Self(rel_path))
3156    }
3157
3158    pub fn from_rel_path(path: &RelPath) -> RepoPath {
3159        Self(Arc::from(path))
3160    }
3161
3162    pub fn as_std_path(&self) -> &Path {
3163        // git2 does not like empty paths and our RelPath infra turns `.` into ``
3164        // so undo that here
3165        if self.is_empty() {
3166            Path::new(".")
3167        } else {
3168            self.0.as_std_path()
3169        }
3170    }
3171}
3172
3173#[cfg(any(test, feature = "test-support"))]
3174pub fn repo_path<S: AsRef<str> + ?Sized>(s: &S) -> RepoPath {
3175    RepoPath(RelPath::unix(s.as_ref()).unwrap().into())
3176}
3177
3178impl AsRef<Arc<RelPath>> for RepoPath {
3179    fn as_ref(&self) -> &Arc<RelPath> {
3180        &self.0
3181    }
3182}
3183
3184impl std::ops::Deref for RepoPath {
3185    type Target = RelPath;
3186
3187    fn deref(&self) -> &Self::Target {
3188        &self.0
3189    }
3190}
3191
3192#[derive(Debug)]
3193pub struct RepoPathDescendants<'a>(pub &'a RepoPath);
3194
3195impl MapSeekTarget<RepoPath> for RepoPathDescendants<'_> {
3196    fn cmp_cursor(&self, key: &RepoPath) -> Ordering {
3197        if key.starts_with(self.0) {
3198            Ordering::Greater
3199        } else {
3200            self.0.cmp(key)
3201        }
3202    }
3203}
3204
3205fn parse_branch_input(input: &str) -> Result<Vec<Branch>> {
3206    let mut branches = Vec::new();
3207    for line in input.split('\n') {
3208        if line.is_empty() {
3209            continue;
3210        }
3211        let mut fields = line.split('\x00');
3212        let Some(head) = fields.next() else {
3213            continue;
3214        };
3215        let Some(head_sha) = fields.next().map(|f| f.to_string().into()) else {
3216            continue;
3217        };
3218        let Some(parent_sha) = fields.next().map(|f| f.to_string()) else {
3219            continue;
3220        };
3221        let Some(ref_name) = fields.next().map(|f| f.to_string().into()) else {
3222            continue;
3223        };
3224        let Some(upstream_name) = fields.next().map(|f| f.to_string()) else {
3225            continue;
3226        };
3227        let Some(upstream_tracking) = fields.next().and_then(|f| parse_upstream_track(f).ok())
3228        else {
3229            continue;
3230        };
3231        let Some(commiterdate) = fields.next().and_then(|f| f.parse::<i64>().ok()) else {
3232            continue;
3233        };
3234        let Some(author_name) = fields.next().map(|f| f.to_string().into()) else {
3235            continue;
3236        };
3237        let Some(subject) = fields.next().map(|f| f.to_string().into()) else {
3238            continue;
3239        };
3240
3241        branches.push(Branch {
3242            is_head: head == "*",
3243            ref_name,
3244            most_recent_commit: Some(CommitSummary {
3245                sha: head_sha,
3246                subject,
3247                commit_timestamp: commiterdate,
3248                author_name: author_name,
3249                has_parent: !parent_sha.is_empty(),
3250            }),
3251            upstream: if upstream_name.is_empty() {
3252                None
3253            } else {
3254                Some(Upstream {
3255                    ref_name: upstream_name.into(),
3256                    tracking: upstream_tracking,
3257                })
3258            },
3259        })
3260    }
3261
3262    Ok(branches)
3263}
3264
3265fn parse_upstream_track(upstream_track: &str) -> Result<UpstreamTracking> {
3266    if upstream_track.is_empty() {
3267        return Ok(UpstreamTracking::Tracked(UpstreamTrackingStatus {
3268            ahead: 0,
3269            behind: 0,
3270        }));
3271    }
3272
3273    let upstream_track = upstream_track.strip_prefix("[").context("missing [")?;
3274    let upstream_track = upstream_track.strip_suffix("]").context("missing [")?;
3275    let mut ahead: u32 = 0;
3276    let mut behind: u32 = 0;
3277    for component in upstream_track.split(", ") {
3278        if component == "gone" {
3279            return Ok(UpstreamTracking::Gone);
3280        }
3281        if let Some(ahead_num) = component.strip_prefix("ahead ") {
3282            ahead = ahead_num.parse::<u32>()?;
3283        }
3284        if let Some(behind_num) = component.strip_prefix("behind ") {
3285            behind = behind_num.parse::<u32>()?;
3286        }
3287    }
3288    Ok(UpstreamTracking::Tracked(UpstreamTrackingStatus {
3289        ahead,
3290        behind,
3291    }))
3292}
3293
3294fn checkpoint_author_envs() -> HashMap<String, String> {
3295    HashMap::from_iter([
3296        ("GIT_AUTHOR_NAME".to_string(), "Zed".to_string()),
3297        ("GIT_AUTHOR_EMAIL".to_string(), "hi@zed.dev".to_string()),
3298        ("GIT_COMMITTER_NAME".to_string(), "Zed".to_string()),
3299        ("GIT_COMMITTER_EMAIL".to_string(), "hi@zed.dev".to_string()),
3300    ])
3301}
3302
3303#[cfg(test)]
3304mod tests {
3305    use super::*;
3306    use gpui::TestAppContext;
3307
3308    fn disable_git_global_config() {
3309        unsafe {
3310            std::env::set_var("GIT_CONFIG_GLOBAL", "");
3311            std::env::set_var("GIT_CONFIG_SYSTEM", "");
3312        }
3313    }
3314
3315    #[gpui::test]
3316    async fn test_checkpoint_basic(cx: &mut TestAppContext) {
3317        disable_git_global_config();
3318
3319        cx.executor().allow_parking();
3320
3321        let repo_dir = tempfile::tempdir().unwrap();
3322
3323        git2::Repository::init(repo_dir.path()).unwrap();
3324        let file_path = repo_dir.path().join("file");
3325        smol::fs::write(&file_path, "initial").await.unwrap();
3326
3327        let repo = RealGitRepository::new(
3328            &repo_dir.path().join(".git"),
3329            None,
3330            Some("git".into()),
3331            cx.executor(),
3332        )
3333        .unwrap();
3334
3335        repo.stage_paths(vec![repo_path("file")], Arc::new(HashMap::default()))
3336            .await
3337            .unwrap();
3338        repo.commit(
3339            "Initial commit".into(),
3340            None,
3341            CommitOptions::default(),
3342            AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}),
3343            Arc::new(checkpoint_author_envs()),
3344        )
3345        .await
3346        .unwrap();
3347
3348        smol::fs::write(&file_path, "modified before checkpoint")
3349            .await
3350            .unwrap();
3351        smol::fs::write(repo_dir.path().join("new_file_before_checkpoint"), "1")
3352            .await
3353            .unwrap();
3354        let checkpoint = repo.checkpoint().await.unwrap();
3355
3356        // Ensure the user can't see any branches after creating a checkpoint.
3357        assert_eq!(repo.branches().await.unwrap().len(), 1);
3358
3359        smol::fs::write(&file_path, "modified after checkpoint")
3360            .await
3361            .unwrap();
3362        repo.stage_paths(vec![repo_path("file")], Arc::new(HashMap::default()))
3363            .await
3364            .unwrap();
3365        repo.commit(
3366            "Commit after checkpoint".into(),
3367            None,
3368            CommitOptions::default(),
3369            AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}),
3370            Arc::new(checkpoint_author_envs()),
3371        )
3372        .await
3373        .unwrap();
3374
3375        smol::fs::remove_file(repo_dir.path().join("new_file_before_checkpoint"))
3376            .await
3377            .unwrap();
3378        smol::fs::write(repo_dir.path().join("new_file_after_checkpoint"), "2")
3379            .await
3380            .unwrap();
3381
3382        // Ensure checkpoint stays alive even after a Git GC.
3383        repo.gc().await.unwrap();
3384        repo.restore_checkpoint(checkpoint.clone()).await.unwrap();
3385
3386        assert_eq!(
3387            smol::fs::read_to_string(&file_path).await.unwrap(),
3388            "modified before checkpoint"
3389        );
3390        assert_eq!(
3391            smol::fs::read_to_string(repo_dir.path().join("new_file_before_checkpoint"))
3392                .await
3393                .unwrap(),
3394            "1"
3395        );
3396        // See TODO above
3397        // assert_eq!(
3398        //     smol::fs::read_to_string(repo_dir.path().join("new_file_after_checkpoint"))
3399        //         .await
3400        //         .ok(),
3401        //     None
3402        // );
3403    }
3404
3405    #[gpui::test]
3406    async fn test_checkpoint_empty_repo(cx: &mut TestAppContext) {
3407        disable_git_global_config();
3408
3409        cx.executor().allow_parking();
3410
3411        let repo_dir = tempfile::tempdir().unwrap();
3412        git2::Repository::init(repo_dir.path()).unwrap();
3413        let repo = RealGitRepository::new(
3414            &repo_dir.path().join(".git"),
3415            None,
3416            Some("git".into()),
3417            cx.executor(),
3418        )
3419        .unwrap();
3420
3421        smol::fs::write(repo_dir.path().join("foo"), "foo")
3422            .await
3423            .unwrap();
3424        let checkpoint_sha = repo.checkpoint().await.unwrap();
3425
3426        // Ensure the user can't see any branches after creating a checkpoint.
3427        assert_eq!(repo.branches().await.unwrap().len(), 1);
3428
3429        smol::fs::write(repo_dir.path().join("foo"), "bar")
3430            .await
3431            .unwrap();
3432        smol::fs::write(repo_dir.path().join("baz"), "qux")
3433            .await
3434            .unwrap();
3435        repo.restore_checkpoint(checkpoint_sha).await.unwrap();
3436        assert_eq!(
3437            smol::fs::read_to_string(repo_dir.path().join("foo"))
3438                .await
3439                .unwrap(),
3440            "foo"
3441        );
3442        // See TODOs above
3443        // assert_eq!(
3444        //     smol::fs::read_to_string(repo_dir.path().join("baz"))
3445        //         .await
3446        //         .ok(),
3447        //     None
3448        // );
3449    }
3450
3451    #[gpui::test]
3452    async fn test_compare_checkpoints(cx: &mut TestAppContext) {
3453        disable_git_global_config();
3454
3455        cx.executor().allow_parking();
3456
3457        let repo_dir = tempfile::tempdir().unwrap();
3458        git2::Repository::init(repo_dir.path()).unwrap();
3459        let repo = RealGitRepository::new(
3460            &repo_dir.path().join(".git"),
3461            None,
3462            Some("git".into()),
3463            cx.executor(),
3464        )
3465        .unwrap();
3466
3467        smol::fs::write(repo_dir.path().join("file1"), "content1")
3468            .await
3469            .unwrap();
3470        let checkpoint1 = repo.checkpoint().await.unwrap();
3471
3472        smol::fs::write(repo_dir.path().join("file2"), "content2")
3473            .await
3474            .unwrap();
3475        let checkpoint2 = repo.checkpoint().await.unwrap();
3476
3477        assert!(
3478            !repo
3479                .compare_checkpoints(checkpoint1, checkpoint2.clone())
3480                .await
3481                .unwrap()
3482        );
3483
3484        let checkpoint3 = repo.checkpoint().await.unwrap();
3485        assert!(
3486            repo.compare_checkpoints(checkpoint2, checkpoint3)
3487                .await
3488                .unwrap()
3489        );
3490    }
3491
3492    #[gpui::test]
3493    async fn test_checkpoint_exclude_binary_files(cx: &mut TestAppContext) {
3494        disable_git_global_config();
3495
3496        cx.executor().allow_parking();
3497
3498        let repo_dir = tempfile::tempdir().unwrap();
3499        let text_path = repo_dir.path().join("main.rs");
3500        let bin_path = repo_dir.path().join("binary.o");
3501
3502        git2::Repository::init(repo_dir.path()).unwrap();
3503
3504        smol::fs::write(&text_path, "fn main() {}").await.unwrap();
3505
3506        smol::fs::write(&bin_path, "some binary file here")
3507            .await
3508            .unwrap();
3509
3510        let repo = RealGitRepository::new(
3511            &repo_dir.path().join(".git"),
3512            None,
3513            Some("git".into()),
3514            cx.executor(),
3515        )
3516        .unwrap();
3517
3518        // initial commit
3519        repo.stage_paths(vec![repo_path("main.rs")], Arc::new(HashMap::default()))
3520            .await
3521            .unwrap();
3522        repo.commit(
3523            "Initial commit".into(),
3524            None,
3525            CommitOptions::default(),
3526            AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}),
3527            Arc::new(checkpoint_author_envs()),
3528        )
3529        .await
3530        .unwrap();
3531
3532        let checkpoint = repo.checkpoint().await.unwrap();
3533
3534        smol::fs::write(&text_path, "fn main() { println!(\"Modified\"); }")
3535            .await
3536            .unwrap();
3537        smol::fs::write(&bin_path, "Modified binary file")
3538            .await
3539            .unwrap();
3540
3541        repo.restore_checkpoint(checkpoint).await.unwrap();
3542
3543        // Text files should be restored to checkpoint state,
3544        // but binaries should not (they aren't tracked)
3545        assert_eq!(
3546            smol::fs::read_to_string(&text_path).await.unwrap(),
3547            "fn main() {}"
3548        );
3549
3550        assert_eq!(
3551            smol::fs::read_to_string(&bin_path).await.unwrap(),
3552            "Modified binary file"
3553        );
3554    }
3555
3556    #[test]
3557    fn test_branches_parsing() {
3558        // suppress "help: octal escapes are not supported, `\0` is always null"
3559        #[allow(clippy::octal_escapes)]
3560        let input = "*\0060964da10574cd9bf06463a53bf6e0769c5c45e\0\0refs/heads/zed-patches\0refs/remotes/origin/zed-patches\0\01733187470\0John Doe\0generated protobuf\n";
3561        assert_eq!(
3562            parse_branch_input(input).unwrap(),
3563            vec![Branch {
3564                is_head: true,
3565                ref_name: "refs/heads/zed-patches".into(),
3566                upstream: Some(Upstream {
3567                    ref_name: "refs/remotes/origin/zed-patches".into(),
3568                    tracking: UpstreamTracking::Tracked(UpstreamTrackingStatus {
3569                        ahead: 0,
3570                        behind: 0
3571                    })
3572                }),
3573                most_recent_commit: Some(CommitSummary {
3574                    sha: "060964da10574cd9bf06463a53bf6e0769c5c45e".into(),
3575                    subject: "generated protobuf".into(),
3576                    commit_timestamp: 1733187470,
3577                    author_name: SharedString::new_static("John Doe"),
3578                    has_parent: false,
3579                })
3580            }]
3581        )
3582    }
3583
3584    #[test]
3585    fn test_branches_parsing_containing_refs_with_missing_fields() {
3586        #[allow(clippy::octal_escapes)]
3587        let input = " \090012116c03db04344ab10d50348553aa94f1ea0\0refs/heads/broken\n \0eb0cae33272689bd11030822939dd2701c52f81e\0895951d681e5561478c0acdd6905e8aacdfd2249\0refs/heads/dev\0\0\01762948725\0Zed\0Add feature\n*\0895951d681e5561478c0acdd6905e8aacdfd2249\0\0refs/heads/main\0\0\01762948695\0Zed\0Initial commit\n";
3588
3589        let branches = parse_branch_input(input).unwrap();
3590        assert_eq!(branches.len(), 2);
3591        assert_eq!(
3592            branches,
3593            vec![
3594                Branch {
3595                    is_head: false,
3596                    ref_name: "refs/heads/dev".into(),
3597                    upstream: None,
3598                    most_recent_commit: Some(CommitSummary {
3599                        sha: "eb0cae33272689bd11030822939dd2701c52f81e".into(),
3600                        subject: "Add feature".into(),
3601                        commit_timestamp: 1762948725,
3602                        author_name: SharedString::new_static("Zed"),
3603                        has_parent: true,
3604                    })
3605                },
3606                Branch {
3607                    is_head: true,
3608                    ref_name: "refs/heads/main".into(),
3609                    upstream: None,
3610                    most_recent_commit: Some(CommitSummary {
3611                        sha: "895951d681e5561478c0acdd6905e8aacdfd2249".into(),
3612                        subject: "Initial commit".into(),
3613                        commit_timestamp: 1762948695,
3614                        author_name: SharedString::new_static("Zed"),
3615                        has_parent: false,
3616                    })
3617                }
3618            ]
3619        )
3620    }
3621
3622    #[test]
3623    fn test_upstream_branch_name() {
3624        let upstream = Upstream {
3625            ref_name: "refs/remotes/origin/feature/branch".into(),
3626            tracking: UpstreamTracking::Tracked(UpstreamTrackingStatus {
3627                ahead: 0,
3628                behind: 0,
3629            }),
3630        };
3631        assert_eq!(upstream.branch_name(), Some("feature/branch"));
3632
3633        let upstream = Upstream {
3634            ref_name: "refs/remotes/upstream/main".into(),
3635            tracking: UpstreamTracking::Tracked(UpstreamTrackingStatus {
3636                ahead: 0,
3637                behind: 0,
3638            }),
3639        };
3640        assert_eq!(upstream.branch_name(), Some("main"));
3641
3642        let upstream = Upstream {
3643            ref_name: "refs/heads/local".into(),
3644            tracking: UpstreamTracking::Tracked(UpstreamTrackingStatus {
3645                ahead: 0,
3646                behind: 0,
3647            }),
3648        };
3649        assert_eq!(upstream.branch_name(), None);
3650
3651        // Test case where upstream branch name differs from what might be the local branch name
3652        let upstream = Upstream {
3653            ref_name: "refs/remotes/origin/feature/git-pull-request".into(),
3654            tracking: UpstreamTracking::Tracked(UpstreamTrackingStatus {
3655                ahead: 0,
3656                behind: 0,
3657            }),
3658        };
3659        assert_eq!(upstream.branch_name(), Some("feature/git-pull-request"));
3660    }
3661
3662    impl RealGitRepository {
3663        /// Force a Git garbage collection on the repository.
3664        fn gc(&self) -> BoxFuture<'_, Result<()>> {
3665            let working_directory = self.working_directory();
3666            let git_binary_path = self.any_git_binary_path.clone();
3667            let executor = self.executor.clone();
3668            self.executor
3669                .spawn(async move {
3670                    let git_binary_path = git_binary_path.clone();
3671                    let working_directory = working_directory?;
3672                    let git = GitBinary::new(git_binary_path, working_directory, executor);
3673                    git.run(&["gc", "--prune"]).await?;
3674                    Ok(())
3675                })
3676                .boxed()
3677        }
3678    }
3679}