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};
 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]) -> BoxFuture<'_, 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        async move { result? }.boxed()
315    }
316
317    fn branches(&self) -> BoxFuture<'_, Result<Vec<Branch>>> {
318        self.with_state_async(false, move |state| {
319            let current_branch = &state.current_branch_name;
320            Ok(state
321                .branches
322                .iter()
323                .map(|branch_name| Branch {
324                    is_head: Some(branch_name) == current_branch.as_ref(),
325                    ref_name: branch_name.into(),
326                    most_recent_commit: None,
327                    upstream: None,
328                })
329                .collect())
330        })
331    }
332
333    fn change_branch(&self, name: String) -> BoxFuture<'_, Result<()>> {
334        self.with_state_async(true, |state| {
335            state.current_branch_name = Some(name);
336            Ok(())
337        })
338    }
339
340    fn create_branch(&self, name: String) -> BoxFuture<'_, Result<()>> {
341        self.with_state_async(true, move |state| {
342            state.branches.insert(name.to_owned());
343            Ok(())
344        })
345    }
346
347    fn blame(&self, path: RepoPath, _content: Rope) -> BoxFuture<'_, Result<git::blame::Blame>> {
348        self.with_state_async(false, move |state| {
349            state
350                .blames
351                .get(&path)
352                .with_context(|| format!("failed to get blame for {:?}", path.0))
353                .cloned()
354        })
355    }
356
357    fn stage_paths(
358        &self,
359        paths: Vec<RepoPath>,
360        _env: Arc<HashMap<String, String>>,
361    ) -> BoxFuture<'_, Result<()>> {
362        Box::pin(async move {
363            let contents = paths
364                .into_iter()
365                .map(|path| {
366                    let abs_path = self.dot_git_path.parent().unwrap().join(&path);
367                    Box::pin(async move { (path.clone(), self.fs.load(&abs_path).await.ok()) })
368                })
369                .collect::<Vec<_>>();
370            let contents = join_all(contents).await;
371            self.with_state_async(true, move |state| {
372                for (path, content) in contents {
373                    if let Some(content) = content {
374                        state.index_contents.insert(path, content);
375                    } else {
376                        state.index_contents.remove(&path);
377                    }
378                }
379                Ok(())
380            })
381            .await
382        })
383    }
384
385    fn unstage_paths(
386        &self,
387        paths: Vec<RepoPath>,
388        _env: Arc<HashMap<String, String>>,
389    ) -> BoxFuture<'_, Result<()>> {
390        self.with_state_async(true, move |state| {
391            for path in paths {
392                match state.head_contents.get(&path) {
393                    Some(content) => state.index_contents.insert(path, content.clone()),
394                    None => state.index_contents.remove(&path),
395                };
396            }
397            Ok(())
398        })
399    }
400
401    fn stash_paths(
402        &self,
403        _paths: Vec<RepoPath>,
404        _env: Arc<HashMap<String, String>>,
405    ) -> BoxFuture<Result<()>> {
406        unimplemented!()
407    }
408
409    fn stash_pop(&self, _env: Arc<HashMap<String, String>>) -> BoxFuture<Result<()>> {
410        unimplemented!()
411    }
412
413    fn commit(
414        &self,
415        _message: gpui::SharedString,
416        _name_and_email: Option<(gpui::SharedString, gpui::SharedString)>,
417        _options: CommitOptions,
418        _env: Arc<HashMap<String, String>>,
419    ) -> BoxFuture<'_, Result<()>> {
420        unimplemented!()
421    }
422
423    fn push(
424        &self,
425        _branch: String,
426        _remote: String,
427        _options: Option<PushOptions>,
428        _askpass: AskPassDelegate,
429        _env: Arc<HashMap<String, String>>,
430        _cx: AsyncApp,
431    ) -> BoxFuture<'_, Result<git::repository::RemoteCommandOutput>> {
432        unimplemented!()
433    }
434
435    fn pull(
436        &self,
437        _branch: String,
438        _remote: String,
439        _askpass: AskPassDelegate,
440        _env: Arc<HashMap<String, String>>,
441        _cx: AsyncApp,
442    ) -> BoxFuture<'_, Result<git::repository::RemoteCommandOutput>> {
443        unimplemented!()
444    }
445
446    fn fetch(
447        &self,
448        _fetch_options: FetchOptions,
449        _askpass: AskPassDelegate,
450        _env: Arc<HashMap<String, String>>,
451        _cx: AsyncApp,
452    ) -> BoxFuture<'_, Result<git::repository::RemoteCommandOutput>> {
453        unimplemented!()
454    }
455
456    fn get_remotes(&self, _branch: Option<String>) -> BoxFuture<'_, Result<Vec<Remote>>> {
457        unimplemented!()
458    }
459
460    fn check_for_pushed_commit(&self) -> BoxFuture<'_, Result<Vec<gpui::SharedString>>> {
461        future::ready(Ok(Vec::new())).boxed()
462    }
463
464    fn diff(&self, _diff: git::repository::DiffType) -> BoxFuture<'_, Result<String>> {
465        unimplemented!()
466    }
467
468    fn checkpoint(&self) -> BoxFuture<'static, Result<GitRepositoryCheckpoint>> {
469        unimplemented!()
470    }
471
472    fn restore_checkpoint(
473        &self,
474        _checkpoint: GitRepositoryCheckpoint,
475    ) -> BoxFuture<'_, Result<()>> {
476        unimplemented!()
477    }
478
479    fn compare_checkpoints(
480        &self,
481        _left: GitRepositoryCheckpoint,
482        _right: GitRepositoryCheckpoint,
483    ) -> BoxFuture<'_, Result<bool>> {
484        unimplemented!()
485    }
486
487    fn diff_checkpoints(
488        &self,
489        _base_checkpoint: GitRepositoryCheckpoint,
490        _target_checkpoint: GitRepositoryCheckpoint,
491    ) -> BoxFuture<'_, Result<String>> {
492        unimplemented!()
493    }
494
495    fn default_branch(&self) -> BoxFuture<'_, Result<Option<SharedString>>> {
496        unimplemented!()
497    }
498}