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<'static, 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) -> 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) -> 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    ) -> BoxFuture<anyhow::Result<()>> {
120        self.with_state_async(true, move |state| {
121            if let Some(message) = state.simulated_index_write_error_message.clone() {
122                return Err(anyhow!("{}", message));
123            } else if let Some(content) = content {
124                state.index_contents.insert(path, content);
125            } else {
126                state.index_contents.remove(&path);
127            }
128            Ok(())
129        })
130    }
131
132    fn remote_url(&self, _name: &str) -> Option<String> {
133        None
134    }
135
136    fn head_sha(&self) -> Option<String> {
137        None
138    }
139
140    fn merge_head_shas(&self) -> Vec<String> {
141        vec![]
142    }
143
144    fn show(&self, _commit: String) -> BoxFuture<Result<CommitDetails>> {
145        unimplemented!()
146    }
147
148    fn reset(
149        &self,
150        _commit: String,
151        _mode: ResetMode,
152        _env: HashMap<String, String>,
153    ) -> BoxFuture<Result<()>> {
154        unimplemented!()
155    }
156
157    fn checkout_files(
158        &self,
159        _commit: String,
160        _paths: Vec<RepoPath>,
161        _env: HashMap<String, String>,
162    ) -> BoxFuture<Result<()>> {
163        unimplemented!()
164    }
165
166    fn path(&self) -> PathBuf {
167        self.with_state(|state| state.path.clone())
168    }
169
170    fn main_repository_path(&self) -> PathBuf {
171        self.path()
172    }
173
174    fn status(&self, path_prefixes: &[RepoPath]) -> BoxFuture<'static, Result<GitStatus>> {
175        let status = self.status_blocking(path_prefixes);
176        async move { status }.boxed()
177    }
178
179    fn status_blocking(&self, path_prefixes: &[RepoPath]) -> Result<GitStatus> {
180        let workdir_path = self.dot_git_path.parent().unwrap();
181
182        // Load gitignores
183        let ignores = workdir_path
184            .ancestors()
185            .filter_map(|dir| {
186                let ignore_path = dir.join(".gitignore");
187                let content = self.fs.read_file_sync(ignore_path).ok()?;
188                let content = String::from_utf8(content).ok()?;
189                let mut builder = GitignoreBuilder::new(dir);
190                for line in content.lines() {
191                    builder.add_line(Some(dir.into()), line).ok()?;
192                }
193                builder.build().ok()
194            })
195            .collect::<Vec<_>>();
196
197        // Load working copy files.
198        let git_files: HashMap<RepoPath, (String, bool)> = self
199            .fs
200            .files()
201            .iter()
202            .filter_map(|path| {
203                let repo_path = path.strip_prefix(workdir_path).ok()?;
204                let mut is_ignored = false;
205                for ignore in &ignores {
206                    match ignore.matched_path_or_any_parents(path, false) {
207                        ignore::Match::None => {}
208                        ignore::Match::Ignore(_) => is_ignored = true,
209                        ignore::Match::Whitelist(_) => break,
210                    }
211                }
212                let content = self
213                    .fs
214                    .read_file_sync(path)
215                    .ok()
216                    .map(|content| String::from_utf8(content).unwrap())?;
217                Some((repo_path.into(), (content, is_ignored)))
218            })
219            .collect();
220
221        self.fs.with_git_state(&self.dot_git_path, false, |state| {
222            let mut entries = Vec::new();
223            let paths = state
224                .head_contents
225                .keys()
226                .chain(state.index_contents.keys())
227                .chain(git_files.keys())
228                .collect::<HashSet<_>>();
229            for path in paths {
230                if !path_prefixes.iter().any(|prefix| path.starts_with(prefix)) {
231                    continue;
232                }
233
234                let head = state.head_contents.get(path);
235                let index = state.index_contents.get(path);
236                let unmerged = state.unmerged_paths.get(path);
237                let fs = git_files.get(path);
238                let status = match (unmerged, head, index, fs) {
239                    (Some(unmerged), _, _, _) => FileStatus::Unmerged(*unmerged),
240                    (_, Some(head), Some(index), Some((fs, _))) => {
241                        FileStatus::Tracked(TrackedStatus {
242                            index_status: if head == index {
243                                StatusCode::Unmodified
244                            } else {
245                                StatusCode::Modified
246                            },
247                            worktree_status: if fs == index {
248                                StatusCode::Unmodified
249                            } else {
250                                StatusCode::Modified
251                            },
252                        })
253                    }
254                    (_, Some(head), Some(index), None) => FileStatus::Tracked(TrackedStatus {
255                        index_status: if head == index {
256                            StatusCode::Unmodified
257                        } else {
258                            StatusCode::Modified
259                        },
260                        worktree_status: StatusCode::Deleted,
261                    }),
262                    (_, Some(_), None, Some(_)) => FileStatus::Tracked(TrackedStatus {
263                        index_status: StatusCode::Deleted,
264                        worktree_status: StatusCode::Added,
265                    }),
266                    (_, Some(_), None, None) => FileStatus::Tracked(TrackedStatus {
267                        index_status: StatusCode::Deleted,
268                        worktree_status: StatusCode::Deleted,
269                    }),
270                    (_, None, Some(index), Some((fs, _))) => FileStatus::Tracked(TrackedStatus {
271                        index_status: StatusCode::Added,
272                        worktree_status: if fs == index {
273                            StatusCode::Unmodified
274                        } else {
275                            StatusCode::Modified
276                        },
277                    }),
278                    (_, None, Some(_), None) => FileStatus::Tracked(TrackedStatus {
279                        index_status: StatusCode::Added,
280                        worktree_status: StatusCode::Deleted,
281                    }),
282                    (_, None, None, Some((_, is_ignored))) => {
283                        if *is_ignored {
284                            continue;
285                        }
286                        FileStatus::Untracked
287                    }
288                    (_, None, None, None) => {
289                        unreachable!();
290                    }
291                };
292                if status
293                    != FileStatus::Tracked(TrackedStatus {
294                        index_status: StatusCode::Unmodified,
295                        worktree_status: StatusCode::Unmodified,
296                    })
297                {
298                    entries.push((path.clone(), status));
299                }
300            }
301            entries.sort_by(|a, b| a.0.cmp(&b.0));
302            Ok(GitStatus {
303                entries: entries.into(),
304            })
305        })?
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: HashMap<String, String>,
352    ) -> BoxFuture<Result<()>> {
353        unimplemented!()
354    }
355
356    fn unstage_paths(
357        &self,
358        _paths: Vec<RepoPath>,
359        _env: 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        _env: HashMap<String, String>,
369    ) -> BoxFuture<Result<()>> {
370        unimplemented!()
371    }
372
373    fn push(
374        &self,
375        _branch: String,
376        _remote: String,
377        _options: Option<PushOptions>,
378        _askpass: AskPassSession,
379        _env: HashMap<String, String>,
380        _cx: AsyncApp,
381    ) -> BoxFuture<Result<git::repository::RemoteCommandOutput>> {
382        unimplemented!()
383    }
384
385    fn pull(
386        &self,
387        _branch: String,
388        _remote: String,
389        _askpass: AskPassSession,
390        _env: HashMap<String, String>,
391        _cx: AsyncApp,
392    ) -> BoxFuture<Result<git::repository::RemoteCommandOutput>> {
393        unimplemented!()
394    }
395
396    fn fetch(
397        &self,
398        _askpass: AskPassSession,
399        _env: HashMap<String, String>,
400        _cx: AsyncApp,
401    ) -> BoxFuture<Result<git::repository::RemoteCommandOutput>> {
402        unimplemented!()
403    }
404
405    fn get_remotes(&self, _branch: Option<String>) -> BoxFuture<Result<Vec<Remote>>> {
406        unimplemented!()
407    }
408
409    fn check_for_pushed_commit(&self) -> BoxFuture<Result<Vec<gpui::SharedString>>> {
410        future::ready(Ok(Vec::new())).boxed()
411    }
412
413    fn diff(&self, _diff: git::repository::DiffType) -> BoxFuture<Result<String>> {
414        unimplemented!()
415    }
416
417    fn checkpoint(&self) -> BoxFuture<Result<GitRepositoryCheckpoint>> {
418        unimplemented!()
419    }
420
421    fn restore_checkpoint(&self, _checkpoint: GitRepositoryCheckpoint) -> BoxFuture<Result<()>> {
422        unimplemented!()
423    }
424
425    fn compare_checkpoints(
426        &self,
427        _left: GitRepositoryCheckpoint,
428        _right: GitRepositoryCheckpoint,
429    ) -> BoxFuture<Result<bool>> {
430        unimplemented!()
431    }
432
433    fn delete_checkpoint(&self, _checkpoint: GitRepositoryCheckpoint) -> BoxFuture<Result<()>> {
434        unimplemented!()
435    }
436}