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