fake_git_repo.rs

  1use crate::FakeFs;
  2use anyhow::{Context as _, Result};
  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    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        unimplemented!()
363    }
364
365    fn unstage_paths(
366        &self,
367        _paths: Vec<RepoPath>,
368        _env: Arc<HashMap<String, String>>,
369    ) -> BoxFuture<Result<()>> {
370        unimplemented!()
371    }
372
373    fn commit(
374        &self,
375        _message: gpui::SharedString,
376        _name_and_email: Option<(gpui::SharedString, gpui::SharedString)>,
377        _options: CommitOptions,
378        _env: Arc<HashMap<String, String>>,
379    ) -> BoxFuture<Result<()>> {
380        unimplemented!()
381    }
382
383    fn push(
384        &self,
385        _branch: String,
386        _remote: String,
387        _options: Option<PushOptions>,
388        _askpass: AskPassDelegate,
389        _env: Arc<HashMap<String, String>>,
390        _cx: AsyncApp,
391    ) -> BoxFuture<Result<git::repository::RemoteCommandOutput>> {
392        unimplemented!()
393    }
394
395    fn pull(
396        &self,
397        _branch: String,
398        _remote: String,
399        _askpass: AskPassDelegate,
400        _env: Arc<HashMap<String, String>>,
401        _cx: AsyncApp,
402    ) -> BoxFuture<Result<git::repository::RemoteCommandOutput>> {
403        unimplemented!()
404    }
405
406    fn fetch(
407        &self,
408        _askpass: AskPassDelegate,
409        _env: Arc<HashMap<String, String>>,
410        _cx: AsyncApp,
411    ) -> BoxFuture<Result<git::repository::RemoteCommandOutput>> {
412        unimplemented!()
413    }
414
415    fn get_remotes(&self, _branch: Option<String>) -> BoxFuture<Result<Vec<Remote>>> {
416        unimplemented!()
417    }
418
419    fn check_for_pushed_commit(&self) -> BoxFuture<Result<Vec<gpui::SharedString>>> {
420        future::ready(Ok(Vec::new())).boxed()
421    }
422
423    fn diff(&self, _diff: git::repository::DiffType) -> BoxFuture<Result<String>> {
424        unimplemented!()
425    }
426
427    fn checkpoint(&self) -> BoxFuture<'static, Result<GitRepositoryCheckpoint>> {
428        unimplemented!()
429    }
430
431    fn restore_checkpoint(&self, _checkpoint: GitRepositoryCheckpoint) -> BoxFuture<Result<()>> {
432        unimplemented!()
433    }
434
435    fn compare_checkpoints(
436        &self,
437        _left: GitRepositoryCheckpoint,
438        _right: GitRepositoryCheckpoint,
439    ) -> BoxFuture<Result<bool>> {
440        unimplemented!()
441    }
442
443    fn diff_checkpoints(
444        &self,
445        _base_checkpoint: GitRepositoryCheckpoint,
446        _target_checkpoint: GitRepositoryCheckpoint,
447    ) -> BoxFuture<Result<String>> {
448        unimplemented!()
449    }
450}