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