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