fake_git_repo.rs

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