repository.rs

   1use crate::commit::parse_git_diff_name_status;
   2use crate::status::{GitStatus, StatusCode};
   3use crate::{Oid, SHORT_SHA_LENGTH};
   4use anyhow::{Context as _, Result, anyhow, bail};
   5use collections::HashMap;
   6use futures::future::BoxFuture;
   7use futures::{AsyncWriteExt, FutureExt as _, select_biased};
   8use git2::BranchType;
   9use gpui::{AppContext as _, AsyncApp, BackgroundExecutor, SharedString, Task};
  10use parking_lot::Mutex;
  11use rope::Rope;
  12use schemars::JsonSchema;
  13use serde::Deserialize;
  14use std::borrow::{Borrow, Cow};
  15use std::ffi::{OsStr, OsString};
  16use std::io::prelude::*;
  17use std::path::Component;
  18use std::process::{ExitStatus, Stdio};
  19use std::sync::LazyLock;
  20use std::{
  21    cmp::Ordering,
  22    future,
  23    io::{BufRead, BufReader, BufWriter, Read},
  24    path::{Path, PathBuf},
  25    sync::Arc,
  26};
  27use sum_tree::MapSeekTarget;
  28use thiserror::Error;
  29use util::command::{new_smol_command, new_std_command};
  30use util::{ResultExt, paths};
  31use uuid::Uuid;
  32
  33pub use askpass::{AskPassDelegate, AskPassResult, AskPassSession};
  34
  35pub const REMOTE_CANCELLED_BY_USER: &str = "Operation cancelled by user";
  36
  37#[derive(Clone, Debug, Hash, PartialEq, Eq)]
  38pub struct Branch {
  39    pub is_head: bool,
  40    pub ref_name: SharedString,
  41    pub upstream: Option<Upstream>,
  42    pub most_recent_commit: Option<CommitSummary>,
  43}
  44
  45impl Branch {
  46    pub fn name(&self) -> &str {
  47        self.ref_name
  48            .as_ref()
  49            .strip_prefix("refs/heads/")
  50            .or_else(|| self.ref_name.as_ref().strip_prefix("refs/remotes/"))
  51            .unwrap_or(self.ref_name.as_ref())
  52    }
  53
  54    pub fn is_remote(&self) -> bool {
  55        self.ref_name.starts_with("refs/remotes/")
  56    }
  57
  58    pub fn tracking_status(&self) -> Option<UpstreamTrackingStatus> {
  59        self.upstream
  60            .as_ref()
  61            .and_then(|upstream| upstream.tracking.status())
  62    }
  63
  64    pub fn priority_key(&self) -> (bool, Option<i64>) {
  65        (
  66            self.is_head,
  67            self.most_recent_commit
  68                .as_ref()
  69                .map(|commit| commit.commit_timestamp),
  70        )
  71    }
  72}
  73
  74#[derive(Clone, Debug, Hash, PartialEq, Eq)]
  75pub struct Upstream {
  76    pub ref_name: SharedString,
  77    pub tracking: UpstreamTracking,
  78}
  79
  80impl Upstream {
  81    pub fn is_remote(&self) -> bool {
  82        self.remote_name().is_some()
  83    }
  84
  85    pub fn remote_name(&self) -> Option<&str> {
  86        self.ref_name
  87            .strip_prefix("refs/remotes/")
  88            .and_then(|stripped| stripped.split("/").next())
  89    }
  90
  91    pub fn stripped_ref_name(&self) -> Option<&str> {
  92        self.ref_name.strip_prefix("refs/remotes/")
  93    }
  94}
  95
  96#[derive(Clone, Copy, Default)]
  97pub struct CommitOptions {
  98    pub amend: bool,
  99    pub signoff: bool,
 100}
 101
 102#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
 103pub enum UpstreamTracking {
 104    /// Remote ref not present in local repository.
 105    Gone,
 106    /// Remote ref present in local repository (fetched from remote).
 107    Tracked(UpstreamTrackingStatus),
 108}
 109
 110impl From<UpstreamTrackingStatus> for UpstreamTracking {
 111    fn from(status: UpstreamTrackingStatus) -> Self {
 112        UpstreamTracking::Tracked(status)
 113    }
 114}
 115
 116impl UpstreamTracking {
 117    pub fn is_gone(&self) -> bool {
 118        matches!(self, UpstreamTracking::Gone)
 119    }
 120
 121    pub fn status(&self) -> Option<UpstreamTrackingStatus> {
 122        match self {
 123            UpstreamTracking::Gone => None,
 124            UpstreamTracking::Tracked(status) => Some(*status),
 125        }
 126    }
 127}
 128
 129#[derive(Debug, Clone)]
 130pub struct RemoteCommandOutput {
 131    pub stdout: String,
 132    pub stderr: String,
 133}
 134
 135impl RemoteCommandOutput {
 136    pub fn is_empty(&self) -> bool {
 137        self.stdout.is_empty() && self.stderr.is_empty()
 138    }
 139}
 140
 141#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
 142pub struct UpstreamTrackingStatus {
 143    pub ahead: u32,
 144    pub behind: u32,
 145}
 146
 147#[derive(Clone, Debug, Hash, PartialEq, Eq)]
 148pub struct CommitSummary {
 149    pub sha: SharedString,
 150    pub subject: SharedString,
 151    /// This is a unix timestamp
 152    pub commit_timestamp: i64,
 153    pub has_parent: bool,
 154}
 155
 156#[derive(Clone, Debug, Default, Hash, PartialEq, Eq)]
 157pub struct CommitDetails {
 158    pub sha: SharedString,
 159    pub message: SharedString,
 160    pub commit_timestamp: i64,
 161    pub author_email: SharedString,
 162    pub author_name: SharedString,
 163}
 164
 165#[derive(Debug)]
 166pub struct CommitDiff {
 167    pub files: Vec<CommitFile>,
 168}
 169
 170#[derive(Debug)]
 171pub struct CommitFile {
 172    pub path: RepoPath,
 173    pub old_text: Option<String>,
 174    pub new_text: Option<String>,
 175}
 176
 177impl CommitDetails {
 178    pub fn short_sha(&self) -> SharedString {
 179        self.sha[..SHORT_SHA_LENGTH].to_string().into()
 180    }
 181}
 182
 183#[derive(Debug, Clone, Hash, PartialEq, Eq)]
 184pub struct Remote {
 185    pub name: SharedString,
 186}
 187
 188pub enum ResetMode {
 189    /// Reset the branch pointer, leave index and worktree unchanged (this will make it look like things that were
 190    /// committed are now staged).
 191    Soft,
 192    /// Reset the branch pointer and index, leave worktree unchanged (this makes it look as though things that were
 193    /// committed are now unstaged).
 194    Mixed,
 195}
 196
 197#[derive(Debug, Clone, Hash, PartialEq, Eq)]
 198pub enum FetchOptions {
 199    All,
 200    Remote(Remote),
 201}
 202
 203impl FetchOptions {
 204    pub fn to_proto(&self) -> Option<String> {
 205        match self {
 206            FetchOptions::All => None,
 207            FetchOptions::Remote(remote) => Some(remote.clone().name.into()),
 208        }
 209    }
 210
 211    pub fn from_proto(remote_name: Option<String>) -> Self {
 212        match remote_name {
 213            Some(name) => FetchOptions::Remote(Remote { name: name.into() }),
 214            None => FetchOptions::All,
 215        }
 216    }
 217
 218    pub fn name(&self) -> SharedString {
 219        match self {
 220            Self::All => "Fetch all remotes".into(),
 221            Self::Remote(remote) => remote.name.clone(),
 222        }
 223    }
 224}
 225
 226impl std::fmt::Display for FetchOptions {
 227    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
 228        match self {
 229            FetchOptions::All => write!(f, "--all"),
 230            FetchOptions::Remote(remote) => write!(f, "{}", remote.name),
 231        }
 232    }
 233}
 234
 235/// Modifies .git/info/exclude temporarily
 236pub struct GitExcludeOverride {
 237    git_exclude_path: PathBuf,
 238    original_excludes: Option<String>,
 239    added_excludes: Option<String>,
 240}
 241
 242impl GitExcludeOverride {
 243    pub async fn new(git_exclude_path: PathBuf) -> Result<Self> {
 244        let original_excludes = smol::fs::read_to_string(&git_exclude_path).await.ok();
 245
 246        Ok(GitExcludeOverride {
 247            git_exclude_path,
 248            original_excludes,
 249            added_excludes: None,
 250        })
 251    }
 252
 253    pub async fn add_excludes(&mut self, excludes: &str) -> Result<()> {
 254        self.added_excludes = Some(if let Some(ref already_added) = self.added_excludes {
 255            format!("{already_added}\n{excludes}")
 256        } else {
 257            excludes.to_string()
 258        });
 259
 260        let mut content = self.original_excludes.clone().unwrap_or_default();
 261        content.push_str("\n\n#  ====== Auto-added by Zed: =======\n");
 262        content.push_str(self.added_excludes.as_ref().unwrap());
 263        content.push('\n');
 264
 265        // Ensure the parent directory exists before writing
 266        if let Some(parent) = self.git_exclude_path.parent() {
 267            if !parent.exists() {
 268                smol::fs::create_dir_all(parent).await?;
 269            }
 270        }
 271
 272        smol::fs::write(&self.git_exclude_path, content).await?;
 273        Ok(())
 274    }
 275
 276    pub async fn restore_original(&mut self) -> Result<()> {
 277        if let Some(ref original) = self.original_excludes {
 278            smol::fs::write(&self.git_exclude_path, original).await?;
 279        } else if self.git_exclude_path.exists() {
 280            smol::fs::remove_file(&self.git_exclude_path).await?;
 281        }
 282
 283        self.added_excludes = None;
 284
 285        Ok(())
 286    }
 287}
 288
 289impl Drop for GitExcludeOverride {
 290    fn drop(&mut self) {
 291        if self.added_excludes.is_some() {
 292            let git_exclude_path = self.git_exclude_path.clone();
 293            let original_excludes = self.original_excludes.clone();
 294            smol::spawn(async move {
 295                if let Some(original) = original_excludes {
 296                    smol::fs::write(&git_exclude_path, original).await
 297                } else {
 298                    smol::fs::remove_file(&git_exclude_path).await
 299                }
 300            })
 301            .detach();
 302        }
 303    }
 304}
 305
 306pub trait GitRepository: Send + Sync {
 307    fn reload_index(&self);
 308
 309    /// Returns the contents of an entry in the repository's index, or None if there is no entry for the given path.
 310    ///
 311    /// Also returns `None` for symlinks.
 312    fn load_index_text(&self, path: RepoPath) -> BoxFuture<'_, Option<String>>;
 313
 314    /// 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.
 315    ///
 316    /// Also returns `None` for symlinks.
 317    fn load_committed_text(&self, path: RepoPath) -> BoxFuture<'_, Option<String>>;
 318
 319    fn set_index_text(
 320        &self,
 321        path: RepoPath,
 322        content: Option<String>,
 323        env: Arc<HashMap<String, String>>,
 324    ) -> BoxFuture<'_, anyhow::Result<()>>;
 325
 326    /// Returns the URL of the remote with the given name.
 327    fn remote_url(&self, name: &str) -> Option<String>;
 328
 329    /// Resolve a list of refs to SHAs.
 330    fn revparse_batch(&self, revs: Vec<String>) -> BoxFuture<'_, Result<Vec<Option<String>>>>;
 331
 332    fn head_sha(&self) -> BoxFuture<'_, Option<String>> {
 333        async move {
 334            self.revparse_batch(vec!["HEAD".into()])
 335                .await
 336                .unwrap_or_default()
 337                .into_iter()
 338                .next()
 339                .flatten()
 340        }
 341        .boxed()
 342    }
 343
 344    fn merge_message(&self) -> BoxFuture<'_, Option<String>>;
 345
 346    fn status(&self, path_prefixes: &[RepoPath]) -> Task<Result<GitStatus>>;
 347
 348    fn branches(&self) -> BoxFuture<'_, Result<Vec<Branch>>>;
 349
 350    fn change_branch(&self, name: String) -> BoxFuture<'_, Result<()>>;
 351    fn create_branch(&self, name: String) -> BoxFuture<'_, Result<()>>;
 352
 353    fn reset(
 354        &self,
 355        commit: String,
 356        mode: ResetMode,
 357        env: Arc<HashMap<String, String>>,
 358    ) -> BoxFuture<'_, Result<()>>;
 359
 360    fn checkout_files(
 361        &self,
 362        commit: String,
 363        paths: Vec<RepoPath>,
 364        env: Arc<HashMap<String, String>>,
 365    ) -> BoxFuture<'_, Result<()>>;
 366
 367    fn show(&self, commit: String) -> BoxFuture<'_, Result<CommitDetails>>;
 368
 369    fn load_commit(&self, commit: String, cx: AsyncApp) -> BoxFuture<'_, Result<CommitDiff>>;
 370    fn blame(&self, path: RepoPath, content: Rope) -> BoxFuture<'_, Result<crate::blame::Blame>>;
 371
 372    /// Returns the absolute path to the repository. For worktrees, this will be the path to the
 373    /// worktree's gitdir within the main repository (typically `.git/worktrees/<name>`).
 374    fn path(&self) -> PathBuf;
 375
 376    fn main_repository_path(&self) -> PathBuf;
 377
 378    /// Updates the index to match the worktree at the given paths.
 379    ///
 380    /// If any of the paths have been deleted from the worktree, they will be removed from the index if found there.
 381    fn stage_paths(
 382        &self,
 383        paths: Vec<RepoPath>,
 384        env: Arc<HashMap<String, String>>,
 385    ) -> BoxFuture<'_, Result<()>>;
 386    /// Updates the index to match HEAD at the given paths.
 387    ///
 388    /// If any of the paths were previously staged but do not exist in HEAD, they will be removed from the index.
 389    fn unstage_paths(
 390        &self,
 391        paths: Vec<RepoPath>,
 392        env: Arc<HashMap<String, String>>,
 393    ) -> BoxFuture<'_, Result<()>>;
 394
 395    fn commit(
 396        &self,
 397        message: SharedString,
 398        name_and_email: Option<(SharedString, SharedString)>,
 399        options: CommitOptions,
 400        env: Arc<HashMap<String, String>>,
 401    ) -> BoxFuture<'_, Result<()>>;
 402
 403    fn stash_paths(
 404        &self,
 405        paths: Vec<RepoPath>,
 406        env: Arc<HashMap<String, String>>,
 407    ) -> BoxFuture<'_, Result<()>>;
 408
 409    fn stash_pop(&self, env: Arc<HashMap<String, String>>) -> BoxFuture<'_, Result<()>>;
 410
 411    fn push(
 412        &self,
 413        branch_name: String,
 414        upstream_name: String,
 415        options: Option<PushOptions>,
 416        askpass: AskPassDelegate,
 417        env: Arc<HashMap<String, String>>,
 418        // This method takes an AsyncApp to ensure it's invoked on the main thread,
 419        // otherwise git-credentials-manager won't work.
 420        cx: AsyncApp,
 421    ) -> BoxFuture<'_, Result<RemoteCommandOutput>>;
 422
 423    fn pull(
 424        &self,
 425        branch_name: String,
 426        upstream_name: String,
 427        askpass: AskPassDelegate,
 428        env: Arc<HashMap<String, String>>,
 429        // This method takes an AsyncApp to ensure it's invoked on the main thread,
 430        // otherwise git-credentials-manager won't work.
 431        cx: AsyncApp,
 432    ) -> BoxFuture<'_, Result<RemoteCommandOutput>>;
 433
 434    fn fetch(
 435        &self,
 436        fetch_options: FetchOptions,
 437        askpass: AskPassDelegate,
 438        env: Arc<HashMap<String, String>>,
 439        // This method takes an AsyncApp to ensure it's invoked on the main thread,
 440        // otherwise git-credentials-manager won't work.
 441        cx: AsyncApp,
 442    ) -> BoxFuture<'_, Result<RemoteCommandOutput>>;
 443
 444    fn get_remotes(&self, branch_name: Option<String>) -> BoxFuture<'_, Result<Vec<Remote>>>;
 445
 446    /// returns a list of remote branches that contain HEAD
 447    fn check_for_pushed_commit(&self) -> BoxFuture<'_, Result<Vec<SharedString>>>;
 448
 449    /// Run git diff
 450    fn diff(&self, diff: DiffType) -> BoxFuture<'_, Result<String>>;
 451
 452    /// Creates a checkpoint for the repository.
 453    fn checkpoint(&self) -> BoxFuture<'static, Result<GitRepositoryCheckpoint>>;
 454
 455    /// Resets to a previously-created checkpoint.
 456    fn restore_checkpoint(&self, checkpoint: GitRepositoryCheckpoint) -> BoxFuture<'_, Result<()>>;
 457
 458    /// Compares two checkpoints, returning true if they are equal
 459    fn compare_checkpoints(
 460        &self,
 461        left: GitRepositoryCheckpoint,
 462        right: GitRepositoryCheckpoint,
 463    ) -> BoxFuture<'_, Result<bool>>;
 464
 465    /// Computes a diff between two checkpoints.
 466    fn diff_checkpoints(
 467        &self,
 468        base_checkpoint: GitRepositoryCheckpoint,
 469        target_checkpoint: GitRepositoryCheckpoint,
 470    ) -> BoxFuture<'_, Result<String>>;
 471
 472    fn default_branch(&self) -> BoxFuture<'_, Result<Option<SharedString>>>;
 473}
 474
 475pub enum DiffType {
 476    HeadToIndex,
 477    HeadToWorktree,
 478}
 479
 480#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, JsonSchema)]
 481pub enum PushOptions {
 482    SetUpstream,
 483    Force,
 484}
 485
 486impl std::fmt::Debug for dyn GitRepository {
 487    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
 488        f.debug_struct("dyn GitRepository<...>").finish()
 489    }
 490}
 491
 492pub struct RealGitRepository {
 493    pub repository: Arc<Mutex<git2::Repository>>,
 494    pub git_binary_path: PathBuf,
 495    executor: BackgroundExecutor,
 496}
 497
 498impl RealGitRepository {
 499    pub fn new(
 500        dotgit_path: &Path,
 501        git_binary_path: Option<PathBuf>,
 502        executor: BackgroundExecutor,
 503    ) -> Option<Self> {
 504        let workdir_root = dotgit_path.parent()?;
 505        let repository = git2::Repository::open(workdir_root).log_err()?;
 506        Some(Self {
 507            repository: Arc::new(Mutex::new(repository)),
 508            git_binary_path: git_binary_path.unwrap_or_else(|| PathBuf::from("git")),
 509            executor,
 510        })
 511    }
 512
 513    fn working_directory(&self) -> Result<PathBuf> {
 514        self.repository
 515            .lock()
 516            .workdir()
 517            .context("failed to read git work directory")
 518            .map(Path::to_path_buf)
 519    }
 520}
 521
 522#[derive(Clone, Debug)]
 523pub struct GitRepositoryCheckpoint {
 524    pub commit_sha: Oid,
 525}
 526
 527#[derive(Debug)]
 528pub struct GitCommitter {
 529    pub name: Option<String>,
 530    pub email: Option<String>,
 531}
 532
 533pub async fn get_git_committer(cx: &AsyncApp) -> GitCommitter {
 534    if cfg!(any(feature = "test-support", test)) {
 535        return GitCommitter {
 536            name: None,
 537            email: None,
 538        };
 539    }
 540
 541    let git_binary_path =
 542        if cfg!(target_os = "macos") && option_env!("ZED_BUNDLE").as_deref() == Some("true") {
 543            cx.update(|cx| {
 544                cx.path_for_auxiliary_executable("git")
 545                    .context("could not find git binary path")
 546                    .log_err()
 547            })
 548            .ok()
 549            .flatten()
 550        } else {
 551            None
 552        };
 553
 554    let git = GitBinary::new(
 555        git_binary_path.unwrap_or(PathBuf::from("git")),
 556        paths::home_dir().clone(),
 557        cx.background_executor().clone(),
 558    );
 559
 560    cx.background_spawn(async move {
 561        let name = git.run(["config", "--global", "user.name"]).await.log_err();
 562        let email = git
 563            .run(["config", "--global", "user.email"])
 564            .await
 565            .log_err();
 566        GitCommitter { name, email }
 567    })
 568    .await
 569}
 570
 571impl GitRepository for RealGitRepository {
 572    fn reload_index(&self) {
 573        if let Ok(mut index) = self.repository.lock().index() {
 574            _ = index.read(false);
 575        }
 576    }
 577
 578    fn path(&self) -> PathBuf {
 579        let repo = self.repository.lock();
 580        repo.path().into()
 581    }
 582
 583    fn main_repository_path(&self) -> PathBuf {
 584        let repo = self.repository.lock();
 585        repo.commondir().into()
 586    }
 587
 588    fn show(&self, commit: String) -> BoxFuture<'_, Result<CommitDetails>> {
 589        let working_directory = self.working_directory();
 590        self.executor
 591            .spawn(async move {
 592                let working_directory = working_directory?;
 593                let output = new_std_command("git")
 594                    .current_dir(&working_directory)
 595                    .args([
 596                        "--no-optional-locks",
 597                        "show",
 598                        "--no-patch",
 599                        "--format=%H%x00%B%x00%at%x00%ae%x00%an%x00",
 600                        &commit,
 601                    ])
 602                    .output()?;
 603                let output = std::str::from_utf8(&output.stdout)?;
 604                let fields = output.split('\0').collect::<Vec<_>>();
 605                if fields.len() != 6 {
 606                    bail!("unexpected git-show output for {commit:?}: {output:?}")
 607                }
 608                let sha = fields[0].to_string().into();
 609                let message = fields[1].to_string().into();
 610                let commit_timestamp = fields[2].parse()?;
 611                let author_email = fields[3].to_string().into();
 612                let author_name = fields[4].to_string().into();
 613                Ok(CommitDetails {
 614                    sha,
 615                    message,
 616                    commit_timestamp,
 617                    author_email,
 618                    author_name,
 619                })
 620            })
 621            .boxed()
 622    }
 623
 624    fn load_commit(&self, commit: String, cx: AsyncApp) -> BoxFuture<'_, Result<CommitDiff>> {
 625        let Some(working_directory) = self.repository.lock().workdir().map(ToOwned::to_owned)
 626        else {
 627            return future::ready(Err(anyhow!("no working directory"))).boxed();
 628        };
 629        cx.background_spawn(async move {
 630            let show_output = util::command::new_std_command("git")
 631                .current_dir(&working_directory)
 632                .args([
 633                    "--no-optional-locks",
 634                    "show",
 635                    "--format=%P",
 636                    "-z",
 637                    "--no-renames",
 638                    "--name-status",
 639                ])
 640                .arg(&commit)
 641                .stdin(Stdio::null())
 642                .stdout(Stdio::piped())
 643                .stderr(Stdio::piped())
 644                .output()
 645                .context("starting git show process")?;
 646
 647            let show_stdout = String::from_utf8_lossy(&show_output.stdout);
 648            let mut lines = show_stdout.split('\n');
 649            let parent_sha = lines.next().unwrap().trim().trim_end_matches('\0');
 650            let changes = parse_git_diff_name_status(lines.next().unwrap_or(""));
 651
 652            let mut cat_file_process = util::command::new_std_command("git")
 653                .current_dir(&working_directory)
 654                .args(["--no-optional-locks", "cat-file", "--batch=%(objectsize)"])
 655                .stdin(Stdio::piped())
 656                .stdout(Stdio::piped())
 657                .stderr(Stdio::piped())
 658                .spawn()
 659                .context("starting git cat-file process")?;
 660
 661            use std::io::Write as _;
 662            let mut files = Vec::<CommitFile>::new();
 663            let mut stdin = BufWriter::with_capacity(512, cat_file_process.stdin.take().unwrap());
 664            let mut stdout = BufReader::new(cat_file_process.stdout.take().unwrap());
 665            let mut info_line = String::new();
 666            let mut newline = [b'\0'];
 667            for (path, status_code) in changes {
 668                match status_code {
 669                    StatusCode::Modified => {
 670                        writeln!(&mut stdin, "{commit}:{}", path.display())?;
 671                        writeln!(&mut stdin, "{parent_sha}:{}", path.display())?;
 672                    }
 673                    StatusCode::Added => {
 674                        writeln!(&mut stdin, "{commit}:{}", path.display())?;
 675                    }
 676                    StatusCode::Deleted => {
 677                        writeln!(&mut stdin, "{parent_sha}:{}", path.display())?;
 678                    }
 679                    _ => continue,
 680                }
 681                stdin.flush()?;
 682
 683                info_line.clear();
 684                stdout.read_line(&mut info_line)?;
 685
 686                let len = info_line.trim_end().parse().with_context(|| {
 687                    format!("invalid object size output from cat-file {info_line}")
 688                })?;
 689                let mut text = vec![0; len];
 690                stdout.read_exact(&mut text)?;
 691                stdout.read_exact(&mut newline)?;
 692                let text = String::from_utf8_lossy(&text).to_string();
 693
 694                let mut old_text = None;
 695                let mut new_text = None;
 696                match status_code {
 697                    StatusCode::Modified => {
 698                        info_line.clear();
 699                        stdout.read_line(&mut info_line)?;
 700                        let len = info_line.trim_end().parse().with_context(|| {
 701                            format!("invalid object size output from cat-file {}", info_line)
 702                        })?;
 703                        let mut parent_text = vec![0; len];
 704                        stdout.read_exact(&mut parent_text)?;
 705                        stdout.read_exact(&mut newline)?;
 706                        old_text = Some(String::from_utf8_lossy(&parent_text).to_string());
 707                        new_text = Some(text);
 708                    }
 709                    StatusCode::Added => new_text = Some(text),
 710                    StatusCode::Deleted => old_text = Some(text),
 711                    _ => continue,
 712                }
 713
 714                files.push(CommitFile {
 715                    path: path.into(),
 716                    old_text,
 717                    new_text,
 718                })
 719            }
 720
 721            Ok(CommitDiff { files })
 722        })
 723        .boxed()
 724    }
 725
 726    fn reset(
 727        &self,
 728        commit: String,
 729        mode: ResetMode,
 730        env: Arc<HashMap<String, String>>,
 731    ) -> BoxFuture<'_, Result<()>> {
 732        async move {
 733            let working_directory = self.working_directory();
 734
 735            let mode_flag = match mode {
 736                ResetMode::Mixed => "--mixed",
 737                ResetMode::Soft => "--soft",
 738            };
 739
 740            let output = new_smol_command(&self.git_binary_path)
 741                .envs(env.iter())
 742                .current_dir(&working_directory?)
 743                .args(["reset", mode_flag, &commit])
 744                .output()
 745                .await?;
 746            anyhow::ensure!(
 747                output.status.success(),
 748                "Failed to reset:\n{}",
 749                String::from_utf8_lossy(&output.stderr),
 750            );
 751            Ok(())
 752        }
 753        .boxed()
 754    }
 755
 756    fn checkout_files(
 757        &self,
 758        commit: String,
 759        paths: Vec<RepoPath>,
 760        env: Arc<HashMap<String, String>>,
 761    ) -> BoxFuture<'_, Result<()>> {
 762        let working_directory = self.working_directory();
 763        let git_binary_path = self.git_binary_path.clone();
 764        async move {
 765            if paths.is_empty() {
 766                return Ok(());
 767            }
 768
 769            let output = new_smol_command(&git_binary_path)
 770                .current_dir(&working_directory?)
 771                .envs(env.iter())
 772                .args(["checkout", &commit, "--"])
 773                .args(paths.iter().map(|path| path.as_ref()))
 774                .output()
 775                .await?;
 776            anyhow::ensure!(
 777                output.status.success(),
 778                "Failed to checkout files:\n{}",
 779                String::from_utf8_lossy(&output.stderr),
 780            );
 781            Ok(())
 782        }
 783        .boxed()
 784    }
 785
 786    fn load_index_text(&self, path: RepoPath) -> BoxFuture<'_, Option<String>> {
 787        // https://git-scm.com/book/en/v2/Git-Internals-Git-Objects
 788        const GIT_MODE_SYMLINK: u32 = 0o120000;
 789
 790        let repo = self.repository.clone();
 791        self.executor
 792            .spawn(async move {
 793                fn logic(repo: &git2::Repository, path: &RepoPath) -> Result<Option<String>> {
 794                    // This check is required because index.get_path() unwraps internally :(
 795                    check_path_to_repo_path_errors(path)?;
 796
 797                    let mut index = repo.index()?;
 798                    index.read(false)?;
 799
 800                    const STAGE_NORMAL: i32 = 0;
 801                    let oid = match index.get_path(path, STAGE_NORMAL) {
 802                        Some(entry) if entry.mode != GIT_MODE_SYMLINK => entry.id,
 803                        _ => return Ok(None),
 804                    };
 805
 806                    let content = repo.find_blob(oid)?.content().to_owned();
 807                    Ok(String::from_utf8(content).ok())
 808                }
 809
 810                match logic(&repo.lock(), &path) {
 811                    Ok(value) => return value,
 812                    Err(err) => log::error!("Error loading index text: {:?}", err),
 813                }
 814                None
 815            })
 816            .boxed()
 817    }
 818
 819    fn load_committed_text(&self, path: RepoPath) -> BoxFuture<'_, Option<String>> {
 820        let repo = self.repository.clone();
 821        self.executor
 822            .spawn(async move {
 823                let repo = repo.lock();
 824                let head = repo.head().ok()?.peel_to_tree().log_err()?;
 825                let entry = head.get_path(&path).ok()?;
 826                if entry.filemode() == i32::from(git2::FileMode::Link) {
 827                    return None;
 828                }
 829                let content = repo.find_blob(entry.id()).log_err()?.content().to_owned();
 830                String::from_utf8(content).ok()
 831            })
 832            .boxed()
 833    }
 834
 835    fn set_index_text(
 836        &self,
 837        path: RepoPath,
 838        content: Option<String>,
 839        env: Arc<HashMap<String, String>>,
 840    ) -> BoxFuture<'_, anyhow::Result<()>> {
 841        let working_directory = self.working_directory();
 842        let git_binary_path = self.git_binary_path.clone();
 843        self.executor
 844            .spawn(async move {
 845                let working_directory = working_directory?;
 846                if let Some(content) = content {
 847                    let mut child = new_smol_command(&git_binary_path)
 848                        .current_dir(&working_directory)
 849                        .envs(env.iter())
 850                        .args(["hash-object", "-w", "--stdin"])
 851                        .stdin(Stdio::piped())
 852                        .stdout(Stdio::piped())
 853                        .spawn()?;
 854                    let mut stdin = child.stdin.take().unwrap();
 855                    stdin.write_all(content.as_bytes()).await?;
 856                    stdin.flush().await?;
 857                    drop(stdin);
 858                    let output = child.output().await?.stdout;
 859                    let sha = str::from_utf8(&output)?.trim();
 860
 861                    log::debug!("indexing SHA: {sha}, path {path:?}");
 862
 863                    let output = new_smol_command(&git_binary_path)
 864                        .current_dir(&working_directory)
 865                        .envs(env.iter())
 866                        .args(["update-index", "--add", "--cacheinfo", "100644", sha])
 867                        .arg(path.to_unix_style())
 868                        .output()
 869                        .await?;
 870
 871                    anyhow::ensure!(
 872                        output.status.success(),
 873                        "Failed to stage:\n{}",
 874                        String::from_utf8_lossy(&output.stderr)
 875                    );
 876                } else {
 877                    log::debug!("removing path {path:?} from the index");
 878                    let output = new_smol_command(&git_binary_path)
 879                        .current_dir(&working_directory)
 880                        .envs(env.iter())
 881                        .args(["update-index", "--force-remove"])
 882                        .arg(path.to_unix_style())
 883                        .output()
 884                        .await?;
 885                    anyhow::ensure!(
 886                        output.status.success(),
 887                        "Failed to unstage:\n{}",
 888                        String::from_utf8_lossy(&output.stderr)
 889                    );
 890                }
 891
 892                Ok(())
 893            })
 894            .boxed()
 895    }
 896
 897    fn remote_url(&self, name: &str) -> Option<String> {
 898        let repo = self.repository.lock();
 899        let remote = repo.find_remote(name).ok()?;
 900        remote.url().map(|url| url.to_string())
 901    }
 902
 903    fn revparse_batch(&self, revs: Vec<String>) -> BoxFuture<'_, Result<Vec<Option<String>>>> {
 904        let working_directory = self.working_directory();
 905        self.executor
 906            .spawn(async move {
 907                let working_directory = working_directory?;
 908                let mut process = new_std_command("git")
 909                    .current_dir(&working_directory)
 910                    .args([
 911                        "--no-optional-locks",
 912                        "cat-file",
 913                        "--batch-check=%(objectname)",
 914                    ])
 915                    .stdin(Stdio::piped())
 916                    .stdout(Stdio::piped())
 917                    .stderr(Stdio::piped())
 918                    .spawn()?;
 919
 920                let stdin = process
 921                    .stdin
 922                    .take()
 923                    .context("no stdin for git cat-file subprocess")?;
 924                let mut stdin = BufWriter::new(stdin);
 925                for rev in &revs {
 926                    writeln!(&mut stdin, "{rev}")?;
 927                }
 928                stdin.flush()?;
 929                drop(stdin);
 930
 931                let output = process.wait_with_output()?;
 932                let output = std::str::from_utf8(&output.stdout)?;
 933                let shas = output
 934                    .lines()
 935                    .map(|line| {
 936                        if line.ends_with("missing") {
 937                            None
 938                        } else {
 939                            Some(line.to_string())
 940                        }
 941                    })
 942                    .collect::<Vec<_>>();
 943
 944                if shas.len() != revs.len() {
 945                    // In an octopus merge, git cat-file still only outputs the first sha from MERGE_HEAD.
 946                    bail!("unexpected number of shas")
 947                }
 948
 949                Ok(shas)
 950            })
 951            .boxed()
 952    }
 953
 954    fn merge_message(&self) -> BoxFuture<'_, Option<String>> {
 955        let path = self.path().join("MERGE_MSG");
 956        self.executor
 957            .spawn(async move { std::fs::read_to_string(&path).ok() })
 958            .boxed()
 959    }
 960
 961    fn status(&self, path_prefixes: &[RepoPath]) -> Task<Result<GitStatus>> {
 962        let git_binary_path = self.git_binary_path.clone();
 963        let working_directory = match self.working_directory() {
 964            Ok(working_directory) => working_directory,
 965            Err(e) => return Task::ready(Err(e)),
 966        };
 967        let args = git_status_args(path_prefixes);
 968        log::debug!("Checking for git status in {path_prefixes:?}");
 969        self.executor.spawn(async move {
 970            let output = new_std_command(&git_binary_path)
 971                .current_dir(working_directory)
 972                .args(args)
 973                .output()?;
 974            if output.status.success() {
 975                let stdout = String::from_utf8_lossy(&output.stdout);
 976                stdout.parse()
 977            } else {
 978                let stderr = String::from_utf8_lossy(&output.stderr);
 979                anyhow::bail!("git status failed: {stderr}");
 980            }
 981        })
 982    }
 983
 984    fn branches(&self) -> BoxFuture<'_, Result<Vec<Branch>>> {
 985        let working_directory = self.working_directory();
 986        let git_binary_path = self.git_binary_path.clone();
 987        self.executor
 988            .spawn(async move {
 989                let fields = [
 990                    "%(HEAD)",
 991                    "%(objectname)",
 992                    "%(parent)",
 993                    "%(refname)",
 994                    "%(upstream)",
 995                    "%(upstream:track)",
 996                    "%(committerdate:unix)",
 997                    "%(contents:subject)",
 998                ]
 999                .join("%00");
1000                let args = vec![
1001                    "for-each-ref",
1002                    "refs/heads/**/*",
1003                    "refs/remotes/**/*",
1004                    "--format",
1005                    &fields,
1006                ];
1007                let working_directory = working_directory?;
1008                let output = new_smol_command(&git_binary_path)
1009                    .current_dir(&working_directory)
1010                    .args(args)
1011                    .output()
1012                    .await?;
1013
1014                anyhow::ensure!(
1015                    output.status.success(),
1016                    "Failed to git git branches:\n{}",
1017                    String::from_utf8_lossy(&output.stderr)
1018                );
1019
1020                let input = String::from_utf8_lossy(&output.stdout);
1021
1022                let mut branches = parse_branch_input(&input)?;
1023                if branches.is_empty() {
1024                    let args = vec!["symbolic-ref", "--quiet", "HEAD"];
1025
1026                    let output = new_smol_command(&git_binary_path)
1027                        .current_dir(&working_directory)
1028                        .args(args)
1029                        .output()
1030                        .await?;
1031
1032                    // git symbolic-ref returns a non-0 exit code if HEAD points
1033                    // to something other than a branch
1034                    if output.status.success() {
1035                        let name = String::from_utf8_lossy(&output.stdout).trim().to_string();
1036
1037                        branches.push(Branch {
1038                            ref_name: name.into(),
1039                            is_head: true,
1040                            upstream: None,
1041                            most_recent_commit: None,
1042                        });
1043                    }
1044                }
1045
1046                Ok(branches)
1047            })
1048            .boxed()
1049    }
1050
1051    fn change_branch(&self, name: String) -> BoxFuture<'_, Result<()>> {
1052        let repo = self.repository.clone();
1053        let working_directory = self.working_directory();
1054        let git_binary_path = self.git_binary_path.clone();
1055        let executor = self.executor.clone();
1056        let branch = self.executor.spawn(async move {
1057            let repo = repo.lock();
1058            let branch = if let Ok(branch) = repo.find_branch(&name, BranchType::Local) {
1059                branch
1060            } else if let Ok(revision) = repo.find_branch(&name, BranchType::Remote) {
1061                let (_, branch_name) = name.split_once("/").context("Unexpected branch format")?;
1062                let revision = revision.get();
1063                let branch_commit = revision.peel_to_commit()?;
1064                let mut branch = repo.branch(branch_name, &branch_commit, false)?;
1065                branch.set_upstream(Some(&name))?;
1066                branch
1067            } else {
1068                anyhow::bail!("Branch not found");
1069            };
1070
1071            Ok(branch
1072                .name()?
1073                .context("cannot checkout anonymous branch")?
1074                .to_string())
1075        });
1076
1077        self.executor
1078            .spawn(async move {
1079                let branch = branch.await?;
1080
1081                GitBinary::new(git_binary_path, working_directory?, executor)
1082                    .run(&["checkout", &branch])
1083                    .await?;
1084
1085                anyhow::Ok(())
1086            })
1087            .boxed()
1088    }
1089
1090    fn create_branch(&self, name: String) -> BoxFuture<'_, Result<()>> {
1091        let repo = self.repository.clone();
1092        self.executor
1093            .spawn(async move {
1094                let repo = repo.lock();
1095                let current_commit = repo.head()?.peel_to_commit()?;
1096                repo.branch(&name, &current_commit, false)?;
1097                Ok(())
1098            })
1099            .boxed()
1100    }
1101
1102    fn blame(&self, path: RepoPath, content: Rope) -> BoxFuture<'_, Result<crate::blame::Blame>> {
1103        let working_directory = self.working_directory();
1104        let git_binary_path = self.git_binary_path.clone();
1105
1106        let remote_url = self
1107            .remote_url("upstream")
1108            .or_else(|| self.remote_url("origin"));
1109
1110        self.executor
1111            .spawn(async move {
1112                crate::blame::Blame::for_path(
1113                    &git_binary_path,
1114                    &working_directory?,
1115                    &path,
1116                    &content,
1117                    remote_url,
1118                )
1119                .await
1120            })
1121            .boxed()
1122    }
1123
1124    fn diff(&self, diff: DiffType) -> BoxFuture<'_, Result<String>> {
1125        let working_directory = self.working_directory();
1126        let git_binary_path = self.git_binary_path.clone();
1127        self.executor
1128            .spawn(async move {
1129                let args = match diff {
1130                    DiffType::HeadToIndex => Some("--staged"),
1131                    DiffType::HeadToWorktree => None,
1132                };
1133
1134                let output = new_smol_command(&git_binary_path)
1135                    .current_dir(&working_directory?)
1136                    .args(["diff"])
1137                    .args(args)
1138                    .output()
1139                    .await?;
1140
1141                anyhow::ensure!(
1142                    output.status.success(),
1143                    "Failed to run git diff:\n{}",
1144                    String::from_utf8_lossy(&output.stderr)
1145                );
1146                Ok(String::from_utf8_lossy(&output.stdout).to_string())
1147            })
1148            .boxed()
1149    }
1150
1151    fn stage_paths(
1152        &self,
1153        paths: Vec<RepoPath>,
1154        env: Arc<HashMap<String, String>>,
1155    ) -> BoxFuture<'_, Result<()>> {
1156        let working_directory = self.working_directory();
1157        let git_binary_path = self.git_binary_path.clone();
1158        self.executor
1159            .spawn(async move {
1160                if !paths.is_empty() {
1161                    let output = new_smol_command(&git_binary_path)
1162                        .current_dir(&working_directory?)
1163                        .envs(env.iter())
1164                        .args(["update-index", "--add", "--remove", "--"])
1165                        .args(paths.iter().map(|p| p.to_unix_style()))
1166                        .output()
1167                        .await?;
1168                    anyhow::ensure!(
1169                        output.status.success(),
1170                        "Failed to stage paths:\n{}",
1171                        String::from_utf8_lossy(&output.stderr),
1172                    );
1173                }
1174                Ok(())
1175            })
1176            .boxed()
1177    }
1178
1179    fn unstage_paths(
1180        &self,
1181        paths: Vec<RepoPath>,
1182        env: Arc<HashMap<String, String>>,
1183    ) -> BoxFuture<'_, Result<()>> {
1184        let working_directory = self.working_directory();
1185        let git_binary_path = self.git_binary_path.clone();
1186
1187        self.executor
1188            .spawn(async move {
1189                if !paths.is_empty() {
1190                    let output = new_smol_command(&git_binary_path)
1191                        .current_dir(&working_directory?)
1192                        .envs(env.iter())
1193                        .args(["reset", "--quiet", "--"])
1194                        .args(paths.iter().map(|p| p.as_ref()))
1195                        .output()
1196                        .await?;
1197
1198                    anyhow::ensure!(
1199                        output.status.success(),
1200                        "Failed to unstage:\n{}",
1201                        String::from_utf8_lossy(&output.stderr),
1202                    );
1203                }
1204                Ok(())
1205            })
1206            .boxed()
1207    }
1208
1209    fn stash_paths(
1210        &self,
1211        paths: Vec<RepoPath>,
1212        env: Arc<HashMap<String, String>>,
1213    ) -> BoxFuture<'_, Result<()>> {
1214        let working_directory = self.working_directory();
1215        self.executor
1216            .spawn(async move {
1217                let mut cmd = new_smol_command("git");
1218                cmd.current_dir(&working_directory?)
1219                    .envs(env.iter())
1220                    .args(["stash", "push", "--quiet"])
1221                    .arg("--include-untracked");
1222
1223                cmd.args(paths.iter().map(|p| p.as_ref()));
1224
1225                let output = cmd.output().await?;
1226
1227                anyhow::ensure!(
1228                    output.status.success(),
1229                    "Failed to stash:\n{}",
1230                    String::from_utf8_lossy(&output.stderr)
1231                );
1232                Ok(())
1233            })
1234            .boxed()
1235    }
1236
1237    fn stash_pop(&self, env: Arc<HashMap<String, String>>) -> BoxFuture<'_, Result<()>> {
1238        let working_directory = self.working_directory();
1239        self.executor
1240            .spawn(async move {
1241                let mut cmd = new_smol_command("git");
1242                cmd.current_dir(&working_directory?)
1243                    .envs(env.iter())
1244                    .args(["stash", "pop"]);
1245
1246                let output = cmd.output().await?;
1247
1248                anyhow::ensure!(
1249                    output.status.success(),
1250                    "Failed to stash pop:\n{}",
1251                    String::from_utf8_lossy(&output.stderr)
1252                );
1253                Ok(())
1254            })
1255            .boxed()
1256    }
1257
1258    fn commit(
1259        &self,
1260        message: SharedString,
1261        name_and_email: Option<(SharedString, SharedString)>,
1262        options: CommitOptions,
1263        env: Arc<HashMap<String, String>>,
1264    ) -> BoxFuture<'_, Result<()>> {
1265        let working_directory = self.working_directory();
1266        self.executor
1267            .spawn(async move {
1268                let mut cmd = new_smol_command("git");
1269                cmd.current_dir(&working_directory?)
1270                    .envs(env.iter())
1271                    .args(["commit", "--quiet", "-m"])
1272                    .arg(&message.to_string())
1273                    .arg("--cleanup=strip");
1274
1275                if options.amend {
1276                    cmd.arg("--amend");
1277                }
1278
1279                if options.signoff {
1280                    cmd.arg("--signoff");
1281                }
1282
1283                if let Some((name, email)) = name_and_email {
1284                    cmd.arg("--author").arg(&format!("{name} <{email}>"));
1285                }
1286
1287                let output = cmd.output().await?;
1288
1289                anyhow::ensure!(
1290                    output.status.success(),
1291                    "Failed to commit:\n{}",
1292                    String::from_utf8_lossy(&output.stderr)
1293                );
1294                Ok(())
1295            })
1296            .boxed()
1297    }
1298
1299    fn push(
1300        &self,
1301        branch_name: String,
1302        remote_name: String,
1303        options: Option<PushOptions>,
1304        ask_pass: AskPassDelegate,
1305        env: Arc<HashMap<String, String>>,
1306        cx: AsyncApp,
1307    ) -> BoxFuture<'_, Result<RemoteCommandOutput>> {
1308        let working_directory = self.working_directory();
1309        let executor = cx.background_executor().clone();
1310        async move {
1311            let working_directory = working_directory?;
1312            let mut command = new_smol_command("git");
1313            command
1314                .envs(env.iter())
1315                .current_dir(&working_directory)
1316                .args(["push"])
1317                .args(options.map(|option| match option {
1318                    PushOptions::SetUpstream => "--set-upstream",
1319                    PushOptions::Force => "--force-with-lease",
1320                }))
1321                .arg(remote_name)
1322                .arg(format!("{}:{}", branch_name, branch_name))
1323                .stdin(smol::process::Stdio::null())
1324                .stdout(smol::process::Stdio::piped())
1325                .stderr(smol::process::Stdio::piped());
1326
1327            run_git_command(env, ask_pass, command, &executor).await
1328        }
1329        .boxed()
1330    }
1331
1332    fn pull(
1333        &self,
1334        branch_name: String,
1335        remote_name: String,
1336        ask_pass: AskPassDelegate,
1337        env: Arc<HashMap<String, String>>,
1338        cx: AsyncApp,
1339    ) -> BoxFuture<'_, Result<RemoteCommandOutput>> {
1340        let working_directory = self.working_directory();
1341        let executor = cx.background_executor().clone();
1342        async move {
1343            let mut command = new_smol_command("git");
1344            command
1345                .envs(env.iter())
1346                .current_dir(&working_directory?)
1347                .args(["pull"])
1348                .arg(remote_name)
1349                .arg(branch_name)
1350                .stdout(smol::process::Stdio::piped())
1351                .stderr(smol::process::Stdio::piped());
1352
1353            run_git_command(env, ask_pass, command, &executor).await
1354        }
1355        .boxed()
1356    }
1357
1358    fn fetch(
1359        &self,
1360        fetch_options: FetchOptions,
1361        ask_pass: AskPassDelegate,
1362        env: Arc<HashMap<String, String>>,
1363        cx: AsyncApp,
1364    ) -> BoxFuture<'_, Result<RemoteCommandOutput>> {
1365        let working_directory = self.working_directory();
1366        let remote_name = format!("{}", fetch_options);
1367        let executor = cx.background_executor().clone();
1368        async move {
1369            let mut command = new_smol_command("git");
1370            command
1371                .envs(env.iter())
1372                .current_dir(&working_directory?)
1373                .args(["fetch", &remote_name])
1374                .stdout(smol::process::Stdio::piped())
1375                .stderr(smol::process::Stdio::piped());
1376
1377            run_git_command(env, ask_pass, command, &executor).await
1378        }
1379        .boxed()
1380    }
1381
1382    fn get_remotes(&self, branch_name: Option<String>) -> BoxFuture<'_, Result<Vec<Remote>>> {
1383        let working_directory = self.working_directory();
1384        let git_binary_path = self.git_binary_path.clone();
1385        self.executor
1386            .spawn(async move {
1387                let working_directory = working_directory?;
1388                if let Some(branch_name) = branch_name {
1389                    let output = new_smol_command(&git_binary_path)
1390                        .current_dir(&working_directory)
1391                        .args(["config", "--get"])
1392                        .arg(format!("branch.{}.remote", branch_name))
1393                        .output()
1394                        .await?;
1395
1396                    if output.status.success() {
1397                        let remote_name = String::from_utf8_lossy(&output.stdout);
1398
1399                        return Ok(vec![Remote {
1400                            name: remote_name.trim().to_string().into(),
1401                        }]);
1402                    }
1403                }
1404
1405                let output = new_smol_command(&git_binary_path)
1406                    .current_dir(&working_directory)
1407                    .args(["remote"])
1408                    .output()
1409                    .await?;
1410
1411                anyhow::ensure!(
1412                    output.status.success(),
1413                    "Failed to get remotes:\n{}",
1414                    String::from_utf8_lossy(&output.stderr)
1415                );
1416                let remote_names = String::from_utf8_lossy(&output.stdout)
1417                    .split('\n')
1418                    .filter(|name| !name.is_empty())
1419                    .map(|name| Remote {
1420                        name: name.trim().to_string().into(),
1421                    })
1422                    .collect();
1423                Ok(remote_names)
1424            })
1425            .boxed()
1426    }
1427
1428    fn check_for_pushed_commit(&self) -> BoxFuture<'_, Result<Vec<SharedString>>> {
1429        let working_directory = self.working_directory();
1430        let git_binary_path = self.git_binary_path.clone();
1431        self.executor
1432            .spawn(async move {
1433                let working_directory = working_directory?;
1434                let git_cmd = async |args: &[&str]| -> Result<String> {
1435                    let output = new_smol_command(&git_binary_path)
1436                        .current_dir(&working_directory)
1437                        .args(args)
1438                        .output()
1439                        .await?;
1440                    anyhow::ensure!(
1441                        output.status.success(),
1442                        String::from_utf8_lossy(&output.stderr).to_string()
1443                    );
1444                    Ok(String::from_utf8(output.stdout)?)
1445                };
1446
1447                let head = git_cmd(&["rev-parse", "HEAD"])
1448                    .await
1449                    .context("Failed to get HEAD")?
1450                    .trim()
1451                    .to_owned();
1452
1453                let mut remote_branches = vec![];
1454                let mut add_if_matching = async |remote_head: &str| {
1455                    if let Ok(merge_base) = git_cmd(&["merge-base", &head, remote_head]).await
1456                        && merge_base.trim() == head
1457                        && let Some(s) = remote_head.strip_prefix("refs/remotes/")
1458                    {
1459                        remote_branches.push(s.to_owned().into());
1460                    }
1461                };
1462
1463                // check the main branch of each remote
1464                let remotes = git_cmd(&["remote"])
1465                    .await
1466                    .context("Failed to get remotes")?;
1467                for remote in remotes.lines() {
1468                    if let Ok(remote_head) =
1469                        git_cmd(&["symbolic-ref", &format!("refs/remotes/{remote}/HEAD")]).await
1470                    {
1471                        add_if_matching(remote_head.trim()).await;
1472                    }
1473                }
1474
1475                // ... and the remote branch that the checked-out one is tracking
1476                if let Ok(remote_head) =
1477                    git_cmd(&["rev-parse", "--symbolic-full-name", "@{u}"]).await
1478                {
1479                    add_if_matching(remote_head.trim()).await;
1480                }
1481
1482                Ok(remote_branches)
1483            })
1484            .boxed()
1485    }
1486
1487    fn checkpoint(&self) -> BoxFuture<'static, Result<GitRepositoryCheckpoint>> {
1488        let working_directory = self.working_directory();
1489        let git_binary_path = self.git_binary_path.clone();
1490        let executor = self.executor.clone();
1491        self.executor
1492            .spawn(async move {
1493                let working_directory = working_directory?;
1494                let mut git = GitBinary::new(git_binary_path, working_directory.clone(), executor)
1495                    .envs(checkpoint_author_envs());
1496                git.with_temp_index(|git| {
1497                    async move {
1498                        let head_sha = git.run(&["rev-parse", "HEAD"]).await.ok();
1499                        let mut excludes = exclude_files(git).await?;
1500
1501                        git.run(&["add", "--all"]).await?;
1502                        let tree = git.run(&["write-tree"]).await?;
1503                        let checkpoint_sha = if let Some(head_sha) = head_sha.as_deref() {
1504                            git.run(&["commit-tree", &tree, "-p", head_sha, "-m", "Checkpoint"])
1505                                .await?
1506                        } else {
1507                            git.run(&["commit-tree", &tree, "-m", "Checkpoint"]).await?
1508                        };
1509
1510                        excludes.restore_original().await?;
1511
1512                        Ok(GitRepositoryCheckpoint {
1513                            commit_sha: checkpoint_sha.parse()?,
1514                        })
1515                    }
1516                    .boxed()
1517                })
1518                .await
1519            })
1520            .boxed()
1521    }
1522
1523    fn restore_checkpoint(&self, checkpoint: GitRepositoryCheckpoint) -> BoxFuture<'_, Result<()>> {
1524        let working_directory = self.working_directory();
1525        let git_binary_path = self.git_binary_path.clone();
1526
1527        let executor = self.executor.clone();
1528        self.executor
1529            .spawn(async move {
1530                let working_directory = working_directory?;
1531
1532                let git = GitBinary::new(git_binary_path, working_directory, executor);
1533                git.run(&[
1534                    "restore",
1535                    "--source",
1536                    &checkpoint.commit_sha.to_string(),
1537                    "--worktree",
1538                    ".",
1539                ])
1540                .await?;
1541
1542                // TODO: We don't track binary and large files anymore,
1543                //       so the following call would delete them.
1544                //       Implement an alternative way to track files added by agent.
1545                //
1546                // git.with_temp_index(async move |git| {
1547                //     git.run(&["read-tree", &checkpoint.commit_sha.to_string()])
1548                //         .await?;
1549                //     git.run(&["clean", "-d", "--force"]).await
1550                // })
1551                // .await?;
1552
1553                Ok(())
1554            })
1555            .boxed()
1556    }
1557
1558    fn compare_checkpoints(
1559        &self,
1560        left: GitRepositoryCheckpoint,
1561        right: GitRepositoryCheckpoint,
1562    ) -> BoxFuture<'_, Result<bool>> {
1563        let working_directory = self.working_directory();
1564        let git_binary_path = self.git_binary_path.clone();
1565
1566        let executor = self.executor.clone();
1567        self.executor
1568            .spawn(async move {
1569                let working_directory = working_directory?;
1570                let git = GitBinary::new(git_binary_path, working_directory, executor);
1571                let result = git
1572                    .run(&[
1573                        "diff-tree",
1574                        "--quiet",
1575                        &left.commit_sha.to_string(),
1576                        &right.commit_sha.to_string(),
1577                    ])
1578                    .await;
1579                match result {
1580                    Ok(_) => Ok(true),
1581                    Err(error) => {
1582                        if let Some(GitBinaryCommandError { status, .. }) =
1583                            error.downcast_ref::<GitBinaryCommandError>()
1584                            && status.code() == Some(1)
1585                        {
1586                            return Ok(false);
1587                        }
1588
1589                        Err(error)
1590                    }
1591                }
1592            })
1593            .boxed()
1594    }
1595
1596    fn diff_checkpoints(
1597        &self,
1598        base_checkpoint: GitRepositoryCheckpoint,
1599        target_checkpoint: GitRepositoryCheckpoint,
1600    ) -> BoxFuture<'_, Result<String>> {
1601        let working_directory = self.working_directory();
1602        let git_binary_path = self.git_binary_path.clone();
1603
1604        let executor = self.executor.clone();
1605        self.executor
1606            .spawn(async move {
1607                let working_directory = working_directory?;
1608                let git = GitBinary::new(git_binary_path, working_directory, executor);
1609                git.run(&[
1610                    "diff",
1611                    "--find-renames",
1612                    "--patch",
1613                    &base_checkpoint.commit_sha.to_string(),
1614                    &target_checkpoint.commit_sha.to_string(),
1615                ])
1616                .await
1617            })
1618            .boxed()
1619    }
1620
1621    fn default_branch(&self) -> BoxFuture<'_, Result<Option<SharedString>>> {
1622        let working_directory = self.working_directory();
1623        let git_binary_path = self.git_binary_path.clone();
1624
1625        let executor = self.executor.clone();
1626        self.executor
1627            .spawn(async move {
1628                let working_directory = working_directory?;
1629                let git = GitBinary::new(git_binary_path, working_directory, executor);
1630
1631                if let Ok(output) = git
1632                    .run(&["symbolic-ref", "refs/remotes/upstream/HEAD"])
1633                    .await
1634                {
1635                    let output = output
1636                        .strip_prefix("refs/remotes/upstream/")
1637                        .map(|s| SharedString::from(s.to_owned()));
1638                    return Ok(output);
1639                }
1640
1641                let output = git
1642                    .run(&["symbolic-ref", "refs/remotes/origin/HEAD"])
1643                    .await?;
1644
1645                Ok(output
1646                    .strip_prefix("refs/remotes/origin/")
1647                    .map(|s| SharedString::from(s.to_owned())))
1648            })
1649            .boxed()
1650    }
1651}
1652
1653fn git_status_args(path_prefixes: &[RepoPath]) -> Vec<OsString> {
1654    let mut args = vec![
1655        OsString::from("--no-optional-locks"),
1656        OsString::from("status"),
1657        OsString::from("--porcelain=v1"),
1658        OsString::from("--untracked-files=all"),
1659        OsString::from("--no-renames"),
1660        OsString::from("-z"),
1661    ];
1662    args.extend(path_prefixes.iter().map(|path_prefix| {
1663        if path_prefix.0.as_ref() == Path::new("") {
1664            Path::new(".").into()
1665        } else {
1666            path_prefix.as_os_str().into()
1667        }
1668    }));
1669    args
1670}
1671
1672/// Temporarily git-ignore commonly ignored files and files over 2MB
1673async fn exclude_files(git: &GitBinary) -> Result<GitExcludeOverride> {
1674    const MAX_SIZE: u64 = 2 * 1024 * 1024; // 2 MB
1675    let mut excludes = git.with_exclude_overrides().await?;
1676    excludes
1677        .add_excludes(include_str!("./checkpoint.gitignore"))
1678        .await?;
1679
1680    let working_directory = git.working_directory.clone();
1681    let untracked_files = git.list_untracked_files().await?;
1682    let excluded_paths = untracked_files.into_iter().map(|path| {
1683        let working_directory = working_directory.clone();
1684        smol::spawn(async move {
1685            let full_path = working_directory.join(path.clone());
1686            match smol::fs::metadata(&full_path).await {
1687                Ok(metadata) if metadata.is_file() && metadata.len() >= MAX_SIZE => {
1688                    Some(PathBuf::from("/").join(path.clone()))
1689                }
1690                _ => None,
1691            }
1692        })
1693    });
1694
1695    let excluded_paths = futures::future::join_all(excluded_paths).await;
1696    let excluded_paths = excluded_paths.into_iter().flatten().collect::<Vec<_>>();
1697
1698    if !excluded_paths.is_empty() {
1699        let exclude_patterns = excluded_paths
1700            .into_iter()
1701            .map(|path| path.to_string_lossy().to_string())
1702            .collect::<Vec<_>>()
1703            .join("\n");
1704        excludes.add_excludes(&exclude_patterns).await?;
1705    }
1706
1707    Ok(excludes)
1708}
1709
1710struct GitBinary {
1711    git_binary_path: PathBuf,
1712    working_directory: PathBuf,
1713    executor: BackgroundExecutor,
1714    index_file_path: Option<PathBuf>,
1715    envs: HashMap<String, String>,
1716}
1717
1718impl GitBinary {
1719    /// Resolves the actual .git directory path, handling both regular repos and worktrees.
1720    /// In regular repos, .git is a directory.
1721    /// In worktrees, .git is a file containing the path to the actual git directory.
1722    async fn resolve_git_dir(&self) -> Result<PathBuf> {
1723        let dot_git = self.working_directory.join(".git");
1724
1725        if dot_git.is_dir() {
1726            // Regular repository - .git is a directory
1727            Ok(dot_git)
1728        } else if dot_git.is_file() {
1729            // Worktree - .git is a file containing the path to the actual git directory
1730            let contents = smol::fs::read_to_string(&dot_git).await?;
1731
1732            // The file contains a line like: "gitdir: /path/to/actual/.git/worktrees/name"
1733            if let Some(gitdir_line) = contents.lines().find(|line| line.starts_with("gitdir:")) {
1734                let gitdir_path = gitdir_line.trim_start_matches("gitdir:").trim();
1735
1736                // The path may be relative or absolute
1737                let git_dir = if Path::new(gitdir_path).is_absolute() {
1738                    PathBuf::from(gitdir_path)
1739                } else {
1740                    // Relative path - resolve it relative to the .git file's location
1741                    // (not the working directory)
1742                    dot_git
1743                        .parent()
1744                        .ok_or_else(|| anyhow!(".git file has no parent directory"))?
1745                        .join(gitdir_path)
1746                };
1747
1748                // Canonicalize the path to resolve any .. or . components
1749                let git_dir = smol::fs::canonicalize(&git_dir).await.map_err(|e| {
1750                    anyhow!(
1751                        "Failed to canonicalize git directory path {:?}: {}",
1752                        git_dir,
1753                        e
1754                    )
1755                })?;
1756
1757                if git_dir.exists() {
1758                    Ok(git_dir)
1759                } else {
1760                    Err(anyhow!(
1761                        "Git directory specified in .git file does not exist: {:?}",
1762                        git_dir
1763                    ))
1764                }
1765            } else {
1766                Err(anyhow!(
1767                    ".git file does not contain a valid gitdir reference"
1768                ))
1769            }
1770        } else {
1771            Err(anyhow!(
1772                ".git path does not exist or is neither a file nor directory"
1773            ))
1774        }
1775    }
1776
1777    fn new(
1778        git_binary_path: PathBuf,
1779        working_directory: PathBuf,
1780        executor: BackgroundExecutor,
1781    ) -> Self {
1782        Self {
1783            git_binary_path,
1784            working_directory,
1785            executor,
1786            index_file_path: None,
1787            envs: HashMap::default(),
1788        }
1789    }
1790
1791    async fn list_untracked_files(&self) -> Result<Vec<PathBuf>> {
1792        let status_output = self
1793            .run(&["status", "--porcelain=v1", "--untracked-files=all", "-z"])
1794            .await?;
1795
1796        let paths = status_output
1797            .split('\0')
1798            .filter(|entry| entry.len() >= 3 && entry.starts_with("?? "))
1799            .map(|entry| PathBuf::from(&entry[3..]))
1800            .collect::<Vec<_>>();
1801        Ok(paths)
1802    }
1803
1804    fn envs(mut self, envs: HashMap<String, String>) -> Self {
1805        self.envs = envs;
1806        self
1807    }
1808
1809    pub async fn with_temp_index<R, F>(&mut self, f: F) -> Result<R>
1810    where
1811        F: for<'a> FnOnce(&'a Self) -> BoxFuture<'a, Result<R>>,
1812    {
1813        let index_file_path = self.path_for_index_id(Uuid::new_v4()).await?;
1814
1815        let delete_temp_index = util::defer({
1816            let index_file_path = index_file_path.clone();
1817            let executor = self.executor.clone();
1818            move || {
1819                executor
1820                    .spawn(async move {
1821                        smol::fs::remove_file(index_file_path).await.log_err();
1822                    })
1823                    .detach();
1824            }
1825        });
1826
1827        // Copy the default index file so that Git doesn't have to rebuild the
1828        // whole index from scratch. This might fail if this is an empty repository.
1829        let git_dir = self.resolve_git_dir().await?;
1830        smol::fs::copy(git_dir.join("index"), &index_file_path)
1831            .await
1832            .ok();
1833
1834        self.index_file_path = Some(index_file_path.clone());
1835        let result = f(self).await;
1836        self.index_file_path = None;
1837        let result = result?;
1838
1839        smol::fs::remove_file(index_file_path).await.ok();
1840        delete_temp_index.abort();
1841
1842        Ok(result)
1843    }
1844
1845    pub async fn with_exclude_overrides(&self) -> Result<GitExcludeOverride> {
1846        let git_dir = self.resolve_git_dir().await?;
1847        let path = git_dir.join("info").join("exclude");
1848        GitExcludeOverride::new(path).await
1849    }
1850
1851    async fn path_for_index_id(&self, id: Uuid) -> Result<PathBuf> {
1852        let git_dir = self.resolve_git_dir().await?;
1853        Ok(git_dir.join(format!("index-{}.tmp", id)))
1854    }
1855
1856    pub async fn run<S>(&self, args: impl IntoIterator<Item = S>) -> Result<String>
1857    where
1858        S: AsRef<OsStr>,
1859    {
1860        let mut stdout = self.run_raw(args).await?;
1861        if stdout.chars().last() == Some('\n') {
1862            stdout.pop();
1863        }
1864        Ok(stdout)
1865    }
1866
1867    /// Returns the result of the command without trimming the trailing newline.
1868    pub async fn run_raw<S>(&self, args: impl IntoIterator<Item = S>) -> Result<String>
1869    where
1870        S: AsRef<OsStr>,
1871    {
1872        let mut command = self.build_command(args);
1873        let output = command.output().await?;
1874        anyhow::ensure!(
1875            output.status.success(),
1876            GitBinaryCommandError {
1877                stdout: String::from_utf8_lossy(&output.stdout).to_string(),
1878                status: output.status,
1879            }
1880        );
1881        Ok(String::from_utf8(output.stdout)?)
1882    }
1883
1884    fn build_command<S>(&self, args: impl IntoIterator<Item = S>) -> smol::process::Command
1885    where
1886        S: AsRef<OsStr>,
1887    {
1888        let mut command = new_smol_command(&self.git_binary_path);
1889        command.current_dir(&self.working_directory);
1890        command.args(args);
1891        if let Some(index_file_path) = self.index_file_path.as_ref() {
1892            command.env("GIT_INDEX_FILE", index_file_path);
1893        }
1894        command.envs(&self.envs);
1895        command
1896    }
1897}
1898
1899#[derive(Error, Debug)]
1900#[error("Git command failed: {stdout}")]
1901struct GitBinaryCommandError {
1902    stdout: String,
1903    status: ExitStatus,
1904}
1905
1906async fn run_git_command(
1907    env: Arc<HashMap<String, String>>,
1908    ask_pass: AskPassDelegate,
1909    mut command: smol::process::Command,
1910    executor: &BackgroundExecutor,
1911) -> Result<RemoteCommandOutput> {
1912    if env.contains_key("GIT_ASKPASS") {
1913        let git_process = command.spawn()?;
1914        let output = git_process.output().await?;
1915        anyhow::ensure!(
1916            output.status.success(),
1917            "{}",
1918            String::from_utf8_lossy(&output.stderr)
1919        );
1920        Ok(RemoteCommandOutput {
1921            stdout: String::from_utf8_lossy(&output.stdout).to_string(),
1922            stderr: String::from_utf8_lossy(&output.stderr).to_string(),
1923        })
1924    } else {
1925        let ask_pass = AskPassSession::new(executor, ask_pass).await?;
1926        command
1927            .env("GIT_ASKPASS", ask_pass.script_path())
1928            .env("SSH_ASKPASS", ask_pass.script_path())
1929            .env("SSH_ASKPASS_REQUIRE", "force");
1930        let git_process = command.spawn()?;
1931
1932        run_askpass_command(ask_pass, git_process).await
1933    }
1934}
1935
1936async fn run_askpass_command(
1937    mut ask_pass: AskPassSession,
1938    git_process: smol::process::Child,
1939) -> anyhow::Result<RemoteCommandOutput> {
1940    select_biased! {
1941        result = ask_pass.run().fuse() => {
1942            match result {
1943                AskPassResult::CancelledByUser => {
1944                    Err(anyhow!(REMOTE_CANCELLED_BY_USER))?
1945                }
1946                AskPassResult::Timedout => {
1947                    Err(anyhow!("Connecting to host timed out"))?
1948                }
1949            }
1950        }
1951        output = git_process.output().fuse() => {
1952            let output = output?;
1953            anyhow::ensure!(
1954                output.status.success(),
1955                "{}",
1956                String::from_utf8_lossy(&output.stderr)
1957            );
1958            Ok(RemoteCommandOutput {
1959                stdout: String::from_utf8_lossy(&output.stdout).to_string(),
1960                stderr: String::from_utf8_lossy(&output.stderr).to_string(),
1961            })
1962        }
1963    }
1964}
1965
1966pub static WORK_DIRECTORY_REPO_PATH: LazyLock<RepoPath> =
1967    LazyLock::new(|| RepoPath(Path::new("").into()));
1968
1969#[derive(Clone, Debug, Ord, Hash, PartialOrd, Eq, PartialEq)]
1970pub struct RepoPath(pub Arc<Path>);
1971
1972impl RepoPath {
1973    pub fn new(path: PathBuf) -> Self {
1974        debug_assert!(path.is_relative(), "Repo paths must be relative");
1975
1976        RepoPath(path.into())
1977    }
1978
1979    pub fn from_str(path: &str) -> Self {
1980        let path = Path::new(path);
1981        debug_assert!(path.is_relative(), "Repo paths must be relative");
1982
1983        RepoPath(path.into())
1984    }
1985
1986    pub fn to_unix_style(&self) -> Cow<'_, OsStr> {
1987        #[cfg(target_os = "windows")]
1988        {
1989            use std::ffi::OsString;
1990
1991            let path = self.0.as_os_str().to_string_lossy().replace("\\", "/");
1992            Cow::Owned(OsString::from(path))
1993        }
1994        #[cfg(not(target_os = "windows"))]
1995        {
1996            Cow::Borrowed(self.0.as_os_str())
1997        }
1998    }
1999}
2000
2001impl std::fmt::Display for RepoPath {
2002    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2003        self.0.to_string_lossy().fmt(f)
2004    }
2005}
2006
2007impl From<&Path> for RepoPath {
2008    fn from(value: &Path) -> Self {
2009        RepoPath::new(value.into())
2010    }
2011}
2012
2013impl From<Arc<Path>> for RepoPath {
2014    fn from(value: Arc<Path>) -> Self {
2015        RepoPath(value)
2016    }
2017}
2018
2019impl From<PathBuf> for RepoPath {
2020    fn from(value: PathBuf) -> Self {
2021        RepoPath::new(value)
2022    }
2023}
2024
2025impl From<&str> for RepoPath {
2026    fn from(value: &str) -> Self {
2027        Self::from_str(value)
2028    }
2029}
2030
2031impl Default for RepoPath {
2032    fn default() -> Self {
2033        RepoPath(Path::new("").into())
2034    }
2035}
2036
2037impl AsRef<Path> for RepoPath {
2038    fn as_ref(&self) -> &Path {
2039        self.0.as_ref()
2040    }
2041}
2042
2043impl std::ops::Deref for RepoPath {
2044    type Target = Path;
2045
2046    fn deref(&self) -> &Self::Target {
2047        &self.0
2048    }
2049}
2050
2051impl Borrow<Path> for RepoPath {
2052    fn borrow(&self) -> &Path {
2053        self.0.as_ref()
2054    }
2055}
2056
2057#[derive(Debug)]
2058pub struct RepoPathDescendants<'a>(pub &'a Path);
2059
2060impl MapSeekTarget<RepoPath> for RepoPathDescendants<'_> {
2061    fn cmp_cursor(&self, key: &RepoPath) -> Ordering {
2062        if key.starts_with(self.0) {
2063            Ordering::Greater
2064        } else {
2065            self.0.cmp(key)
2066        }
2067    }
2068}
2069
2070fn parse_branch_input(input: &str) -> Result<Vec<Branch>> {
2071    let mut branches = Vec::new();
2072    for line in input.split('\n') {
2073        if line.is_empty() {
2074            continue;
2075        }
2076        let mut fields = line.split('\x00');
2077        let is_current_branch = fields.next().context("no HEAD")? == "*";
2078        let head_sha: SharedString = fields.next().context("no objectname")?.to_string().into();
2079        let parent_sha: SharedString = fields.next().context("no parent")?.to_string().into();
2080        let ref_name = fields.next().context("no refname")?.to_string().into();
2081        let upstream_name = fields.next().context("no upstream")?.to_string();
2082        let upstream_tracking = parse_upstream_track(fields.next().context("no upstream:track")?)?;
2083        let commiterdate = fields.next().context("no committerdate")?.parse::<i64>()?;
2084        let subject: SharedString = fields
2085            .next()
2086            .context("no contents:subject")?
2087            .to_string()
2088            .into();
2089
2090        branches.push(Branch {
2091            is_head: is_current_branch,
2092            ref_name,
2093            most_recent_commit: Some(CommitSummary {
2094                sha: head_sha,
2095                subject,
2096                commit_timestamp: commiterdate,
2097                has_parent: !parent_sha.is_empty(),
2098            }),
2099            upstream: if upstream_name.is_empty() {
2100                None
2101            } else {
2102                Some(Upstream {
2103                    ref_name: upstream_name.into(),
2104                    tracking: upstream_tracking,
2105                })
2106            },
2107        })
2108    }
2109
2110    Ok(branches)
2111}
2112
2113fn parse_upstream_track(upstream_track: &str) -> Result<UpstreamTracking> {
2114    if upstream_track.is_empty() {
2115        return Ok(UpstreamTracking::Tracked(UpstreamTrackingStatus {
2116            ahead: 0,
2117            behind: 0,
2118        }));
2119    }
2120
2121    let upstream_track = upstream_track.strip_prefix("[").context("missing [")?;
2122    let upstream_track = upstream_track.strip_suffix("]").context("missing [")?;
2123    let mut ahead: u32 = 0;
2124    let mut behind: u32 = 0;
2125    for component in upstream_track.split(", ") {
2126        if component == "gone" {
2127            return Ok(UpstreamTracking::Gone);
2128        }
2129        if let Some(ahead_num) = component.strip_prefix("ahead ") {
2130            ahead = ahead_num.parse::<u32>()?;
2131        }
2132        if let Some(behind_num) = component.strip_prefix("behind ") {
2133            behind = behind_num.parse::<u32>()?;
2134        }
2135    }
2136    Ok(UpstreamTracking::Tracked(UpstreamTrackingStatus {
2137        ahead,
2138        behind,
2139    }))
2140}
2141
2142fn check_path_to_repo_path_errors(relative_file_path: &Path) -> Result<()> {
2143    match relative_file_path.components().next() {
2144        None => anyhow::bail!("repo path should not be empty"),
2145        Some(Component::Prefix(_)) => anyhow::bail!(
2146            "repo path `{}` should be relative, not a windows prefix",
2147            relative_file_path.to_string_lossy()
2148        ),
2149        Some(Component::RootDir) => {
2150            anyhow::bail!(
2151                "repo path `{}` should be relative",
2152                relative_file_path.to_string_lossy()
2153            )
2154        }
2155        Some(Component::CurDir) => {
2156            anyhow::bail!(
2157                "repo path `{}` should not start with `.`",
2158                relative_file_path.to_string_lossy()
2159            )
2160        }
2161        Some(Component::ParentDir) => {
2162            anyhow::bail!(
2163                "repo path `{}` should not start with `..`",
2164                relative_file_path.to_string_lossy()
2165            )
2166        }
2167        _ => Ok(()),
2168    }
2169}
2170
2171fn checkpoint_author_envs() -> HashMap<String, String> {
2172    HashMap::from_iter([
2173        ("GIT_AUTHOR_NAME".to_string(), "Zed".to_string()),
2174        ("GIT_AUTHOR_EMAIL".to_string(), "hi@zed.dev".to_string()),
2175        ("GIT_COMMITTER_NAME".to_string(), "Zed".to_string()),
2176        ("GIT_COMMITTER_EMAIL".to_string(), "hi@zed.dev".to_string()),
2177    ])
2178}
2179
2180#[cfg(test)]
2181mod tests {
2182    use super::*;
2183    use gpui::TestAppContext;
2184
2185    #[gpui::test]
2186    async fn test_checkpoint_basic(cx: &mut TestAppContext) {
2187        cx.executor().allow_parking();
2188
2189        let repo_dir = tempfile::tempdir().unwrap();
2190
2191        git2::Repository::init(repo_dir.path()).unwrap();
2192        let file_path = repo_dir.path().join("file");
2193        smol::fs::write(&file_path, "initial").await.unwrap();
2194
2195        let repo =
2196            RealGitRepository::new(&repo_dir.path().join(".git"), None, cx.executor()).unwrap();
2197        repo.stage_paths(
2198            vec![RepoPath::from_str("file")],
2199            Arc::new(HashMap::default()),
2200        )
2201        .await
2202        .unwrap();
2203        repo.commit(
2204            "Initial commit".into(),
2205            None,
2206            CommitOptions::default(),
2207            Arc::new(checkpoint_author_envs()),
2208        )
2209        .await
2210        .unwrap();
2211
2212        smol::fs::write(&file_path, "modified before checkpoint")
2213            .await
2214            .unwrap();
2215        smol::fs::write(repo_dir.path().join("new_file_before_checkpoint"), "1")
2216            .await
2217            .unwrap();
2218        let checkpoint = repo.checkpoint().await.unwrap();
2219
2220        // Ensure the user can't see any branches after creating a checkpoint.
2221        assert_eq!(repo.branches().await.unwrap().len(), 1);
2222
2223        smol::fs::write(&file_path, "modified after checkpoint")
2224            .await
2225            .unwrap();
2226        repo.stage_paths(
2227            vec![RepoPath::from_str("file")],
2228            Arc::new(HashMap::default()),
2229        )
2230        .await
2231        .unwrap();
2232        repo.commit(
2233            "Commit after checkpoint".into(),
2234            None,
2235            CommitOptions::default(),
2236            Arc::new(checkpoint_author_envs()),
2237        )
2238        .await
2239        .unwrap();
2240
2241        smol::fs::remove_file(repo_dir.path().join("new_file_before_checkpoint"))
2242            .await
2243            .unwrap();
2244        smol::fs::write(repo_dir.path().join("new_file_after_checkpoint"), "2")
2245            .await
2246            .unwrap();
2247
2248        // Ensure checkpoint stays alive even after a Git GC.
2249        repo.gc().await.unwrap();
2250        repo.restore_checkpoint(checkpoint.clone()).await.unwrap();
2251
2252        assert_eq!(
2253            smol::fs::read_to_string(&file_path).await.unwrap(),
2254            "modified before checkpoint"
2255        );
2256        assert_eq!(
2257            smol::fs::read_to_string(repo_dir.path().join("new_file_before_checkpoint"))
2258                .await
2259                .unwrap(),
2260            "1"
2261        );
2262        // See TODO above
2263        // assert_eq!(
2264        //     smol::fs::read_to_string(repo_dir.path().join("new_file_after_checkpoint"))
2265        //         .await
2266        //         .ok(),
2267        //     None
2268        // );
2269    }
2270
2271    #[gpui::test]
2272    async fn test_checkpoint_empty_repo(cx: &mut TestAppContext) {
2273        cx.executor().allow_parking();
2274
2275        let repo_dir = tempfile::tempdir().unwrap();
2276        git2::Repository::init(repo_dir.path()).unwrap();
2277        let repo =
2278            RealGitRepository::new(&repo_dir.path().join(".git"), None, cx.executor()).unwrap();
2279
2280        smol::fs::write(repo_dir.path().join("foo"), "foo")
2281            .await
2282            .unwrap();
2283        let checkpoint_sha = repo.checkpoint().await.unwrap();
2284
2285        // Ensure the user can't see any branches after creating a checkpoint.
2286        assert_eq!(repo.branches().await.unwrap().len(), 1);
2287
2288        smol::fs::write(repo_dir.path().join("foo"), "bar")
2289            .await
2290            .unwrap();
2291        smol::fs::write(repo_dir.path().join("baz"), "qux")
2292            .await
2293            .unwrap();
2294        repo.restore_checkpoint(checkpoint_sha).await.unwrap();
2295        assert_eq!(
2296            smol::fs::read_to_string(repo_dir.path().join("foo"))
2297                .await
2298                .unwrap(),
2299            "foo"
2300        );
2301        // See TODOs above
2302        // assert_eq!(
2303        //     smol::fs::read_to_string(repo_dir.path().join("baz"))
2304        //         .await
2305        //         .ok(),
2306        //     None
2307        // );
2308    }
2309
2310    #[gpui::test]
2311    async fn test_compare_checkpoints(cx: &mut TestAppContext) {
2312        cx.executor().allow_parking();
2313
2314        let repo_dir = tempfile::tempdir().unwrap();
2315        git2::Repository::init(repo_dir.path()).unwrap();
2316        let repo =
2317            RealGitRepository::new(&repo_dir.path().join(".git"), None, cx.executor()).unwrap();
2318
2319        smol::fs::write(repo_dir.path().join("file1"), "content1")
2320            .await
2321            .unwrap();
2322        let checkpoint1 = repo.checkpoint().await.unwrap();
2323
2324        smol::fs::write(repo_dir.path().join("file2"), "content2")
2325            .await
2326            .unwrap();
2327        let checkpoint2 = repo.checkpoint().await.unwrap();
2328
2329        assert!(
2330            !repo
2331                .compare_checkpoints(checkpoint1, checkpoint2.clone())
2332                .await
2333                .unwrap()
2334        );
2335
2336        let checkpoint3 = repo.checkpoint().await.unwrap();
2337        assert!(
2338            repo.compare_checkpoints(checkpoint2, checkpoint3)
2339                .await
2340                .unwrap()
2341        );
2342    }
2343
2344    #[gpui::test]
2345    async fn test_checkpoint_exclude_binary_files(cx: &mut TestAppContext) {
2346        cx.executor().allow_parking();
2347
2348        let repo_dir = tempfile::tempdir().unwrap();
2349        let text_path = repo_dir.path().join("main.rs");
2350        let bin_path = repo_dir.path().join("binary.o");
2351
2352        git2::Repository::init(repo_dir.path()).unwrap();
2353
2354        smol::fs::write(&text_path, "fn main() {}").await.unwrap();
2355
2356        smol::fs::write(&bin_path, "some binary file here")
2357            .await
2358            .unwrap();
2359
2360        let repo =
2361            RealGitRepository::new(&repo_dir.path().join(".git"), None, cx.executor()).unwrap();
2362
2363        // initial commit
2364        repo.stage_paths(
2365            vec![RepoPath::from_str("main.rs")],
2366            Arc::new(HashMap::default()),
2367        )
2368        .await
2369        .unwrap();
2370        repo.commit(
2371            "Initial commit".into(),
2372            None,
2373            CommitOptions::default(),
2374            Arc::new(checkpoint_author_envs()),
2375        )
2376        .await
2377        .unwrap();
2378
2379        let checkpoint = repo.checkpoint().await.unwrap();
2380
2381        smol::fs::write(&text_path, "fn main() { println!(\"Modified\"); }")
2382            .await
2383            .unwrap();
2384        smol::fs::write(&bin_path, "Modified binary file")
2385            .await
2386            .unwrap();
2387
2388        repo.restore_checkpoint(checkpoint).await.unwrap();
2389
2390        // Text files should be restored to checkpoint state,
2391        // but binaries should not (they aren't tracked)
2392        assert_eq!(
2393            smol::fs::read_to_string(&text_path).await.unwrap(),
2394            "fn main() {}"
2395        );
2396
2397        assert_eq!(
2398            smol::fs::read_to_string(&bin_path).await.unwrap(),
2399            "Modified binary file"
2400        );
2401    }
2402
2403    #[test]
2404    fn test_branches_parsing() {
2405        // suppress "help: octal escapes are not supported, `\0` is always null"
2406        #[allow(clippy::octal_escapes)]
2407        let input = "*\0060964da10574cd9bf06463a53bf6e0769c5c45e\0\0refs/heads/zed-patches\0refs/remotes/origin/zed-patches\0\01733187470\0generated protobuf\n";
2408        assert_eq!(
2409            parse_branch_input(input).unwrap(),
2410            vec![Branch {
2411                is_head: true,
2412                ref_name: "refs/heads/zed-patches".into(),
2413                upstream: Some(Upstream {
2414                    ref_name: "refs/remotes/origin/zed-patches".into(),
2415                    tracking: UpstreamTracking::Tracked(UpstreamTrackingStatus {
2416                        ahead: 0,
2417                        behind: 0
2418                    })
2419                }),
2420                most_recent_commit: Some(CommitSummary {
2421                    sha: "060964da10574cd9bf06463a53bf6e0769c5c45e".into(),
2422                    subject: "generated protobuf".into(),
2423                    commit_timestamp: 1733187470,
2424                    has_parent: false,
2425                })
2426            }]
2427        )
2428    }
2429
2430    #[gpui::test]
2431    async fn test_checkpoint_with_worktree(cx: &mut TestAppContext) {
2432        cx.executor().allow_parking();
2433
2434        // Create main repository
2435        let main_repo_dir = tempfile::tempdir().unwrap();
2436        let main_repo_path = main_repo_dir.path();
2437
2438        git2::Repository::init(main_repo_path).unwrap();
2439
2440        // Create initial commit in main repo
2441        let file_path = main_repo_path.join("test.txt");
2442        smol::fs::write(&file_path, "initial content")
2443            .await
2444            .unwrap();
2445
2446        let git_repo = git2::Repository::open(main_repo_path).unwrap();
2447        let mut index = git_repo.index().unwrap();
2448        index.add_path(std::path::Path::new("test.txt")).unwrap();
2449        index.write().unwrap();
2450
2451        let tree_id = index.write_tree().unwrap();
2452        let tree = git_repo.find_tree(tree_id).unwrap();
2453        let sig = git2::Signature::now("Test", "test@example.com").unwrap();
2454        let commit = git_repo
2455            .commit(Some("HEAD"), &sig, &sig, "Initial commit", &tree, &[])
2456            .unwrap();
2457
2458        // Create a branch for the worktree
2459        let commit_obj = git_repo.find_commit(commit).unwrap();
2460        git_repo
2461            .branch("worktree-branch", &commit_obj, false)
2462            .unwrap();
2463
2464        // Create a worktree subdirectory in the main repo's parent
2465        let worktree_name = format!("worktree-{}", uuid::Uuid::new_v4());
2466        let worktree_path = main_repo_dir.path().parent().unwrap().join(&worktree_name);
2467        std::fs::create_dir_all(&worktree_path).unwrap();
2468
2469        // Use git command line to create the worktree (more reliable than git2 API)
2470        let output = std::process::Command::new("git")
2471            .arg("-C")
2472            .arg(main_repo_path)
2473            .arg("worktree")
2474            .arg("add")
2475            .arg(&worktree_path)
2476            .arg("worktree-branch")
2477            .output()
2478            .expect("Failed to execute git worktree add");
2479
2480        if !output.status.success() {
2481            panic!(
2482                "Failed to create worktree: {}",
2483                String::from_utf8_lossy(&output.stderr)
2484            );
2485        }
2486
2487        // Verify that .git is a file in the worktree (not a directory)
2488        let git_file = worktree_path.join(".git");
2489        assert!(git_file.exists(), ".git should exist in worktree");
2490        assert!(
2491            git_file.is_file(),
2492            ".git should be a file in worktree, not a directory"
2493        );
2494
2495        // Try to create a RealGitRepository with the worktree
2496        // This should work but currently fails because .git is a file
2497        let repo = RealGitRepository::new(&git_file, None, cx.executor());
2498
2499        if let Some(repo) = repo {
2500            // Test checkpoint functionality
2501            smol::fs::write(worktree_path.join("new_file.txt"), "new content")
2502                .await
2503                .unwrap();
2504
2505            // This should fail with the current implementation because
2506            // with_exclude_overrides() and path_for_index_id() assume .git is a directory
2507            let checkpoint_result = repo.checkpoint().await;
2508
2509            // In a working implementation, this should succeed
2510            assert!(
2511                checkpoint_result.is_ok(),
2512                "Checkpoint should work in worktrees but currently fails with: {:?}",
2513                checkpoint_result.err()
2514            );
2515
2516            if let Ok(checkpoint) = checkpoint_result {
2517                // Test restore
2518                smol::fs::write(worktree_path.join("another_file.txt"), "more content")
2519                    .await
2520                    .unwrap();
2521
2522                let restore_result = repo.restore_checkpoint(checkpoint).await;
2523                assert!(restore_result.is_ok(), "Restore should work in worktrees");
2524            }
2525        } else {
2526            // This might fail to even create the repository with worktrees
2527            panic!("Failed to create RealGitRepository with worktree - this is part of the bug");
2528        }
2529    }
2530
2531    #[gpui::test]
2532    async fn test_checkpoint_with_regular_repo(cx: &mut TestAppContext) {
2533        cx.executor().allow_parking();
2534
2535        // Create a regular repository (not a worktree)
2536        let repo_dir = tempfile::tempdir().unwrap();
2537        let repo_path = repo_dir.path();
2538
2539        git2::Repository::init(repo_path).unwrap();
2540
2541        // Create initial commit
2542        let file_path = repo_path.join("test.txt");
2543        smol::fs::write(&file_path, "initial content")
2544            .await
2545            .unwrap();
2546
2547        let git_repo = git2::Repository::open(repo_path).unwrap();
2548        let mut index = git_repo.index().unwrap();
2549        index.add_path(std::path::Path::new("test.txt")).unwrap();
2550        index.write().unwrap();
2551
2552        let tree_id = index.write_tree().unwrap();
2553        let tree = git_repo.find_tree(tree_id).unwrap();
2554        let sig = git2::Signature::now("Test", "test@example.com").unwrap();
2555        git_repo
2556            .commit(Some("HEAD"), &sig, &sig, "Initial commit", &tree, &[])
2557            .unwrap();
2558
2559        // Verify that .git is a directory in regular repo
2560        let git_dir = repo_path.join(".git");
2561        assert!(git_dir.exists(), ".git should exist");
2562        assert!(
2563            git_dir.is_dir(),
2564            ".git should be a directory in regular repo"
2565        );
2566
2567        // Create a RealGitRepository with the regular repo
2568        let repo = RealGitRepository::new(&git_dir, None, cx.executor()).unwrap();
2569
2570        // Test checkpoint functionality - this should work fine
2571        smol::fs::write(repo_path.join("new_file.txt"), "new content")
2572            .await
2573            .unwrap();
2574
2575        // The main point is that checkpoint creation succeeds in regular repos
2576        let checkpoint = repo.checkpoint().await;
2577        assert!(
2578            checkpoint.is_ok(),
2579            "Checkpoint should work in regular repos"
2580        );
2581
2582        // Test that we can also restore (even if it doesn't fully restore the worktree state)
2583        if let Ok(checkpoint) = checkpoint {
2584            let restore_result = repo.restore_checkpoint(checkpoint).await;
2585            assert!(
2586                restore_result.is_ok(),
2587                "Restore should not fail in regular repos"
2588            );
2589        }
2590    }
2591
2592    impl RealGitRepository {
2593        /// Force a Git garbage collection on the repository.
2594        fn gc(&self) -> BoxFuture<'_, Result<()>> {
2595            let working_directory = self.working_directory();
2596            let git_binary_path = self.git_binary_path.clone();
2597            let executor = self.executor.clone();
2598            self.executor
2599                .spawn(async move {
2600                    let git_binary_path = git_binary_path.clone();
2601                    let working_directory = working_directory?;
2602                    let git = GitBinary::new(git_binary_path, working_directory, executor);
2603                    git.run(&["gc", "--prune"]).await?;
2604                    Ok(())
2605                })
2606                .boxed()
2607        }
2608    }
2609}