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