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};
 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 rename_branch(&self, branch: String, new_name: String) -> BoxFuture<'_, Result<()>> {
358        self.with_state_async(true, move |state| {
359            if !state.branches.remove(&branch) {
360                bail!("no such branch: {branch}");
361            }
362            state.branches.insert(new_name.clone());
363            if state.current_branch_name == Some(branch) {
364                state.current_branch_name = Some(new_name);
365            }
366            Ok(())
367        })
368    }
369
370    fn blame(&self, path: RepoPath, _content: Rope) -> BoxFuture<'_, Result<git::blame::Blame>> {
371        self.with_state_async(false, move |state| {
372            state
373                .blames
374                .get(&path)
375                .with_context(|| format!("failed to get blame for {:?}", path.0))
376                .cloned()
377        })
378    }
379
380    fn stage_paths(
381        &self,
382        paths: Vec<RepoPath>,
383        _env: Arc<HashMap<String, String>>,
384    ) -> BoxFuture<'_, Result<()>> {
385        Box::pin(async move {
386            let contents = paths
387                .into_iter()
388                .map(|path| {
389                    let abs_path = self.dot_git_path.parent().unwrap().join(&path);
390                    Box::pin(async move { (path.clone(), self.fs.load(&abs_path).await.ok()) })
391                })
392                .collect::<Vec<_>>();
393            let contents = join_all(contents).await;
394            self.with_state_async(true, move |state| {
395                for (path, content) in contents {
396                    if let Some(content) = content {
397                        state.index_contents.insert(path, content);
398                    } else {
399                        state.index_contents.remove(&path);
400                    }
401                }
402                Ok(())
403            })
404            .await
405        })
406    }
407
408    fn unstage_paths(
409        &self,
410        paths: Vec<RepoPath>,
411        _env: Arc<HashMap<String, String>>,
412    ) -> BoxFuture<'_, Result<()>> {
413        self.with_state_async(true, move |state| {
414            for path in paths {
415                match state.head_contents.get(&path) {
416                    Some(content) => state.index_contents.insert(path, content.clone()),
417                    None => state.index_contents.remove(&path),
418                };
419            }
420            Ok(())
421        })
422    }
423
424    fn stash_paths(
425        &self,
426        _paths: Vec<RepoPath>,
427        _env: Arc<HashMap<String, String>>,
428    ) -> BoxFuture<'_, Result<()>> {
429        unimplemented!()
430    }
431
432    fn stash_pop(
433        &self,
434        _index: Option<usize>,
435        _env: Arc<HashMap<String, String>>,
436    ) -> BoxFuture<'_, Result<()>> {
437        unimplemented!()
438    }
439
440    fn stash_apply(
441        &self,
442        _index: Option<usize>,
443        _env: Arc<HashMap<String, String>>,
444    ) -> BoxFuture<'_, Result<()>> {
445        unimplemented!()
446    }
447
448    fn stash_drop(
449        &self,
450        _index: Option<usize>,
451        _env: Arc<HashMap<String, String>>,
452    ) -> BoxFuture<'_, Result<()>> {
453        unimplemented!()
454    }
455
456    fn commit(
457        &self,
458        _message: gpui::SharedString,
459        _name_and_email: Option<(gpui::SharedString, gpui::SharedString)>,
460        _options: CommitOptions,
461        _env: Arc<HashMap<String, String>>,
462    ) -> BoxFuture<'_, Result<()>> {
463        unimplemented!()
464    }
465
466    fn push(
467        &self,
468        _branch: String,
469        _remote: String,
470        _options: Option<PushOptions>,
471        _askpass: AskPassDelegate,
472        _env: Arc<HashMap<String, String>>,
473        _cx: AsyncApp,
474    ) -> BoxFuture<'_, Result<git::repository::RemoteCommandOutput>> {
475        unimplemented!()
476    }
477
478    fn pull(
479        &self,
480        _branch: String,
481        _remote: String,
482        _askpass: AskPassDelegate,
483        _env: Arc<HashMap<String, String>>,
484        _cx: AsyncApp,
485    ) -> BoxFuture<'_, Result<git::repository::RemoteCommandOutput>> {
486        unimplemented!()
487    }
488
489    fn fetch(
490        &self,
491        _fetch_options: FetchOptions,
492        _askpass: AskPassDelegate,
493        _env: Arc<HashMap<String, String>>,
494        _cx: AsyncApp,
495    ) -> BoxFuture<'_, Result<git::repository::RemoteCommandOutput>> {
496        unimplemented!()
497    }
498
499    fn get_remotes(&self, _branch: Option<String>) -> BoxFuture<'_, Result<Vec<Remote>>> {
500        unimplemented!()
501    }
502
503    fn check_for_pushed_commit(&self) -> BoxFuture<'_, Result<Vec<gpui::SharedString>>> {
504        future::ready(Ok(Vec::new())).boxed()
505    }
506
507    fn diff(&self, _diff: git::repository::DiffType) -> BoxFuture<'_, Result<String>> {
508        unimplemented!()
509    }
510
511    fn checkpoint(&self) -> BoxFuture<'static, Result<GitRepositoryCheckpoint>> {
512        let executor = self.executor.clone();
513        let fs = self.fs.clone();
514        let checkpoints = self.checkpoints.clone();
515        let repository_dir_path = self.repository_dir_path.parent().unwrap().to_path_buf();
516        async move {
517            executor.simulate_random_delay().await;
518            let oid = Oid::random(&mut executor.rng());
519            let entry = fs.entry(&repository_dir_path)?;
520            checkpoints.lock().insert(oid, entry);
521            Ok(GitRepositoryCheckpoint { commit_sha: oid })
522        }
523        .boxed()
524    }
525
526    fn restore_checkpoint(&self, checkpoint: GitRepositoryCheckpoint) -> BoxFuture<'_, Result<()>> {
527        let executor = self.executor.clone();
528        let fs = self.fs.clone();
529        let checkpoints = self.checkpoints.clone();
530        let repository_dir_path = self.repository_dir_path.parent().unwrap().to_path_buf();
531        async move {
532            executor.simulate_random_delay().await;
533            let checkpoints = checkpoints.lock();
534            let entry = checkpoints
535                .get(&checkpoint.commit_sha)
536                .context(format!("invalid checkpoint: {}", checkpoint.commit_sha))?;
537            fs.insert_entry(&repository_dir_path, entry.clone())?;
538            Ok(())
539        }
540        .boxed()
541    }
542
543    fn compare_checkpoints(
544        &self,
545        left: GitRepositoryCheckpoint,
546        right: GitRepositoryCheckpoint,
547    ) -> BoxFuture<'_, Result<bool>> {
548        let executor = self.executor.clone();
549        let checkpoints = self.checkpoints.clone();
550        async move {
551            executor.simulate_random_delay().await;
552            let checkpoints = checkpoints.lock();
553            let left = checkpoints
554                .get(&left.commit_sha)
555                .context(format!("invalid left checkpoint: {}", left.commit_sha))?;
556            let right = checkpoints
557                .get(&right.commit_sha)
558                .context(format!("invalid right checkpoint: {}", right.commit_sha))?;
559
560            Ok(left == right)
561        }
562        .boxed()
563    }
564
565    fn diff_checkpoints(
566        &self,
567        _base_checkpoint: GitRepositoryCheckpoint,
568        _target_checkpoint: GitRepositoryCheckpoint,
569    ) -> BoxFuture<'_, Result<String>> {
570        unimplemented!()
571    }
572
573    fn default_branch(&self) -> BoxFuture<'_, Result<Option<SharedString>>> {
574        unimplemented!()
575    }
576}
577
578#[cfg(test)]
579mod tests {
580    use crate::{FakeFs, Fs};
581    use gpui::BackgroundExecutor;
582    use serde_json::json;
583    use std::path::Path;
584    use util::path;
585
586    #[gpui::test]
587    async fn test_checkpoints(executor: BackgroundExecutor) {
588        let fs = FakeFs::new(executor);
589        fs.insert_tree(
590            path!("/"),
591            json!({
592                "bar": {
593                    "baz": "qux"
594                },
595                "foo": {
596                    ".git": {},
597                    "a": "lorem",
598                    "b": "ipsum",
599                },
600            }),
601        )
602        .await;
603        fs.with_git_state(Path::new("/foo/.git"), true, |_git| {})
604            .unwrap();
605        let repository = fs.open_repo(Path::new("/foo/.git")).unwrap();
606
607        let checkpoint_1 = repository.checkpoint().await.unwrap();
608        fs.write(Path::new("/foo/b"), b"IPSUM").await.unwrap();
609        fs.write(Path::new("/foo/c"), b"dolor").await.unwrap();
610        let checkpoint_2 = repository.checkpoint().await.unwrap();
611        let checkpoint_3 = repository.checkpoint().await.unwrap();
612
613        assert!(
614            repository
615                .compare_checkpoints(checkpoint_2.clone(), checkpoint_3.clone())
616                .await
617                .unwrap()
618        );
619        assert!(
620            !repository
621                .compare_checkpoints(checkpoint_1.clone(), checkpoint_2.clone())
622                .await
623                .unwrap()
624        );
625
626        repository.restore_checkpoint(checkpoint_1).await.unwrap();
627        assert_eq!(
628            fs.files_with_contents(Path::new("")),
629            [
630                (Path::new(path!("/bar/baz")).into(), b"qux".into()),
631                (Path::new(path!("/foo/a")).into(), b"lorem".into()),
632                (Path::new(path!("/foo/b")).into(), b"ipsum".into())
633            ]
634        );
635    }
636}