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