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