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