fake_git_repo.rs

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