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