repository.rs

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