fake_git_repo.rs

  1use crate::{FakeFs, FakeFsEntry, Fs};
  2use anyhow::{Context as _, Result, bail};
  3use collections::{HashMap, HashSet};
  4use futures::future::{self, BoxFuture, join_all};
  5use git::{
  6    Oid,
  7    blame::Blame,
  8    repository::{
  9        AskPassDelegate, Branch, CommitDetails, CommitOptions, FetchOptions, GitRepository,
 10        GitRepositoryCheckpoint, PushOptions, Remote, RepoPath, ResetMode, Worktree,
 11    },
 12    status::{
 13        DiffTreeType, FileStatus, GitStatus, StatusCode, TrackedStatus, TreeDiff, TreeDiffStatus,
 14        UnmergedStatus,
 15    },
 16};
 17use gpui::{AsyncApp, BackgroundExecutor, SharedString, Task, TaskLabel};
 18use ignore::gitignore::GitignoreBuilder;
 19use parking_lot::Mutex;
 20use rope::Rope;
 21use smol::future::FutureExt as _;
 22use std::{
 23    path::PathBuf,
 24    sync::{Arc, LazyLock},
 25};
 26use util::{paths::PathStyle, rel_path::RelPath};
 27
 28pub static LOAD_INDEX_TEXT_TASK: LazyLock<TaskLabel> = LazyLock::new(TaskLabel::new);
 29pub static LOAD_HEAD_TEXT_TASK: LazyLock<TaskLabel> = LazyLock::new(TaskLabel::new);
 30
 31#[derive(Clone)]
 32pub struct FakeGitRepository {
 33    pub(crate) fs: Arc<FakeFs>,
 34    pub(crate) checkpoints: Arc<Mutex<HashMap<Oid, FakeFsEntry>>>,
 35    pub(crate) executor: BackgroundExecutor,
 36    pub(crate) dot_git_path: PathBuf,
 37    pub(crate) repository_dir_path: PathBuf,
 38    pub(crate) common_dir_path: PathBuf,
 39}
 40
 41#[derive(Debug, Clone)]
 42pub struct FakeGitRepositoryState {
 43    pub event_emitter: smol::channel::Sender<PathBuf>,
 44    pub unmerged_paths: HashMap<RepoPath, UnmergedStatus>,
 45    pub head_contents: HashMap<RepoPath, String>,
 46    pub index_contents: HashMap<RepoPath, String>,
 47    // everything in commit contents is in oids
 48    pub merge_base_contents: HashMap<RepoPath, Oid>,
 49    pub oids: HashMap<Oid, String>,
 50    pub blames: HashMap<RepoPath, Blame>,
 51    pub current_branch_name: Option<String>,
 52    pub branches: HashSet<String>,
 53    pub simulated_index_write_error_message: Option<String>,
 54    pub refs: HashMap<String, String>,
 55}
 56
 57impl FakeGitRepositoryState {
 58    pub fn new(event_emitter: smol::channel::Sender<PathBuf>) -> Self {
 59        FakeGitRepositoryState {
 60            event_emitter,
 61            head_contents: Default::default(),
 62            index_contents: Default::default(),
 63            unmerged_paths: Default::default(),
 64            blames: Default::default(),
 65            current_branch_name: Default::default(),
 66            branches: Default::default(),
 67            simulated_index_write_error_message: Default::default(),
 68            refs: HashMap::from_iter([("HEAD".into(), "abc".into())]),
 69            merge_base_contents: Default::default(),
 70            oids: Default::default(),
 71        }
 72    }
 73}
 74
 75impl FakeGitRepository {
 76    fn with_state_async<F, T>(&self, write: bool, f: F) -> BoxFuture<'static, Result<T>>
 77    where
 78        F: 'static + Send + FnOnce(&mut FakeGitRepositoryState) -> Result<T>,
 79        T: Send,
 80    {
 81        let fs = self.fs.clone();
 82        let executor = self.executor.clone();
 83        let dot_git_path = self.dot_git_path.clone();
 84        async move {
 85            executor.simulate_random_delay().await;
 86            fs.with_git_state(&dot_git_path, write, f)?
 87        }
 88        .boxed()
 89    }
 90}
 91
 92impl GitRepository for FakeGitRepository {
 93    fn reload_index(&self) {}
 94
 95    fn load_index_text(&self, path: RepoPath) -> BoxFuture<'_, Option<String>> {
 96        let fut = self.with_state_async(false, move |state| {
 97            state
 98                .index_contents
 99                .get(&path)
100                .context("not present in index")
101                .cloned()
102        });
103        self.executor
104            .spawn_labeled(*LOAD_INDEX_TEXT_TASK, async move { fut.await.ok() })
105            .boxed()
106    }
107
108    fn load_committed_text(&self, path: RepoPath) -> BoxFuture<'_, Option<String>> {
109        let fut = self.with_state_async(false, move |state| {
110            state
111                .head_contents
112                .get(&path)
113                .context("not present in HEAD")
114                .cloned()
115        });
116        self.executor
117            .spawn_labeled(*LOAD_HEAD_TEXT_TASK, async move { fut.await.ok() })
118            .boxed()
119    }
120
121    fn load_blob_content(&self, oid: git::Oid) -> BoxFuture<'_, Result<String>> {
122        self.with_state_async(false, move |state| {
123            state.oids.get(&oid).cloned().context("oid does not exist")
124        })
125        .boxed()
126    }
127
128    fn load_commit(
129        &self,
130        _commit: String,
131        _cx: AsyncApp,
132    ) -> BoxFuture<'_, Result<git::repository::CommitDiff>> {
133        unimplemented!()
134    }
135
136    fn set_index_text(
137        &self,
138        path: RepoPath,
139        content: Option<String>,
140        _env: Arc<HashMap<String, String>>,
141    ) -> BoxFuture<'_, anyhow::Result<()>> {
142        self.with_state_async(true, move |state| {
143            if let Some(message) = &state.simulated_index_write_error_message {
144                anyhow::bail!("{message}");
145            } else if let Some(content) = content {
146                state.index_contents.insert(path, content);
147            } else {
148                state.index_contents.remove(&path);
149            }
150            Ok(())
151        })
152    }
153
154    fn remote_url(&self, _name: &str) -> Option<String> {
155        None
156    }
157
158    fn diff_tree(&self, _request: DiffTreeType) -> BoxFuture<'_, Result<TreeDiff>> {
159        let mut entries = HashMap::default();
160        self.with_state_async(false, |state| {
161            for (path, content) in &state.head_contents {
162                let status = if let Some((oid, original)) = state
163                    .merge_base_contents
164                    .get(path)
165                    .map(|oid| (oid, &state.oids[oid]))
166                {
167                    if original == content {
168                        continue;
169                    }
170                    TreeDiffStatus::Modified { old: *oid }
171                } else {
172                    TreeDiffStatus::Added
173                };
174                entries.insert(path.clone(), status);
175            }
176            for (path, oid) in &state.merge_base_contents {
177                if !entries.contains_key(path) {
178                    entries.insert(path.clone(), TreeDiffStatus::Deleted { old: *oid });
179                }
180            }
181            Ok(TreeDiff { entries })
182        })
183        .boxed()
184    }
185
186    fn revparse_batch(&self, revs: Vec<String>) -> BoxFuture<'_, Result<Vec<Option<String>>>> {
187        self.with_state_async(false, |state| {
188            Ok(revs
189                .into_iter()
190                .map(|rev| state.refs.get(&rev).cloned())
191                .collect())
192        })
193    }
194
195    fn show(&self, commit: String) -> BoxFuture<'_, Result<CommitDetails>> {
196        async {
197            Ok(CommitDetails {
198                sha: commit.into(),
199                ..Default::default()
200            })
201        }
202        .boxed()
203    }
204
205    fn reset(
206        &self,
207        _commit: String,
208        _mode: ResetMode,
209        _env: Arc<HashMap<String, String>>,
210    ) -> BoxFuture<'_, Result<()>> {
211        unimplemented!()
212    }
213
214    fn checkout_files(
215        &self,
216        _commit: String,
217        _paths: Vec<RepoPath>,
218        _env: Arc<HashMap<String, String>>,
219    ) -> BoxFuture<'_, Result<()>> {
220        unimplemented!()
221    }
222
223    fn path(&self) -> PathBuf {
224        self.repository_dir_path.clone()
225    }
226
227    fn main_repository_path(&self) -> PathBuf {
228        self.common_dir_path.clone()
229    }
230
231    fn merge_message(&self) -> BoxFuture<'_, Option<String>> {
232        async move { None }.boxed()
233    }
234
235    fn status(&self, path_prefixes: &[RepoPath]) -> Task<Result<GitStatus>> {
236        let workdir_path = self.dot_git_path.parent().unwrap();
237
238        // Load gitignores
239        let ignores = workdir_path
240            .ancestors()
241            .filter_map(|dir| {
242                let ignore_path = dir.join(".gitignore");
243                let content = self.fs.read_file_sync(ignore_path).ok()?;
244                let content = String::from_utf8(content).ok()?;
245                let mut builder = GitignoreBuilder::new(dir);
246                for line in content.lines() {
247                    builder.add_line(Some(dir.into()), line).ok()?;
248                }
249                builder.build().ok()
250            })
251            .collect::<Vec<_>>();
252
253        // Load working copy files.
254        let git_files: HashMap<RepoPath, (String, bool)> = self
255            .fs
256            .files()
257            .iter()
258            .filter_map(|path| {
259                // TODO better simulate git status output in the case of submodules and worktrees
260                let repo_path = path.strip_prefix(workdir_path).ok()?;
261                let mut is_ignored = repo_path.starts_with(".git");
262                for ignore in &ignores {
263                    match ignore.matched_path_or_any_parents(path, false) {
264                        ignore::Match::None => {}
265                        ignore::Match::Ignore(_) => is_ignored = true,
266                        ignore::Match::Whitelist(_) => break,
267                    }
268                }
269                let content = self
270                    .fs
271                    .read_file_sync(path)
272                    .ok()
273                    .map(|content| String::from_utf8(content).unwrap())?;
274                let repo_path = RelPath::new(repo_path, PathStyle::local()).ok()?;
275                Some((RepoPath::from_rel_path(&repo_path), (content, is_ignored)))
276            })
277            .collect();
278
279        let result = self.fs.with_git_state(&self.dot_git_path, false, |state| {
280            let mut entries = Vec::new();
281            let paths = state
282                .head_contents
283                .keys()
284                .chain(state.index_contents.keys())
285                .chain(git_files.keys())
286                .collect::<HashSet<_>>();
287            for path in paths {
288                if !path_prefixes.iter().any(|prefix| path.starts_with(prefix)) {
289                    continue;
290                }
291
292                let head = state.head_contents.get(path);
293                let index = state.index_contents.get(path);
294                let unmerged = state.unmerged_paths.get(path);
295                let fs = git_files.get(path);
296                let status = match (unmerged, head, index, fs) {
297                    (Some(unmerged), _, _, _) => FileStatus::Unmerged(*unmerged),
298                    (_, Some(head), Some(index), Some((fs, _))) => {
299                        FileStatus::Tracked(TrackedStatus {
300                            index_status: if head == index {
301                                StatusCode::Unmodified
302                            } else {
303                                StatusCode::Modified
304                            },
305                            worktree_status: if fs == index {
306                                StatusCode::Unmodified
307                            } else {
308                                StatusCode::Modified
309                            },
310                        })
311                    }
312                    (_, Some(head), Some(index), None) => FileStatus::Tracked(TrackedStatus {
313                        index_status: if head == index {
314                            StatusCode::Unmodified
315                        } else {
316                            StatusCode::Modified
317                        },
318                        worktree_status: StatusCode::Deleted,
319                    }),
320                    (_, Some(_), None, Some(_)) => FileStatus::Tracked(TrackedStatus {
321                        index_status: StatusCode::Deleted,
322                        worktree_status: StatusCode::Added,
323                    }),
324                    (_, Some(_), None, None) => FileStatus::Tracked(TrackedStatus {
325                        index_status: StatusCode::Deleted,
326                        worktree_status: StatusCode::Deleted,
327                    }),
328                    (_, None, Some(index), Some((fs, _))) => FileStatus::Tracked(TrackedStatus {
329                        index_status: StatusCode::Added,
330                        worktree_status: if fs == index {
331                            StatusCode::Unmodified
332                        } else {
333                            StatusCode::Modified
334                        },
335                    }),
336                    (_, None, Some(_), None) => FileStatus::Tracked(TrackedStatus {
337                        index_status: StatusCode::Added,
338                        worktree_status: StatusCode::Deleted,
339                    }),
340                    (_, None, None, Some((_, is_ignored))) => {
341                        if *is_ignored {
342                            continue;
343                        }
344                        FileStatus::Untracked
345                    }
346                    (_, None, None, None) => {
347                        unreachable!();
348                    }
349                };
350                if status
351                    != FileStatus::Tracked(TrackedStatus {
352                        index_status: StatusCode::Unmodified,
353                        worktree_status: StatusCode::Unmodified,
354                    })
355                {
356                    entries.push((path.clone(), status));
357                }
358            }
359            entries.sort_by(|a, b| a.0.cmp(&b.0));
360            anyhow::Ok(GitStatus {
361                entries: entries.into(),
362            })
363        });
364        Task::ready(match result {
365            Ok(result) => result,
366            Err(e) => Err(e),
367        })
368    }
369
370    fn stash_entries(&self) -> BoxFuture<'_, Result<git::stash::GitStash>> {
371        async { Ok(git::stash::GitStash::default()) }.boxed()
372    }
373
374    fn branches(&self) -> BoxFuture<'_, Result<Vec<Branch>>> {
375        self.with_state_async(false, move |state| {
376            let current_branch = &state.current_branch_name;
377            Ok(state
378                .branches
379                .iter()
380                .map(|branch_name| Branch {
381                    is_head: Some(branch_name) == current_branch.as_ref(),
382                    ref_name: branch_name.into(),
383                    most_recent_commit: None,
384                    upstream: None,
385                })
386                .collect())
387        })
388    }
389
390    fn worktrees(&self) -> BoxFuture<'_, Result<Vec<Worktree>>> {
391        unimplemented!()
392    }
393
394    fn create_worktree(
395        &self,
396        _: String,
397        _: PathBuf,
398        _: Option<String>,
399    ) -> BoxFuture<'_, Result<()>> {
400        unimplemented!()
401    }
402
403    fn change_branch(&self, name: String) -> BoxFuture<'_, Result<()>> {
404        self.with_state_async(true, |state| {
405            state.current_branch_name = Some(name);
406            Ok(())
407        })
408    }
409
410    fn create_branch(
411        &self,
412        name: String,
413        _base_branch: Option<String>,
414    ) -> BoxFuture<'_, Result<()>> {
415        self.with_state_async(true, move |state| {
416            state.branches.insert(name);
417            Ok(())
418        })
419    }
420
421    fn rename_branch(&self, branch: String, new_name: String) -> BoxFuture<'_, Result<()>> {
422        self.with_state_async(true, move |state| {
423            if !state.branches.remove(&branch) {
424                bail!("no such branch: {branch}");
425            }
426            state.branches.insert(new_name.clone());
427            if state.current_branch_name == Some(branch) {
428                state.current_branch_name = Some(new_name);
429            }
430            Ok(())
431        })
432    }
433
434    fn blame(&self, path: RepoPath, _content: Rope) -> BoxFuture<'_, Result<git::blame::Blame>> {
435        self.with_state_async(false, move |state| {
436            state
437                .blames
438                .get(&path)
439                .with_context(|| format!("failed to get blame for {:?}", path))
440                .cloned()
441        })
442    }
443
444    fn stage_paths(
445        &self,
446        paths: Vec<RepoPath>,
447        _env: Arc<HashMap<String, String>>,
448    ) -> BoxFuture<'_, Result<()>> {
449        Box::pin(async move {
450            let contents = paths
451                .into_iter()
452                .map(|path| {
453                    let abs_path = self
454                        .dot_git_path
455                        .parent()
456                        .unwrap()
457                        .join(&path.as_std_path());
458                    Box::pin(async move { (path.clone(), self.fs.load(&abs_path).await.ok()) })
459                })
460                .collect::<Vec<_>>();
461            let contents = join_all(contents).await;
462            self.with_state_async(true, move |state| {
463                for (path, content) in contents {
464                    if let Some(content) = content {
465                        state.index_contents.insert(path, content);
466                    } else {
467                        state.index_contents.remove(&path);
468                    }
469                }
470                Ok(())
471            })
472            .await
473        })
474    }
475
476    fn unstage_paths(
477        &self,
478        paths: Vec<RepoPath>,
479        _env: Arc<HashMap<String, String>>,
480    ) -> BoxFuture<'_, Result<()>> {
481        self.with_state_async(true, move |state| {
482            for path in paths {
483                match state.head_contents.get(&path) {
484                    Some(content) => state.index_contents.insert(path, content.clone()),
485                    None => state.index_contents.remove(&path),
486                };
487            }
488            Ok(())
489        })
490    }
491
492    fn stash_paths(
493        &self,
494        _paths: Vec<RepoPath>,
495        _env: Arc<HashMap<String, String>>,
496    ) -> BoxFuture<'_, Result<()>> {
497        unimplemented!()
498    }
499
500    fn stash_pop(
501        &self,
502        _index: Option<usize>,
503        _env: Arc<HashMap<String, String>>,
504    ) -> BoxFuture<'_, Result<()>> {
505        unimplemented!()
506    }
507
508    fn stash_apply(
509        &self,
510        _index: Option<usize>,
511        _env: Arc<HashMap<String, String>>,
512    ) -> BoxFuture<'_, Result<()>> {
513        unimplemented!()
514    }
515
516    fn stash_drop(
517        &self,
518        _index: Option<usize>,
519        _env: Arc<HashMap<String, String>>,
520    ) -> BoxFuture<'_, Result<()>> {
521        unimplemented!()
522    }
523
524    fn commit(
525        &self,
526        _message: gpui::SharedString,
527        _name_and_email: Option<(gpui::SharedString, gpui::SharedString)>,
528        _options: CommitOptions,
529        _askpass: AskPassDelegate,
530        _env: Arc<HashMap<String, String>>,
531    ) -> BoxFuture<'_, Result<()>> {
532        unimplemented!()
533    }
534
535    fn push(
536        &self,
537        _branch: String,
538        _remote: String,
539        _options: Option<PushOptions>,
540        _askpass: AskPassDelegate,
541        _env: Arc<HashMap<String, String>>,
542        _cx: AsyncApp,
543    ) -> BoxFuture<'_, Result<git::repository::RemoteCommandOutput>> {
544        unimplemented!()
545    }
546
547    fn pull(
548        &self,
549        _branch: Option<String>,
550        _remote: String,
551        _rebase: bool,
552        _askpass: AskPassDelegate,
553        _env: Arc<HashMap<String, String>>,
554        _cx: AsyncApp,
555    ) -> BoxFuture<'_, Result<git::repository::RemoteCommandOutput>> {
556        unimplemented!()
557    }
558
559    fn fetch(
560        &self,
561        _fetch_options: FetchOptions,
562        _askpass: AskPassDelegate,
563        _env: Arc<HashMap<String, String>>,
564        _cx: AsyncApp,
565    ) -> BoxFuture<'_, Result<git::repository::RemoteCommandOutput>> {
566        unimplemented!()
567    }
568
569    fn get_remotes(&self, _branch: Option<String>) -> BoxFuture<'_, Result<Vec<Remote>>> {
570        unimplemented!()
571    }
572
573    fn check_for_pushed_commit(&self) -> BoxFuture<'_, Result<Vec<gpui::SharedString>>> {
574        future::ready(Ok(Vec::new())).boxed()
575    }
576
577    fn diff(&self, _diff: git::repository::DiffType) -> BoxFuture<'_, Result<String>> {
578        unimplemented!()
579    }
580
581    fn checkpoint(&self) -> BoxFuture<'static, Result<GitRepositoryCheckpoint>> {
582        let executor = self.executor.clone();
583        let fs = self.fs.clone();
584        let checkpoints = self.checkpoints.clone();
585        let repository_dir_path = self.repository_dir_path.parent().unwrap().to_path_buf();
586        async move {
587            executor.simulate_random_delay().await;
588            let oid = git::Oid::random(&mut executor.rng());
589            let entry = fs.entry(&repository_dir_path)?;
590            checkpoints.lock().insert(oid, entry);
591            Ok(GitRepositoryCheckpoint { commit_sha: oid })
592        }
593        .boxed()
594    }
595
596    fn restore_checkpoint(&self, checkpoint: GitRepositoryCheckpoint) -> BoxFuture<'_, Result<()>> {
597        let executor = self.executor.clone();
598        let fs = self.fs.clone();
599        let checkpoints = self.checkpoints.clone();
600        let repository_dir_path = self.repository_dir_path.parent().unwrap().to_path_buf();
601        async move {
602            executor.simulate_random_delay().await;
603            let checkpoints = checkpoints.lock();
604            let entry = checkpoints
605                .get(&checkpoint.commit_sha)
606                .context(format!("invalid checkpoint: {}", checkpoint.commit_sha))?;
607            fs.insert_entry(&repository_dir_path, entry.clone())?;
608            Ok(())
609        }
610        .boxed()
611    }
612
613    fn compare_checkpoints(
614        &self,
615        left: GitRepositoryCheckpoint,
616        right: GitRepositoryCheckpoint,
617    ) -> BoxFuture<'_, Result<bool>> {
618        let executor = self.executor.clone();
619        let checkpoints = self.checkpoints.clone();
620        async move {
621            executor.simulate_random_delay().await;
622            let checkpoints = checkpoints.lock();
623            let left = checkpoints
624                .get(&left.commit_sha)
625                .context(format!("invalid left checkpoint: {}", left.commit_sha))?;
626            let right = checkpoints
627                .get(&right.commit_sha)
628                .context(format!("invalid right checkpoint: {}", right.commit_sha))?;
629
630            Ok(left == right)
631        }
632        .boxed()
633    }
634
635    fn diff_checkpoints(
636        &self,
637        _base_checkpoint: GitRepositoryCheckpoint,
638        _target_checkpoint: GitRepositoryCheckpoint,
639    ) -> BoxFuture<'_, Result<String>> {
640        unimplemented!()
641    }
642
643    fn default_branch(&self) -> BoxFuture<'_, Result<Option<SharedString>>> {
644        async { Ok(Some("main".into())) }.boxed()
645    }
646}
647
648#[cfg(test)]
649mod tests {
650    use crate::{FakeFs, Fs};
651    use gpui::BackgroundExecutor;
652    use serde_json::json;
653    use std::path::Path;
654    use util::path;
655
656    #[gpui::test]
657    async fn test_checkpoints(executor: BackgroundExecutor) {
658        let fs = FakeFs::new(executor);
659        fs.insert_tree(
660            path!("/"),
661            json!({
662                "bar": {
663                    "baz": "qux"
664                },
665                "foo": {
666                    ".git": {},
667                    "a": "lorem",
668                    "b": "ipsum",
669                },
670            }),
671        )
672        .await;
673        fs.with_git_state(Path::new("/foo/.git"), true, |_git| {})
674            .unwrap();
675        let repository = fs
676            .open_repo(Path::new("/foo/.git"), Some("git".as_ref()))
677            .unwrap();
678
679        let checkpoint_1 = repository.checkpoint().await.unwrap();
680        fs.write(Path::new("/foo/b"), b"IPSUM").await.unwrap();
681        fs.write(Path::new("/foo/c"), b"dolor").await.unwrap();
682        let checkpoint_2 = repository.checkpoint().await.unwrap();
683        let checkpoint_3 = repository.checkpoint().await.unwrap();
684
685        assert!(
686            repository
687                .compare_checkpoints(checkpoint_2.clone(), checkpoint_3.clone())
688                .await
689                .unwrap()
690        );
691        assert!(
692            !repository
693                .compare_checkpoints(checkpoint_1.clone(), checkpoint_2.clone())
694                .await
695                .unwrap()
696        );
697
698        repository.restore_checkpoint(checkpoint_1).await.unwrap();
699        assert_eq!(
700            fs.files_with_contents(Path::new("")),
701            [
702                (Path::new(path!("/bar/baz")).into(), b"qux".into()),
703                (Path::new(path!("/foo/a")).into(), b"lorem".into()),
704                (Path::new(path!("/foo/b")).into(), b"ipsum".into())
705            ]
706        );
707    }
708}