Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T04:31:03.219Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

repository.rs

6112 lines · 205.7 KB · rust
1use crate::commit::{CommitDiffObject, CommitDiffObjectKind, parse_git_diff_raw};
2use crate::stash::GitStash;
3use crate::status::{DiffTreeType, GitStatus, TreeDiff};
4use crate::{Oid, RunHook, SHORT_SHA_LENGTH};
5use anyhow::{Context as _, Result, anyhow, bail};
6use async_channel::Sender;
7use collections::HashMap;
8use futures::channel::oneshot;
9use futures::future::BoxFuture;
10use futures::io::BufWriter;
11use futures::{AsyncWriteExt, FutureExt as _, select_biased};
12use gpui::{AppContext as _, AsyncApp, BackgroundExecutor, SharedString, Task};
13use parking_lot::Mutex;
14use rope::Rope;
15use schemars::JsonSchema;
16use serde::Deserialize;
17use smallvec::SmallVec;
18use smol::io::{AsyncBufReadExt, AsyncReadExt, BufReader};
19use text::LineEnding;
20
21use std::borrow::Cow;
22use std::collections::HashSet;
23use std::ffi::{OsStr, OsString};
24use std::sync::atomic::AtomicBool;
25
26use std::process::{ExitStatus, Output};
27use std::str::FromStr;
28use std::time::SystemTime;
29use std::{
30    cmp::Ordering,
31    path::{Path, PathBuf},
32    sync::Arc,
33};
34use sum_tree::MapSeekTarget;
35use thiserror::Error;
36use util::command::{Stdio, new_command};
37use util::paths::PathStyle;
38use util::rel_path::RelPath;
39use util::{ResultExt, paths};
40use uuid::Uuid;
41
42pub use askpass::{AskPassDelegate, AskPassResult, AskPassSession};
43
44pub const REMOTE_CANCELLED_BY_USER: &str = "Operation cancelled by user";
45
46/// Format string used in graph log to get initial data for the git graph
47/// %H - Full commit hash
48/// %P - Parent hashes
49/// %D - Ref names
50/// %x00 - Null byte separator, used to split up commit data
51static GRAPH_COMMIT_FORMAT: &str = "--format=%H%x00%P%x00%D";
52
53/// Used to get commits that match with a search
54/// %H - Full commit hash
55static SEARCH_COMMIT_FORMAT: &str = "--format=%H";
56
57/// Number of commits to load per chunk for the git graph.
58pub const GRAPH_CHUNK_SIZE: usize = 1000;
59
60/// Default value for the `git.worktree_directory` setting.
61pub const DEFAULT_WORKTREE_DIRECTORY: &str = "../worktrees";
62
63/// Given the git common directory (from `commondir()`), derive the original
64/// repository's working directory.
65///
66/// For a standard checkout, `common_dir` is `<work_dir>/.git`, so the parent
67/// is the working directory. For a git worktree, `common_dir` is the **main**
68/// repo's `.git` directory, so the parent is the original repo's working directory.
69///
70/// Returns `None` if `common_dir` doesn't end with `.git` (e.g. bare repos),
71/// because there is no working-tree root to resolve to in that case.
72pub fn original_repo_path_from_common_dir(common_dir: &Path) -> Option<PathBuf> {
73    if common_dir.file_name() == Some(OsStr::new(".git")) {
74        common_dir.parent().map(|p| p.to_path_buf())
75    } else {
76        None
77    }
78}
79
80fn linked_worktree_git_dir(worktree_path: &Path) -> Result<PathBuf> {
81    let dot_git_path = worktree_path.join(".git");
82    let git_file = std::fs::read_to_string(&dot_git_path)
83        .with_context(|| format!("failed to read {}", dot_git_path.display()))?;
84    let git_dir = git_file
85        .strip_prefix("gitdir:")
86        .context("worktree .git file missing gitdir pointer")?
87        .trim();
88    Ok(worktree_path.join(git_dir))
89}
90
91fn normalize_git_metadata_path(path: PathBuf) -> Result<PathBuf> {
92    paths::normalize_lexically(&path)
93        .map_err(|_| anyhow!("git metadata path escapes its filesystem root: {path:?}"))
94}
95
96/// Commit data needed for the git graph visualization.
97#[derive(Debug, Clone)]
98pub struct CommitData {
99    pub sha: Oid,
100    /// Most commits have a single parent, so we use a SmallVec to avoid allocations.
101    pub parents: SmallVec<[Oid; 1]>,
102    pub author_name: SharedString,
103    pub author_email: SharedString,
104    pub commit_timestamp: i64,
105    pub subject: SharedString,
106    pub message: SharedString,
107}
108
109#[derive(Debug)]
110pub struct InitialGraphCommitData {
111    pub sha: Oid,
112    pub parents: SmallVec<[Oid; 1]>,
113    pub ref_names: Vec<SharedString>,
114}
115
116impl InitialGraphCommitData {
117    pub fn tag_names(&self) -> Vec<&str> {
118        self.ref_names
119            .iter()
120            .filter_map(|ref_name| {
121                let tag_name = ref_name.strip_prefix("tag: ")?;
122
123                if tag_name.is_empty() {
124                    return None;
125                }
126
127                Some(tag_name)
128            })
129            .collect()
130    }
131}
132
133struct CommitDataRequest {
134    sha: Oid,
135    response_tx: oneshot::Sender<Result<CommitData>>,
136}
137
138pub struct CommitDataReader {
139    request_tx: async_channel::Sender<CommitDataRequest>,
140    _task: Task<()>,
141}
142
143impl CommitDataReader {
144    pub async fn read(&self, sha: Oid) -> Result<CommitData> {
145        let (response_tx, response_rx) = oneshot::channel();
146        self.request_tx
147            .send(CommitDataRequest { sha, response_tx })
148            .await
149            .map_err(|_| anyhow!("commit data reader task closed"))?;
150        response_rx
151            .await
152            .map_err(|_| anyhow!("commit data reader task dropped response"))?
153    }
154
155    #[cfg(any(test, feature = "test-support"))]
156    pub fn for_test(
157        executor: BackgroundExecutor,
158        resolve: impl 'static + Send + Sync + Fn(Oid) -> Result<CommitData>,
159    ) -> Self {
160        let (request_tx, request_rx) = smol::channel::bounded::<CommitDataRequest>(64);
161        let resolve = Arc::new(resolve);
162        let delay_executor = executor.clone();
163        let task = executor.spawn(async move {
164            while let Ok(CommitDataRequest { sha, response_tx }) = request_rx.recv().await {
165                delay_executor.simulate_random_delay().await;
166                response_tx.send(resolve(sha)).ok();
167            }
168        });
169
170        Self {
171            request_tx,
172            _task: task,
173        }
174    }
175}
176
177fn parse_cat_file_commit(sha: Oid, content: &str) -> Option<CommitData> {
178    let mut parents = SmallVec::new();
179    let mut author_name = SharedString::default();
180    let mut author_email = SharedString::default();
181    let mut commit_timestamp = 0i64;
182    let mut in_headers = true;
183    let mut subject = None;
184    let mut message_lines = Vec::new();
185
186    for line in content.lines() {
187        if in_headers {
188            if line.is_empty() {
189                in_headers = false;
190                continue;
191            }
192
193            if let Some(parent_sha) = line.strip_prefix("parent ") {
194                if let Ok(oid) = Oid::from_str(parent_sha.trim()) {
195                    parents.push(oid);
196                }
197            } else if let Some(author_line) = line.strip_prefix("author ") {
198                if let Some((name_email, _timestamp_tz)) = author_line.rsplit_once(' ') {
199                    if let Some((name_email, timestamp_str)) = name_email.rsplit_once(' ') {
200                        if let Ok(ts) = timestamp_str.parse::<i64>() {
201                            commit_timestamp = ts;
202                        }
203                        if let Some((name, email)) = name_email.rsplit_once(" <") {
204                            author_name = SharedString::from(name.to_string());
205                            author_email =
206                                SharedString::from(email.trim_end_matches('>').to_string());
207                        }
208                    }
209                }
210            }
211        } else {
212            if subject.is_none() {
213                subject = Some(SharedString::from(line.to_string()));
214            }
215            message_lines.push(line);
216        }
217    }
218
219    Some(CommitData {
220        sha,
221        parents,
222        author_name,
223        author_email,
224        commit_timestamp,
225        subject: subject.unwrap_or_default(),
226        message: SharedString::from(message_lines.join("\n")),
227    })
228}
229
230#[derive(Clone, Debug, Hash, PartialEq, Eq)]
231pub struct Branch {
232    pub is_head: bool,
233    pub ref_name: SharedString,
234    pub upstream: Option<Upstream>,
235    pub most_recent_commit: Option<CommitSummary>,
236}
237
238#[derive(Clone, Debug, Default, PartialEq, Eq)]
239pub struct BranchesScanResult {
240    pub branches: Vec<Branch>,
241    pub error: Option<SharedString>,
242}
243
244impl From<Vec<Branch>> for BranchesScanResult {
245    fn from(branches: Vec<Branch>) -> Self {
246        Self {
247            branches,
248            error: None,
249        }
250    }
251}
252
253impl Branch {
254    pub fn name(&self) -> &str {
255        self.ref_name
256            .as_ref()
257            .strip_prefix("refs/heads/")
258            .or_else(|| self.ref_name.as_ref().strip_prefix("refs/remotes/"))
259            .unwrap_or(self.ref_name.as_ref())
260    }
261
262    pub fn is_remote(&self) -> bool {
263        self.ref_name.starts_with("refs/remotes/")
264    }
265
266    pub fn remote_name(&self) -> Option<&str> {
267        self.ref_name
268            .strip_prefix("refs/remotes/")
269            .and_then(|stripped| stripped.split("/").next())
270    }
271
272    pub fn tracking_status(&self) -> Option<UpstreamTrackingStatus> {
273        self.upstream
274            .as_ref()
275            .and_then(|upstream| upstream.tracking.status())
276    }
277
278    pub fn priority_key(&self) -> (bool, Option<i64>) {
279        (
280            self.is_head,
281            self.most_recent_commit
282                .as_ref()
283                .map(|commit| commit.commit_timestamp),
284        )
285    }
286}
287
288#[derive(Clone, Debug, Hash, PartialEq, Eq)]
289pub struct Worktree {
290    pub path: PathBuf,
291    pub ref_name: Option<SharedString>,
292    // todo(git_worktree) This type should be a Oid
293    pub sha: SharedString,
294    pub is_main: bool,
295    pub is_bare: bool,
296}
297
298/// Describes how a new worktree should choose or create its checked-out HEAD.
299#[derive(Clone, Debug, Hash, PartialEq, Eq)]
300pub enum CreateWorktreeTarget {
301    /// Check out an existing local branch in the new worktree.
302    ExistingBranch {
303        /// The existing local branch to check out.
304        branch_name: String,
305    },
306    /// Create a new local branch for the new worktree.
307    NewBranch {
308        /// The new local branch to create and check out.
309        branch_name: String,
310        /// The commit or ref to create the branch from. Uses `HEAD` when `None`.
311        base_sha: Option<String>,
312    },
313    /// Check out a commit or ref in detached HEAD state.
314    Detached {
315        /// The commit or ref to check out. Uses `HEAD` when `None`.
316        base_sha: Option<String>,
317    },
318}
319
320impl CreateWorktreeTarget {
321    pub fn branch_name(&self) -> Option<&str> {
322        match self {
323            Self::ExistingBranch { branch_name } | Self::NewBranch { branch_name, .. } => {
324                Some(branch_name)
325            }
326            Self::Detached { .. } => None,
327        }
328    }
329}
330
331impl Worktree {
332    /// Returns the branch name if the worktree is attached to a branch.
333    pub fn branch_name(&self) -> Option<&str> {
334        self.ref_name.as_ref().map(|ref_name| {
335            ref_name
336                .strip_prefix("refs/heads/")
337                .or_else(|| ref_name.strip_prefix("refs/remotes/"))
338                .unwrap_or(ref_name)
339        })
340    }
341
342    /// Returns a display name for the worktree, suitable for use in the UI.
343    ///
344    /// If the worktree is attached to a branch, returns the branch name.
345    /// Otherwise, returns the short SHA of the worktree's HEAD commit.
346    pub fn display_name(&self) -> &str {
347        self.branch_name()
348            .unwrap_or(&self.sha[..self.sha.len().min(SHORT_SHA_LENGTH)])
349    }
350
351    pub fn directory_name(&self, main_worktree_path: Option<&Path>) -> String {
352        if self.is_main {
353            return "main worktree".to_string();
354        }
355
356        let dir_name = self
357            .path
358            .file_name()
359            .and_then(|name| name.to_str())
360            .unwrap_or(self.display_name());
361
362        if let Some(main_path) = main_worktree_path {
363            let main_dir = main_path.file_name().and_then(|n| n.to_str());
364            if main_dir == Some(dir_name) {
365                if let Some(parent_name) = self
366                    .path
367                    .parent()
368                    .and_then(|p| p.file_name())
369                    .and_then(|n| n.to_str())
370                {
371                    return parent_name.to_string();
372                }
373            }
374        }
375
376        dir_name.to_string()
377    }
378}
379
380pub fn parse_worktrees_from_str<T: AsRef<str>>(
381    raw_worktrees: T,
382    main_worktree_path: Option<&Path>,
383) -> Vec<Worktree> {
384    let mut worktrees = Vec::new();
385    let normalized = raw_worktrees.as_ref().replace("\r\n", "\n");
386    let entries = normalized.split("\n\n");
387    for entry in entries {
388        let mut path = None;
389        let mut sha = None;
390        let mut ref_name = None;
391
392        let mut is_bare = false;
393
394        for line in entry.lines() {
395            let line = line.trim();
396            if line.is_empty() {
397                continue;
398            }
399            if let Some(rest) = line.strip_prefix("worktree ") {
400                path = Some(rest.to_string());
401            } else if let Some(rest) = line.strip_prefix("HEAD ") {
402                sha = Some(rest.to_string());
403            } else if let Some(rest) = line.strip_prefix("branch ") {
404                ref_name = Some(rest.to_string());
405            } else if line == "bare" {
406                is_bare = true;
407            }
408            // Ignore other lines: detached, locked, prunable, etc.
409        }
410
411        if let (Some(path), Some(sha)) = (path, sha) {
412            let path = PathBuf::from(path);
413            let is_main =
414                main_worktree_path.is_some_and(|main_worktree_path| path == main_worktree_path);
415            worktrees.push(Worktree {
416                path,
417                ref_name: ref_name.map(Into::into),
418                sha: sha.into(),
419                is_main,
420                is_bare,
421            });
422        }
423    }
424
425    worktrees
426}
427
428#[derive(Clone, Debug, Hash, PartialEq, Eq)]
429pub struct Upstream {
430    pub ref_name: SharedString,
431    pub tracking: UpstreamTracking,
432}
433
434impl Upstream {
435    pub fn is_remote(&self) -> bool {
436        self.remote_name().is_some()
437    }
438
439    pub fn remote_name(&self) -> Option<&str> {
440        self.ref_name
441            .strip_prefix("refs/remotes/")
442            .and_then(|stripped| stripped.split("/").next())
443    }
444
445    pub fn stripped_ref_name(&self) -> Option<&str> {
446        self.ref_name.strip_prefix("refs/remotes/")
447    }
448
449    pub fn branch_name(&self) -> Option<&str> {
450        self.ref_name
451            .strip_prefix("refs/remotes/")
452            .and_then(|stripped| stripped.split_once('/').map(|(_, name)| name))
453    }
454}
455
456#[derive(Clone, Copy, Default)]
457pub struct CommitOptions {
458    pub amend: bool,
459    pub signoff: bool,
460    pub allow_empty: bool,
461    pub no_verify: bool,
462}
463
464#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
465pub enum UpstreamTracking {
466    /// Remote ref not present in local repository.
467    Gone,
468    /// Remote ref present in local repository (fetched from remote).
469    Tracked(UpstreamTrackingStatus),
470}
471
472impl From<UpstreamTrackingStatus> for UpstreamTracking {
473    fn from(status: UpstreamTrackingStatus) -> Self {
474        UpstreamTracking::Tracked(status)
475    }
476}
477
478impl UpstreamTracking {
479    pub fn is_gone(&self) -> bool {
480        matches!(self, UpstreamTracking::Gone)
481    }
482
483    pub fn status(&self) -> Option<UpstreamTrackingStatus> {
484        match self {
485            UpstreamTracking::Gone => None,
486            UpstreamTracking::Tracked(status) => Some(*status),
487        }
488    }
489}
490
491#[derive(Debug, Clone)]
492pub struct RemoteCommandOutput {
493    pub stdout: String,
494    pub stderr: String,
495}
496
497impl RemoteCommandOutput {
498    pub fn is_empty(&self) -> bool {
499        self.stdout.is_empty() && self.stderr.is_empty()
500    }
501}
502
503#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
504pub struct UpstreamTrackingStatus {
505    pub ahead: u32,
506    pub behind: u32,
507}
508
509#[derive(Clone, Debug, Hash, PartialEq, Eq)]
510pub struct CommitSummary {
511    pub sha: SharedString,
512    pub subject: SharedString,
513    /// This is a unix timestamp
514    pub commit_timestamp: i64,
515    pub author_name: SharedString,
516    pub has_parent: bool,
517}
518
519#[derive(Clone, Debug, Default, Hash, PartialEq, Eq)]
520pub struct CommitDetails {
521    pub sha: SharedString,
522    pub message: SharedString,
523    pub commit_timestamp: i64,
524    pub author_email: SharedString,
525    pub author_name: SharedString,
526}
527
528#[derive(Debug)]
529pub struct CommitDiff {
530    pub files: Vec<CommitFile>,
531}
532
533#[derive(Clone, Debug, Default, Eq, PartialEq)]
534pub struct FileHistoryChangedFileSets {
535    pub file_sets: Vec<Vec<RepoPath>>,
536}
537
538#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
539pub enum CommitFileStatus {
540    Added,
541    Modified,
542    Deleted,
543}
544
545#[derive(Debug)]
546pub struct CommitFile {
547    pub path: RepoPath,
548    pub old_text: Option<String>,
549    pub new_text: Option<String>,
550    pub is_binary: bool,
551}
552
553impl CommitFile {
554    pub fn status(&self) -> CommitFileStatus {
555        match (&self.old_text, &self.new_text) {
556            (None, Some(_)) => CommitFileStatus::Added,
557            (Some(_), None) => CommitFileStatus::Deleted,
558            _ => CommitFileStatus::Modified,
559        }
560    }
561}
562
563impl CommitDetails {
564    pub fn short_sha(&self) -> SharedString {
565        self.sha[..SHORT_SHA_LENGTH].to_string().into()
566    }
567}
568
569/// Detects if content is binary by checking for NUL bytes in the first 8000 bytes.
570/// This matches git's binary detection heuristic.
571pub fn is_binary_content(content: &[u8]) -> bool {
572    let check_len = content.len().min(8000);
573    content[..check_len].contains(&0)
574}
575
576struct LoadedCommitObject {
577    text: String,
578    is_binary: bool,
579}
580
581async fn read_commit_blob<R: smol::io::AsyncBufRead + Unpin>(
582    stdout: &mut R,
583    info_line: &mut String,
584    newline: &mut [u8; 1],
585) -> Result<LoadedCommitObject> {
586    info_line.clear();
587    stdout.read_line(info_line).await?;
588
589    let len = info_line
590        .trim_end()
591        .parse()
592        .with_context(|| format!("invalid object size output from cat-file {info_line}"))?;
593
594    let mut bytes = vec![0; len];
595    stdout.read_exact(&mut bytes).await?;
596    stdout.read_exact(newline).await?;
597
598    let is_binary = is_binary_content(&bytes);
599    Ok(LoadedCommitObject {
600        text: if is_binary {
601            String::new()
602        } else {
603            String::from_utf8_lossy(&bytes).to_string()
604        },
605        is_binary,
606    })
607}
608
609async fn load_commit_object<R: smol::io::AsyncBufRead + Unpin>(
610    object: Option<CommitDiffObject<'_>>,
611    stdout: &mut R,
612    info_line: &mut String,
613    newline: &mut [u8; 1],
614) -> Result<Option<LoadedCommitObject>> {
615    match object {
616        Some(object) if object.kind == CommitDiffObjectKind::Gitlink => {
617            Ok(Some(LoadedCommitObject {
618                text: format!("Subproject commit {}\n", object.oid),
619                is_binary: false,
620            }))
621        }
622        Some(_) => Ok(Some(read_commit_blob(stdout, info_line, newline).await?)),
623        None => Ok(None),
624    }
625}
626
627#[derive(Debug, Clone, Hash, PartialEq, Eq)]
628pub struct Remote {
629    pub name: SharedString,
630}
631
632pub enum ResetMode {
633    /// Reset the branch pointer, leave index and worktree unchanged (this will make it look like things that were
634    /// committed are now staged).
635    Soft,
636    /// Reset the branch pointer and index, leave worktree unchanged (this makes it look as though things that were
637    /// committed are now unstaged).
638    Mixed,
639}
640
641#[derive(Debug, Clone, Hash, PartialEq, Eq)]
642pub enum FetchOptions {
643    All,
644    Remote(Remote),
645}
646
647impl FetchOptions {
648    pub fn to_proto(&self) -> Option<String> {
649        match self {
650            FetchOptions::All => None,
651            FetchOptions::Remote(remote) => Some(remote.clone().name.into()),
652        }
653    }
654
655    pub fn from_proto(remote_name: Option<String>) -> Self {
656        match remote_name {
657            Some(name) => FetchOptions::Remote(Remote { name: name.into() }),
658            None => FetchOptions::All,
659        }
660    }
661
662    pub fn name(&self) -> SharedString {
663        match self {
664            Self::All => "Fetch all remotes".into(),
665            Self::Remote(remote) => remote.name.clone(),
666        }
667    }
668}
669
670impl std::fmt::Display for FetchOptions {
671    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
672        match self {
673            FetchOptions::All => write!(f, "--all"),
674            FetchOptions::Remote(remote) => write!(f, "{}", remote.name),
675        }
676    }
677}
678
679/// Modifies .git/info/exclude temporarily
680pub struct GitExcludeOverride {
681    git_exclude_path: PathBuf,
682    original_excludes: Option<String>,
683    added_excludes: Option<String>,
684}
685
686impl GitExcludeOverride {
687    const START_BLOCK_MARKER: &str = "\n\n#  ====== Auto-added by Omega: =======\n";
688    const END_BLOCK_MARKER: &str = "\n#  ====== End of auto-added by Omega =======\n";
689    /// The markers Omega wrote into `.git/info/exclude` before the prose rename.
690    ///
691    /// A marker is not only a label: it is how the block is found again and
692    /// removed. Renaming it without reading the old one back would strand an
693    /// inherited-marker block in the user's repository, still excluding files
694    /// from git with nothing left that knows how to clean it up.
695    const LEGACY_BLOCK_MARKERS: &[(&str, &str)] = &[(
696        "\n\n#  ====== Auto-added by Zed: =======\n",
697        "\n#  ====== End of auto-added by Zed =======\n",
698    )];
699
700    pub async fn new(git_exclude_path: PathBuf) -> Result<Self> {
701        let original_excludes =
702            smol::fs::read_to_string(&git_exclude_path)
703                .await
704                .ok()
705                .map(|content| {
706                    // Auto-generated lines are normally cleaned up in
707                    // `restore_original()` or `drop()`, but may stuck in rare cases.
708                    // Make sure to remove them.
709                    Self::remove_auto_generated_block(&content)
710                });
711
712        Ok(GitExcludeOverride {
713            git_exclude_path,
714            original_excludes,
715            added_excludes: None,
716        })
717    }
718
719    pub async fn add_excludes(&mut self, excludes: &str) -> Result<()> {
720        self.added_excludes = Some(if let Some(ref already_added) = self.added_excludes {
721            format!("{already_added}\n{excludes}")
722        } else {
723            excludes.to_string()
724        });
725
726        let mut content = self.original_excludes.clone().unwrap_or_default();
727
728        content.push_str(Self::START_BLOCK_MARKER);
729        content.push_str(self.added_excludes.as_ref().unwrap());
730        content.push_str(Self::END_BLOCK_MARKER);
731
732        smol::fs::write(&self.git_exclude_path, content).await?;
733        Ok(())
734    }
735
736    pub async fn restore_original(&mut self) -> Result<()> {
737        if let Some(ref original) = self.original_excludes {
738            smol::fs::write(&self.git_exclude_path, original).await?;
739        } else if self.git_exclude_path.exists() {
740            smol::fs::remove_file(&self.git_exclude_path).await?;
741        }
742
743        self.added_excludes = None;
744
745        Ok(())
746    }
747
748    fn remove_auto_generated_block(content: &str) -> String {
749        let mut content = content.to_string();
750
751        for (start_marker, end_marker) in std::iter::once(&(
752            Self::START_BLOCK_MARKER,
753            Self::END_BLOCK_MARKER,
754        ))
755        .chain(Self::LEGACY_BLOCK_MARKERS)
756        {
757            let start_index = content.find(start_marker);
758            let end_index = content.rfind(end_marker);
759
760            if let (Some(start), Some(end)) = (start_index, end_index) {
761                if end > start {
762                    content.replace_range(start..end + end_marker.len(), "");
763                }
764            }
765
766            // Older versions didn't have end-of-block markers, so it's
767            // impossible to determine auto-generated lines. Conservatively
768            // remove the standard list of excludes.
769            let standard_excludes =
770                format!("{}{}", start_marker, include_str!("./checkpoint.gitignore"));
771            content = content.replace(&standard_excludes, "");
772        }
773
774        content
775    }
776}
777
778impl Drop for GitExcludeOverride {
779    fn drop(&mut self) {
780        if self.added_excludes.is_some() {
781            let git_exclude_path = self.git_exclude_path.clone();
782            let original_excludes = self.original_excludes.clone();
783            smol::spawn(async move {
784                if let Some(original) = original_excludes {
785                    smol::fs::write(&git_exclude_path, original).await
786                } else {
787                    smol::fs::remove_file(&git_exclude_path).await
788                }
789            })
790            .detach();
791        }
792    }
793}
794
795#[derive(Clone, Debug, Default, PartialEq, Eq, Hash, Copy)]
796pub enum LogOrder {
797    #[default]
798    DateOrder,
799    TopoOrder,
800    AuthorDateOrder,
801    ReverseChronological,
802}
803
804impl LogOrder {
805    pub fn as_arg(&self) -> &'static str {
806        match self {
807            LogOrder::DateOrder => "--date-order",
808            LogOrder::TopoOrder => "--topo-order",
809            LogOrder::AuthorDateOrder => "--author-date-order",
810            LogOrder::ReverseChronological => "--reverse",
811        }
812    }
813}
814
815#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)]
816pub enum LogSource {
817    #[default]
818    All,
819    Branch(SharedString),
820    Sha(Oid),
821    Path(RepoPath),
822}
823
824impl LogSource {
825    fn get_args(&self) -> Vec<Cow<'_, str>> {
826        match self {
827            LogSource::All => vec![
828                Cow::Borrowed("--ignore-missing"), // needed in case of unborn HEAD
829                Cow::Borrowed("--branches"),
830                Cow::Borrowed("--remotes"),
831                Cow::Borrowed("--tags"),
832                Cow::Borrowed("HEAD"),
833            ],
834            LogSource::Branch(branch) => vec![Cow::Borrowed(branch.as_str())],
835            LogSource::Sha(oid) => vec![Cow::Owned(oid.to_string())],
836            LogSource::Path(path) => vec![
837                Cow::Borrowed("--follow"),
838                Cow::Borrowed("--"),
839                Cow::Borrowed(path.as_unix_str()),
840            ],
841        }
842    }
843}
844
845pub struct SearchCommitArgs {
846    pub query: SharedString,
847    pub case_sensitive: bool,
848}
849
850pub fn commit_hash_search_query(query: &str) -> Option<&str> {
851    let query = query.trim();
852    (7..=40)
853        .contains(&query.len())
854        .then_some(query)
855        .filter(|query| query.bytes().all(|byte| byte.is_ascii_hexdigit()))
856}
857
858pub fn delete_branch_flag(is_remote_tracking_ref: bool, force: bool) -> &'static str {
859    match (is_remote_tracking_ref, force) {
860        (true, true) => "-Dr",
861        (true, false) => "-dr",
862        (false, true) => "-D",
863        (false, false) => "-d",
864    }
865}
866
867pub trait GitRepository: Send + Sync {
868    /// Returns the contents of an entry in the repository's index, or None if there is no entry for the given path.
869    ///
870    /// Also returns `None` for symlinks.
871    fn load_index_text(&self, path: RepoPath) -> BoxFuture<'_, Option<String>> {
872        let future = self.load_revisions(vec![format!(":{}", path.as_unix_str())]);
873        async move { future.await.ok()?.pop()? }.boxed()
874    }
875
876    /// 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.
877    ///
878    /// Also returns `None` for symlinks.
879    fn load_committed_text(&self, path: RepoPath) -> BoxFuture<'_, Option<String>> {
880        let future = self.load_revisions(vec![format!("HEAD:{}", path.as_unix_str())]);
881        async move { future.await.ok()?.pop()? }.boxed()
882    }
883    fn load_blob_content(&self, oid: Oid) -> BoxFuture<'_, Result<String>>;
884
885    fn set_index_text(
886        &self,
887        path: RepoPath,
888        content: Option<String>,
889        env: Arc<HashMap<String, String>>,
890        is_executable: bool,
891    ) -> BoxFuture<'_, anyhow::Result<()>>;
892
893    /// Returns the URL of the remote with the given name.
894    fn remote_url(&self, name: &str) -> BoxFuture<'_, Option<String>> {
895        let name = name.to_string();
896        let fut = self.remote_urls();
897        async move { fut.await.remove(&name) }.boxed()
898    }
899
900    /// Returns the URL of all remotes.
901    fn remote_urls(&self) -> BoxFuture<'_, HashMap<String, String>>;
902
903    /// Resolve a list of refs to SHAs.
904    fn revparse_batch(&self, revs: Vec<String>) -> BoxFuture<'_, Result<Vec<Option<String>>>>;
905
906    fn load_revisions(&self, revisions: Vec<String>) -> BoxFuture<'_, Result<Vec<Option<String>>>>;
907
908    fn head_sha(&self) -> BoxFuture<'_, Option<String>> {
909        async move {
910            self.revparse_batch(vec!["HEAD".into()])
911                .await
912                .unwrap_or_default()
913                .into_iter()
914                .next()
915                .flatten()
916        }
917        .boxed()
918    }
919
920    fn merge_message(&self) -> BoxFuture<'_, Option<String>>;
921
922    fn status(&self, path_prefixes: &[RepoPath]) -> Task<Result<GitStatus>>;
923    fn diff_tree(&self, request: DiffTreeType) -> BoxFuture<'_, Result<TreeDiff>>;
924
925    fn stash_entries(&self) -> BoxFuture<'static, Result<GitStash>>;
926
927    fn check_access(&self) -> BoxFuture<'_, Result<()>> {
928        async move { Ok(()) }.boxed()
929    }
930
931    fn branches(&self) -> BoxFuture<'_, Result<BranchesScanResult>>;
932
933    fn change_branch(&self, name: String) -> BoxFuture<'_, Result<()>>;
934    fn create_branch(&self, name: String, base_branch: Option<String>)
935    -> BoxFuture<'_, Result<()>>;
936    fn rename_branch(&self, branch: String, new_name: String) -> BoxFuture<'_, Result<()>>;
937
938    fn delete_branch(
939        &self,
940        is_remote: bool,
941        name: String,
942        force: bool,
943    ) -> BoxFuture<'_, Result<()>>;
944
945    fn worktrees(&self) -> BoxFuture<'_, Result<Vec<Worktree>>>;
946
947    /// Returns the creation time of a linked worktree's git metadata
948    /// directory (`.git/worktrees/<name>/`), resolved via the worktree's
949    /// `.git` file.
950    ///
951    /// The metadata directory is created by `git worktree add` and removed
952    /// by `git worktree remove`, so its creation time identifies a
953    /// particular incarnation of the worktree: if the worktree is removed
954    /// and recreated at the same path, the creation time changes.
955    ///
956    /// Returns `Ok(None)` when the worktree directory does not exist at
957    /// all, and an error when the directory exists but the time cannot be
958    /// determined (e.g. on filesystems without birthtime support); callers
959    /// should fail safe in the error case.
960    fn worktree_created_at(
961        &self,
962        worktree_path: PathBuf,
963    ) -> BoxFuture<'_, Result<Option<SystemTime>>>;
964
965    fn create_worktree(
966        &self,
967        target: CreateWorktreeTarget,
968        path: PathBuf,
969    ) -> BoxFuture<'_, Result<()>>;
970
971    fn checkout_branch_in_worktree(
972        &self,
973        branch_name: String,
974        worktree_path: PathBuf,
975        create: bool,
976    ) -> BoxFuture<'_, Result<()>>;
977
978    fn remove_worktree(&self, path: PathBuf, force: bool) -> BoxFuture<'_, Result<()>>;
979
980    fn rename_worktree(&self, old_path: PathBuf, new_path: PathBuf) -> BoxFuture<'_, Result<()>>;
981
982    fn reset(
983        &self,
984        commit: String,
985        mode: ResetMode,
986        env: Arc<HashMap<String, String>>,
987    ) -> BoxFuture<'_, Result<()>>;
988
989    fn checkout_files(
990        &self,
991        commit: String,
992        paths: Vec<RepoPath>,
993        env: Arc<HashMap<String, String>>,
994    ) -> BoxFuture<'_, Result<()>>;
995
996    fn show(&self, commit: String) -> BoxFuture<'_, Result<CommitDetails>>;
997
998    fn load_commit(&self, commit: String, cx: AsyncApp) -> BoxFuture<'_, Result<CommitDiff>>;
999    fn blame(
1000        &self,
1001        path: RepoPath,
1002        content: Rope,
1003        line_ending: LineEnding,
1004    ) -> BoxFuture<'_, Result<crate::blame::Blame>>;
1005
1006    /// Returns the absolute path to the repository. For worktrees, this will be the path to the
1007    /// worktree's gitdir within the main repository (typically `.git/worktrees/<name>`).
1008    fn path(&self) -> PathBuf;
1009
1010    fn main_repository_path(&self) -> PathBuf;
1011
1012    /// Updates the index to match the worktree at the given paths.
1013    ///
1014    /// If any of the paths have been deleted from the worktree, they will be removed from the index if found there.
1015    fn stage_paths(
1016        &self,
1017        paths: Vec<RepoPath>,
1018        env: Arc<HashMap<String, String>>,
1019    ) -> BoxFuture<'_, Result<()>>;
1020    /// Updates the index to match HEAD at the given paths.
1021    ///
1022    /// If any of the paths were previously staged but do not exist in HEAD, they will be removed from the index.
1023    fn unstage_paths(
1024        &self,
1025        paths: Vec<RepoPath>,
1026        env: Arc<HashMap<String, String>>,
1027    ) -> BoxFuture<'_, Result<()>>;
1028
1029    /// Only used to serve `proto::RunGitHook` requests from older remote clients;
1030    /// new code lets `git commit` run hooks itself.
1031    ///
1032    /// TODO: remove together with `proto::RunGitHook` (see the deprecation note in git.proto).
1033    fn run_hook(
1034        &self,
1035        hook: RunHook,
1036        env: Arc<HashMap<String, String>>,
1037    ) -> BoxFuture<'_, Result<()>>;
1038
1039    fn commit(
1040        &self,
1041        message: SharedString,
1042        name_and_email: Option<(SharedString, SharedString)>,
1043        options: CommitOptions,
1044        askpass: AskPassDelegate,
1045        env: Arc<HashMap<String, String>>,
1046    ) -> BoxFuture<'_, Result<()>>;
1047
1048    fn stash_paths(
1049        &self,
1050        paths: Vec<RepoPath>,
1051        env: Arc<HashMap<String, String>>,
1052    ) -> BoxFuture<'_, Result<()>>;
1053
1054    fn stash_pop(
1055        &self,
1056        index: Option<usize>,
1057        env: Arc<HashMap<String, String>>,
1058    ) -> BoxFuture<'_, Result<()>>;
1059
1060    fn stash_apply(
1061        &self,
1062        index: Option<usize>,
1063        env: Arc<HashMap<String, String>>,
1064    ) -> BoxFuture<'_, Result<()>>;
1065
1066    fn stash_drop(
1067        &self,
1068        index: Option<usize>,
1069        env: Arc<HashMap<String, String>>,
1070    ) -> BoxFuture<'_, Result<()>>;
1071
1072    fn push(
1073        &self,
1074        branch_name: String,
1075        remote_branch_name: String,
1076        upstream_name: String,
1077        options: Option<PushOptions>,
1078        askpass: AskPassDelegate,
1079        env: Arc<HashMap<String, String>>,
1080        // This method takes an AsyncApp to ensure it's invoked on the main thread,
1081        // otherwise git-credentials-manager won't work.
1082        cx: AsyncApp,
1083    ) -> BoxFuture<'_, Result<RemoteCommandOutput>>;
1084
1085    fn pull(
1086        &self,
1087        branch_name: Option<String>,
1088        upstream_name: String,
1089        rebase: bool,
1090        askpass: AskPassDelegate,
1091        env: Arc<HashMap<String, String>>,
1092        // This method takes an AsyncApp to ensure it's invoked on the main thread,
1093        // otherwise git-credentials-manager won't work.
1094        cx: AsyncApp,
1095    ) -> BoxFuture<'_, Result<RemoteCommandOutput>>;
1096
1097    fn fetch(
1098        &self,
1099        fetch_options: FetchOptions,
1100        askpass: AskPassDelegate,
1101        env: Arc<HashMap<String, String>>,
1102        // This method takes an AsyncApp to ensure it's invoked on the main thread,
1103        // otherwise git-credentials-manager won't work.
1104        cx: AsyncApp,
1105    ) -> BoxFuture<'_, Result<RemoteCommandOutput>>;
1106
1107    fn get_push_remote(&self, branch: String) -> BoxFuture<'_, Result<Option<Remote>>>;
1108
1109    fn get_branch_remote(&self, branch: String) -> BoxFuture<'_, Result<Option<Remote>>>;
1110
1111    fn get_all_remotes(&self) -> BoxFuture<'_, Result<Vec<Remote>>>;
1112
1113    fn remove_remote(&self, name: String) -> BoxFuture<'_, Result<()>>;
1114
1115    fn create_remote(&self, name: String, url: String) -> BoxFuture<'_, Result<()>>;
1116
1117    /// returns a list of remote branches that contain HEAD
1118    fn check_for_pushed_commit(&self) -> BoxFuture<'_, Result<Vec<SharedString>>>;
1119
1120    /// Run git diff
1121    fn diff(&self, diff: DiffType) -> BoxFuture<'_, Result<String>>;
1122
1123    fn diff_stat(
1124        &self,
1125        diff: DiffStatType,
1126        path_prefixes: &[RepoPath],
1127    ) -> BoxFuture<'static, Result<crate::status::GitDiffStat>>;
1128
1129    /// Creates a checkpoint for the repository.
1130    fn checkpoint(&self) -> BoxFuture<'static, Result<GitRepositoryCheckpoint>>;
1131
1132    /// Resets to a previously-created checkpoint.
1133    fn restore_checkpoint(&self, checkpoint: GitRepositoryCheckpoint) -> BoxFuture<'_, Result<()>>;
1134
1135    /// Creates two detached commits capturing the current staged and unstaged
1136    /// state without moving any branch. Returns (staged_sha, unstaged_sha).
1137    fn create_archive_checkpoint(&self) -> BoxFuture<'_, Result<(String, String)>>;
1138
1139    /// Restores the working directory and index from archive checkpoint SHAs.
1140    /// Assumes HEAD is already at the correct commit (original_commit_hash).
1141    /// Restores the index to match staged_sha's tree, and the working
1142    /// directory to match unstaged_sha's tree.
1143    fn restore_archive_checkpoint(
1144        &self,
1145        staged_sha: String,
1146        unstaged_sha: String,
1147    ) -> BoxFuture<'_, Result<()>>;
1148
1149    /// Compares two checkpoints, returning true if they are equal
1150    fn compare_checkpoints(
1151        &self,
1152        left: GitRepositoryCheckpoint,
1153        right: GitRepositoryCheckpoint,
1154    ) -> BoxFuture<'_, Result<bool>>;
1155
1156    /// Computes a diff between two checkpoints.
1157    fn diff_checkpoints(
1158        &self,
1159        base_checkpoint: GitRepositoryCheckpoint,
1160        target_checkpoint: GitRepositoryCheckpoint,
1161    ) -> BoxFuture<'_, Result<String>>;
1162
1163    fn load_commit_template(&self) -> BoxFuture<'_, Result<Option<GitCommitTemplate>>>;
1164
1165    fn default_branch(
1166        &self,
1167        include_remote_name: bool,
1168    ) -> BoxFuture<'_, Result<Option<SharedString>>>;
1169
1170    /// Runs `git rev-list --parents` to get the commit graph structure.
1171    /// Returns commit SHAs and their parent SHAs for building the graph visualization.
1172    fn initial_graph_data(
1173        &self,
1174        log_source: LogSource,
1175        log_order: LogOrder,
1176        request_tx: Sender<Vec<Arc<InitialGraphCommitData>>>,
1177    ) -> BoxFuture<'_, Result<()>>;
1178
1179    fn search_commits(
1180        &self,
1181        log_source: LogSource,
1182        search_args: SearchCommitArgs,
1183        request_tx: Sender<Oid>,
1184    ) -> BoxFuture<'_, Result<()>>;
1185
1186    fn file_history_changed_files(
1187        &self,
1188        paths: Vec<RepoPath>,
1189        commit_limit: usize,
1190    ) -> BoxFuture<'_, Result<Vec<FileHistoryChangedFileSets>>>;
1191
1192    fn commit_data_reader(&self) -> Result<CommitDataReader>;
1193
1194    fn update_ref(&self, ref_name: String, commit: String) -> BoxFuture<'_, Result<()>>;
1195
1196    fn delete_ref(&self, ref_name: String) -> BoxFuture<'_, Result<()>>;
1197
1198    fn repair_worktrees(&self) -> BoxFuture<'_, Result<()>>;
1199
1200    fn set_trusted(&self, trusted: bool);
1201    fn is_trusted(&self) -> bool;
1202}
1203
1204pub enum DiffType {
1205    HeadToIndex,
1206    HeadToWorktree,
1207    MergeBase { base_ref: SharedString },
1208}
1209
1210#[derive(Clone, Copy)]
1211pub enum DiffStatType {
1212    HeadToIndex,
1213    HeadToWorktree,
1214    IndexToWorktree,
1215}
1216
1217#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, JsonSchema)]
1218pub enum PushOptions {
1219    SetUpstream,
1220    Force,
1221}
1222
1223impl std::fmt::Debug for dyn GitRepository {
1224    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1225        f.debug_struct("dyn GitRepository<...>").finish()
1226    }
1227}
1228
1229pub struct RealGitRepository {
1230    pub git_dir: PathBuf,
1231    pub common_dir: PathBuf,
1232    /// `None` only for bare repositories, which do not have a working directory.
1233    pub working_directory: Option<PathBuf>,
1234    pub system_git_binary_path: Option<PathBuf>,
1235    pub any_git_binary_path: PathBuf,
1236    any_git_binary_help_output: Arc<Mutex<Option<SharedString>>>,
1237    executor: BackgroundExecutor,
1238    is_trusted: Arc<AtomicBool>,
1239}
1240
1241#[derive(Debug)]
1242pub enum RefEdit {
1243    Update { ref_name: String, commit: String },
1244    Delete { ref_name: String },
1245}
1246
1247impl RefEdit {
1248    fn into_args(self) -> Vec<OsString> {
1249        match self {
1250            Self::Update { ref_name, commit } => {
1251                vec!["update-ref".into(), ref_name.into(), commit.into()]
1252            }
1253            Self::Delete { ref_name } => {
1254                vec!["update-ref".into(), "-d".into(), ref_name.into()]
1255            }
1256        }
1257    }
1258}
1259
1260impl RealGitRepository {
1261    pub fn new(
1262        dotgit_path: &Path,
1263        bundled_git_binary_path: Option<PathBuf>,
1264        system_git_binary_path: Option<PathBuf>,
1265        executor: BackgroundExecutor,
1266    ) -> Result<Self> {
1267        let any_git_binary_path = system_git_binary_path
1268            .clone()
1269            .or(bundled_git_binary_path)
1270            .context("no git binary available")?;
1271        log::info!(
1272            "opening git repository at {dotgit_path:?} using git binary {any_git_binary_path:?}"
1273        );
1274        let dotgit_parent = dotgit_path.parent().context(".git has no parent")?;
1275        let has_working_directory =
1276            dotgit_path.is_file() || dotgit_path.file_name() == Some(OsStr::new(".git"));
1277        let working_directory = if has_working_directory {
1278            Some(normalize_git_metadata_path(dotgit_parent.to_path_buf())?)
1279        } else {
1280            None
1281        };
1282
1283        let git_dir = if dotgit_path.is_file() {
1284            let content =
1285                std::fs::read_to_string(dotgit_path).context("reading .git worktree file")?;
1286            let path_str = content
1287                .strip_prefix("gitdir: ")
1288                .context("expected .git file to start with 'gitdir: '")?
1289                .trim();
1290            let resolved = PathBuf::from(path_str);
1291            let resolved = if resolved.is_absolute() {
1292                resolved
1293            } else {
1294                dotgit_parent.join(resolved)
1295            };
1296            normalize_git_metadata_path(resolved)?
1297        } else {
1298            normalize_git_metadata_path(dotgit_path.to_path_buf())?
1299        };
1300
1301        let common_dir = {
1302            let commondir_file = git_dir.join("commondir");
1303            if commondir_file.is_file() {
1304                let content =
1305                    std::fs::read_to_string(&commondir_file).context("reading commondir file")?;
1306                let path_str = content.trim();
1307                let resolved = PathBuf::from(path_str);
1308                let resolved = if resolved.is_absolute() {
1309                    resolved
1310                } else {
1311                    git_dir.join(resolved)
1312                };
1313                normalize_git_metadata_path(resolved)?
1314            } else {
1315                git_dir.clone()
1316            }
1317        };
1318
1319        Ok(Self {
1320            git_dir,
1321            common_dir,
1322            working_directory,
1323            system_git_binary_path,
1324            any_git_binary_path,
1325            executor,
1326            any_git_binary_help_output: Arc::new(Mutex::new(None)),
1327            is_trusted: Arc::new(AtomicBool::new(false)),
1328        })
1329    }
1330
1331    fn working_directory(&self) -> Result<PathBuf> {
1332        self.working_directory
1333            .clone()
1334            .context("bare repositories do not have a working directory")
1335    }
1336
1337    fn command_directory(&self) -> PathBuf {
1338        self.working_directory
1339            .clone()
1340            .unwrap_or_else(|| self.git_dir.clone())
1341    }
1342
1343    fn git_binary_in_worktree(&self) -> Result<GitBinary> {
1344        Ok(GitBinary::new(
1345            self.any_git_binary_path.clone(),
1346            self.working_directory()?,
1347            self.path(),
1348            self.executor.clone(),
1349            self.is_trusted(),
1350        ))
1351    }
1352
1353    fn git_binary(&self) -> GitBinary {
1354        GitBinary::new(
1355            self.any_git_binary_path.clone(),
1356            self.command_directory(),
1357            self.path(),
1358            self.executor.clone(),
1359            self.is_trusted(),
1360        )
1361    }
1362
1363    fn edit_ref(&self, edit: RefEdit) -> BoxFuture<'_, Result<()>> {
1364        let git_binary = self.git_binary();
1365        self.executor
1366            .spawn(async move {
1367                let git = git_binary;
1368                let args = edit.into_args();
1369                git.run(&args).await?;
1370                Ok(())
1371            })
1372            .boxed()
1373    }
1374
1375    async fn any_git_binary_help_output(&self) -> SharedString {
1376        if let Some(output) = self.any_git_binary_help_output.lock().clone() {
1377            return output;
1378        }
1379        let git = self.git_binary();
1380        let output: SharedString = self
1381            .executor
1382            .spawn(async move { git.run(&["help", "-a"]).await })
1383            .await
1384            .unwrap_or_default()
1385            .into();
1386        *self.any_git_binary_help_output.lock() = Some(output.clone());
1387        output
1388    }
1389}
1390
1391#[derive(Clone, Debug)]
1392pub struct GitRepositoryCheckpoint {
1393    pub commit_sha: Oid,
1394}
1395
1396#[derive(Debug)]
1397pub struct GitCommitter {
1398    pub name: Option<String>,
1399    pub email: Option<String>,
1400}
1401
1402#[derive(Clone, Debug)]
1403pub struct GitCommitTemplate {
1404    pub template: String,
1405}
1406
1407pub async fn get_git_committer(cx: &AsyncApp) -> GitCommitter {
1408    if cfg!(any(feature = "test-support", test)) {
1409        return GitCommitter {
1410            name: None,
1411            email: None,
1412        };
1413    }
1414
1415    let git_binary_path =
1416        if cfg!(target_os = "macos") && option_env!("ZED_BUNDLE").as_deref() == Some("true") {
1417            cx.update(|cx| {
1418                cx.path_for_auxiliary_executable("git")
1419                    .context("could not find git binary path")
1420                    .log_err()
1421            })
1422        } else {
1423            None
1424        };
1425
1426    let git = GitBinary::new(
1427        git_binary_path.unwrap_or(PathBuf::from("git")),
1428        paths::home_dir().clone(),
1429        paths::home_dir().join(".git"),
1430        cx.background_executor().clone(),
1431        true,
1432    );
1433
1434    cx.background_spawn(async move {
1435        let name = git
1436            .run(&["config", "--global", "user.name"])
1437            .await
1438            .log_err();
1439        let email = git
1440            .run(&["config", "--global", "user.email"])
1441            .await
1442            .log_err();
1443        GitCommitter { name, email }
1444    })
1445    .await
1446}
1447
1448impl GitRepository for RealGitRepository {
1449    fn path(&self) -> PathBuf {
1450        self.git_dir.clone()
1451    }
1452
1453    fn main_repository_path(&self) -> PathBuf {
1454        self.common_dir.clone()
1455    }
1456
1457    fn show(&self, commit: String) -> BoxFuture<'_, Result<CommitDetails>> {
1458        let git = self.git_binary();
1459        self.executor
1460            .spawn(async move {
1461                let output = git
1462                    .build_command(&[
1463                        "show",
1464                        "--no-patch",
1465                        "--format=%H%x00%B%x00%at%x00%ae%x00%an%x00",
1466                        &commit,
1467                    ])
1468                    .output()
1469                    .await?;
1470                let output = std::str::from_utf8(&output.stdout)?;
1471                let fields = output.split('\0').collect::<Vec<_>>();
1472                if fields.len() != 6 {
1473                    bail!("unexpected git-show output for {commit:?}: {output:?}")
1474                }
1475                let sha = fields[0].to_string().into();
1476                let message = fields[1].to_string().into();
1477                let commit_timestamp = fields[2].parse()?;
1478                let author_email = fields[3].to_string().into();
1479                let author_name = fields[4].to_string().into();
1480                Ok(CommitDetails {
1481                    sha,
1482                    message,
1483                    commit_timestamp,
1484                    author_email,
1485                    author_name,
1486                })
1487            })
1488            .boxed()
1489    }
1490
1491    fn load_commit(&self, commit: String, cx: AsyncApp) -> BoxFuture<'_, Result<CommitDiff>> {
1492        let git = self.git_binary();
1493        cx.background_spawn(async move {
1494            let show_output = git
1495                .build_command(&[
1496                    "show",
1497                    "--format=",
1498                    "-z",
1499                    "--no-renames",
1500                    "--raw",
1501                    "--no-abbrev",
1502                    "--first-parent",
1503                ])
1504                .arg(&commit)
1505                .stdin(Stdio::null())
1506                .stdout(Stdio::piped())
1507                .stderr(Stdio::piped())
1508                .output()
1509                .await
1510                .context("starting git show process")?;
1511            anyhow::ensure!(
1512                show_output.status.success(),
1513                "git show failed: {}",
1514                String::from_utf8_lossy(&show_output.stderr)
1515            );
1516
1517            let show_stdout = String::from_utf8_lossy(&show_output.stdout);
1518            let changes = parse_git_diff_raw(&show_stdout);
1519
1520            let mut cat_file_process = git
1521                .build_command(&["cat-file", "--batch=%(objectsize)"])
1522                .stdin(Stdio::piped())
1523                .stdout(Stdio::piped())
1524                .stderr(Stdio::piped())
1525                .spawn()
1526                .context("starting git cat-file process")?;
1527
1528            let mut files = Vec::<CommitFile>::new();
1529            let stdin = cat_file_process
1530                .stdin
1531                .take()
1532                .context("git cat-file process has no stdin")?;
1533            let stdout = cat_file_process
1534                .stdout
1535                .take()
1536                .context("git cat-file process has no stdout")?;
1537            let mut stdin = BufWriter::with_capacity(512, stdin);
1538            let mut stdout = BufReader::new(stdout);
1539            let mut info_line = String::new();
1540            let mut newline = [b'\0'];
1541            for change in changes {
1542                let change = change?;
1543                let path = change.path;
1544                // git-show outputs `/`-delimited paths even on Windows.
1545                let Some(rel_path) = RelPath::from_unix_str(path).log_err() else {
1546                    continue;
1547                };
1548
1549                let objects = [change.new_object, change.old_object];
1550                let mut has_blobs = false;
1551                for object in objects.iter().flatten() {
1552                    if object.kind == CommitDiffObjectKind::Blob {
1553                        stdin.write_all(object.oid.as_bytes()).await?;
1554                        stdin.write_all(b"\n").await?;
1555                        has_blobs = true;
1556                    }
1557                }
1558                if has_blobs {
1559                    stdin.flush().await?;
1560                }
1561
1562                let [new_object, old_object] = objects;
1563                let new_object =
1564                    load_commit_object(new_object, &mut stdout, &mut info_line, &mut newline)
1565                        .await?;
1566                let old_object =
1567                    load_commit_object(old_object, &mut stdout, &mut info_line, &mut newline)
1568                        .await?;
1569                let is_binary = new_object.as_ref().is_some_and(|object| object.is_binary)
1570                    || old_object.as_ref().is_some_and(|object| object.is_binary);
1571                let new_text = new_object.map(|object| {
1572                    if is_binary {
1573                        String::new()
1574                    } else {
1575                        object.text
1576                    }
1577                });
1578                let old_text = old_object.map(|object| {
1579                    if is_binary {
1580                        String::new()
1581                    } else {
1582                        object.text
1583                    }
1584                });
1585
1586                files.push(CommitFile {
1587                    path: RepoPath(Arc::from(rel_path)),
1588                    old_text,
1589                    new_text,
1590                    is_binary,
1591                })
1592            }
1593
1594            Ok(CommitDiff { files })
1595        })
1596        .boxed()
1597    }
1598
1599    fn reset(
1600        &self,
1601        commit: String,
1602        mode: ResetMode,
1603        env: Arc<HashMap<String, String>>,
1604    ) -> BoxFuture<'_, Result<()>> {
1605        let git = self.git_binary_in_worktree();
1606        async move {
1607            let git = git?;
1608            let mode_flag = match mode {
1609                ResetMode::Mixed => "--mixed",
1610                ResetMode::Soft => "--soft",
1611            };
1612
1613            let output = git
1614                .build_command(&["reset", mode_flag, &commit])
1615                .envs(env.iter())
1616                .output()
1617                .await?;
1618            anyhow::ensure!(
1619                output.status.success(),
1620                "Failed to reset:\n{}",
1621                String::from_utf8_lossy(&output.stderr),
1622            );
1623            Ok(())
1624        }
1625        .boxed()
1626    }
1627
1628    fn checkout_files(
1629        &self,
1630        commit: String,
1631        paths: Vec<RepoPath>,
1632        env: Arc<HashMap<String, String>>,
1633    ) -> BoxFuture<'_, Result<()>> {
1634        let git = self.git_binary_in_worktree();
1635        async move {
1636            let git = git?;
1637            if paths.is_empty() {
1638                return Ok(());
1639            }
1640
1641            let output = git
1642                .build_command(&["checkout", &commit, "--"])
1643                .envs(env.iter())
1644                .args(paths.iter().map(|path| path.as_unix_str()))
1645                .output()
1646                .await?;
1647            anyhow::ensure!(
1648                output.status.success(),
1649                "Failed to checkout files:\n{}",
1650                String::from_utf8_lossy(&output.stderr),
1651            );
1652            Ok(())
1653        }
1654        .boxed()
1655    }
1656
1657    fn load_blob_content(&self, oid: Oid) -> BoxFuture<'_, Result<String>> {
1658        let git_binary = self.git_binary();
1659        let oid_str = oid.to_string();
1660        self.executor
1661            .spawn(async move { git_binary.run_raw(&["cat-file", "blob", &oid_str]).await })
1662            .boxed()
1663    }
1664
1665    fn load_commit_template(&self) -> BoxFuture<'_, Result<Option<GitCommitTemplate>>> {
1666        let working_directory = self.working_directory();
1667        let git_binary = self.git_binary_in_worktree();
1668
1669        self.executor
1670            .spawn(async move {
1671                let working_directory = working_directory?;
1672                let git_binary = git_binary?;
1673                let output = git_binary
1674                    .build_command(&["config", "--get", "commit.template"])
1675                    .output()
1676                    .await
1677                    .context("failed to run git config --get commit.template")?;
1678
1679                let raw_path = String::from_utf8_lossy(&output.stdout).trim().to_string();
1680                if !output.status.success() || raw_path.is_empty() {
1681                    return Ok(None);
1682                }
1683
1684                let path = PathBuf::from(&raw_path);
1685                let path = if let Some(path) = raw_path.strip_prefix("~/") {
1686                    paths::home_dir().join(path)
1687                } else if path.is_relative() {
1688                    working_directory.join(path)
1689                } else {
1690                    path
1691                };
1692
1693                let template = match std::fs::read_to_string(&path) {
1694                    Ok(s) if !s.trim().is_empty() => Some(s),
1695                    Err(err) => {
1696                        log::warn!("failed to read commit template {}: {}", path.display(), err);
1697                        None
1698                    }
1699                    _ => None,
1700                };
1701
1702                Ok(template.map(|template| GitCommitTemplate { template }))
1703            })
1704            .boxed()
1705    }
1706
1707    fn set_index_text(
1708        &self,
1709        path: RepoPath,
1710        content: Option<String>,
1711        env: Arc<HashMap<String, String>>,
1712        is_executable: bool,
1713    ) -> BoxFuture<'_, anyhow::Result<()>> {
1714        let git = self.git_binary();
1715        self.executor
1716            .spawn(async move {
1717                let mode = if is_executable { "100755" } else { "100644" };
1718
1719                if let Some(content) = content {
1720                    let mut child = git
1721                        .build_command(&["hash-object", "-w", "--stdin"])
1722                        .envs(env.iter())
1723                        .stdin(Stdio::piped())
1724                        .stdout(Stdio::piped())
1725                        .spawn()?;
1726                    let mut stdin = child.stdin.take().unwrap();
1727                    stdin.write_all(content.as_bytes()).await?;
1728                    stdin.flush().await?;
1729                    drop(stdin);
1730                    let output = child.output().await?.stdout;
1731                    let sha = str::from_utf8(&output)?.trim();
1732
1733                    log::debug!("indexing SHA: {sha}, path {path:?}");
1734
1735                    let output = git
1736                        .build_command(&["update-index", "--add", "--cacheinfo", mode, sha])
1737                        .envs(env.iter())
1738                        .arg(path.as_unix_str())
1739                        .output()
1740                        .await?;
1741
1742                    anyhow::ensure!(
1743                        output.status.success(),
1744                        "Failed to stage:\n{}",
1745                        String::from_utf8_lossy(&output.stderr)
1746                    );
1747                } else {
1748                    log::debug!("removing path {path:?} from the index");
1749                    let output = git
1750                        .build_command(&["update-index", "--force-remove", "--"])
1751                        .envs(env.iter())
1752                        .arg(path.as_unix_str())
1753                        .output()
1754                        .await?;
1755                    anyhow::ensure!(
1756                        output.status.success(),
1757                        "Failed to unstage:\n{}",
1758                        String::from_utf8_lossy(&output.stderr)
1759                    );
1760                }
1761
1762                Ok(())
1763            })
1764            .boxed()
1765    }
1766
1767    fn remote_urls(&self) -> BoxFuture<'_, HashMap<String, String>> {
1768        let git = self.git_binary();
1769        self.executor
1770            .spawn(async move {
1771                let mut urls = HashMap::default();
1772                if let Ok(stdout) = git.run(&["remote", "-v"]).await {
1773                    for line in stdout.lines() {
1774                        if let Some(line) = line.strip_suffix(" (fetch)")
1775                            && let Some((name, url)) = line.split_once(char::is_whitespace)
1776                        {
1777                            urls.insert(name.to_string(), url.trim_start().to_string());
1778                        }
1779                    }
1780                }
1781                urls
1782            })
1783            .boxed()
1784    }
1785
1786    fn revparse_batch(&self, revs: Vec<String>) -> BoxFuture<'_, Result<Vec<Option<String>>>> {
1787        let git = self.git_binary();
1788        self.executor
1789            .spawn(async move {
1790                let mut process = git
1791                    .build_command(&["cat-file", "--batch-check=%(objectname)"])
1792                    .stdin(Stdio::piped())
1793                    .stdout(Stdio::piped())
1794                    .stderr(Stdio::piped())
1795                    .spawn()?;
1796
1797                let stdin = process
1798                    .stdin
1799                    .take()
1800                    .context("no stdin for git cat-file subprocess")?;
1801                let mut stdin = BufWriter::new(stdin);
1802                for rev in &revs {
1803                    stdin.write_all(rev.as_bytes()).await?;
1804                    stdin.write_all(b"\n").await?;
1805                }
1806                stdin.flush().await?;
1807                drop(stdin);
1808
1809                let output = process.output().await?;
1810                let output = std::str::from_utf8(&output.stdout)?;
1811                let shas = output
1812                    .lines()
1813                    .map(|line| {
1814                        if line.ends_with("missing") {
1815                            None
1816                        } else {
1817                            Some(line.to_string())
1818                        }
1819                    })
1820                    .collect::<Vec<_>>();
1821
1822                if shas.len() != revs.len() {
1823                    // In an octopus merge, git cat-file still only outputs the first sha from MERGE_HEAD.
1824                    bail!("unexpected number of shas")
1825                }
1826
1827                Ok(shas)
1828            })
1829            .boxed()
1830    }
1831
1832    fn load_revisions(&self, revisions: Vec<String>) -> BoxFuture<'_, Result<Vec<Option<String>>>> {
1833        let git = self.git_binary();
1834        self.executor
1835            .spawn(async move {
1836                if revisions.is_empty() {
1837                    return Ok(Vec::new());
1838                }
1839
1840                let mut process = git
1841                    .build_command(&["cat-file", "--batch"])
1842                    .stdin(Stdio::piped())
1843                    .stdout(Stdio::piped())
1844                    .stderr(Stdio::piped())
1845                    .kill_on_drop(true)
1846                    .spawn()?;
1847
1848                let mut stdin = BufWriter::new(process.stdin.take().context("no stdin")?);
1849                let mut stdout = BufReader::new(process.stdout.take().context("no stdout")?);
1850                let mut newline = [0u8; 1];
1851
1852                let mut header_bytes = Vec::new();
1853                let mut results = Vec::with_capacity(revisions.len());
1854                for rev in &revisions {
1855                    stdin.write_all(rev.as_bytes()).await?;
1856                    stdin.write_all(b"\n").await?;
1857                    stdin.flush().await?;
1858
1859                    header_bytes.clear();
1860                    stdout.read_until(b'\n', &mut header_bytes).await?;
1861                    let header_line = String::from_utf8_lossy(&header_bytes);
1862
1863                    let parts: Vec<&str> = header_line.trim().split(' ').collect();
1864                    match parts[..] {
1865                        [.., "missing"] => {
1866                            results.push(None);
1867                        }
1868                        [_, object_type, size_str] => {
1869                            let size: usize = size_str
1870                                .parse()
1871                                .with_context(|| format!("invalid object size: {size_str}"))?;
1872
1873                            let mut content = vec![0u8; size];
1874                            stdout.read_exact(&mut content).await?;
1875                            stdout.read_exact(&mut newline).await?;
1876
1877                            if object_type == "blob" {
1878                                results.push(String::from_utf8(content).ok());
1879                            } else {
1880                                results.push(None);
1881                            }
1882                        }
1883                        _ => bail!("invalid cat-file header: {header_line}"),
1884                    }
1885                }
1886
1887                drop(stdin);
1888                process.output().await?;
1889                Ok(results)
1890            })
1891            .boxed()
1892    }
1893
1894    fn merge_message(&self) -> BoxFuture<'_, Option<String>> {
1895        let path = self.path().join("MERGE_MSG");
1896        self.executor
1897            .spawn(async move { std::fs::read_to_string(&path).ok() })
1898            .boxed()
1899    }
1900
1901    fn status(&self, path_prefixes: &[RepoPath]) -> Task<Result<GitStatus>> {
1902        let git = self.git_binary_in_worktree();
1903        let args = git_status_args(path_prefixes);
1904        log::debug!("Checking for git status in {path_prefixes:?}");
1905        self.executor.spawn(async move {
1906            let git = git?;
1907            let output = git.build_command(&args).output().await?;
1908            if output.status.success() {
1909                let stdout = String::from_utf8_lossy(&output.stdout);
1910                stdout.parse()
1911            } else {
1912                let stderr = String::from_utf8_lossy(&output.stderr);
1913                anyhow::bail!("git status failed: {stderr}");
1914            }
1915        })
1916    }
1917
1918    fn check_access(&self) -> BoxFuture<'_, Result<()>> {
1919        let git = self.git_binary_in_worktree();
1920        self.executor
1921            .spawn(async move {
1922                git?.run(&["rev-parse"]).await?;
1923                Ok(())
1924            })
1925            .boxed()
1926    }
1927
1928    fn diff_tree(&self, request: DiffTreeType) -> BoxFuture<'_, Result<TreeDiff>> {
1929        let git = self.git_binary_in_worktree();
1930
1931        let mut args = vec![
1932            OsString::from("diff-tree"),
1933            OsString::from("-r"),
1934            OsString::from("-z"),
1935            OsString::from("--no-renames"),
1936        ];
1937        match request {
1938            DiffTreeType::MergeBase { base, head } => {
1939                args.push("--merge-base".into());
1940                args.push(OsString::from(base.as_str()));
1941                args.push(OsString::from(head.as_str()));
1942            }
1943            DiffTreeType::Since { base, head } => {
1944                args.push(OsString::from(base.as_str()));
1945                args.push(OsString::from(head.as_str()));
1946            }
1947        }
1948
1949        self.executor
1950            .spawn(async move {
1951                let git = git?;
1952                let output = git.build_command(&args).output().await?;
1953                if output.status.success() {
1954                    let stdout = String::from_utf8_lossy(&output.stdout);
1955                    stdout.parse()
1956                } else {
1957                    let stderr = String::from_utf8_lossy(&output.stderr);
1958                    anyhow::bail!("git status failed: {stderr}");
1959                }
1960            })
1961            .boxed()
1962    }
1963
1964    fn stash_entries(&self) -> BoxFuture<'static, Result<GitStash>> {
1965        let git = self.git_binary_in_worktree();
1966        self.executor
1967            .spawn(async move {
1968                let git = git?;
1969                let output = git
1970                    .build_command(&["stash", "list", "--pretty=format:%gd%x00%H%x00%ct%x00%s"])
1971                    .output()
1972                    .await?;
1973                if output.status.success() {
1974                    let stdout = String::from_utf8_lossy(&output.stdout);
1975                    stdout.parse()
1976                } else {
1977                    let stderr = String::from_utf8_lossy(&output.stderr);
1978                    anyhow::bail!("git status failed: {stderr}");
1979                }
1980            })
1981            .boxed()
1982    }
1983
1984    fn branches(&self) -> BoxFuture<'_, Result<BranchesScanResult>> {
1985        let git = self.git_binary();
1986        self.executor
1987            .spawn(async move {
1988                let fields = [
1989                    "%(HEAD)",
1990                    "%(objectname)",
1991                    "%(parent)",
1992                    "%(refname)",
1993                    "%(upstream)",
1994                    "%(upstream:track)",
1995                    "%(committerdate:unix)",
1996                    "%(authorname)",
1997                    "%(contents:subject)",
1998                ]
1999                .join("%00");
2000                let args = vec![
2001                    "for-each-ref",
2002                    "refs/heads/**/*",
2003                    "refs/remotes/**/*",
2004                    "--format",
2005                    &fields,
2006                ];
2007                let output = git.build_command(&args).output().await?;
2008
2009                let error = if output.status.success() {
2010                    None
2011                } else {
2012                    let error = format_branch_scan_error(&output);
2013                    log::warn!("failed to get git branches with commit metadata: {error}");
2014                    Some(error.into())
2015                };
2016
2017                let input = String::from_utf8_lossy(&output.stdout);
2018                let mut branches = parse_branch_input(&input)?;
2019                if branches.is_empty() {
2020                    let args = vec!["symbolic-ref", "--quiet", "HEAD"];
2021
2022                    let output = git.build_command(&args).output().await?;
2023
2024                    // git symbolic-ref returns a non-0 exit code if HEAD points
2025                    // to something other than a branch
2026                    if output.status.success() {
2027                        let name = String::from_utf8_lossy(&output.stdout).trim().to_string();
2028
2029                        branches.push(Branch {
2030                            ref_name: name.into(),
2031                            is_head: true,
2032                            upstream: None,
2033                            most_recent_commit: None,
2034                        });
2035                    }
2036                }
2037
2038                Ok(BranchesScanResult { branches, error })
2039            })
2040            .boxed()
2041    }
2042
2043    fn worktrees(&self) -> BoxFuture<'_, Result<Vec<Worktree>>> {
2044        let git = self.git_binary();
2045        let main_worktree_path = original_repo_path_from_common_dir(&self.common_dir);
2046        self.executor
2047            .spawn(async move {
2048                let output = git
2049                    .build_command(&["worktree", "list", "--porcelain"])
2050                    .output()
2051                    .await?;
2052                if output.status.success() {
2053                    let stdout = String::from_utf8_lossy(&output.stdout);
2054                    Ok(parse_worktrees_from_str(
2055                        &stdout,
2056                        main_worktree_path.as_deref(),
2057                    ))
2058                } else {
2059                    let stderr = String::from_utf8_lossy(&output.stderr);
2060                    anyhow::bail!("git worktree list failed: {stderr}");
2061                }
2062            })
2063            .boxed()
2064    }
2065
2066    fn worktree_created_at(
2067        &self,
2068        worktree_path: PathBuf,
2069    ) -> BoxFuture<'_, Result<Option<SystemTime>>> {
2070        self.executor
2071            .spawn(async move {
2072                match std::fs::metadata(&worktree_path) {
2073                    Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
2074                        return Ok(None);
2075                    }
2076                    Err(error) => {
2077                        return Err(error).with_context(|| {
2078                            format!("failed to stat {}", worktree_path.display())
2079                        });
2080                    }
2081                    Ok(_) => {}
2082                }
2083                let git_dir = linked_worktree_git_dir(&worktree_path)?;
2084                let metadata = std::fs::metadata(&git_dir)
2085                    .with_context(|| format!("failed to stat {}", git_dir.display()))?;
2086                let created_at = metadata.created().with_context(|| {
2087                    format!("creation time unavailable for {}", git_dir.display())
2088                })?;
2089                Ok(Some(created_at))
2090            })
2091            .boxed()
2092    }
2093
2094    fn create_worktree(
2095        &self,
2096        target: CreateWorktreeTarget,
2097        path: PathBuf,
2098    ) -> BoxFuture<'_, Result<()>> {
2099        let git = self.git_binary();
2100        let mut args = vec![OsString::from("worktree"), OsString::from("add")];
2101
2102        match &target {
2103            CreateWorktreeTarget::ExistingBranch { branch_name } => {
2104                args.push(OsString::from("--"));
2105                args.push(OsString::from(path.as_os_str()));
2106                args.push(OsString::from(branch_name));
2107            }
2108            CreateWorktreeTarget::NewBranch {
2109                branch_name,
2110                base_sha: start_point,
2111            } => {
2112                args.push(OsString::from("-b"));
2113                args.push(OsString::from(branch_name));
2114                args.push(OsString::from("--"));
2115                args.push(OsString::from(path.as_os_str()));
2116                args.push(OsString::from(start_point.as_deref().unwrap_or("HEAD")));
2117            }
2118            CreateWorktreeTarget::Detached {
2119                base_sha: start_point,
2120            } => {
2121                args.push(OsString::from("--detach"));
2122                args.push(OsString::from("--"));
2123                args.push(OsString::from(path.as_os_str()));
2124                args.push(OsString::from(start_point.as_deref().unwrap_or("HEAD")));
2125            }
2126        }
2127
2128        self.executor
2129            .spawn(async move {
2130                std::fs::create_dir_all(path.parent().unwrap_or(&path))?;
2131                let output = git.build_command(&args).output().await?;
2132                if output.status.success() {
2133                    Ok(())
2134                } else {
2135                    let stderr = String::from_utf8_lossy(&output.stderr);
2136                    anyhow::bail!("git worktree add failed: {stderr}");
2137                }
2138            })
2139            .boxed()
2140    }
2141
2142    fn remove_worktree(&self, path: PathBuf, force: bool) -> BoxFuture<'_, Result<()>> {
2143        let git = self.git_binary();
2144
2145        self.executor
2146            .spawn(async move {
2147                let mut args: Vec<OsString> = vec!["worktree".into(), "remove".into()];
2148                if force {
2149                    args.push("--force".into());
2150                }
2151                args.push("--".into());
2152                args.push(path.as_os_str().into());
2153                git.run(&args).await?;
2154                anyhow::Ok(())
2155            })
2156            .boxed()
2157    }
2158
2159    fn rename_worktree(&self, old_path: PathBuf, new_path: PathBuf) -> BoxFuture<'_, Result<()>> {
2160        let git = self.git_binary();
2161
2162        self.executor
2163            .spawn(async move {
2164                let args: Vec<OsString> = vec![
2165                    "worktree".into(),
2166                    "move".into(),
2167                    "--".into(),
2168                    old_path.as_os_str().into(),
2169                    new_path.as_os_str().into(),
2170                ];
2171                git.run(&args).await?;
2172                anyhow::Ok(())
2173            })
2174            .boxed()
2175    }
2176
2177    fn checkout_branch_in_worktree(
2178        &self,
2179        branch_name: String,
2180        worktree_path: PathBuf,
2181        create: bool,
2182    ) -> BoxFuture<'_, Result<()>> {
2183        let git_binary = GitBinary::new(
2184            self.any_git_binary_path.clone(),
2185            worktree_path,
2186            self.path(),
2187            self.executor.clone(),
2188            self.is_trusted(),
2189        );
2190
2191        self.executor
2192            .spawn(async move {
2193                if create {
2194                    git_binary.run(&["checkout", "-b", &branch_name]).await?;
2195                } else {
2196                    git_binary.run(&["checkout", &branch_name]).await?;
2197                }
2198                anyhow::Ok(())
2199            })
2200            .boxed()
2201    }
2202
2203    fn change_branch(&self, name: String) -> BoxFuture<'_, Result<()>> {
2204        let git_binary = self.git_binary_in_worktree();
2205        self.executor
2206            .spawn(async move {
2207                let git_binary = git_binary?;
2208                let local_ref = format!("refs/heads/{name}");
2209                if git_binary
2210                    .run(&["show-ref", "--verify", "--quiet", &local_ref])
2211                    .await
2212                    .is_ok()
2213                {
2214                    git_binary.run(&["checkout", &name]).await?;
2215                    return anyhow::Ok(());
2216                }
2217
2218                let remote_ref = format!("refs/remotes/{name}");
2219                if git_binary
2220                    .run(&["show-ref", "--verify", "--quiet", &remote_ref])
2221                    .await
2222                    .is_ok()
2223                {
2224                    let name = match git_binary.run(&["symbolic-ref", &remote_ref]).await {
2225                        Ok(resolved) => resolved
2226                            .strip_prefix("refs/remotes/")
2227                            .map(str::to_owned)
2228                            .unwrap_or(name),
2229                        Err(_) => name,
2230                    };
2231                    let (_, branch_name) =
2232                        name.split_once('/').context("Unexpected branch format")?;
2233                    let local_branch_ref = format!("refs/heads/{branch_name}");
2234                    if git_binary
2235                        .run(&["show-ref", "--verify", "--quiet", &local_branch_ref])
2236                        .await
2237                        .is_ok()
2238                    {
2239                        git_binary
2240                            .run(&["branch", "--set-upstream-to", &name, branch_name])
2241                            .await?;
2242                    } else {
2243                        git_binary
2244                            .run(&["branch", "--track", branch_name, &name])
2245                            .await?;
2246                    }
2247
2248                    git_binary.run(&["checkout", branch_name]).await?;
2249                    return anyhow::Ok(());
2250                }
2251
2252                anyhow::bail!("Branch '{}' not found", name);
2253            })
2254            .boxed()
2255    }
2256
2257    fn create_branch(
2258        &self,
2259        name: String,
2260        base_branch: Option<String>,
2261    ) -> BoxFuture<'_, Result<()>> {
2262        let git_binary = self.git_binary_in_worktree();
2263
2264        self.executor
2265            .spawn(async move {
2266                let git_binary = git_binary?;
2267                let mut args = vec!["switch", "-c", &name];
2268                let base_branch_str;
2269                if let Some(ref base) = base_branch {
2270                    base_branch_str = base.clone();
2271                    args.push(&base_branch_str);
2272                }
2273
2274                git_binary.run(&args).await?;
2275                anyhow::Ok(())
2276            })
2277            .boxed()
2278    }
2279
2280    fn rename_branch(&self, branch: String, new_name: String) -> BoxFuture<'_, Result<()>> {
2281        let git_binary = self.git_binary_in_worktree();
2282
2283        self.executor
2284            .spawn(async move {
2285                let git_binary = git_binary?;
2286                git_binary
2287                    .run(&["branch", "-m", &branch, &new_name])
2288                    .await?;
2289                anyhow::Ok(())
2290            })
2291            .boxed()
2292    }
2293
2294    fn delete_branch(
2295        &self,
2296        is_remote: bool,
2297        name: String,
2298        force: bool,
2299    ) -> BoxFuture<'_, Result<()>> {
2300        let git_binary = self.git_binary_in_worktree();
2301
2302        self.executor
2303            .spawn(async move {
2304                let git_binary = git_binary?;
2305                let flag = delete_branch_flag(is_remote, force);
2306                git_binary.run(&["branch", flag, &name]).await?;
2307                anyhow::Ok(())
2308            })
2309            .boxed()
2310    }
2311
2312    fn blame(
2313        &self,
2314        path: RepoPath,
2315        content: Rope,
2316        line_ending: LineEnding,
2317    ) -> BoxFuture<'_, Result<crate::blame::Blame>> {
2318        let git = self.git_binary_in_worktree();
2319
2320        self.executor
2321            .spawn(async move {
2322                let git = git?;
2323                crate::blame::Blame::for_path(&git, &path, &content, line_ending).await
2324            })
2325            .boxed()
2326    }
2327
2328    fn diff(&self, diff: DiffType) -> BoxFuture<'_, Result<String>> {
2329        let git = self.git_binary_in_worktree();
2330        self.executor
2331            .spawn(async move {
2332                let git = git?;
2333                let output = match diff {
2334                    DiffType::HeadToIndex => {
2335                        git.build_command(&["diff", "--staged"]).output().await?
2336                    }
2337                    DiffType::HeadToWorktree => git.build_command(&["diff"]).output().await?,
2338                    DiffType::MergeBase { base_ref } => {
2339                        git.build_command(&["diff", "--merge-base", base_ref.as_ref()])
2340                            .output()
2341                            .await?
2342                    }
2343                };
2344
2345                anyhow::ensure!(
2346                    output.status.success(),
2347                    "Failed to run git diff:\n{}",
2348                    String::from_utf8_lossy(&output.stderr)
2349                );
2350                Ok(String::from_utf8_lossy(&output.stdout).to_string())
2351            })
2352            .boxed()
2353    }
2354
2355    fn diff_stat(
2356        &self,
2357        diff: DiffStatType,
2358        path_prefixes: &[RepoPath],
2359    ) -> BoxFuture<'static, Result<crate::status::GitDiffStat>> {
2360        let path_prefixes = path_prefixes.to_vec();
2361        let git_binary = self.git_binary_in_worktree();
2362
2363        self.executor
2364            .spawn(async move {
2365                let git_binary = git_binary?;
2366                let mut args: Vec<String> =
2367                    vec!["diff".into(), "--numstat".into(), "--no-renames".into()];
2368                match diff {
2369                    DiffStatType::HeadToIndex => args.extend(["--cached".into(), "HEAD".into()]),
2370                    DiffStatType::HeadToWorktree => args.push("HEAD".into()),
2371                    DiffStatType::IndexToWorktree => {}
2372                }
2373                if !path_prefixes.is_empty() {
2374                    args.push("--".into());
2375                    args.extend(
2376                        path_prefixes
2377                            .iter()
2378                            .map(|p| p.as_std_path().to_string_lossy().into_owned()),
2379                    );
2380                }
2381                let output = git_binary.run(&args).await?;
2382                Ok(crate::status::parse_numstat(&output))
2383            })
2384            .boxed()
2385    }
2386
2387    fn stage_paths(
2388        &self,
2389        paths: Vec<RepoPath>,
2390        env: Arc<HashMap<String, String>>,
2391    ) -> BoxFuture<'_, Result<()>> {
2392        let git = self.git_binary_in_worktree();
2393        self.executor
2394            .spawn(async move {
2395                let git = git?;
2396                if !paths.is_empty() {
2397                    let output = git
2398                        .build_command(&["update-index", "--add", "--remove", "--"])
2399                        .envs(env.iter())
2400                        .args(paths.iter().map(|p| p.as_unix_str()))
2401                        .output()
2402                        .await?;
2403                    anyhow::ensure!(
2404                        output.status.success(),
2405                        "Failed to stage paths:\n{}",
2406                        String::from_utf8_lossy(&output.stderr),
2407                    );
2408                }
2409                Ok(())
2410            })
2411            .boxed()
2412    }
2413
2414    fn unstage_paths(
2415        &self,
2416        paths: Vec<RepoPath>,
2417        env: Arc<HashMap<String, String>>,
2418    ) -> BoxFuture<'_, Result<()>> {
2419        let git = self.git_binary_in_worktree();
2420
2421        self.executor
2422            .spawn(async move {
2423                let git = git?;
2424                if !paths.is_empty() {
2425                    let output = git
2426                        .build_command(&["reset", "--quiet", "--"])
2427                        .envs(env.iter())
2428                        .args(paths.iter().map(|p| p.as_std_path()))
2429                        .output()
2430                        .await?;
2431
2432                    anyhow::ensure!(
2433                        output.status.success(),
2434                        "Failed to unstage:\n{}",
2435                        String::from_utf8_lossy(&output.stderr),
2436                    );
2437                }
2438                Ok(())
2439            })
2440            .boxed()
2441    }
2442
2443    fn stash_paths(
2444        &self,
2445        paths: Vec<RepoPath>,
2446        env: Arc<HashMap<String, String>>,
2447    ) -> BoxFuture<'_, Result<()>> {
2448        let git = self.git_binary_in_worktree();
2449        self.executor
2450            .spawn(async move {
2451                let git = git?;
2452                let output = git
2453                    .build_command(&["stash", "push", "--quiet", "--include-untracked", "--"])
2454                    .envs(env.iter())
2455                    .args(paths.iter().map(|p| p.as_unix_str()))
2456                    .output()
2457                    .await?;
2458
2459                anyhow::ensure!(
2460                    output.status.success(),
2461                    "Failed to stash:\n{}",
2462                    String::from_utf8_lossy(&output.stderr)
2463                );
2464                Ok(())
2465            })
2466            .boxed()
2467    }
2468
2469    fn stash_pop(
2470        &self,
2471        index: Option<usize>,
2472        env: Arc<HashMap<String, String>>,
2473    ) -> BoxFuture<'_, Result<()>> {
2474        let git = self.git_binary_in_worktree();
2475        self.executor
2476            .spawn(async move {
2477                let git = git?;
2478                let mut args = vec!["stash".to_string(), "pop".to_string()];
2479                if let Some(index) = index {
2480                    args.push(format!("stash@{{{}}}", index));
2481                }
2482                let output = git.build_command(&args).envs(env.iter()).output().await?;
2483
2484                anyhow::ensure!(
2485                    output.status.success(),
2486                    "Failed to stash pop:\n{}",
2487                    String::from_utf8_lossy(&output.stderr)
2488                );
2489                Ok(())
2490            })
2491            .boxed()
2492    }
2493
2494    fn stash_apply(
2495        &self,
2496        index: Option<usize>,
2497        env: Arc<HashMap<String, String>>,
2498    ) -> BoxFuture<'_, Result<()>> {
2499        let git = self.git_binary_in_worktree();
2500        self.executor
2501            .spawn(async move {
2502                let git = git?;
2503                let mut args = vec!["stash".to_string(), "apply".to_string()];
2504                if let Some(index) = index {
2505                    args.push(format!("stash@{{{}}}", index));
2506                }
2507                let output = git.build_command(&args).envs(env.iter()).output().await?;
2508
2509                anyhow::ensure!(
2510                    output.status.success(),
2511                    "Failed to apply stash:\n{}",
2512                    String::from_utf8_lossy(&output.stderr)
2513                );
2514                Ok(())
2515            })
2516            .boxed()
2517    }
2518
2519    fn stash_drop(
2520        &self,
2521        index: Option<usize>,
2522        env: Arc<HashMap<String, String>>,
2523    ) -> BoxFuture<'_, Result<()>> {
2524        let git = self.git_binary_in_worktree();
2525        self.executor
2526            .spawn(async move {
2527                let git = git?;
2528                let mut args = vec!["stash".to_string(), "drop".to_string()];
2529                if let Some(index) = index {
2530                    args.push(format!("stash@{{{}}}", index));
2531                }
2532                let output = git.build_command(&args).envs(env.iter()).output().await?;
2533
2534                anyhow::ensure!(
2535                    output.status.success(),
2536                    "Failed to stash drop:\n{}",
2537                    String::from_utf8_lossy(&output.stderr)
2538                );
2539                Ok(())
2540            })
2541            .boxed()
2542    }
2543
2544    fn commit(
2545        &self,
2546        message: SharedString,
2547        name_and_email: Option<(SharedString, SharedString)>,
2548        options: CommitOptions,
2549        ask_pass: AskPassDelegate,
2550        env: Arc<HashMap<String, String>>,
2551    ) -> BoxFuture<'_, Result<()>> {
2552        let git = self.git_binary_in_worktree();
2553        let executor = self.executor.clone();
2554        // Note: Do not spawn this command on the background thread, it might pop open the credential helper
2555        // which we want to block on.
2556        async move {
2557            let git = git?;
2558            let mut cmd = git.build_command(&["commit", "--quiet", "-m"]);
2559            cmd.envs(env.iter())
2560                .arg(&message.to_string())
2561                .arg("--cleanup=strip")
2562                .stdout(Stdio::piped())
2563                .stderr(Stdio::piped());
2564
2565            if options.amend {
2566                cmd.arg("--amend");
2567            }
2568
2569            if options.signoff {
2570                cmd.arg("--signoff");
2571            }
2572
2573            if options.allow_empty {
2574                cmd.arg("--allow-empty");
2575            }
2576
2577            if options.no_verify {
2578                cmd.arg("--no-verify");
2579            }
2580
2581            if let Some((name, email)) = name_and_email {
2582                cmd.arg("--author").arg(&format!("{name} <{email}>"));
2583            }
2584
2585            run_git_command(env, ask_pass, cmd, executor).await?;
2586
2587            Ok(())
2588        }
2589        .boxed()
2590    }
2591
2592    fn update_ref(&self, ref_name: String, commit: String) -> BoxFuture<'_, Result<()>> {
2593        self.edit_ref(RefEdit::Update { ref_name, commit })
2594    }
2595
2596    fn delete_ref(&self, ref_name: String) -> BoxFuture<'_, Result<()>> {
2597        self.edit_ref(RefEdit::Delete { ref_name })
2598    }
2599
2600    fn repair_worktrees(&self) -> BoxFuture<'_, Result<()>> {
2601        let git = self.git_binary();
2602        self.executor
2603            .spawn(async move {
2604                let args: Vec<OsString> = vec!["worktree".into(), "repair".into()];
2605                git.run(&args).await?;
2606                Ok(())
2607            })
2608            .boxed()
2609    }
2610
2611    fn push(
2612        &self,
2613        branch_name: String,
2614        remote_branch_name: String,
2615        remote_name: String,
2616        options: Option<PushOptions>,
2617        ask_pass: AskPassDelegate,
2618        env: Arc<HashMap<String, String>>,
2619        cx: AsyncApp,
2620    ) -> BoxFuture<'_, Result<RemoteCommandOutput>> {
2621        let working_directory = self.command_directory();
2622        let git_directory = self.path();
2623        let executor = cx.background_executor().clone();
2624        let git_binary_path = self.system_git_binary_path.clone();
2625        let is_trusted = self.is_trusted();
2626        // Note: Do not spawn this command on the background thread, it might pop open the credential helper
2627        // which we want to block on.
2628        async move {
2629            let git_binary_path = git_binary_path.context("git not found on $PATH, can't push")?;
2630            let git = GitBinary::new(
2631                git_binary_path,
2632                working_directory,
2633                git_directory,
2634                executor.clone(),
2635                is_trusted,
2636            );
2637            let mut command = git.build_command(&["push"]);
2638            command
2639                .envs(env.iter())
2640                .args(options.map(|option| match option {
2641                    PushOptions::SetUpstream => "--set-upstream",
2642                    PushOptions::Force => "--force-with-lease",
2643                }))
2644                .arg(remote_name)
2645                .arg(format!("{}:{}", branch_name, remote_branch_name))
2646                .stdin(Stdio::null())
2647                .stdout(Stdio::piped())
2648                .stderr(Stdio::piped());
2649
2650            run_git_command(env, ask_pass, command, executor).await
2651        }
2652        .boxed()
2653    }
2654
2655    fn pull(
2656        &self,
2657        branch_name: Option<String>,
2658        remote_name: String,
2659        rebase: bool,
2660        ask_pass: AskPassDelegate,
2661        env: Arc<HashMap<String, String>>,
2662        cx: AsyncApp,
2663    ) -> BoxFuture<'_, Result<RemoteCommandOutput>> {
2664        let working_directory = self.command_directory();
2665        let git_directory = self.path();
2666        let executor = cx.background_executor().clone();
2667        let git_binary_path = self.system_git_binary_path.clone();
2668        let is_trusted = self.is_trusted();
2669        // Note: Do not spawn this command on the background thread, it might pop open the credential helper
2670        // which we want to block on.
2671        async move {
2672            let git_binary_path = git_binary_path.context("git not found on $PATH, can't pull")?;
2673            let git = GitBinary::new(
2674                git_binary_path,
2675                working_directory,
2676                git_directory,
2677                executor.clone(),
2678                is_trusted,
2679            );
2680            let mut command = git.build_command(&["pull"]);
2681            command.envs(env.iter());
2682
2683            if rebase {
2684                command.arg("--rebase");
2685            }
2686
2687            command
2688                .arg(remote_name)
2689                .args(branch_name)
2690                .stdout(Stdio::piped())
2691                .stderr(Stdio::piped());
2692
2693            run_git_command(env, ask_pass, command, executor).await
2694        }
2695        .boxed()
2696    }
2697
2698    fn fetch(
2699        &self,
2700        fetch_options: FetchOptions,
2701        ask_pass: AskPassDelegate,
2702        env: Arc<HashMap<String, String>>,
2703        cx: AsyncApp,
2704    ) -> BoxFuture<'_, Result<RemoteCommandOutput>> {
2705        let working_directory = self.command_directory();
2706        let git_directory = self.path();
2707        let remote_name = format!("{}", fetch_options);
2708        let git_binary_path = self.system_git_binary_path.clone();
2709        let executor = cx.background_executor().clone();
2710        let is_trusted = self.is_trusted();
2711        // Note: Do not spawn this command on the background thread, it might pop open the credential helper
2712        // which we want to block on.
2713        async move {
2714            let git_binary_path = git_binary_path.context("git not found on $PATH, can't fetch")?;
2715            let git = GitBinary::new(
2716                git_binary_path,
2717                working_directory,
2718                git_directory,
2719                executor.clone(),
2720                is_trusted,
2721            );
2722            let mut command = git.build_command(&["fetch", &remote_name]);
2723            command
2724                .envs(env.iter())
2725                .stdout(Stdio::piped())
2726                .stderr(Stdio::piped());
2727
2728            run_git_command(env, ask_pass, command, executor).await
2729        }
2730        .boxed()
2731    }
2732
2733    fn get_push_remote(&self, branch: String) -> BoxFuture<'_, Result<Option<Remote>>> {
2734        let git = self.git_binary();
2735        self.executor
2736            .spawn(async move {
2737                let output = git
2738                    .build_command(&["rev-parse", "--abbrev-ref"])
2739                    .arg(format!("{branch}@{{push}}"))
2740                    .output()
2741                    .await?;
2742                if !output.status.success() {
2743                    return Ok(None);
2744                }
2745                let remote_name = String::from_utf8_lossy(&output.stdout)
2746                    .split('/')
2747                    .next()
2748                    .map(|name| Remote {
2749                        name: name.trim().to_string().into(),
2750                    });
2751
2752                Ok(remote_name)
2753            })
2754            .boxed()
2755    }
2756
2757    fn get_branch_remote(&self, branch: String) -> BoxFuture<'_, Result<Option<Remote>>> {
2758        let git = self.git_binary();
2759        self.executor
2760            .spawn(async move {
2761                let output = git
2762                    .build_command(&["config", "--get"])
2763                    .arg(format!("branch.{branch}.remote"))
2764                    .output()
2765                    .await?;
2766                if !output.status.success() {
2767                    return Ok(None);
2768                }
2769
2770                let remote_name = String::from_utf8_lossy(&output.stdout);
2771                return Ok(Some(Remote {
2772                    name: remote_name.trim().to_string().into(),
2773                }));
2774            })
2775            .boxed()
2776    }
2777
2778    fn get_all_remotes(&self) -> BoxFuture<'_, Result<Vec<Remote>>> {
2779        let git = self.git_binary();
2780        self.executor
2781            .spawn(async move {
2782                let output = git.build_command(&["remote", "-v"]).output().await?;
2783
2784                anyhow::ensure!(
2785                    output.status.success(),
2786                    "Failed to get all remotes:\n{}",
2787                    String::from_utf8_lossy(&output.stderr)
2788                );
2789                let remote_names: HashSet<Remote> = String::from_utf8_lossy(&output.stdout)
2790                    .lines()
2791                    .filter(|line| !line.is_empty())
2792                    .filter_map(|line| {
2793                        let mut split_line = line.split_whitespace();
2794                        let remote_name = split_line.next()?;
2795
2796                        Some(Remote {
2797                            name: remote_name.trim().to_string().into(),
2798                        })
2799                    })
2800                    .collect();
2801
2802                Ok(remote_names.into_iter().collect())
2803            })
2804            .boxed()
2805    }
2806
2807    fn remove_remote(&self, name: String) -> BoxFuture<'_, Result<()>> {
2808        let git_binary = self.git_binary();
2809        self.executor
2810            .spawn(async move {
2811                git_binary.run(&["remote", "remove", &name]).await?;
2812                Ok(())
2813            })
2814            .boxed()
2815    }
2816
2817    fn create_remote(&self, name: String, url: String) -> BoxFuture<'_, Result<()>> {
2818        let git_binary = self.git_binary();
2819        self.executor
2820            .spawn(async move {
2821                git_binary.run(&["remote", "add", &name, &url]).await?;
2822                Ok(())
2823            })
2824            .boxed()
2825    }
2826
2827    fn check_for_pushed_commit(&self) -> BoxFuture<'_, Result<Vec<SharedString>>> {
2828        let git = self.git_binary_in_worktree();
2829        self.executor
2830            .spawn(async move {
2831                // This command outputs a list of remote tracking refs, e.g.:
2832                // refs/remotes/origin/HEAD
2833                // refs/remotes/origin/main
2834                let Ok(output) = git?
2835                    .run(&[
2836                        "for-each-ref",
2837                        "--format=%(refname)",
2838                        "--contains",
2839                        "HEAD",
2840                        "refs/remotes/",
2841                    ])
2842                    .await
2843                else {
2844                    return Ok(Vec::new());
2845                };
2846
2847                Ok(output
2848                    .lines()
2849                    .map(|line| line.trim())
2850                    .filter(|line| !line.ends_with("/HEAD"))
2851                    .filter_map(|line| line.strip_prefix("refs/remotes/"))
2852                    .map(SharedString::from)
2853                    .collect())
2854            })
2855            .boxed()
2856    }
2857
2858    fn checkpoint(&self) -> BoxFuture<'static, Result<GitRepositoryCheckpoint>> {
2859        let git = self.git_binary_in_worktree();
2860        self.executor
2861            .spawn(async move {
2862                let mut git = git?.envs(checkpoint_author_envs());
2863                git.with_temp_index(async |git| {
2864                    let head_sha = git.run(&["rev-parse", "HEAD"]).await.ok();
2865                    let mut excludes = exclude_files(git).await?;
2866
2867                    git.run(&["add", "--all"]).await?;
2868                    let tree = git.run(&["write-tree"]).await?;
2869                    let checkpoint_sha = if let Some(head_sha) = head_sha.as_deref() {
2870                        git.run(&["commit-tree", &tree, "-p", head_sha, "-m", "Checkpoint"])
2871                            .await?
2872                    } else {
2873                        git.run(&["commit-tree", &tree, "-m", "Checkpoint"]).await?
2874                    };
2875
2876                    excludes.restore_original().await?;
2877
2878                    Ok(GitRepositoryCheckpoint {
2879                        commit_sha: checkpoint_sha.parse()?,
2880                    })
2881                })
2882                .await
2883            })
2884            .boxed()
2885    }
2886
2887    fn restore_checkpoint(&self, checkpoint: GitRepositoryCheckpoint) -> BoxFuture<'_, Result<()>> {
2888        let git = self.git_binary_in_worktree();
2889        self.executor
2890            .spawn(async move {
2891                let git = git?;
2892                git.run(&[
2893                    "restore",
2894                    "--source",
2895                    &checkpoint.commit_sha.to_string(),
2896                    "--worktree",
2897                    ".",
2898                ])
2899                .await?;
2900
2901                // TODO: We don't track binary and large files anymore,
2902                //       so the following call would delete them.
2903                //       Implement an alternative way to track files added by agent.
2904                //
2905                // git.with_temp_index(async move |git| {
2906                //     git.run(&["read-tree", &checkpoint.commit_sha.to_string()])
2907                //         .await?;
2908                //     git.run(&["clean", "-d", "--force"]).await
2909                // })
2910                // .await?;
2911
2912                Ok(())
2913            })
2914            .boxed()
2915    }
2916
2917    fn create_archive_checkpoint(&self) -> BoxFuture<'_, Result<(String, String)>> {
2918        let git = self.git_binary_in_worktree();
2919        self.executor
2920            .spawn(async move {
2921                let mut git = git?.envs(checkpoint_author_envs());
2922                let head_sha = git
2923                    .run(&["rev-parse", "HEAD"])
2924                    .await
2925                    .context("failed to read HEAD")?;
2926
2927                // Capture the staged state: write-tree reads the current index
2928                let staged_tree = git
2929                    .run(&["write-tree"])
2930                    .await
2931                    .context("failed to write staged tree")?;
2932                let staged_sha = git
2933                    .run(&[
2934                        "commit-tree",
2935                        &staged_tree,
2936                        "-p",
2937                        &head_sha,
2938                        "-m",
2939                        "WIP staged",
2940                    ])
2941                    .await
2942                    .context("failed to create staged commit")?;
2943
2944                // Capture the full state (staged + unstaged + untracked) using
2945                // a temporary index so we don't disturb the real one.
2946                let unstaged_sha = git
2947                    .with_temp_index(async |git| {
2948                        git.run(&["add", "--all"]).await?;
2949                        let full_tree = git.run(&["write-tree"]).await?;
2950                        let sha = git
2951                            .run(&[
2952                                "commit-tree",
2953                                &full_tree,
2954                                "-p",
2955                                &staged_sha,
2956                                "-m",
2957                                "WIP unstaged",
2958                            ])
2959                            .await?;
2960                        Ok(sha)
2961                    })
2962                    .await
2963                    .context("failed to create unstaged commit")?;
2964
2965                Ok((staged_sha, unstaged_sha))
2966            })
2967            .boxed()
2968    }
2969
2970    fn restore_archive_checkpoint(
2971        &self,
2972        staged_sha: String,
2973        unstaged_sha: String,
2974    ) -> BoxFuture<'_, Result<()>> {
2975        let git = self.git_binary_in_worktree();
2976        self.executor
2977            .spawn(async move {
2978                let git = git?;
2979                // First, set the index AND working tree to match the unstaged
2980                // tree. --reset -u computes a tree-level diff between the
2981                // current index and unstaged_sha's tree and applies additions,
2982                // modifications, and deletions to the working directory.
2983                git.run(&["read-tree", "--reset", "-u", &unstaged_sha])
2984                    .await
2985                    .context("failed to restore working directory from unstaged commit")?;
2986
2987                // Then replace just the index with the staged tree. Without -u
2988                // this doesn't touch the working directory, so the result is:
2989                // working tree = unstaged state, index = staged state.
2990                git.run(&["read-tree", &staged_sha])
2991                    .await
2992                    .context("failed to restore index from staged commit")?;
2993
2994                Ok(())
2995            })
2996            .boxed()
2997    }
2998
2999    fn compare_checkpoints(
3000        &self,
3001        left: GitRepositoryCheckpoint,
3002        right: GitRepositoryCheckpoint,
3003    ) -> BoxFuture<'_, Result<bool>> {
3004        let git = self.git_binary_in_worktree();
3005        self.executor
3006            .spawn(async move {
3007                let git = git?;
3008                let result = git
3009                    .run(&[
3010                        "diff-tree",
3011                        "--quiet",
3012                        &left.commit_sha.to_string(),
3013                        &right.commit_sha.to_string(),
3014                    ])
3015                    .await;
3016                match result {
3017                    Ok(_) => Ok(true),
3018                    Err(error) => {
3019                        if let Some(GitBinaryCommandError { status, .. }) =
3020                            error.downcast_ref::<GitBinaryCommandError>()
3021                            && status.code() == Some(1)
3022                        {
3023                            return Ok(false);
3024                        }
3025
3026                        Err(error)
3027                    }
3028                }
3029            })
3030            .boxed()
3031    }
3032
3033    fn diff_checkpoints(
3034        &self,
3035        base_checkpoint: GitRepositoryCheckpoint,
3036        target_checkpoint: GitRepositoryCheckpoint,
3037    ) -> BoxFuture<'_, Result<String>> {
3038        let git = self.git_binary_in_worktree();
3039        self.executor
3040            .spawn(async move {
3041                let git = git?;
3042                git.run(&[
3043                    "diff",
3044                    "--find-renames",
3045                    "--patch",
3046                    &base_checkpoint.commit_sha.to_string(),
3047                    &target_checkpoint.commit_sha.to_string(),
3048                ])
3049                .await
3050            })
3051            .boxed()
3052    }
3053
3054    fn default_branch(
3055        &self,
3056        include_remote_name: bool,
3057    ) -> BoxFuture<'_, Result<Option<SharedString>>> {
3058        let git = self.git_binary();
3059        self.executor
3060            .spawn(async move {
3061                let output = git
3062                    .run(&[
3063                        "for-each-ref",
3064                        "--format=%(refname)\t%(symref)",
3065                        "refs/remotes/upstream/HEAD",
3066                        "refs/remotes/origin/HEAD",
3067                        "refs/heads/",
3068                    ])
3069                    .await
3070                    .unwrap_or_default();
3071                let refs: HashMap<&str, &str> = output
3072                    .lines()
3073                    .filter_map(|line| line.split_once('\t'))
3074                    .collect();
3075
3076                if let Some(target) = refs.get("refs/remotes/upstream/HEAD") {
3077                    let strip_prefix = if include_remote_name {
3078                        "refs/remotes/"
3079                    } else {
3080                        "refs/remotes/upstream/"
3081                    };
3082                    if let Some(branch) = target.strip_prefix(strip_prefix) {
3083                        return Ok(Some(branch.into()));
3084                    }
3085                }
3086
3087                if let Some(target) = refs.get("refs/remotes/origin/HEAD") {
3088                    let strip_prefix = if include_remote_name {
3089                        "refs/remotes/"
3090                    } else {
3091                        "refs/remotes/origin/"
3092                    };
3093                    if let Some(branch) = target.strip_prefix(strip_prefix) {
3094                        return Ok(Some(branch.into()));
3095                    }
3096                }
3097
3098                let local_branch_exists =
3099                    |branch: &str| refs.contains_key(format!("refs/heads/{branch}").as_str());
3100
3101                if let Ok(default_branch) = git.run(&["config", "init.defaultBranch"]).await {
3102                    if local_branch_exists(&default_branch) {
3103                        return Ok(Some(default_branch.into()));
3104                    }
3105                }
3106
3107                if local_branch_exists("main") {
3108                    return Ok(Some("main".into()));
3109                }
3110
3111                if local_branch_exists("master") {
3112                    return Ok(Some("master".into()));
3113                }
3114
3115                Ok(None)
3116            })
3117            .boxed()
3118    }
3119
3120    fn run_hook(
3121        &self,
3122        hook: RunHook,
3123        env: Arc<HashMap<String, String>>,
3124    ) -> BoxFuture<'_, Result<()>> {
3125        let git_binary = self.git_binary_in_worktree();
3126        let git_dir = self.git_dir.clone();
3127        let help_output = self.any_git_binary_help_output();
3128
3129        // Note: Do not spawn these commands on the background thread, as this causes some git hooks to hang.
3130        async move {
3131            let git_binary = git_binary?;
3132            let working_directory = git_binary.working_directory.clone();
3133            if !help_output
3134                .await
3135                .lines()
3136                .any(|line| line.trim().starts_with("hook "))
3137            {
3138                let hook_abs_path = git_dir.join("hooks").join(hook.as_str());
3139                if hook_abs_path.is_file() && git_binary.is_trusted {
3140                    #[allow(clippy::disallowed_methods)]
3141                    let output = new_command(&hook_abs_path)
3142                        .envs(env.iter())
3143                        .current_dir(&working_directory)
3144                        .output()
3145                        .await?;
3146
3147                    if !output.status.success() {
3148                        return Err(GitBinaryCommandError {
3149                            stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
3150                            stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
3151                            status: output.status,
3152                        }
3153                        .into());
3154                    }
3155                }
3156
3157                return Ok(());
3158            }
3159
3160            if git_binary.is_trusted {
3161                let git_binary = git_binary.envs(HashMap::clone(&env));
3162                git_binary
3163                    .run(&["hook", "run", "--ignore-missing", hook.as_str()])
3164                    .await?;
3165            }
3166            Ok(())
3167        }
3168        .boxed()
3169    }
3170
3171    fn initial_graph_data(
3172        &self,
3173        log_source: LogSource,
3174        log_order: LogOrder,
3175        request_tx: Sender<Vec<Arc<InitialGraphCommitData>>>,
3176    ) -> BoxFuture<'_, Result<()>> {
3177        let git = self.git_binary();
3178
3179        async move {
3180            let log_source_args = log_source.get_args();
3181            let mut git_log_command = vec!["log", GRAPH_COMMIT_FORMAT, log_order.as_arg()];
3182            git_log_command.extend(log_source_args.iter().map(|arg| arg.as_ref()));
3183            let mut command = git.build_command(&git_log_command);
3184            command.stdout(Stdio::piped());
3185            command.stderr(Stdio::piped());
3186
3187            let mut child = command.spawn()?;
3188            let stdout = child.stdout.take().context("failed to get stdout")?;
3189            let stderr = child.stderr.take().context("failed to get stderr")?;
3190            let mut reader = BufReader::new(stdout);
3191
3192            let mut line_buffer = String::new();
3193            let mut lines: Vec<String> = Vec::with_capacity(GRAPH_CHUNK_SIZE);
3194
3195            loop {
3196                line_buffer.clear();
3197                let bytes_read = reader.read_line(&mut line_buffer).await?;
3198
3199                if bytes_read == 0 {
3200                    if !lines.is_empty() {
3201                        let commits = parse_initial_graph_output(lines.iter().map(|s| s.as_str()));
3202                        if request_tx.send(commits).await.is_err() {
3203                            log::warn!(
3204                                "initial_graph_data: receiver dropped while sending commits"
3205                            );
3206                        }
3207                    }
3208                    break;
3209                }
3210
3211                let line = line_buffer.trim_end_matches('\n').to_string();
3212                lines.push(line);
3213
3214                if lines.len() >= GRAPH_CHUNK_SIZE {
3215                    let commits = parse_initial_graph_output(lines.iter().map(|s| s.as_str()));
3216                    if request_tx.send(commits).await.is_err() {
3217                        log::warn!("initial_graph_data: receiver dropped while streaming commits");
3218                        break;
3219                    }
3220                    lines.clear();
3221                }
3222            }
3223
3224            let status = child.status().await?;
3225            if !status.success() {
3226                let mut stderr_output = String::new();
3227                BufReader::new(stderr)
3228                    .read_to_string(&mut stderr_output)
3229                    .await
3230                    .log_err();
3231
3232                if stderr_output.is_empty() {
3233                    anyhow::bail!("git log command failed with {}", status);
3234                } else {
3235                    anyhow::bail!("git log command failed with {}: {}", status, stderr_output);
3236                }
3237            }
3238            Ok(())
3239        }
3240        .boxed()
3241    }
3242
3243    fn search_commits(
3244        &self,
3245        log_source: LogSource,
3246        search_args: SearchCommitArgs,
3247        request_tx: Sender<Oid>,
3248    ) -> BoxFuture<'_, Result<()>> {
3249        let git = self.git_binary();
3250
3251        async move {
3252            let log_source_args = log_source.get_args();
3253            let mut args = vec!["log", SEARCH_COMMIT_FORMAT];
3254            let hash_query = commit_hash_search_query(search_args.query.as_str())
3255                .map(|query| query.to_ascii_lowercase());
3256
3257            if hash_query.is_none() {
3258                args.push("--fixed-strings");
3259
3260                if !search_args.case_sensitive {
3261                    args.push("--regexp-ignore-case");
3262                }
3263
3264                args.push("--grep");
3265                args.push(search_args.query.as_str());
3266            }
3267
3268            args.extend(log_source_args.iter().map(|arg| arg.as_ref()));
3269            let mut command = git.build_command(&args);
3270            command.stdout(Stdio::piped());
3271            command.stderr(Stdio::null());
3272
3273            let mut child = command.spawn()?;
3274            let stdout = child.stdout.take().context("failed to get stdout")?;
3275            let mut reader = BufReader::new(stdout);
3276
3277            let mut line_buffer = String::new();
3278
3279            loop {
3280                line_buffer.clear();
3281                let bytes_read = reader.read_line(&mut line_buffer).await?;
3282
3283                if bytes_read == 0 {
3284                    break;
3285                }
3286
3287                let sha = line_buffer.trim_end_matches('\n');
3288                if let Some(hash_query) = hash_query.as_ref()
3289                    && !sha.to_ascii_lowercase().starts_with(hash_query)
3290                {
3291                    continue;
3292                }
3293
3294                if let Ok(oid) = Oid::from_str(sha)
3295                    && request_tx.send(oid).await.is_err()
3296                {
3297                    break;
3298                }
3299            }
3300
3301            child.status().await?;
3302            Ok(())
3303        }
3304        .boxed()
3305    }
3306
3307    fn file_history_changed_files(
3308        &self,
3309        paths: Vec<RepoPath>,
3310        commit_limit: usize,
3311    ) -> BoxFuture<'_, Result<Vec<FileHistoryChangedFileSets>>> {
3312        let git = self.git_binary();
3313
3314        async move {
3315            if paths.is_empty() {
3316                return Ok(Vec::new());
3317            }
3318
3319            if commit_limit == 0 {
3320                return Ok(vec![FileHistoryChangedFileSets::default(); paths.len()]);
3321            }
3322
3323            let max_count_arg = format!("--max-count={commit_limit}");
3324            let mut args = [
3325                "log",
3326                max_count_arg.as_str(),
3327                "--full-diff",
3328                "--no-renames",
3329                "--name-only",
3330                "-z",
3331                "--format=%x1e",
3332                "--",
3333            ]
3334            .map(OsString::from)
3335            .to_vec();
3336            args.extend(paths.iter().map(|path| OsString::from(path.as_unix_str())));
3337
3338            let output = git.build_command(&args).output().await?;
3339            anyhow::ensure!(
3340                output.status.success(),
3341                "git log failed:\n{}",
3342                String::from_utf8_lossy(&output.stderr)
3343            );
3344
3345            let stdout = String::from_utf8_lossy(&output.stdout);
3346            Ok(parse_file_history_changed_files_output(&stdout, &paths))
3347        }
3348        .boxed()
3349    }
3350
3351    fn commit_data_reader(&self) -> Result<CommitDataReader> {
3352        let git_binary = self.git_binary();
3353
3354        let (request_tx, request_rx) = async_channel::bounded::<CommitDataRequest>(64);
3355
3356        let task = self.executor.spawn(async move {
3357            if let Err(error) = run_commit_data_reader(git_binary, request_rx).await {
3358                log::error!("commit data reader failed: {error:?}");
3359            }
3360        });
3361
3362        Ok(CommitDataReader {
3363            request_tx,
3364            _task: task,
3365        })
3366    }
3367
3368    fn set_trusted(&self, trusted: bool) {
3369        self.is_trusted
3370            .store(trusted, std::sync::atomic::Ordering::Release);
3371    }
3372
3373    fn is_trusted(&self) -> bool {
3374        self.is_trusted.load(std::sync::atomic::Ordering::Acquire)
3375    }
3376}
3377
3378async fn run_commit_data_reader(
3379    git: GitBinary,
3380    request_rx: async_channel::Receiver<CommitDataRequest>,
3381) -> Result<()> {
3382    let mut process = git
3383        .build_command(&["cat-file", "--batch"])
3384        .stdin(Stdio::piped())
3385        .stdout(Stdio::piped())
3386        .stderr(Stdio::piped())
3387        .spawn()
3388        .context("starting git cat-file --batch process")?;
3389
3390    let mut stdin = BufWriter::new(process.stdin.take().context("no stdin")?);
3391    let mut stdout = BufReader::new(process.stdout.take().context("no stdout")?);
3392
3393    const MAX_BATCH_SIZE: usize = 64;
3394
3395    while let Ok(first_request) = request_rx.recv().await {
3396        let mut pending_requests = vec![first_request];
3397
3398        while pending_requests.len() < MAX_BATCH_SIZE {
3399            match request_rx.try_recv() {
3400                Ok(request) => pending_requests.push(request),
3401                Err(_) => break,
3402            }
3403        }
3404
3405        for request in &pending_requests {
3406            stdin.write_all(request.sha.to_string().as_bytes()).await?;
3407            stdin.write_all(b"\n").await?;
3408        }
3409        stdin.flush().await?;
3410
3411        for request in pending_requests {
3412            let result = read_single_commit_response(&mut stdout, &request.sha).await;
3413            request.response_tx.send(result).ok();
3414        }
3415    }
3416
3417    drop(stdin);
3418    process.kill().ok();
3419
3420    Ok(())
3421}
3422
3423async fn read_single_commit_response<R: smol::io::AsyncBufRead + Unpin>(
3424    stdout: &mut R,
3425    sha: &Oid,
3426) -> Result<CommitData> {
3427    let mut header_bytes = Vec::new();
3428    stdout.read_until(b'\n', &mut header_bytes).await?;
3429    let header_line = String::from_utf8_lossy(&header_bytes);
3430
3431    let parts: Vec<&str> = header_line.trim().split(' ').collect();
3432    if parts.len() < 3 {
3433        bail!("invalid cat-file header: {header_line}");
3434    }
3435
3436    let object_type = parts[1];
3437    if object_type == "missing" {
3438        bail!("object not found: {}", sha);
3439    }
3440
3441    if object_type != "commit" {
3442        bail!("expected commit object, got {object_type}");
3443    }
3444
3445    let size: usize = parts[2]
3446        .parse()
3447        .with_context(|| format!("invalid object size: {}", parts[2]))?;
3448
3449    let mut content = vec![0u8; size];
3450    stdout.read_exact(&mut content).await?;
3451
3452    let mut newline = [0u8; 1];
3453    stdout.read_exact(&mut newline).await?;
3454
3455    let content_str = String::from_utf8_lossy(&content);
3456    parse_cat_file_commit(*sha, &content_str)
3457        .ok_or_else(|| anyhow!("failed to parse commit {}", sha))
3458}
3459
3460fn parse_file_history_changed_files_output(
3461    output: &str,
3462    queried_paths: &[RepoPath],
3463) -> Vec<FileHistoryChangedFileSets> {
3464    let mut histories = vec![FileHistoryChangedFileSets::default(); queried_paths.len()];
3465
3466    for record in output.split('\x1e') {
3467        let changed_files = record
3468            .split('\0')
3469            .filter_map(|field| {
3470                let path = field.trim_start_matches('\n');
3471                if path.is_empty() {
3472                    return None;
3473                }
3474                RepoPath::new(path).ok()
3475            })
3476            .collect::<std::collections::BTreeSet<_>>();
3477
3478        if changed_files.is_empty() {
3479            continue;
3480        }
3481
3482        let file_set = changed_files.iter().cloned().collect::<Vec<_>>();
3483        for (index, queried_path) in queried_paths.iter().enumerate() {
3484            if changed_files.contains(queried_path) {
3485                histories[index].file_sets.push(file_set.clone());
3486            }
3487        }
3488    }
3489
3490    histories
3491}
3492
3493fn parse_initial_graph_output<'a>(
3494    lines: impl Iterator<Item = &'a str>,
3495) -> Vec<Arc<InitialGraphCommitData>> {
3496    lines
3497        .filter(|line| !line.is_empty())
3498        .filter_map(|line| {
3499            // Format: "SHA\x00PARENT1 PARENT2...\x00REF1, REF2, ..."
3500            let mut parts = line.split('\x00');
3501
3502            let sha = Oid::from_str(parts.next()?).ok()?;
3503            let parents_str = parts.next()?;
3504            let parents = parents_str
3505                .split_whitespace()
3506                .filter_map(|p| Oid::from_str(p).ok())
3507                .collect();
3508
3509            let ref_names_str = parts.next().unwrap_or("");
3510            let ref_names = if ref_names_str.is_empty() {
3511                Vec::new()
3512            } else {
3513                ref_names_str
3514                    .split(", ")
3515                    .map(|s| SharedString::from(s.to_string()))
3516                    .collect()
3517            };
3518
3519            Some(Arc::new(InitialGraphCommitData {
3520                sha,
3521                parents,
3522                ref_names,
3523            }))
3524        })
3525        .collect()
3526}
3527
3528fn git_status_args(path_prefixes: &[RepoPath]) -> Vec<OsString> {
3529    let mut args = vec![
3530        OsString::from("status"),
3531        OsString::from("--porcelain=v1"),
3532        OsString::from("--untracked-files=all"),
3533        OsString::from("--no-renames"),
3534        OsString::from("-z"),
3535        OsString::from("--"),
3536    ];
3537    args.extend(path_prefixes.iter().map(|path_prefix| {
3538        if path_prefix.is_empty() {
3539            Path::new(".").into()
3540        } else {
3541            path_prefix.as_std_path().into()
3542        }
3543    }));
3544    args
3545}
3546
3547/// Temporarily git-ignore commonly ignored files and files over 2MB
3548async fn exclude_files(git: &GitBinary) -> Result<GitExcludeOverride> {
3549    const MAX_SIZE: u64 = 2 * 1024 * 1024; // 2 MB
3550    let mut excludes = git.with_exclude_overrides().await?;
3551    excludes
3552        .add_excludes(include_str!("./checkpoint.gitignore"))
3553        .await?;
3554
3555    let working_directory = git.working_directory.clone();
3556    let untracked_files = git.list_untracked_files().await?;
3557    let excluded_paths = untracked_files.into_iter().map(|path| {
3558        let working_directory = working_directory.clone();
3559        smol::spawn(async move {
3560            let full_path = working_directory.join(path.clone());
3561            match smol::fs::metadata(&full_path).await {
3562                Ok(metadata) if metadata.is_file() && metadata.len() >= MAX_SIZE => {
3563                    Some(PathBuf::from("/").join(path.clone()))
3564                }
3565                _ => None,
3566            }
3567        })
3568    });
3569
3570    let excluded_paths = futures::future::join_all(excluded_paths).await;
3571    let excluded_paths = excluded_paths.into_iter().flatten().collect::<Vec<_>>();
3572
3573    if !excluded_paths.is_empty() {
3574        let exclude_patterns = excluded_paths
3575            .into_iter()
3576            .map(|path| path.to_string_lossy().into_owned())
3577            .collect::<Vec<_>>()
3578            .join("\n");
3579        excludes.add_excludes(&exclude_patterns).await?;
3580    }
3581
3582    Ok(excludes)
3583}
3584
3585pub(crate) struct GitBinary {
3586    git_binary_path: PathBuf,
3587    working_directory: PathBuf,
3588    git_directory: PathBuf,
3589    executor: BackgroundExecutor,
3590    index_file_path: Option<PathBuf>,
3591    envs: HashMap<String, String>,
3592    is_trusted: bool,
3593}
3594
3595impl GitBinary {
3596    pub(crate) fn new(
3597        git_binary_path: PathBuf,
3598        working_directory: PathBuf,
3599        git_directory: PathBuf,
3600        executor: BackgroundExecutor,
3601        is_trusted: bool,
3602    ) -> Self {
3603        Self {
3604            git_binary_path,
3605            working_directory,
3606            git_directory,
3607            executor,
3608            index_file_path: None,
3609            envs: HashMap::default(),
3610            is_trusted,
3611        }
3612    }
3613
3614    async fn list_untracked_files(&self) -> Result<Vec<PathBuf>> {
3615        let status_output = self
3616            .run(&["status", "--porcelain=v1", "--untracked-files=all", "-z"])
3617            .await?;
3618
3619        let paths = status_output
3620            .split('\0')
3621            .filter(|entry| entry.len() >= 3 && entry.starts_with("?? "))
3622            .map(|entry| PathBuf::from(&entry[3..]))
3623            .collect::<Vec<_>>();
3624        Ok(paths)
3625    }
3626
3627    fn envs(mut self, envs: HashMap<String, String>) -> Self {
3628        self.envs = envs;
3629        self
3630    }
3631
3632    pub async fn with_temp_index<R>(
3633        &mut self,
3634        f: impl AsyncFnOnce(&Self) -> Result<R>,
3635    ) -> Result<R> {
3636        let index_file_path = self.path_for_index_id(Uuid::new_v4());
3637
3638        let delete_temp_index = util::defer({
3639            let index_file_path = index_file_path.clone();
3640            let executor = self.executor.clone();
3641            move || {
3642                executor
3643                    .spawn(async move {
3644                        smol::fs::remove_file(index_file_path).await.log_err();
3645                    })
3646                    .detach();
3647            }
3648        });
3649
3650        // Copy the default index file so that Git doesn't have to rebuild the
3651        // whole index from scratch. This might fail if this is an empty repository.
3652        smol::fs::copy(self.git_directory.join("index"), &index_file_path)
3653            .await
3654            .ok();
3655
3656        self.index_file_path = Some(index_file_path.clone());
3657        let result = f(self).await;
3658        self.index_file_path = None;
3659        let result = result?;
3660
3661        smol::fs::remove_file(index_file_path).await.ok();
3662        delete_temp_index.abort();
3663
3664        Ok(result)
3665    }
3666
3667    pub async fn with_exclude_overrides(&self) -> Result<GitExcludeOverride> {
3668        let path = self.git_directory.join("info").join("exclude");
3669
3670        GitExcludeOverride::new(path).await
3671    }
3672
3673    fn path_for_index_id(&self, id: Uuid) -> PathBuf {
3674        self.git_directory.join(format!("index-{}.tmp", id))
3675    }
3676
3677    pub async fn run<S>(&self, args: &[S]) -> Result<String>
3678    where
3679        S: AsRef<OsStr>,
3680    {
3681        let mut stdout = self.run_raw(args).await?;
3682        if stdout.chars().last() == Some('\n') {
3683            stdout.pop();
3684        }
3685        Ok(stdout)
3686    }
3687
3688    /// Returns the result of the command without trimming the trailing newline.
3689    pub async fn run_raw<S>(&self, args: &[S]) -> Result<String>
3690    where
3691        S: AsRef<OsStr>,
3692    {
3693        let mut command = self.build_command(args);
3694        let output = command.output().await?;
3695        anyhow::ensure!(
3696            output.status.success(),
3697            GitBinaryCommandError {
3698                stdout: String::from_utf8_lossy(&output.stdout).to_string(),
3699                stderr: String::from_utf8_lossy(&output.stderr).to_string(),
3700                status: output.status,
3701            }
3702        );
3703        Ok(String::from_utf8(output.stdout)?)
3704    }
3705
3706    #[allow(clippy::disallowed_methods)]
3707    pub(crate) fn build_command<S>(&self, args: &[S]) -> util::command::Command
3708    where
3709        S: AsRef<OsStr>,
3710    {
3711        let mut command = new_command(&self.git_binary_path);
3712        command.current_dir(&self.working_directory);
3713        // Disabled to stop malicious actors from running arbitrary commands via fsmonitor hooks
3714        command.args(["-c", "core.fsmonitor=false"]);
3715        // Prepended signature lines would corrupt our --format parsers.
3716        command.args(["-c", "log.showSignature=false"]);
3717        command.arg("--no-optional-locks");
3718        // Internal commands must be non-interactive so background tasks never block on user input.
3719        command.arg("--no-pager");
3720
3721        if !self.is_trusted {
3722            command.args(["-c", "core.hooksPath=/dev/null"]);
3723            command.args(["-c", "core.sshCommand=ssh"]);
3724            command.args(["-c", "credential.helper="]);
3725            command.args(["-c", "protocol.ext.allow=never"]);
3726            command.args(["-c", "diff.external="]);
3727        }
3728        command.args(args);
3729
3730        // If the `diff` command is being used, we'll want to add the
3731        // `--no-ext-diff` flag when working on an untrusted repository,
3732        // preventing any external diff programs from being invoked.
3733        if !self.is_trusted && args.iter().any(|arg| arg.as_ref() == "diff") {
3734            command.arg("--no-ext-diff");
3735        }
3736
3737        if let Some(index_file_path) = self.index_file_path.as_ref() {
3738            command.env("GIT_INDEX_FILE", index_file_path);
3739        }
3740        command.envs(&self.envs);
3741        command
3742    }
3743}
3744
3745#[derive(Error, Debug)]
3746#[error("Git command failed:\n{stdout}{stderr}\n")]
3747struct GitBinaryCommandError {
3748    stdout: String,
3749    stderr: String,
3750    status: ExitStatus,
3751}
3752
3753async fn run_git_command(
3754    env: Arc<HashMap<String, String>>,
3755    ask_pass: AskPassDelegate,
3756    mut command: util::command::Command,
3757    executor: BackgroundExecutor,
3758) -> Result<RemoteCommandOutput> {
3759    if env.contains_key("GIT_ASKPASS") {
3760        let git_process = command.spawn()?;
3761        let output = git_process.output().await?;
3762        anyhow::ensure!(
3763            output.status.success(),
3764            "{}",
3765            String::from_utf8_lossy(&output.stderr)
3766        );
3767        Ok(RemoteCommandOutput {
3768            stdout: String::from_utf8_lossy(&output.stdout).to_string(),
3769            stderr: String::from_utf8_lossy(&output.stderr).to_string(),
3770        })
3771    } else {
3772        let ask_pass = AskPassSession::new(executor, ask_pass).await?;
3773        command
3774            .env("GIT_ASKPASS", ask_pass.script_path())
3775            .env("SSH_ASKPASS", ask_pass.script_path())
3776            .env("SSH_ASKPASS_REQUIRE", "force");
3777
3778        if !env.contains_key("GIT_CONFIG_COUNT")
3779            && let Some(gpg_wrapper) = ask_pass.gpg_wrapper_path()
3780        {
3781            command
3782                .env("GIT_CONFIG_COUNT", "1")
3783                .env("GIT_CONFIG_KEY_0", "gpg.program")
3784                .env("GIT_CONFIG_VALUE_0", gpg_wrapper);
3785        }
3786
3787        #[cfg(target_os = "windows")]
3788        command.env("ZED_ASKPASS_SOCKET", ask_pass.socket_path());
3789        let git_process = command.spawn()?;
3790
3791        run_askpass_command(ask_pass, git_process).await
3792    }
3793}
3794
3795async fn run_askpass_command(
3796    mut ask_pass: AskPassSession,
3797    git_process: util::command::Child,
3798) -> anyhow::Result<RemoteCommandOutput> {
3799    select_biased! {
3800        // Git can legitimately run long without prompting (e.g. large fetches,
3801        // hooks), so completion is determined by the process itself.
3802        result = ask_pass.run(None).fuse() => {
3803            match result {
3804                AskPassResult::CancelledByUser => {
3805                    Err(anyhow!(REMOTE_CANCELLED_BY_USER))?
3806                }
3807                AskPassResult::Timedout => {
3808                    // Unreachable since no timeout is passed to run()
3809                    Err(anyhow!("Connecting to host timed out"))?
3810                }
3811            }
3812        }
3813        output = git_process.output().fuse() => {
3814            let output = output?;
3815            anyhow::ensure!(
3816                output.status.success(),
3817                "{}",
3818                String::from_utf8_lossy(&output.stderr)
3819            );
3820            Ok(RemoteCommandOutput {
3821                stdout: String::from_utf8_lossy(&output.stdout).to_string(),
3822                stderr: String::from_utf8_lossy(&output.stderr).to_string(),
3823            })
3824        }
3825    }
3826}
3827
3828#[derive(Clone, Ord, Hash, PartialOrd, Eq, PartialEq)]
3829pub struct RepoPath(Arc<RelPath>);
3830
3831impl std::fmt::Debug for RepoPath {
3832    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3833        self.0.fmt(f)
3834    }
3835}
3836
3837impl RepoPath {
3838    pub fn new<S: AsRef<str> + ?Sized>(s: &S) -> Result<Self> {
3839        let rel_path = RelPath::from_unix_str(s.as_ref())?;
3840        Ok(Self::from_rel_path(rel_path))
3841    }
3842
3843    pub fn from_std_path(path: &Path, path_style: PathStyle) -> Result<Self> {
3844        let rel_path = RelPath::new(path, path_style)?;
3845        Ok(Self::from_rel_path(&rel_path))
3846    }
3847
3848    pub fn from_proto(proto: &str) -> Result<Self> {
3849        let rel_path = RelPath::from_unix_str(proto)?.into();
3850        Ok(Self(rel_path))
3851    }
3852
3853    pub fn from_rel_path(path: &RelPath) -> RepoPath {
3854        Self(Arc::from(path))
3855    }
3856
3857    pub fn as_std_path(&self) -> &Path {
3858        if self.is_empty() {
3859            Path::new(".")
3860        } else {
3861            self.0.as_std_path()
3862        }
3863    }
3864}
3865
3866#[cfg(any(test, feature = "test-support"))]
3867pub fn repo_path<S: AsRef<str> + ?Sized>(s: &S) -> RepoPath {
3868    RepoPath(RelPath::from_unix_str(s.as_ref()).unwrap().into())
3869}
3870
3871impl AsRef<Arc<RelPath>> for RepoPath {
3872    fn as_ref(&self) -> &Arc<RelPath> {
3873        &self.0
3874    }
3875}
3876
3877impl std::ops::Deref for RepoPath {
3878    type Target = RelPath;
3879
3880    fn deref(&self) -> &Self::Target {
3881        &self.0
3882    }
3883}
3884
3885#[derive(Debug)]
3886pub struct RepoPathDescendants<'a>(pub &'a RepoPath);
3887
3888impl MapSeekTarget<RepoPath> for RepoPathDescendants<'_> {
3889    fn cmp_cursor(&self, key: &RepoPath) -> Ordering {
3890        if key.starts_with(self.0) {
3891            Ordering::Greater
3892        } else {
3893            self.0.cmp(key)
3894        }
3895    }
3896}
3897
3898fn parse_branch_input(input: &str) -> Result<Vec<Branch>> {
3899    let mut branches = Vec::new();
3900    for line in input.split('\n') {
3901        if line.is_empty() {
3902            continue;
3903        }
3904        let mut fields = line.split('\x00');
3905        let Some(head) = fields.next() else {
3906            continue;
3907        };
3908        let Some(head_sha) = fields.next().map(|f| f.to_string().into()) else {
3909            continue;
3910        };
3911        let Some(parent_sha) = fields.next().map(|f| f.to_string()) else {
3912            continue;
3913        };
3914        let Some(ref_name) = fields.next().map(|f| f.to_string().into()) else {
3915            continue;
3916        };
3917        let Some(upstream_name) = fields.next().map(|f| f.to_string()) else {
3918            continue;
3919        };
3920        let Some(upstream_tracking) = fields.next().and_then(|f| parse_upstream_track(f).ok())
3921        else {
3922            continue;
3923        };
3924        let Some(commiterdate) = fields.next().and_then(|f| f.parse::<i64>().ok()) else {
3925            continue;
3926        };
3927        let Some(author_name) = fields.next().map(|f| f.to_string().into()) else {
3928            continue;
3929        };
3930        let Some(subject) = fields.next().map(|f| f.to_string().into()) else {
3931            continue;
3932        };
3933
3934        branches.push(Branch {
3935            is_head: head == "*",
3936            ref_name,
3937            most_recent_commit: Some(CommitSummary {
3938                sha: head_sha,
3939                subject,
3940                commit_timestamp: commiterdate,
3941                author_name: author_name,
3942                has_parent: !parent_sha.is_empty(),
3943            }),
3944            upstream: if upstream_name.is_empty() {
3945                None
3946            } else {
3947                Some(Upstream {
3948                    ref_name: upstream_name.into(),
3949                    tracking: upstream_tracking,
3950                })
3951            },
3952        })
3953    }
3954
3955    Ok(branches)
3956}
3957
3958fn format_branch_scan_error(output: &Output) -> String {
3959    let stderr = String::from_utf8_lossy(&output.stderr)
3960        .trim()
3961        .replace('\n', " ");
3962    if stderr.is_empty() {
3963        format!("git for-each-ref exited with {}", output.status)
3964    } else {
3965        stderr
3966    }
3967}
3968
3969fn parse_upstream_track(upstream_track: &str) -> Result<UpstreamTracking> {
3970    if upstream_track.is_empty() {
3971        return Ok(UpstreamTracking::Tracked(UpstreamTrackingStatus {
3972            ahead: 0,
3973            behind: 0,
3974        }));
3975    }
3976
3977    let upstream_track = upstream_track.strip_prefix("[").context("missing [")?;
3978    let upstream_track = upstream_track.strip_suffix("]").context("missing [")?;
3979    let mut ahead: u32 = 0;
3980    let mut behind: u32 = 0;
3981    for component in upstream_track.split(", ") {
3982        if component == "gone" {
3983            return Ok(UpstreamTracking::Gone);
3984        }
3985        if let Some(ahead_num) = component.strip_prefix("ahead ") {
3986            ahead = ahead_num.parse::<u32>()?;
3987        }
3988        if let Some(behind_num) = component.strip_prefix("behind ") {
3989            behind = behind_num.parse::<u32>()?;
3990        }
3991    }
3992    Ok(UpstreamTracking::Tracked(UpstreamTrackingStatus {
3993        ahead,
3994        behind,
3995    }))
3996}
3997
3998fn checkpoint_author_envs() -> HashMap<String, String> {
3999    HashMap::from_iter([
4000        ("GIT_AUTHOR_NAME".to_string(), "Zed".to_string()),
4001        ("GIT_AUTHOR_EMAIL".to_string(), "hi@zed.dev".to_string()),
4002        ("GIT_COMMITTER_NAME".to_string(), "Zed".to_string()),
4003        ("GIT_COMMITTER_EMAIL".to_string(), "hi@zed.dev".to_string()),
4004    ])
4005}
4006
4007#[cfg(test)]
4008mod tests {
4009    use std::{
4010        ffi::{OsStr, OsString},
4011        fs,
4012    };
4013
4014    use super::*;
4015    use gpui::TestAppContext;
4016
4017    fn disable_git_global_config() {
4018        unsafe {
4019            std::env::set_var("GIT_CONFIG_GLOBAL", "");
4020            std::env::set_var("GIT_CONFIG_SYSTEM", "");
4021        }
4022    }
4023
4024    #[allow(clippy::disallowed_methods)]
4025    #[track_caller]
4026    fn git_command_output<I, S>(working_directory: &Path, arguments: I) -> String
4027    where
4028        I: IntoIterator<Item = S>,
4029        S: AsRef<OsStr>,
4030    {
4031        let output = std::process::Command::new("git")
4032            .args(arguments)
4033            .current_dir(working_directory)
4034            .env("GIT_CONFIG_GLOBAL", "")
4035            .env("GIT_CONFIG_SYSTEM", "")
4036            .env("GIT_AUTHOR_NAME", "test")
4037            .env("GIT_AUTHOR_EMAIL", "test@zed.dev")
4038            .env("GIT_COMMITTER_NAME", "test")
4039            .env("GIT_COMMITTER_EMAIL", "test@zed.dev")
4040            .output()
4041            .expect("failed to run git command");
4042        assert!(
4043            output.status.success(),
4044            "git command failed: {}",
4045            String::from_utf8_lossy(&output.stderr)
4046        );
4047        String::from_utf8(output.stdout)
4048            .expect("git command output was not valid UTF-8")
4049            .trim()
4050            .to_string()
4051    }
4052
4053    #[track_caller]
4054    fn git_command<I, S>(working_directory: &Path, arguments: I)
4055    where
4056        I: IntoIterator<Item = S>,
4057        S: AsRef<OsStr>,
4058    {
4059        git_command_output(working_directory, arguments);
4060    }
4061
4062    fn git_init_repo(path: &Path) {
4063        fs::create_dir_all(path).expect("failed to create repo directory");
4064        git_command(path, ["init", "-b", "main"]);
4065    }
4066
4067    fn clone_remote_repository_with_main_and_feature(temp_dir: &Path) -> (PathBuf, PathBuf) {
4068        let remote_directory = temp_dir.join("remote.git");
4069        let seed_directory = temp_dir.join("seed");
4070        let clone_directory = temp_dir.join("clone");
4071
4072        git_command(
4073            temp_dir,
4074            [
4075                OsString::from("init"),
4076                OsString::from("--bare"),
4077                OsString::from("-b"),
4078                OsString::from("main"),
4079                remote_directory.as_os_str().into(),
4080            ],
4081        );
4082        git_init_repo(&seed_directory);
4083        fs::write(seed_directory.join("file.txt"), "main").unwrap();
4084        git_command(&seed_directory, ["add", "file.txt"]);
4085        git_command(&seed_directory, ["commit", "-m", "initial"]);
4086        git_command(&seed_directory, ["switch", "-c", "feature"]);
4087        fs::write(seed_directory.join("feature.txt"), "feature").unwrap();
4088        git_command(&seed_directory, ["add", "feature.txt"]);
4089        git_command(&seed_directory, ["commit", "-m", "feature"]);
4090        git_command(
4091            &seed_directory,
4092            [
4093                OsString::from("remote"),
4094                OsString::from("add"),
4095                OsString::from("origin"),
4096                remote_directory.as_os_str().into(),
4097            ],
4098        );
4099        git_command(&seed_directory, ["push", "-u", "origin", "main"]);
4100        git_command(&seed_directory, ["push", "-u", "origin", "feature"]);
4101        git_command(
4102            temp_dir,
4103            [
4104                OsString::from("clone"),
4105                remote_directory.as_os_str().into(),
4106                clone_directory.as_os_str().into(),
4107            ],
4108        );
4109
4110        (remote_directory, clone_directory)
4111    }
4112
4113    fn test_commit_envs() -> HashMap<String, String> {
4114        let mut env = checkpoint_author_envs();
4115        env.insert("GIT_ASKPASS".to_string(), "false".to_string());
4116        env
4117    }
4118
4119    #[track_caller]
4120    fn assert_same_path(left: impl AsRef<Path>, right: impl AsRef<Path>) {
4121        assert_eq!(
4122            fs::canonicalize(left.as_ref()).unwrap(),
4123            fs::canonicalize(right.as_ref()).unwrap()
4124        );
4125    }
4126
4127    #[gpui::test]
4128    async fn test_real_git_repository_new_resolves_normal_repository_paths(
4129        cx: &mut TestAppContext,
4130    ) {
4131        disable_git_global_config();
4132        cx.executor().allow_parking();
4133
4134        let repo_dir = tempfile::tempdir().unwrap();
4135        git_init_repo(repo_dir.path());
4136
4137        let repository = RealGitRepository::new(
4138            &repo_dir.path().join(".git"),
4139            None,
4140            Some("git".into()),
4141            cx.executor(),
4142        )
4143        .unwrap();
4144
4145        assert_same_path(&repository.git_dir, repo_dir.path().join(".git"));
4146        assert_same_path(&repository.common_dir, repo_dir.path().join(".git"));
4147        assert_same_path(
4148            repository.working_directory.as_ref().unwrap(),
4149            repo_dir.path(),
4150        );
4151        assert_same_path(
4152            original_repo_path_from_common_dir(&repository.common_dir).unwrap(),
4153            repo_dir.path(),
4154        );
4155    }
4156
4157    #[gpui::test]
4158    async fn test_load_commit_with_type_changed_file(cx: &mut TestAppContext) {
4159        disable_git_global_config();
4160        cx.executor().allow_parking();
4161
4162        let repo_dir = tempfile::tempdir().expect("failed to create temporary repository");
4163        git_init_repo(repo_dir.path());
4164        fs::write(repo_dir.path().join("file.txt"), "regular contents\n")
4165            .expect("failed to write regular file");
4166        git_command(repo_dir.path(), ["add", "file.txt"]);
4167        git_command(repo_dir.path(), ["commit", "-m", "initial"]);
4168
4169        let repository = RealGitRepository::new(
4170            &repo_dir.path().join(".git"),
4171            None,
4172            Some("git".into()),
4173            cx.executor(),
4174        )
4175        .expect("failed to open repository");
4176        fs::write(repo_dir.path().join("file.txt"), "target")
4177            .expect("failed to write symlink target");
4178
4179        let symlink_blob = repository
4180            .git_binary()
4181            .run(&["hash-object", "-w", "file.txt"])
4182            .await
4183            .expect("failed to write symlink blob");
4184        git_command(
4185            repo_dir.path(),
4186            [
4187                OsString::from("update-index"),
4188                OsString::from("--cacheinfo"),
4189                OsString::from("120000"),
4190                OsString::from(symlink_blob),
4191                OsString::from("file.txt"),
4192            ],
4193        );
4194        git_command(repo_dir.path(), ["commit", "-m", "type change"]);
4195
4196        let commit_diff = repository
4197            .load_commit("HEAD".to_string(), cx.to_async())
4198            .await
4199            .expect("failed to load type-changed commit");
4200        assert_eq!(commit_diff.files.len(), 1);
4201
4202        let file = commit_diff
4203            .files
4204            .first()
4205            .expect("type-changed file should be present");
4206        assert_eq!(file.path.as_unix_str(), "file.txt");
4207        assert_eq!(file.old_text.as_deref(), Some("regular contents\n"));
4208        assert_eq!(file.new_text.as_deref(), Some("target"));
4209        assert_eq!(file.status(), CommitFileStatus::Modified);
4210    }
4211
4212    #[gpui::test]
4213    async fn test_load_commit_with_gitlink_changes(cx: &mut TestAppContext) {
4214        const FIRST_SUBMODULE_COMMIT: &str = "1111111111111111111111111111111111111111";
4215        const SECOND_SUBMODULE_COMMIT: &str = "2222222222222222222222222222222222222222";
4216
4217        disable_git_global_config();
4218        cx.executor().allow_parking();
4219
4220        let repo_dir = tempfile::tempdir().expect("failed to create temporary repository");
4221        git_init_repo(repo_dir.path());
4222        fs::write(repo_dir.path().join("README.md"), "parent repository\n")
4223            .expect("failed to write regular file");
4224        git_command(repo_dir.path(), ["add", "README.md"]);
4225        git_command(
4226            repo_dir.path(),
4227            [
4228                "update-index",
4229                "--add",
4230                "--cacheinfo",
4231                crate::commit::GITLINK_MODE,
4232                FIRST_SUBMODULE_COMMIT,
4233                "modules/example",
4234            ],
4235        );
4236        git_command(repo_dir.path(), ["commit", "-m", "add submodule"]);
4237
4238        let repository = RealGitRepository::new(
4239            &repo_dir.path().join(".git"),
4240            None,
4241            Some("git".into()),
4242            cx.executor(),
4243        )
4244        .expect("failed to open repository");
4245
4246        let commit_diff = repository
4247            .load_commit("HEAD".to_string(), cx.to_async())
4248            .await
4249            .expect("failed to load commit that adds a gitlink");
4250        assert_eq!(commit_diff.files.len(), 2);
4251        let gitlink = commit_diff
4252            .files
4253            .iter()
4254            .find(|file| file.path.as_unix_str() == "modules/example")
4255            .expect("gitlink should be present alongside the regular file");
4256        assert_eq!(gitlink.status(), CommitFileStatus::Added);
4257        assert_eq!(gitlink.old_text, None);
4258        assert_eq!(
4259            gitlink.new_text.as_deref(),
4260            Some("Subproject commit 1111111111111111111111111111111111111111\n")
4261        );
4262        assert!(!gitlink.is_binary);
4263
4264        git_command(
4265            repo_dir.path(),
4266            [
4267                "update-index",
4268                "--cacheinfo",
4269                crate::commit::GITLINK_MODE,
4270                SECOND_SUBMODULE_COMMIT,
4271                "modules/example",
4272            ],
4273        );
4274        git_command(repo_dir.path(), ["commit", "-m", "update submodule"]);
4275
4276        let commit_diff = repository
4277            .load_commit("HEAD".to_string(), cx.to_async())
4278            .await
4279            .expect("failed to load commit that updates a gitlink");
4280        let [gitlink] = commit_diff.files.as_slice() else {
4281            panic!("expected one updated gitlink");
4282        };
4283        assert_eq!(gitlink.status(), CommitFileStatus::Modified);
4284        assert_eq!(
4285            gitlink.old_text.as_deref(),
4286            Some("Subproject commit 1111111111111111111111111111111111111111\n")
4287        );
4288        assert_eq!(
4289            gitlink.new_text.as_deref(),
4290            Some("Subproject commit 2222222222222222222222222222222222222222\n")
4291        );
4292        assert!(!gitlink.is_binary);
4293
4294        git_command(repo_dir.path(), ["rm", "--cached", "modules/example"]);
4295        git_command(repo_dir.path(), ["commit", "-m", "remove submodule"]);
4296
4297        let commit_diff = repository
4298            .load_commit("HEAD".to_string(), cx.to_async())
4299            .await
4300            .expect("failed to load commit that deletes a gitlink");
4301        let [gitlink] = commit_diff.files.as_slice() else {
4302            panic!("expected one deleted gitlink");
4303        };
4304        assert_eq!(gitlink.status(), CommitFileStatus::Deleted);
4305        assert_eq!(
4306            gitlink.old_text.as_deref(),
4307            Some("Subproject commit 2222222222222222222222222222222222222222\n")
4308        );
4309        assert_eq!(gitlink.new_text, None);
4310        assert!(!gitlink.is_binary);
4311    }
4312
4313    #[gpui::test]
4314    async fn test_check_access(cx: &mut TestAppContext) {
4315        disable_git_global_config();
4316        cx.executor().allow_parking();
4317
4318        let repo_dir = tempfile::tempdir().unwrap();
4319        let repository = RealGitRepository::new(
4320            &repo_dir.path().join(".git"),
4321            None,
4322            Some("git".into()),
4323            cx.executor(),
4324        )
4325        .unwrap();
4326
4327        assert!(repository.check_access().await.is_err());
4328        git_init_repo(repo_dir.path());
4329        assert!(repository.check_access().await.is_ok());
4330    }
4331
4332    #[gpui::test]
4333    async fn test_real_git_repository_new_resolves_linked_worktree_paths(cx: &mut TestAppContext) {
4334        disable_git_global_config();
4335        cx.executor().allow_parking();
4336
4337        let temp_dir = tempfile::tempdir().unwrap();
4338        let repo_dir = temp_dir.path().join("repo");
4339        let worktree_dir = temp_dir.path().join("worktree");
4340        git_init_repo(&repo_dir);
4341        fs::write(repo_dir.join("file.txt"), "initial").unwrap();
4342        git_command(&repo_dir, ["add", "file.txt"]);
4343        git_command(&repo_dir, ["commit", "-m", "initial"]);
4344        git_command(
4345            &repo_dir,
4346            [
4347                OsString::from("worktree"),
4348                OsString::from("add"),
4349                OsString::from("-b"),
4350                OsString::from("feature"),
4351                worktree_dir.as_os_str().into(),
4352            ],
4353        );
4354
4355        let repository = RealGitRepository::new(
4356            &worktree_dir.join(".git"),
4357            None,
4358            Some("git".into()),
4359            cx.executor(),
4360        )
4361        .unwrap();
4362
4363        assert_same_path(
4364            repository.working_directory.as_ref().unwrap(),
4365            &worktree_dir,
4366        );
4367        assert_same_path(&repository.common_dir, repo_dir.join(".git"));
4368        assert_same_path(
4369            original_repo_path_from_common_dir(&repository.common_dir).unwrap(),
4370            repo_dir,
4371        );
4372    }
4373
4374    #[gpui::test]
4375    async fn test_real_git_repository_new_supports_bare_repositories(cx: &mut TestAppContext) {
4376        disable_git_global_config();
4377        cx.executor().allow_parking();
4378
4379        let temp_dir = tempfile::tempdir().unwrap();
4380        let repo_dir = temp_dir.path().join("repo.git");
4381        git_command(
4382            temp_dir.path(),
4383            [
4384                OsString::from("init"),
4385                OsString::from("--bare"),
4386                repo_dir.as_os_str().into(),
4387            ],
4388        );
4389
4390        let repository =
4391            RealGitRepository::new(&repo_dir, None, Some("git".into()), cx.executor()).unwrap();
4392
4393        assert_same_path(&repository.git_dir, &repo_dir);
4394        assert_same_path(&repository.common_dir, &repo_dir);
4395        assert_eq!(repository.working_directory, None);
4396        assert_same_path(repository.main_repository_path(), &repo_dir);
4397        assert_eq!(
4398            repository
4399                .git_binary()
4400                .run(&["rev-parse", "--is-bare-repository"])
4401                .await
4402                .unwrap(),
4403            "true"
4404        );
4405    }
4406
4407    #[gpui::test]
4408    async fn test_change_branch_creates_local_tracking_branch_from_remote(cx: &mut TestAppContext) {
4409        disable_git_global_config();
4410        cx.executor().allow_parking();
4411
4412        let temp_dir = tempfile::tempdir().unwrap();
4413        let (_remote_directory, clone_directory) =
4414            clone_remote_repository_with_main_and_feature(temp_dir.path());
4415
4416        let repository = RealGitRepository::new(
4417            &clone_directory.join(".git"),
4418            None,
4419            Some("git".into()),
4420            cx.executor(),
4421        )
4422        .unwrap();
4423        let git = repository.git_binary_in_worktree().unwrap();
4424        assert!(
4425            git.run(&[
4426                "show-ref",
4427                "--verify",
4428                "--quiet",
4429                "refs/remotes/origin/feature"
4430            ])
4431            .await
4432            .is_ok()
4433        );
4434        assert!(
4435            git.run(&["show-ref", "--verify", "--quiet", "refs/heads/feature"])
4436                .await
4437                .is_err()
4438        );
4439
4440        repository
4441            .change_branch("origin/feature".to_string())
4442            .await
4443            .unwrap();
4444
4445        let git = repository.git_binary_in_worktree().unwrap();
4446        assert_eq!(
4447            git.run(&["branch", "--show-current"]).await.unwrap(),
4448            "feature"
4449        );
4450        assert_eq!(
4451            git.run(&["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}",])
4452                .await
4453                .unwrap(),
4454            "origin/feature"
4455        );
4456
4457        git.run(&["checkout", "main"]).await.unwrap();
4458        git.run(&["branch", "--unset-upstream", "feature"])
4459            .await
4460            .unwrap();
4461
4462        repository
4463            .change_branch("origin/feature".to_string())
4464            .await
4465            .unwrap();
4466
4467        let git = repository.git_binary_in_worktree().unwrap();
4468        assert_eq!(
4469            git.run(&["branch", "--show-current"]).await.unwrap(),
4470            "feature"
4471        );
4472        assert_eq!(
4473            git.run(&["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}",])
4474                .await
4475                .unwrap(),
4476            "origin/feature"
4477        );
4478    }
4479
4480    #[gpui::test]
4481    async fn test_change_branch_resolves_remote_head_to_tracking_branch(cx: &mut TestAppContext) {
4482        disable_git_global_config();
4483        cx.executor().allow_parking();
4484
4485        let temp_dir = tempfile::tempdir().unwrap();
4486        let (_remote_directory, clone_directory) =
4487            clone_remote_repository_with_main_and_feature(temp_dir.path());
4488
4489        let repository = RealGitRepository::new(
4490            &clone_directory.join(".git"),
4491            None,
4492            Some("git".into()),
4493            cx.executor(),
4494        )
4495        .unwrap();
4496        let git = repository.git_binary_in_worktree().unwrap();
4497        git.run(&[
4498            "symbolic-ref",
4499            "refs/remotes/origin/HEAD",
4500            "refs/remotes/origin/feature",
4501        ])
4502        .await
4503        .unwrap();
4504        assert_eq!(
4505            git.run(&["symbolic-ref", "refs/remotes/origin/HEAD"])
4506                .await
4507                .unwrap(),
4508            "refs/remotes/origin/feature"
4509        );
4510        assert!(
4511            git.run(&["show-ref", "--verify", "--quiet", "refs/heads/feature"])
4512                .await
4513                .is_err()
4514        );
4515
4516        repository
4517            .change_branch("origin/HEAD".to_string())
4518            .await
4519            .unwrap();
4520
4521        let git = repository.git_binary_in_worktree().unwrap();
4522        assert_eq!(
4523            git.run(&["branch", "--show-current"]).await.unwrap(),
4524            "feature"
4525        );
4526        assert_eq!(
4527            git.run(&["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}",])
4528                .await
4529                .unwrap(),
4530            "origin/feature"
4531        );
4532    }
4533
4534    #[gpui::test]
4535    async fn test_change_branch_resolves_non_origin_remote_head(cx: &mut TestAppContext) {
4536        disable_git_global_config();
4537        cx.executor().allow_parking();
4538
4539        let temp_dir = tempfile::tempdir().unwrap();
4540        let (remote_directory, clone_directory) =
4541            clone_remote_repository_with_main_and_feature(temp_dir.path());
4542
4543        git_command(
4544            &clone_directory,
4545            [
4546                OsString::from("remote"),
4547                OsString::from("add"),
4548                OsString::from("upstream"),
4549                remote_directory.as_os_str().into(),
4550            ],
4551        );
4552        git_command(&clone_directory, ["fetch", "upstream"]);
4553
4554        let repository = RealGitRepository::new(
4555            &clone_directory.join(".git"),
4556            None,
4557            Some("git".into()),
4558            cx.executor(),
4559        )
4560        .unwrap();
4561        let git = repository.git_binary_in_worktree().unwrap();
4562        git.run(&[
4563            "symbolic-ref",
4564            "refs/remotes/upstream/HEAD",
4565            "refs/remotes/upstream/main",
4566        ])
4567        .await
4568        .unwrap();
4569        git.run(&["checkout", "-b", "scratch"]).await.unwrap();
4570
4571        repository
4572            .change_branch("upstream/HEAD".to_string())
4573            .await
4574            .unwrap();
4575
4576        let git = repository.git_binary_in_worktree().unwrap();
4577        assert_eq!(
4578            git.run(&["branch", "--show-current"]).await.unwrap(),
4579            "main"
4580        );
4581        assert_eq!(
4582            git.run(&["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}",])
4583                .await
4584                .unwrap(),
4585            "upstream/main"
4586        );
4587    }
4588
4589    #[gpui::test]
4590    fn test_real_git_repository_new_rejects_malformed_git_file(cx: &mut TestAppContext) {
4591        disable_git_global_config();
4592
4593        let temp_dir = tempfile::tempdir().unwrap();
4594        let worktree_dir = temp_dir.path().join("worktree");
4595        fs::create_dir_all(&worktree_dir).unwrap();
4596        fs::write(worktree_dir.join(".git"), "not a gitdir file\n").unwrap();
4597
4598        let error = match RealGitRepository::new(
4599            &worktree_dir.join(".git"),
4600            None,
4601            Some("git".into()),
4602            cx.executor(),
4603        ) {
4604            Ok(_) => panic!("malformed .git file should be rejected"),
4605            Err(error) => error,
4606        };
4607
4608        assert!(
4609            error
4610                .to_string()
4611                .contains("expected .git file to start with 'gitdir: '"),
4612            "unexpected error: {error:#}"
4613        );
4614    }
4615
4616    #[test]
4617    fn test_initial_graph_commit_data_tag_names() {
4618        let commit = InitialGraphCommitData {
4619            sha: Oid::from_bytes(&[0; 20]).unwrap(),
4620            parents: SmallVec::new(),
4621            ref_names: vec![
4622                SharedString::from("HEAD -> main"),
4623                SharedString::from("origin/main"),
4624                SharedString::from("tag: v1.0.0"),
4625                SharedString::from("tag: v1.1.0"),
4626                SharedString::from("tag: "),
4627                SharedString::from("refs/heads/feature"),
4628            ],
4629        };
4630
4631        assert_eq!(commit.tag_names(), ["v1.0.0", "v1.1.0"]);
4632    }
4633
4634    #[test]
4635    fn test_parse_file_history_changed_files_output() {
4636        let queried_paths = vec![
4637            RepoPath::new("src/a.rs").unwrap(),
4638            RepoPath::new("src/b.rs").unwrap(),
4639        ];
4640        let output = concat!(
4641            "\x1e\0\nsrc/a.rs\0src/shared.rs\0",
4642            "\x1e\0\nsrc/b.rs\0src/shared.rs\0",
4643            "\x1e\0\nsrc/a.rs\0src/b.rs\0src/shared.rs\0",
4644        );
4645
4646        let histories = parse_file_history_changed_files_output(output, &queried_paths);
4647
4648        assert_eq!(histories.len(), 2);
4649        assert_eq!(
4650            histories[0].file_sets,
4651            vec![
4652                vec![
4653                    RepoPath::new("src/a.rs").unwrap(),
4654                    RepoPath::new("src/shared.rs").unwrap(),
4655                ],
4656                vec![
4657                    RepoPath::new("src/a.rs").unwrap(),
4658                    RepoPath::new("src/b.rs").unwrap(),
4659                    RepoPath::new("src/shared.rs").unwrap(),
4660                ],
4661            ]
4662        );
4663        assert_eq!(
4664            histories[1].file_sets,
4665            vec![
4666                vec![
4667                    RepoPath::new("src/b.rs").unwrap(),
4668                    RepoPath::new("src/shared.rs").unwrap(),
4669                ],
4670                vec![
4671                    RepoPath::new("src/a.rs").unwrap(),
4672                    RepoPath::new("src/b.rs").unwrap(),
4673                    RepoPath::new("src/shared.rs").unwrap(),
4674                ],
4675            ]
4676        );
4677    }
4678
4679    #[gpui::test]
4680    async fn test_initial_graph_data_accepts_sha_log_source(cx: &mut TestAppContext) {
4681        disable_git_global_config();
4682
4683        cx.executor().allow_parking();
4684
4685        let repo_dir = tempfile::tempdir().unwrap();
4686
4687        git_init_repo(repo_dir.path());
4688        fs::write(repo_dir.path().join("file"), "initial").unwrap();
4689        git_command(repo_dir.path(), ["add", "file"]);
4690        git_command(repo_dir.path(), ["commit", "-m", "Initial commit"]);
4691
4692        let commit_sha: Oid = git_command_output(repo_dir.path(), ["rev-parse", "HEAD"])
4693            .parse()
4694            .unwrap();
4695
4696        let repo = RealGitRepository::new(
4697            &repo_dir.path().join(".git"),
4698            None,
4699            Some("git".into()),
4700            cx.executor(),
4701        )
4702        .unwrap();
4703
4704        let (request_tx, request_rx) = async_channel::unbounded();
4705
4706        repo.initial_graph_data(LogSource::Sha(commit_sha), LogOrder::DateOrder, request_tx)
4707            .await
4708            .unwrap();
4709
4710        let graph_data = request_rx.recv().await.unwrap();
4711        assert_eq!(graph_data.len(), 1);
4712        assert_eq!(graph_data[0].sha, commit_sha);
4713    }
4714
4715    #[gpui::test]
4716    async fn test_build_command_untrusted_includes_both_safety_args(cx: &mut TestAppContext) {
4717        cx.executor().allow_parking();
4718        let dir = tempfile::tempdir().unwrap();
4719        let git = GitBinary::new(
4720            PathBuf::from("git"),
4721            dir.path().to_path_buf(),
4722            dir.path().join(".git"),
4723            cx.executor(),
4724            false,
4725        );
4726        let output = git
4727            .build_command(&["version"])
4728            .output()
4729            .await
4730            .expect("git version should succeed");
4731        assert!(output.status.success());
4732
4733        let git = GitBinary::new(
4734            PathBuf::from("git"),
4735            dir.path().to_path_buf(),
4736            dir.path().join(".git"),
4737            cx.executor(),
4738            false,
4739        );
4740        let output = git
4741            .build_command(&["config", "--get", "core.fsmonitor"])
4742            .output()
4743            .await
4744            .expect("git config should run");
4745        let stdout = String::from_utf8_lossy(&output.stdout);
4746        assert_eq!(
4747            stdout.trim(),
4748            "false",
4749            "fsmonitor should be disabled for untrusted repos"
4750        );
4751
4752        git_init_repo(dir.path());
4753        let git = GitBinary::new(
4754            PathBuf::from("git"),
4755            dir.path().to_path_buf(),
4756            dir.path().join(".git"),
4757            cx.executor(),
4758            false,
4759        );
4760        let output = git
4761            .build_command(&["config", "--get", "core.hooksPath"])
4762            .output()
4763            .await
4764            .expect("git config should run");
4765        let stdout = String::from_utf8_lossy(&output.stdout);
4766        assert_eq!(
4767            stdout.trim(),
4768            "/dev/null",
4769            "hooksPath should be /dev/null for untrusted repos"
4770        );
4771    }
4772
4773    #[gpui::test]
4774    async fn test_build_command_trusted_only_disables_fsmonitor(cx: &mut TestAppContext) {
4775        cx.executor().allow_parking();
4776        let dir = tempfile::tempdir().unwrap();
4777        git_init_repo(dir.path());
4778
4779        let git = GitBinary::new(
4780            PathBuf::from("git"),
4781            dir.path().to_path_buf(),
4782            dir.path().join(".git"),
4783            cx.executor(),
4784            true,
4785        );
4786        let output = git
4787            .build_command(&["config", "--get", "core.fsmonitor"])
4788            .output()
4789            .await
4790            .expect("git config should run");
4791        let stdout = String::from_utf8_lossy(&output.stdout);
4792        assert_eq!(
4793            stdout.trim(),
4794            "false",
4795            "fsmonitor should be disabled even for trusted repos"
4796        );
4797
4798        let git = GitBinary::new(
4799            PathBuf::from("git"),
4800            dir.path().to_path_buf(),
4801            dir.path().join(".git"),
4802            cx.executor(),
4803            true,
4804        );
4805        let output = git
4806            .build_command(&["config", "--get", "core.hooksPath"])
4807            .output()
4808            .await
4809            .expect("git config should run");
4810        assert!(
4811            !output.status.success(),
4812            "hooksPath should NOT be overridden for trusted repos"
4813        );
4814    }
4815
4816    #[gpui::test]
4817    async fn test_build_command_disables_log_show_signature(cx: &mut TestAppContext) {
4818        cx.executor().allow_parking();
4819        let dir = tempfile::tempdir().unwrap();
4820        git_init_repo(dir.path());
4821
4822        let git = GitBinary::new(
4823            PathBuf::from("git"),
4824            dir.path().to_path_buf(),
4825            dir.path().join(".git"),
4826            cx.executor(),
4827            true,
4828        );
4829        let output = git
4830            .build_command(&["config", "--get", "log.showSignature"])
4831            .output()
4832            .await
4833            .expect("git config should run");
4834        let stdout = String::from_utf8_lossy(&output.stdout);
4835        assert_eq!(
4836            stdout.trim(),
4837            "false",
4838            "log.showSignature should be disabled for trusted repos"
4839        );
4840
4841        let git = GitBinary::new(
4842            PathBuf::from("git"),
4843            dir.path().to_path_buf(),
4844            dir.path().join(".git"),
4845            cx.executor(),
4846            false,
4847        );
4848        let output = git
4849            .build_command(&["config", "--get", "log.showSignature"])
4850            .output()
4851            .await
4852            .expect("git config should run");
4853        let stdout = String::from_utf8_lossy(&output.stdout);
4854        assert_eq!(
4855            stdout.trim(),
4856            "false",
4857            "log.showSignature should be disabled for untrusted repos"
4858        );
4859    }
4860
4861    #[gpui::test]
4862    async fn test_path_for_index_id_uses_real_git_directory(cx: &mut TestAppContext) {
4863        cx.executor().allow_parking();
4864        let working_directory = PathBuf::from("/code/worktree");
4865        let git_directory = PathBuf::from("/code/repo/.git/modules/worktree");
4866        let git = GitBinary::new(
4867            PathBuf::from("git"),
4868            working_directory,
4869            git_directory.clone(),
4870            cx.executor(),
4871            false,
4872        );
4873
4874        let path = git.path_for_index_id(Uuid::nil());
4875
4876        assert_eq!(
4877            path,
4878            git_directory.join(format!("index-{}.tmp", Uuid::nil()))
4879        );
4880    }
4881
4882    #[gpui::test]
4883    async fn test_checkpoint_basic(cx: &mut TestAppContext) {
4884        disable_git_global_config();
4885
4886        cx.executor().allow_parking();
4887
4888        let repo_dir = tempfile::tempdir().unwrap();
4889
4890        git_init_repo(repo_dir.path());
4891        let file_path = repo_dir.path().join("file");
4892        smol::fs::write(&file_path, "initial").await.unwrap();
4893
4894        let repo = RealGitRepository::new(
4895            &repo_dir.path().join(".git"),
4896            None,
4897            Some("git".into()),
4898            cx.executor(),
4899        )
4900        .unwrap();
4901
4902        repo.stage_paths(vec![repo_path("file")], Arc::new(HashMap::default()))
4903            .await
4904            .unwrap();
4905        repo.commit(
4906            "Initial commit".into(),
4907            None,
4908            CommitOptions::default(),
4909            AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}),
4910            Arc::new(test_commit_envs()),
4911        )
4912        .await
4913        .unwrap();
4914
4915        smol::fs::write(&file_path, "modified before checkpoint")
4916            .await
4917            .unwrap();
4918        smol::fs::write(repo_dir.path().join("new_file_before_checkpoint"), "1")
4919            .await
4920            .unwrap();
4921        let checkpoint = repo.checkpoint().await.unwrap();
4922
4923        // Ensure the user can't see any branches after creating a checkpoint.
4924        assert_eq!(repo.branches().await.unwrap().branches.len(), 1);
4925
4926        smol::fs::write(&file_path, "modified after checkpoint")
4927            .await
4928            .unwrap();
4929        repo.stage_paths(vec![repo_path("file")], Arc::new(HashMap::default()))
4930            .await
4931            .unwrap();
4932        repo.commit(
4933            "Commit after checkpoint".into(),
4934            None,
4935            CommitOptions::default(),
4936            AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}),
4937            Arc::new(test_commit_envs()),
4938        )
4939        .await
4940        .unwrap();
4941
4942        smol::fs::remove_file(repo_dir.path().join("new_file_before_checkpoint"))
4943            .await
4944            .unwrap();
4945        smol::fs::write(repo_dir.path().join("new_file_after_checkpoint"), "2")
4946            .await
4947            .unwrap();
4948
4949        // Ensure checkpoint stays alive even after a Git GC.
4950        repo.gc().await.unwrap();
4951        repo.restore_checkpoint(checkpoint.clone()).await.unwrap();
4952
4953        assert_eq!(
4954            smol::fs::read_to_string(&file_path).await.unwrap(),
4955            "modified before checkpoint"
4956        );
4957        assert_eq!(
4958            smol::fs::read_to_string(repo_dir.path().join("new_file_before_checkpoint"))
4959                .await
4960                .unwrap(),
4961            "1"
4962        );
4963        // See TODO above
4964        // assert_eq!(
4965        //     smol::fs::read_to_string(repo_dir.path().join("new_file_after_checkpoint"))
4966        //         .await
4967        //         .ok(),
4968        //     None
4969        // );
4970    }
4971
4972    #[cfg(unix)]
4973    #[gpui::test]
4974    async fn test_commit_runs_git_hooks(cx: &mut TestAppContext) {
4975        use std::os::unix::fs::PermissionsExt as _;
4976
4977        disable_git_global_config();
4978        cx.executor().allow_parking();
4979
4980        let repo_dir = tempfile::tempdir().unwrap();
4981        git_init_repo(repo_dir.path());
4982        let repo = RealGitRepository::new(
4983            &repo_dir.path().join(".git"),
4984            None,
4985            Some("git".into()),
4986            cx.executor(),
4987        )
4988        .unwrap();
4989
4990        let hooks_dir = repo_dir.path().join(".git").join("hooks");
4991        fs::create_dir_all(&hooks_dir).unwrap();
4992        let write_hook = |name: &str, contents: &str| {
4993            let path = hooks_dir.join(name);
4994            fs::write(&path, contents).unwrap();
4995            fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
4996        };
4997
4998        write_hook("pre-commit", "#!/bin/sh\nexit 1\n");
4999
5000        fs::write(repo_dir.path().join("file"), "one").unwrap();
5001        repo.stage_paths(vec![repo_path("file")], Arc::new(HashMap::default()))
5002            .await
5003            .unwrap();
5004
5005        // Hooks must not run for untrusted repositories.
5006        repo.commit(
5007            "Commit in untrusted repo".into(),
5008            None,
5009            CommitOptions::default(),
5010            AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}),
5011            Arc::new(test_commit_envs()),
5012        )
5013        .await
5014        .expect("failing pre-commit hook should be skipped in untrusted repos");
5015
5016        repo.set_trusted(true);
5017
5018        fs::write(repo_dir.path().join("file"), "two").unwrap();
5019        repo.stage_paths(vec![repo_path("file")], Arc::new(HashMap::default()))
5020            .await
5021            .unwrap();
5022
5023        repo.commit(
5024            "Commit blocked by hook".into(),
5025            None,
5026            CommitOptions::default(),
5027            AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}),
5028            Arc::new(test_commit_envs()),
5029        )
5030        .await
5031        .expect_err("failing pre-commit hook should abort the commit");
5032
5033        write_hook("pre-commit", "#!/bin/sh\nexit 0\n");
5034        write_hook(
5035            "commit-msg",
5036            "#!/bin/sh\necho 'rewritten by commit-msg hook' > \"$1\"\n",
5037        );
5038
5039        repo.commit(
5040            "Original message".into(),
5041            None,
5042            CommitOptions::default(),
5043            AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}),
5044            Arc::new(test_commit_envs()),
5045        )
5046        .await
5047        .unwrap();
5048
5049        let message = git_command_output(repo_dir.path(), ["log", "-1", "--pretty=%B"]);
5050        assert_eq!(message, "rewritten by commit-msg hook");
5051
5052        write_hook("pre-commit", "#!/bin/sh\nexit 1\n");
5053        fs::write(repo_dir.path().join("file"), "three").unwrap();
5054        repo.stage_paths(vec![repo_path("file")], Arc::new(HashMap::default()))
5055            .await
5056            .unwrap();
5057
5058        repo.commit(
5059            "Commit without verification".into(),
5060            None,
5061            CommitOptions {
5062                no_verify: true,
5063                ..Default::default()
5064            },
5065            AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}),
5066            Arc::new(test_commit_envs()),
5067        )
5068        .await
5069        .expect("--no-verify should skip pre-commit and commit-msg hooks");
5070
5071        let message = git_command_output(repo_dir.path(), ["log", "-1", "--pretty=%B"]);
5072        assert_eq!(message, "Commit without verification");
5073    }
5074
5075    #[gpui::test]
5076    async fn test_load_revisions(cx: &mut TestAppContext) {
5077        disable_git_global_config();
5078        cx.executor().allow_parking();
5079
5080        let repo_dir = tempfile::tempdir().unwrap();
5081        git_init_repo(repo_dir.path());
5082
5083        let file1_path = repo_dir.path().join("file1");
5084        let file2_path = repo_dir.path().join("file2");
5085        let space_file_path = repo_dir.path().join("file with spaces");
5086
5087        smol::fs::write(&file1_path, "file1 committed contents")
5088            .await
5089            .unwrap();
5090        smol::fs::write(&file2_path, "file2 committed contents")
5091            .await
5092            .unwrap();
5093        smol::fs::write(&space_file_path, "space file committed contents")
5094            .await
5095            .unwrap();
5096
5097        let repo = RealGitRepository::new(
5098            &repo_dir.path().join(".git"),
5099            None,
5100            Some("git".into()),
5101            cx.executor(),
5102        )
5103        .unwrap();
5104
5105        // Stage files and commit
5106        repo.stage_paths(
5107            vec![
5108                repo_path("file1"),
5109                repo_path("file2"),
5110                repo_path("file with spaces"),
5111            ],
5112            Arc::new(HashMap::default()),
5113        )
5114        .await
5115        .unwrap();
5116        repo.commit(
5117            "Initial commit".into(),
5118            None,
5119            CommitOptions::default(),
5120            AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}),
5121            Arc::new(test_commit_envs()),
5122        )
5123        .await
5124        .unwrap();
5125
5126        // Now modify files in index but not yet committed
5127        smol::fs::write(&file1_path, "file1 index contents")
5128            .await
5129            .unwrap();
5130        repo.stage_paths(vec![repo_path("file1")], Arc::new(HashMap::default()))
5131            .await
5132            .unwrap();
5133
5134        // Write working tree contents (not indexed, not committed)
5135        smol::fs::write(&file1_path, "file1 worktree contents")
5136            .await
5137            .unwrap();
5138
5139        // Now test load_revisions
5140        let results = repo
5141            .load_revisions(
5142                [
5143                    "HEAD:file1",
5144                    ":file1",
5145                    "HEAD:file2",
5146                    ":file2",
5147                    "HEAD:nonexistent",
5148                    "HEAD:file with spaces",
5149                    "HEAD:nonexistent file with spaces",
5150                ]
5151                .into_iter()
5152                .map(String::from)
5153                .collect(),
5154            )
5155            .await
5156            .unwrap();
5157
5158        assert_eq!(
5159            results,
5160            vec![
5161                Some("file1 committed contents".into()),
5162                Some("file1 index contents".into()),
5163                Some("file2 committed contents".into()),
5164                Some("file2 committed contents".into()), // untouched in index, should match HEAD
5165                None,
5166                Some("space file committed contents".into()),
5167                None,
5168            ]
5169        );
5170    }
5171
5172    #[gpui::test]
5173    async fn test_checkpoint_empty_repo(cx: &mut TestAppContext) {
5174        disable_git_global_config();
5175
5176        cx.executor().allow_parking();
5177
5178        let repo_dir = tempfile::tempdir().unwrap();
5179        git_init_repo(repo_dir.path());
5180        let repo = RealGitRepository::new(
5181            &repo_dir.path().join(".git"),
5182            None,
5183            Some("git".into()),
5184            cx.executor(),
5185        )
5186        .unwrap();
5187
5188        smol::fs::write(repo_dir.path().join("foo"), "foo")
5189            .await
5190            .unwrap();
5191        let checkpoint_sha = repo.checkpoint().await.unwrap();
5192
5193        // Ensure the user can't see any branches after creating a checkpoint.
5194        assert_eq!(repo.branches().await.unwrap().branches.len(), 1);
5195
5196        smol::fs::write(repo_dir.path().join("foo"), "bar")
5197            .await
5198            .unwrap();
5199        smol::fs::write(repo_dir.path().join("baz"), "qux")
5200            .await
5201            .unwrap();
5202        repo.restore_checkpoint(checkpoint_sha).await.unwrap();
5203        assert_eq!(
5204            smol::fs::read_to_string(repo_dir.path().join("foo"))
5205                .await
5206                .unwrap(),
5207            "foo"
5208        );
5209        // See TODOs above
5210        // assert_eq!(
5211        //     smol::fs::read_to_string(repo_dir.path().join("baz"))
5212        //         .await
5213        //         .ok(),
5214        //     None
5215        // );
5216    }
5217
5218    #[gpui::test]
5219    async fn test_branches_return_head_when_commit_metadata_cannot_be_read(
5220        cx: &mut TestAppContext,
5221    ) {
5222        disable_git_global_config();
5223
5224        cx.executor().allow_parking();
5225
5226        let repo_dir = tempfile::tempdir().unwrap();
5227        git_init_repo(repo_dir.path());
5228        let repo = RealGitRepository::new(
5229            &repo_dir.path().join(".git"),
5230            None,
5231            Some("git".into()),
5232            cx.executor(),
5233        )
5234        .unwrap();
5235
5236        smol::fs::write(repo_dir.path().join("file.txt"), "content")
5237            .await
5238            .unwrap();
5239        repo.stage_paths(vec![repo_path("file.txt")], Arc::new(HashMap::default()))
5240            .await
5241            .unwrap();
5242        repo.commit(
5243            "Initial commit".into(),
5244            None,
5245            CommitOptions::default(),
5246            AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}),
5247            Arc::new(test_commit_envs()),
5248        )
5249        .await
5250        .unwrap();
5251
5252        smol::fs::write(
5253            repo_dir.path().join(".git").join("refs/heads/broken"),
5254            "0a103ede22f159c792dc6405e0c8304d9bd4dc29\n",
5255        )
5256        .await
5257        .unwrap();
5258
5259        let branches_scan = repo.branches().await.unwrap();
5260        assert!(branches_scan.error.is_some());
5261        let head_branch = branches_scan
5262            .branches
5263            .iter()
5264            .find(|branch| branch.is_head)
5265            .expect("branch list should include HEAD");
5266        assert!(head_branch.ref_name.starts_with("refs/heads/"));
5267
5268        assert!(
5269            branches_scan
5270                .branches
5271                .iter()
5272                .all(|branch| branch.ref_name.as_ref() != "refs/heads/broken")
5273        );
5274    }
5275
5276    #[gpui::test]
5277    async fn test_compare_checkpoints(cx: &mut TestAppContext) {
5278        disable_git_global_config();
5279
5280        cx.executor().allow_parking();
5281
5282        let repo_dir = tempfile::tempdir().unwrap();
5283        git_init_repo(repo_dir.path());
5284        let repo = RealGitRepository::new(
5285            &repo_dir.path().join(".git"),
5286            None,
5287            Some("git".into()),
5288            cx.executor(),
5289        )
5290        .unwrap();
5291
5292        smol::fs::write(repo_dir.path().join("file1"), "content1")
5293            .await
5294            .unwrap();
5295        let checkpoint1 = repo.checkpoint().await.unwrap();
5296
5297        smol::fs::write(repo_dir.path().join("file2"), "content2")
5298            .await
5299            .unwrap();
5300        let checkpoint2 = repo.checkpoint().await.unwrap();
5301
5302        assert!(
5303            !repo
5304                .compare_checkpoints(checkpoint1, checkpoint2.clone())
5305                .await
5306                .unwrap()
5307        );
5308
5309        let checkpoint3 = repo.checkpoint().await.unwrap();
5310        assert!(
5311            repo.compare_checkpoints(checkpoint2, checkpoint3)
5312                .await
5313                .unwrap()
5314        );
5315    }
5316
5317    #[gpui::test]
5318    async fn test_checkpoint_exclude_binary_files(cx: &mut TestAppContext) {
5319        disable_git_global_config();
5320
5321        cx.executor().allow_parking();
5322
5323        let repo_dir = tempfile::tempdir().unwrap();
5324        let text_path = repo_dir.path().join("main.rs");
5325        let bin_path = repo_dir.path().join("binary.o");
5326
5327        git_init_repo(repo_dir.path());
5328
5329        smol::fs::write(&text_path, "fn main() {}").await.unwrap();
5330
5331        smol::fs::write(&bin_path, "some binary file here")
5332            .await
5333            .unwrap();
5334
5335        let repo = RealGitRepository::new(
5336            &repo_dir.path().join(".git"),
5337            None,
5338            Some("git".into()),
5339            cx.executor(),
5340        )
5341        .unwrap();
5342
5343        // initial commit
5344        repo.stage_paths(vec![repo_path("main.rs")], Arc::new(HashMap::default()))
5345            .await
5346            .unwrap();
5347        repo.commit(
5348            "Initial commit".into(),
5349            None,
5350            CommitOptions::default(),
5351            AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}),
5352            Arc::new(test_commit_envs()),
5353        )
5354        .await
5355        .unwrap();
5356
5357        let checkpoint = repo.checkpoint().await.unwrap();
5358
5359        smol::fs::write(&text_path, "fn main() { println!(\"Modified\"); }")
5360            .await
5361            .unwrap();
5362        smol::fs::write(&bin_path, "Modified binary file")
5363            .await
5364            .unwrap();
5365
5366        repo.restore_checkpoint(checkpoint).await.unwrap();
5367
5368        // Text files should be restored to checkpoint state,
5369        // but binaries should not (they aren't tracked)
5370        assert_eq!(
5371            smol::fs::read_to_string(&text_path).await.unwrap(),
5372            "fn main() {}"
5373        );
5374
5375        assert_eq!(
5376            smol::fs::read_to_string(&bin_path).await.unwrap(),
5377            "Modified binary file"
5378        );
5379    }
5380
5381    #[test]
5382    fn test_branches_parsing() {
5383        // suppress "help: octal escapes are not supported, `\0` is always null"
5384        #[allow(clippy::octal_escapes)]
5385        let input = "*\0060964da10574cd9bf06463a53bf6e0769c5c45e\0\0refs/heads/zed-patches\0refs/remotes/origin/zed-patches\0\01733187470\0John Doe\0generated protobuf\n";
5386        assert_eq!(
5387            parse_branch_input(input).unwrap(),
5388            vec![Branch {
5389                is_head: true,
5390                ref_name: "refs/heads/zed-patches".into(),
5391                upstream: Some(Upstream {
5392                    ref_name: "refs/remotes/origin/zed-patches".into(),
5393                    tracking: UpstreamTracking::Tracked(UpstreamTrackingStatus {
5394                        ahead: 0,
5395                        behind: 0
5396                    })
5397                }),
5398                most_recent_commit: Some(CommitSummary {
5399                    sha: "060964da10574cd9bf06463a53bf6e0769c5c45e".into(),
5400                    subject: "generated protobuf".into(),
5401                    commit_timestamp: 1733187470,
5402                    author_name: SharedString::new_static("John Doe"),
5403                    has_parent: false,
5404                })
5405            }]
5406        )
5407    }
5408
5409    #[test]
5410    fn test_branches_parsing_containing_refs_with_missing_fields() {
5411        #[allow(clippy::octal_escapes)]
5412        let input = " \090012116c03db04344ab10d50348553aa94f1ea0\0refs/heads/broken\n \0eb0cae33272689bd11030822939dd2701c52f81e\0895951d681e5561478c0acdd6905e8aacdfd2249\0refs/heads/dev\0\0\01762948725\0Zed\0Add feature\n*\0895951d681e5561478c0acdd6905e8aacdfd2249\0\0refs/heads/main\0\0\01762948695\0Zed\0Initial commit\n";
5413
5414        let branches = parse_branch_input(input).unwrap();
5415        assert_eq!(branches.len(), 2);
5416        assert_eq!(
5417            branches,
5418            vec![
5419                Branch {
5420                    is_head: false,
5421                    ref_name: "refs/heads/dev".into(),
5422                    upstream: None,
5423                    most_recent_commit: Some(CommitSummary {
5424                        sha: "eb0cae33272689bd11030822939dd2701c52f81e".into(),
5425                        subject: "Add feature".into(),
5426                        commit_timestamp: 1762948725,
5427                        author_name: SharedString::new_static("Zed"),
5428                        has_parent: true,
5429                    })
5430                },
5431                Branch {
5432                    is_head: true,
5433                    ref_name: "refs/heads/main".into(),
5434                    upstream: None,
5435                    most_recent_commit: Some(CommitSummary {
5436                        sha: "895951d681e5561478c0acdd6905e8aacdfd2249".into(),
5437                        subject: "Initial commit".into(),
5438                        commit_timestamp: 1762948695,
5439                        author_name: SharedString::new_static("Zed"),
5440                        has_parent: false,
5441                    })
5442                }
5443            ]
5444        )
5445    }
5446
5447    #[test]
5448    fn test_upstream_branch_name() {
5449        let upstream = Upstream {
5450            ref_name: "refs/remotes/origin/feature/branch".into(),
5451            tracking: UpstreamTracking::Tracked(UpstreamTrackingStatus {
5452                ahead: 0,
5453                behind: 0,
5454            }),
5455        };
5456        assert_eq!(upstream.branch_name(), Some("feature/branch"));
5457
5458        let upstream = Upstream {
5459            ref_name: "refs/remotes/upstream/main".into(),
5460            tracking: UpstreamTracking::Tracked(UpstreamTrackingStatus {
5461                ahead: 0,
5462                behind: 0,
5463            }),
5464        };
5465        assert_eq!(upstream.branch_name(), Some("main"));
5466
5467        let upstream = Upstream {
5468            ref_name: "refs/heads/local".into(),
5469            tracking: UpstreamTracking::Tracked(UpstreamTrackingStatus {
5470                ahead: 0,
5471                behind: 0,
5472            }),
5473        };
5474        assert_eq!(upstream.branch_name(), None);
5475
5476        // Test case where upstream branch name differs from what might be the local branch name
5477        let upstream = Upstream {
5478            ref_name: "refs/remotes/origin/feature/git-pull-request".into(),
5479            tracking: UpstreamTracking::Tracked(UpstreamTrackingStatus {
5480                ahead: 0,
5481                behind: 0,
5482            }),
5483        };
5484        assert_eq!(upstream.branch_name(), Some("feature/git-pull-request"));
5485    }
5486
5487    #[test]
5488    fn test_parse_worktrees_from_str() {
5489        // Empty input
5490        let result = parse_worktrees_from_str("", None);
5491        assert!(result.is_empty());
5492
5493        // Single worktree (main)
5494        let input = "worktree /home/user/project\nHEAD abc123def\nbranch refs/heads/main\n\n";
5495        let result = parse_worktrees_from_str(input, Some(Path::new("/home/user/project")));
5496        assert_eq!(result.len(), 1);
5497        assert_eq!(result[0].path, PathBuf::from("/home/user/project"));
5498        assert_eq!(result[0].sha.as_ref(), "abc123def");
5499        assert_eq!(result[0].ref_name, Some("refs/heads/main".into()));
5500        assert!(result[0].is_main);
5501        assert!(!result[0].is_bare);
5502
5503        // Multiple worktrees
5504        let input = "worktree /home/user/project-wt\nHEAD def456\nbranch refs/heads/feature\n\n\
5505                      worktree /home/user/project\nHEAD abc123\nbranch refs/heads/main\n\n";
5506        let result = parse_worktrees_from_str(input, Some(Path::new("/home/user/project")));
5507        assert_eq!(result.len(), 2);
5508        assert_eq!(result[0].path, PathBuf::from("/home/user/project-wt"));
5509        assert_eq!(result[0].ref_name, Some("refs/heads/feature".into()));
5510        assert!(!result[0].is_main);
5511        assert!(!result[0].is_bare);
5512        assert_eq!(result[1].path, PathBuf::from("/home/user/project"));
5513        assert_eq!(result[1].ref_name, Some("refs/heads/main".into()));
5514        assert!(result[1].is_main);
5515        assert!(!result[1].is_bare);
5516
5517        // Detached HEAD entry (included with ref_name: None)
5518        let input = "worktree /home/user/project\nHEAD abc123\nbranch refs/heads/main\n\n\
5519                      worktree /home/user/detached\nHEAD def456\ndetached\n\n";
5520        let result = parse_worktrees_from_str(input, Some(Path::new("/home/user/project")));
5521        assert_eq!(result.len(), 2);
5522        assert_eq!(result[0].path, PathBuf::from("/home/user/project"));
5523        assert_eq!(result[0].ref_name, Some("refs/heads/main".into()));
5524        assert!(result[0].is_main);
5525        assert_eq!(result[1].path, PathBuf::from("/home/user/detached"));
5526        assert_eq!(result[1].ref_name, None);
5527        assert_eq!(result[1].sha.as_ref(), "def456");
5528        assert!(!result[1].is_main);
5529        assert!(!result[1].is_bare);
5530
5531        // Bare repo entry with no main worktree.
5532        let input = "worktree /home/user/bare.git\nHEAD abc123\nbare\n\n\
5533                      worktree /home/user/project\nHEAD def456\nbranch refs/heads/main\n\n";
5534        let result = parse_worktrees_from_str(input, None);
5535        assert_eq!(result.len(), 2);
5536        assert_eq!(result[0].path, PathBuf::from("/home/user/bare.git"));
5537        assert_eq!(result[0].ref_name, None);
5538        assert!(!result[0].is_main);
5539        assert!(result[0].is_bare);
5540        assert_eq!(result[1].path, PathBuf::from("/home/user/project"));
5541        assert_eq!(result[1].ref_name, Some("refs/heads/main".into()));
5542        assert!(!result[1].is_main);
5543        assert!(!result[1].is_bare);
5544
5545        // Extra porcelain lines (locked, prunable) should be ignored
5546        let input = "worktree /home/user/project\nHEAD abc123\nbranch refs/heads/main\n\n\
5547                      worktree /home/user/locked-wt\nHEAD def456\nbranch refs/heads/locked-branch\nlocked\n\n\
5548                      worktree /home/user/prunable-wt\nHEAD 789aaa\nbranch refs/heads/prunable-branch\nprunable\n\n";
5549        let result = parse_worktrees_from_str(input, Some(Path::new("/home/user/project")));
5550        assert_eq!(result.len(), 3);
5551        assert_eq!(result[0].path, PathBuf::from("/home/user/project"));
5552        assert_eq!(result[0].ref_name, Some("refs/heads/main".into()));
5553        assert!(result[0].is_main);
5554        assert_eq!(result[1].path, PathBuf::from("/home/user/locked-wt"));
5555        assert_eq!(result[1].ref_name, Some("refs/heads/locked-branch".into()));
5556        assert!(!result[1].is_main);
5557        assert_eq!(result[2].path, PathBuf::from("/home/user/prunable-wt"));
5558        assert_eq!(
5559            result[2].ref_name,
5560            Some("refs/heads/prunable-branch".into())
5561        );
5562        assert!(!result[2].is_main);
5563
5564        // Leading/trailing whitespace on lines should be tolerated
5565        let input =
5566            "  worktree /home/user/project  \n  HEAD abc123  \n  branch refs/heads/main  \n\n";
5567        let result = parse_worktrees_from_str(input, Some(Path::new("/home/user/project")));
5568        assert_eq!(result.len(), 1);
5569        assert_eq!(result[0].path, PathBuf::from("/home/user/project"));
5570        assert_eq!(result[0].sha.as_ref(), "abc123");
5571        assert_eq!(result[0].ref_name, Some("refs/heads/main".into()));
5572        assert!(result[0].is_main);
5573
5574        // Windows-style line endings should be handled
5575        let input = "worktree /home/user/project\r\nHEAD abc123\r\nbranch refs/heads/main\r\n\r\n";
5576        let result = parse_worktrees_from_str(input, Some(Path::new("/home/user/project")));
5577        assert_eq!(result.len(), 1);
5578        assert_eq!(result[0].path, PathBuf::from("/home/user/project"));
5579        assert_eq!(result[0].sha.as_ref(), "abc123");
5580        assert_eq!(result[0].ref_name, Some("refs/heads/main".into()));
5581        assert!(result[0].is_main);
5582    }
5583
5584    #[gpui::test]
5585    async fn test_create_and_list_worktrees(cx: &mut TestAppContext) {
5586        disable_git_global_config();
5587        cx.executor().allow_parking();
5588
5589        let temp_dir = tempfile::tempdir().unwrap();
5590        let repo_dir = temp_dir.path().join("repo");
5591        let worktrees_dir = temp_dir.path().join("worktrees");
5592
5593        fs::create_dir_all(&repo_dir).unwrap();
5594        fs::create_dir_all(&worktrees_dir).unwrap();
5595
5596        git_init_repo(&repo_dir);
5597
5598        let repo = RealGitRepository::new(
5599            &repo_dir.join(".git"),
5600            None,
5601            Some("git".into()),
5602            cx.executor(),
5603        )
5604        .unwrap();
5605
5606        // Create an initial commit (required for worktrees)
5607        smol::fs::write(repo_dir.join("file.txt"), "content")
5608            .await
5609            .unwrap();
5610        repo.stage_paths(vec![repo_path("file.txt")], Arc::new(HashMap::default()))
5611            .await
5612            .unwrap();
5613        repo.commit(
5614            "Initial commit".into(),
5615            None,
5616            CommitOptions::default(),
5617            AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}),
5618            Arc::new(test_commit_envs()),
5619        )
5620        .await
5621        .unwrap();
5622
5623        // List worktrees — should have just the main one
5624        let worktrees = repo.worktrees().await.unwrap();
5625        assert_eq!(worktrees.len(), 1);
5626        assert_eq!(
5627            worktrees[0].path.canonicalize().unwrap(),
5628            repo_dir.canonicalize().unwrap()
5629        );
5630
5631        let worktree_path = worktrees_dir.join("some-worktree");
5632
5633        // Create a new worktree
5634        repo.create_worktree(
5635            CreateWorktreeTarget::NewBranch {
5636                branch_name: "test-branch".to_string(),
5637                base_sha: Some("HEAD".to_string()),
5638            },
5639            worktree_path.clone(),
5640        )
5641        .await
5642        .unwrap();
5643
5644        // List worktrees — should have two
5645        let worktrees = repo.worktrees().await.unwrap();
5646        assert_eq!(worktrees.len(), 2);
5647
5648        let new_worktree = worktrees
5649            .iter()
5650            .find(|w| w.display_name() == "test-branch")
5651            .expect("should find worktree with test-branch");
5652        assert_eq!(
5653            new_worktree.path.canonicalize().unwrap(),
5654            worktree_path.canonicalize().unwrap(),
5655        );
5656
5657        // The new worktree's git metadata directory should report a creation
5658        // time, resolved via the worktree's `.git` file.
5659        let created_at = repo
5660            .worktree_created_at(worktree_path.clone())
5661            .await
5662            .unwrap();
5663        assert!(
5664            created_at.is_some(),
5665            "creation time should be available for a freshly created worktree"
5666        );
5667
5668        // A path with no worktree at all reports `None`.
5669        let missing = repo
5670            .worktree_created_at(worktrees_dir.join("does-not-exist"))
5671            .await
5672            .unwrap();
5673        assert_eq!(missing, None);
5674    }
5675
5676    #[gpui::test]
5677    async fn test_remove_worktree(cx: &mut TestAppContext) {
5678        disable_git_global_config();
5679        cx.executor().allow_parking();
5680
5681        let temp_dir = tempfile::tempdir().unwrap();
5682        let repo_dir = temp_dir.path().join("repo");
5683        let worktrees_dir = temp_dir.path().join("worktrees");
5684        git_init_repo(&repo_dir);
5685
5686        let repo = RealGitRepository::new(
5687            &repo_dir.join(".git"),
5688            None,
5689            Some("git".into()),
5690            cx.executor(),
5691        )
5692        .unwrap();
5693
5694        // Create an initial commit
5695        smol::fs::write(repo_dir.join("file.txt"), "content")
5696            .await
5697            .unwrap();
5698        repo.stage_paths(vec![repo_path("file.txt")], Arc::new(HashMap::default()))
5699            .await
5700            .unwrap();
5701        repo.commit(
5702            "Initial commit".into(),
5703            None,
5704            CommitOptions::default(),
5705            AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}),
5706            Arc::new(test_commit_envs()),
5707        )
5708        .await
5709        .unwrap();
5710
5711        // Create a worktree
5712        let worktree_path = worktrees_dir.join("worktree-to-remove");
5713        repo.create_worktree(
5714            CreateWorktreeTarget::NewBranch {
5715                branch_name: "to-remove".to_string(),
5716                base_sha: Some("HEAD".to_string()),
5717            },
5718            worktree_path.clone(),
5719        )
5720        .await
5721        .unwrap();
5722
5723        // Remove the worktree
5724        repo.remove_worktree(worktree_path.clone(), false)
5725            .await
5726            .unwrap();
5727
5728        // Verify the directory is removed
5729        let worktrees = repo.worktrees().await.unwrap();
5730        assert_eq!(worktrees.len(), 1);
5731        assert!(
5732            worktrees.iter().all(|w| w.display_name() != "to-remove"),
5733            "removed worktree should not appear in list"
5734        );
5735        assert!(!worktree_path.exists());
5736
5737        // Create a worktree
5738        let worktree_path = worktrees_dir.join("dirty-wt");
5739        repo.create_worktree(
5740            CreateWorktreeTarget::NewBranch {
5741                branch_name: "dirty-wt".to_string(),
5742                base_sha: Some("HEAD".to_string()),
5743            },
5744            worktree_path.clone(),
5745        )
5746        .await
5747        .unwrap();
5748
5749        assert!(worktree_path.exists());
5750
5751        // Add uncommitted changes in the worktree
5752        smol::fs::write(worktree_path.join("dirty-file.txt"), "uncommitted")
5753            .await
5754            .unwrap();
5755
5756        // Non-force removal should fail with dirty worktree
5757        let result = repo.remove_worktree(worktree_path.clone(), false).await;
5758        assert!(
5759            result.is_err(),
5760            "non-force removal of dirty worktree should fail"
5761        );
5762
5763        // Force removal should succeed
5764        repo.remove_worktree(worktree_path.clone(), true)
5765            .await
5766            .unwrap();
5767
5768        let worktrees = repo.worktrees().await.unwrap();
5769        assert_eq!(worktrees.len(), 1);
5770        assert!(!worktree_path.exists());
5771    }
5772
5773    #[gpui::test]
5774    async fn test_rename_worktree(cx: &mut TestAppContext) {
5775        disable_git_global_config();
5776        cx.executor().allow_parking();
5777
5778        let temp_dir = tempfile::tempdir().unwrap();
5779        let repo_dir = temp_dir.path().join("repo");
5780        let worktrees_dir = temp_dir.path().join("worktrees");
5781
5782        git_init_repo(&repo_dir);
5783
5784        let repo = RealGitRepository::new(
5785            &repo_dir.join(".git"),
5786            None,
5787            Some("git".into()),
5788            cx.executor(),
5789        )
5790        .unwrap();
5791
5792        // Create an initial commit
5793        smol::fs::write(repo_dir.join("file.txt"), "content")
5794            .await
5795            .unwrap();
5796        repo.stage_paths(vec![repo_path("file.txt")], Arc::new(HashMap::default()))
5797            .await
5798            .unwrap();
5799        repo.commit(
5800            "Initial commit".into(),
5801            None,
5802            CommitOptions::default(),
5803            AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}),
5804            Arc::new(test_commit_envs()),
5805        )
5806        .await
5807        .unwrap();
5808
5809        // Create a worktree
5810        let old_path = worktrees_dir.join("old-worktree-name");
5811        repo.create_worktree(
5812            CreateWorktreeTarget::NewBranch {
5813                branch_name: "old-name".to_string(),
5814                base_sha: Some("HEAD".to_string()),
5815            },
5816            old_path.clone(),
5817        )
5818        .await
5819        .unwrap();
5820
5821        assert!(old_path.exists());
5822
5823        // Move the worktree to a new path
5824        let new_path = worktrees_dir.join("new-worktree-name");
5825        repo.rename_worktree(old_path.clone(), new_path.clone())
5826            .await
5827            .unwrap();
5828
5829        // Verify the old path is gone and new path exists
5830        assert!(!old_path.exists());
5831        assert!(new_path.exists());
5832
5833        // Verify it shows up in worktree list at the new path
5834        let worktrees = repo.worktrees().await.unwrap();
5835        assert_eq!(worktrees.len(), 2);
5836        let moved_worktree = worktrees
5837            .iter()
5838            .find(|w| w.display_name() == "old-name")
5839            .expect("should find worktree by branch name");
5840        assert_eq!(
5841            moved_worktree.path.canonicalize().unwrap(),
5842            new_path.canonicalize().unwrap()
5843        );
5844    }
5845
5846    #[gpui::test]
5847    async fn test_initial_graph_data_ref_set(cx: &mut TestAppContext) {
5848        disable_git_global_config();
5849        cx.executor().allow_parking();
5850
5851        let repo_dir = tempfile::tempdir().unwrap();
5852        git_init_repo(repo_dir.path());
5853
5854        let repo = RealGitRepository::new(
5855            &repo_dir.path().join(".git"),
5856            None,
5857            Some("git".into()),
5858            cx.executor(),
5859        )
5860        .unwrap();
5861        let git = repo.git_binary();
5862
5863        let graph_commits = async || {
5864            let (tx, rx) = smol::channel::unbounded();
5865            repo.initial_graph_data(LogSource::All, LogOrder::DateOrder, tx)
5866                .await
5867                .unwrap();
5868            let mut commits = std::collections::HashSet::new();
5869            while let Ok(chunk) = rx.try_recv() {
5870                for commit in chunk {
5871                    commits.insert(commit.sha);
5872                }
5873            }
5874            commits
5875        };
5876
5877        smol::fs::write(repo_dir.path().join("file1"), "1")
5878            .await
5879            .unwrap();
5880        let branch_sha = repo.checkpoint().await.unwrap().commit_sha;
5881        repo.update_ref("refs/heads/main".into(), branch_sha.to_string())
5882            .await
5883            .unwrap();
5884
5885        smol::fs::write(repo_dir.path().join("file2"), "2")
5886            .await
5887            .unwrap();
5888        let hidden_sha = repo.checkpoint().await.unwrap().commit_sha;
5889        repo.update_ref("refs/custom/hidden".into(), hidden_sha.to_string())
5890            .await
5891            .unwrap();
5892
5893        let graph = graph_commits().await;
5894        assert!(graph.contains(&branch_sha));
5895        assert!(!graph.contains(&hidden_sha));
5896
5897        git.build_command(&["update-ref", "--no-deref", "HEAD", &hidden_sha.to_string()])
5898            .output()
5899            .await
5900            .unwrap();
5901
5902        let graph = graph_commits().await;
5903        assert!(graph.contains(&branch_sha));
5904        assert!(graph.contains(&hidden_sha));
5905    }
5906
5907    #[gpui::test]
5908    async fn test_check_for_pushed_commit(cx: &mut TestAppContext) {
5909        disable_git_global_config();
5910        cx.executor().allow_parking();
5911
5912        let temp_dir = tempfile::tempdir().unwrap();
5913        let repo_dir = temp_dir.path().join("repo");
5914        git_init_repo(&repo_dir);
5915
5916        let repo = RealGitRepository::new(
5917            &repo_dir.join(".git"),
5918            None,
5919            Some("git".into()),
5920            cx.executor(),
5921        )
5922        .unwrap();
5923
5924        // New repo doesn't have any commits yet
5925        assert!(repo.check_for_pushed_commit().await.unwrap().is_empty());
5926
5927        git_command(
5928            &repo_dir,
5929            ["commit", "--allow-empty", "-m", "Initial commit"],
5930        );
5931
5932        // No remote branches exist yet
5933        assert!(repo.check_for_pushed_commit().await.unwrap().is_empty());
5934
5935        // Create simulated remote branches
5936        git_command(
5937            &repo_dir,
5938            ["update-ref", "refs/remotes/origin/main", "HEAD"],
5939        );
5940        git_command(
5941            &repo_dir,
5942            ["update-ref", "refs/remotes/origin/other-branch", "HEAD"],
5943        );
5944        assert_eq!(
5945            repo.check_for_pushed_commit().await.unwrap(),
5946            vec![
5947                SharedString::from("origin/main"),
5948                SharedString::from("origin/other-branch")
5949            ]
5950        );
5951
5952        // Switch to a new branch, commit but do not push
5953        git_command(&repo_dir, ["switch", "-c", "local-feature"]);
5954        git_command(&repo_dir, ["commit", "--allow-empty", "-m", "Local commit"]);
5955
5956        // New commit has not been pushed
5957        assert!(repo.check_for_pushed_commit().await.unwrap().is_empty());
5958    }
5959
5960    #[test]
5961    fn test_original_repo_path_from_common_dir() {
5962        // Normal repo: common_dir is <work_dir>/.git
5963        assert_eq!(
5964            original_repo_path_from_common_dir(Path::new("/code/zed5/.git")),
5965            Some(PathBuf::from("/code/zed5"))
5966        );
5967
5968        // Worktree: common_dir is the main repo's .git
5969        // (same result — that's the point, it always traces back to the original)
5970        assert_eq!(
5971            original_repo_path_from_common_dir(Path::new("/code/zed5/.git")),
5972            Some(PathBuf::from("/code/zed5"))
5973        );
5974
5975        // Bare repo: no .git suffix, returns None (no working-tree root)
5976        assert_eq!(
5977            original_repo_path_from_common_dir(Path::new("/code/zed5.git")),
5978            None
5979        );
5980
5981        // Root-level .git directory
5982        assert_eq!(
5983            original_repo_path_from_common_dir(Path::new("/.git")),
5984            Some(PathBuf::from("/"))
5985        );
5986    }
5987
5988    #[gpui::test]
5989    async fn test_default_branch(cx: &mut TestAppContext) {
5990        disable_git_global_config();
5991        cx.executor().allow_parking();
5992
5993        let repo_dir = tempfile::tempdir().unwrap();
5994        git_init_repo(repo_dir.path());
5995
5996        let repo = RealGitRepository::new(
5997            &repo_dir.path().join(".git"),
5998            None,
5999            Some("git".into()),
6000            cx.executor(),
6001        )
6002        .unwrap();
6003
6004        assert_eq!(repo.default_branch(false).await.unwrap(), None);
6005
6006        git_command(
6007            repo_dir.path(),
6008            ["commit", "--allow-empty", "-m", "Initial commit"],
6009        );
6010
6011        assert_eq!(
6012            repo.default_branch(false).await.unwrap(),
6013            Some("main".into())
6014        );
6015
6016        git_command(
6017            repo_dir.path(),
6018            ["update-ref", "refs/remotes/origin/main", "HEAD"],
6019        );
6020        git_command(
6021            repo_dir.path(),
6022            [
6023                "symbolic-ref",
6024                "refs/remotes/origin/HEAD",
6025                "refs/remotes/origin/main",
6026            ],
6027        );
6028
6029        assert_eq!(
6030            repo.default_branch(false).await.unwrap(),
6031            Some("main".into())
6032        );
6033        assert_eq!(
6034            repo.default_branch(true).await.unwrap(),
6035            Some("origin/main".into())
6036        );
6037    }
6038
6039    impl RealGitRepository {
6040        /// Force a Git garbage collection on the repository.
6041        fn gc(&self) -> BoxFuture<'_, Result<()>> {
6042            let working_directory = self.command_directory();
6043            let git_directory = self.path();
6044            let git_binary_path = self.any_git_binary_path.clone();
6045            let executor = self.executor.clone();
6046            self.executor
6047                .spawn(async move {
6048                    let git_binary_path = git_binary_path.clone();
6049                    let git = GitBinary::new(
6050                        git_binary_path,
6051                        working_directory,
6052                        git_directory,
6053                        executor,
6054                        true,
6055                    );
6056                    git.run(&["gc", "--prune"]).await?;
6057                    Ok(())
6058                })
6059                .boxed()
6060        }
6061    }
6062
6063    #[gpui::test]
6064    async fn test_remote_urls(cx: &mut TestAppContext) {
6065        disable_git_global_config();
6066        cx.executor().allow_parking();
6067
6068        let temp_dir = tempfile::tempdir().unwrap();
6069        let repo_dir = temp_dir.path().join("repo");
6070        std::fs::create_dir_all(&repo_dir).unwrap();
6071
6072        git_init_repo(&repo_dir);
6073
6074        let repo = RealGitRepository::new(
6075            &repo_dir.join(".git"),
6076            None,
6077            Some("git".into()),
6078            cx.executor(),
6079        )
6080        .unwrap();
6081
6082        let git = repo.git_binary();
6083        git.run(&[
6084            "remote",
6085            "add",
6086            "origin",
6087            "https://github.com/zed-industries/zed.git",
6088        ])
6089        .await
6090        .unwrap();
6091        git.run(&[
6092            "remote",
6093            "add",
6094            "upstream",
6095            "/Users/user/My Projects/upstream.git",
6096        ])
6097        .await
6098        .unwrap();
6099
6100        let remote_urls = repo.remote_urls().await;
6101        assert_eq!(remote_urls.len(), 2);
6102        assert_eq!(
6103            remote_urls.get("origin").unwrap(),
6104            "https://github.com/zed-industries/zed.git"
6105        );
6106        assert_eq!(
6107            remote_urls.get("upstream").unwrap(),
6108            "/Users/user/My Projects/upstream.git"
6109        );
6110    }
6111}
6112
Served at tenant.openagents/omega Member data and write actions are omitted.