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