fake_git_repo.rs

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