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