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