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