fake_git_repo.rs

  1use crate::{FakeFs, Fs};
  2use anyhow::{Context as _, Result};
  3use collections::{HashMap, HashSet};
  4use futures::future::{self, BoxFuture, join_all};
  5use git::{
  6    blame::Blame,
  7    repository::{
  8        AskPassDelegate, Branch, CommitDetails, CommitOptions, FetchOptions, GitRepository,
  9        GitRepositoryCheckpoint, PushOptions, Remote, RepoPath, ResetMode,
 10    },
 11    status::{FileStatus, GitStatus, StatusCode, TrackedStatus, UnmergedStatus},
 12};
 13use gpui::{AsyncApp, BackgroundExecutor, SharedString, Task};
 14use ignore::gitignore::GitignoreBuilder;
 15use rope::Rope;
 16use smol::future::FutureExt as _;
 17use std::{path::PathBuf, sync::Arc};
 18
 19#[derive(Clone)]
 20pub struct FakeGitRepository {
 21    pub(crate) fs: Arc<FakeFs>,
 22    pub(crate) executor: BackgroundExecutor,
 23    pub(crate) dot_git_path: PathBuf,
 24    pub(crate) repository_dir_path: PathBuf,
 25    pub(crate) common_dir_path: PathBuf,
 26}
 27
 28#[derive(Debug, Clone)]
 29pub struct FakeGitRepositoryState {
 30    pub event_emitter: smol::channel::Sender<PathBuf>,
 31    pub unmerged_paths: HashMap<RepoPath, UnmergedStatus>,
 32    pub head_contents: HashMap<RepoPath, String>,
 33    pub index_contents: HashMap<RepoPath, String>,
 34    pub blames: HashMap<RepoPath, Blame>,
 35    pub current_branch_name: Option<String>,
 36    pub branches: HashSet<String>,
 37    pub simulated_index_write_error_message: Option<String>,
 38    pub refs: HashMap<String, String>,
 39}
 40
 41impl FakeGitRepositoryState {
 42    pub fn new(event_emitter: smol::channel::Sender<PathBuf>) -> Self {
 43        FakeGitRepositoryState {
 44            event_emitter,
 45            head_contents: Default::default(),
 46            index_contents: Default::default(),
 47            unmerged_paths: Default::default(),
 48            blames: Default::default(),
 49            current_branch_name: Default::default(),
 50            branches: Default::default(),
 51            simulated_index_write_error_message: Default::default(),
 52            refs: HashMap::from_iter([("HEAD".into(), "abc".into())]),
 53        }
 54    }
 55}
 56
 57impl FakeGitRepository {
 58    fn with_state_async<F, T>(&self, write: bool, f: F) -> BoxFuture<'static, Result<T>>
 59    where
 60        F: 'static + Send + FnOnce(&mut FakeGitRepositoryState) -> Result<T>,
 61        T: Send,
 62    {
 63        let fs = self.fs.clone();
 64        let executor = self.executor.clone();
 65        let dot_git_path = self.dot_git_path.clone();
 66        async move {
 67            executor.simulate_random_delay().await;
 68            fs.with_git_state(&dot_git_path, write, f)?
 69        }
 70        .boxed()
 71    }
 72}
 73
 74impl GitRepository for FakeGitRepository {
 75    fn reload_index(&self) {}
 76
 77    fn load_index_text(&self, path: RepoPath) -> BoxFuture<'_, Option<String>> {
 78        async {
 79            self.with_state_async(false, move |state| {
 80                state
 81                    .index_contents
 82                    .get(path.as_ref())
 83                    .context("not present in index")
 84                    .cloned()
 85            })
 86            .await
 87            .ok()
 88        }
 89        .boxed()
 90    }
 91
 92    fn load_committed_text(&self, path: RepoPath) -> BoxFuture<'_, Option<String>> {
 93        async {
 94            self.with_state_async(false, move |state| {
 95                state
 96                    .head_contents
 97                    .get(path.as_ref())
 98                    .context("not present in HEAD")
 99                    .cloned()
100            })
101            .await
102            .ok()
103        }
104        .boxed()
105    }
106
107    fn load_commit(
108        &self,
109        _commit: String,
110        _cx: AsyncApp,
111    ) -> BoxFuture<'_, Result<git::repository::CommitDiff>> {
112        unimplemented!()
113    }
114
115    fn set_index_text(
116        &self,
117        path: RepoPath,
118        content: Option<String>,
119        _env: Arc<HashMap<String, String>>,
120    ) -> BoxFuture<'_, anyhow::Result<()>> {
121        self.with_state_async(true, move |state| {
122            if let Some(message) = &state.simulated_index_write_error_message {
123                anyhow::bail!("{message}");
124            } else if let Some(content) = content {
125                state.index_contents.insert(path, content);
126            } else {
127                state.index_contents.remove(&path);
128            }
129            Ok(())
130        })
131    }
132
133    fn remote_url(&self, _name: &str) -> Option<String> {
134        None
135    }
136
137    fn revparse_batch(&self, revs: Vec<String>) -> BoxFuture<'_, Result<Vec<Option<String>>>> {
138        self.with_state_async(false, |state| {
139            Ok(revs
140                .into_iter()
141                .map(|rev| state.refs.get(&rev).cloned())
142                .collect())
143        })
144    }
145
146    fn show(&self, commit: String) -> BoxFuture<'_, Result<CommitDetails>> {
147        async {
148            Ok(CommitDetails {
149                sha: commit.into(),
150                ..Default::default()
151            })
152        }
153        .boxed()
154    }
155
156    fn reset(
157        &self,
158        _commit: String,
159        _mode: ResetMode,
160        _env: Arc<HashMap<String, String>>,
161    ) -> BoxFuture<'_, Result<()>> {
162        unimplemented!()
163    }
164
165    fn checkout_files(
166        &self,
167        _commit: String,
168        _paths: Vec<RepoPath>,
169        _env: Arc<HashMap<String, String>>,
170    ) -> BoxFuture<'_, Result<()>> {
171        unimplemented!()
172    }
173
174    fn path(&self) -> PathBuf {
175        self.repository_dir_path.clone()
176    }
177
178    fn main_repository_path(&self) -> PathBuf {
179        self.common_dir_path.clone()
180    }
181
182    fn merge_message(&self) -> BoxFuture<'_, Option<String>> {
183        async move { None }.boxed()
184    }
185
186    fn status(&self, path_prefixes: &[RepoPath]) -> Task<Result<GitStatus>> {
187        let workdir_path = self.dot_git_path.parent().unwrap();
188
189        // Load gitignores
190        let ignores = workdir_path
191            .ancestors()
192            .filter_map(|dir| {
193                let ignore_path = dir.join(".gitignore");
194                let content = self.fs.read_file_sync(ignore_path).ok()?;
195                let content = String::from_utf8(content).ok()?;
196                let mut builder = GitignoreBuilder::new(dir);
197                for line in content.lines() {
198                    builder.add_line(Some(dir.into()), line).ok()?;
199                }
200                builder.build().ok()
201            })
202            .collect::<Vec<_>>();
203
204        // Load working copy files.
205        let git_files: HashMap<RepoPath, (String, bool)> = self
206            .fs
207            .files()
208            .iter()
209            .filter_map(|path| {
210                // TODO better simulate git status output in the case of submodules and worktrees
211                let repo_path = path.strip_prefix(workdir_path).ok()?;
212                let mut is_ignored = repo_path.starts_with(".git");
213                for ignore in &ignores {
214                    match ignore.matched_path_or_any_parents(path, false) {
215                        ignore::Match::None => {}
216                        ignore::Match::Ignore(_) => is_ignored = true,
217                        ignore::Match::Whitelist(_) => break,
218                    }
219                }
220                let content = self
221                    .fs
222                    .read_file_sync(path)
223                    .ok()
224                    .map(|content| String::from_utf8(content).unwrap())?;
225                Some((repo_path.into(), (content, is_ignored)))
226            })
227            .collect();
228
229        let result = self.fs.with_git_state(&self.dot_git_path, false, |state| {
230            let mut entries = Vec::new();
231            let paths = state
232                .head_contents
233                .keys()
234                .chain(state.index_contents.keys())
235                .chain(git_files.keys())
236                .collect::<HashSet<_>>();
237            for path in paths {
238                if !path_prefixes.iter().any(|prefix| path.starts_with(prefix)) {
239                    continue;
240                }
241
242                let head = state.head_contents.get(path);
243                let index = state.index_contents.get(path);
244                let unmerged = state.unmerged_paths.get(path);
245                let fs = git_files.get(path);
246                let status = match (unmerged, head, index, fs) {
247                    (Some(unmerged), _, _, _) => FileStatus::Unmerged(*unmerged),
248                    (_, Some(head), Some(index), Some((fs, _))) => {
249                        FileStatus::Tracked(TrackedStatus {
250                            index_status: if head == index {
251                                StatusCode::Unmodified
252                            } else {
253                                StatusCode::Modified
254                            },
255                            worktree_status: if fs == index {
256                                StatusCode::Unmodified
257                            } else {
258                                StatusCode::Modified
259                            },
260                        })
261                    }
262                    (_, Some(head), Some(index), None) => FileStatus::Tracked(TrackedStatus {
263                        index_status: if head == index {
264                            StatusCode::Unmodified
265                        } else {
266                            StatusCode::Modified
267                        },
268                        worktree_status: StatusCode::Deleted,
269                    }),
270                    (_, Some(_), None, Some(_)) => FileStatus::Tracked(TrackedStatus {
271                        index_status: StatusCode::Deleted,
272                        worktree_status: StatusCode::Added,
273                    }),
274                    (_, Some(_), None, None) => FileStatus::Tracked(TrackedStatus {
275                        index_status: StatusCode::Deleted,
276                        worktree_status: StatusCode::Deleted,
277                    }),
278                    (_, None, Some(index), Some((fs, _))) => FileStatus::Tracked(TrackedStatus {
279                        index_status: StatusCode::Added,
280                        worktree_status: if fs == index {
281                            StatusCode::Unmodified
282                        } else {
283                            StatusCode::Modified
284                        },
285                    }),
286                    (_, None, Some(_), None) => FileStatus::Tracked(TrackedStatus {
287                        index_status: StatusCode::Added,
288                        worktree_status: StatusCode::Deleted,
289                    }),
290                    (_, None, None, Some((_, is_ignored))) => {
291                        if *is_ignored {
292                            continue;
293                        }
294                        FileStatus::Untracked
295                    }
296                    (_, None, None, None) => {
297                        unreachable!();
298                    }
299                };
300                if status
301                    != FileStatus::Tracked(TrackedStatus {
302                        index_status: StatusCode::Unmodified,
303                        worktree_status: StatusCode::Unmodified,
304                    })
305                {
306                    entries.push((path.clone(), status));
307                }
308            }
309            entries.sort_by(|a, b| a.0.cmp(&b.0));
310            anyhow::Ok(GitStatus {
311                entries: entries.into(),
312            })
313        });
314        Task::ready(match result {
315            Ok(result) => result,
316            Err(e) => Err(e),
317        })
318    }
319
320    fn branches(&self) -> BoxFuture<'_, Result<Vec<Branch>>> {
321        self.with_state_async(false, move |state| {
322            let current_branch = &state.current_branch_name;
323            Ok(state
324                .branches
325                .iter()
326                .map(|branch_name| Branch {
327                    is_head: Some(branch_name) == current_branch.as_ref(),
328                    ref_name: branch_name.into(),
329                    most_recent_commit: None,
330                    upstream: None,
331                })
332                .collect())
333        })
334    }
335
336    fn change_branch(&self, name: String) -> BoxFuture<'_, Result<()>> {
337        self.with_state_async(true, |state| {
338            state.current_branch_name = Some(name);
339            Ok(())
340        })
341    }
342
343    fn create_branch(&self, name: String) -> BoxFuture<'_, Result<()>> {
344        self.with_state_async(true, move |state| {
345            state.branches.insert(name.to_owned());
346            Ok(())
347        })
348    }
349
350    fn blame(&self, path: RepoPath, _content: Rope) -> BoxFuture<'_, Result<git::blame::Blame>> {
351        self.with_state_async(false, move |state| {
352            state
353                .blames
354                .get(&path)
355                .with_context(|| format!("failed to get blame for {:?}", path.0))
356                .cloned()
357        })
358    }
359
360    fn stage_paths(
361        &self,
362        paths: Vec<RepoPath>,
363        _env: Arc<HashMap<String, String>>,
364    ) -> BoxFuture<'_, Result<()>> {
365        Box::pin(async move {
366            let contents = paths
367                .into_iter()
368                .map(|path| {
369                    let abs_path = self.dot_git_path.parent().unwrap().join(&path);
370                    Box::pin(async move { (path.clone(), self.fs.load(&abs_path).await.ok()) })
371                })
372                .collect::<Vec<_>>();
373            let contents = join_all(contents).await;
374            self.with_state_async(true, move |state| {
375                for (path, content) in contents {
376                    if let Some(content) = content {
377                        state.index_contents.insert(path, content);
378                    } else {
379                        state.index_contents.remove(&path);
380                    }
381                }
382                Ok(())
383            })
384            .await
385        })
386    }
387
388    fn unstage_paths(
389        &self,
390        paths: Vec<RepoPath>,
391        _env: Arc<HashMap<String, String>>,
392    ) -> BoxFuture<'_, Result<()>> {
393        self.with_state_async(true, move |state| {
394            for path in paths {
395                match state.head_contents.get(&path) {
396                    Some(content) => state.index_contents.insert(path, content.clone()),
397                    None => state.index_contents.remove(&path),
398                };
399            }
400            Ok(())
401        })
402    }
403
404    fn stash_paths(
405        &self,
406        _paths: Vec<RepoPath>,
407        _env: Arc<HashMap<String, String>>,
408    ) -> BoxFuture<'_, Result<()>> {
409        unimplemented!()
410    }
411
412    fn stash_pop(&self, _env: Arc<HashMap<String, String>>) -> BoxFuture<'_, Result<()>> {
413        unimplemented!()
414    }
415
416    fn commit(
417        &self,
418        _message: gpui::SharedString,
419        _name_and_email: Option<(gpui::SharedString, gpui::SharedString)>,
420        _options: CommitOptions,
421        _env: Arc<HashMap<String, String>>,
422    ) -> BoxFuture<'_, Result<()>> {
423        unimplemented!()
424    }
425
426    fn push(
427        &self,
428        _branch: String,
429        _remote: String,
430        _options: Option<PushOptions>,
431        _askpass: AskPassDelegate,
432        _env: Arc<HashMap<String, String>>,
433        _cx: AsyncApp,
434    ) -> BoxFuture<'_, Result<git::repository::RemoteCommandOutput>> {
435        unimplemented!()
436    }
437
438    fn pull(
439        &self,
440        _branch: String,
441        _remote: String,
442        _askpass: AskPassDelegate,
443        _env: Arc<HashMap<String, String>>,
444        _cx: AsyncApp,
445    ) -> BoxFuture<'_, Result<git::repository::RemoteCommandOutput>> {
446        unimplemented!()
447    }
448
449    fn fetch(
450        &self,
451        _fetch_options: FetchOptions,
452        _askpass: AskPassDelegate,
453        _env: Arc<HashMap<String, String>>,
454        _cx: AsyncApp,
455    ) -> BoxFuture<'_, Result<git::repository::RemoteCommandOutput>> {
456        unimplemented!()
457    }
458
459    fn get_remotes(&self, _branch: Option<String>) -> BoxFuture<'_, Result<Vec<Remote>>> {
460        unimplemented!()
461    }
462
463    fn check_for_pushed_commit(&self) -> BoxFuture<'_, Result<Vec<gpui::SharedString>>> {
464        future::ready(Ok(Vec::new())).boxed()
465    }
466
467    fn diff(&self, _diff: git::repository::DiffType) -> BoxFuture<'_, Result<String>> {
468        unimplemented!()
469    }
470
471    fn checkpoint(&self) -> BoxFuture<'static, Result<GitRepositoryCheckpoint>> {
472        unimplemented!()
473    }
474
475    fn restore_checkpoint(
476        &self,
477        _checkpoint: GitRepositoryCheckpoint,
478    ) -> BoxFuture<'_, Result<()>> {
479        unimplemented!()
480    }
481
482    fn compare_checkpoints(
483        &self,
484        _left: GitRepositoryCheckpoint,
485        _right: GitRepositoryCheckpoint,
486    ) -> BoxFuture<'_, Result<bool>> {
487        unimplemented!()
488    }
489
490    fn diff_checkpoints(
491        &self,
492        _base_checkpoint: GitRepositoryCheckpoint,
493        _target_checkpoint: GitRepositoryCheckpoint,
494    ) -> BoxFuture<'_, Result<String>> {
495        unimplemented!()
496    }
497
498    fn default_branch(&self) -> BoxFuture<'_, Result<Option<SharedString>>> {
499        unimplemented!()
500    }
501}