1use crate::{FakeFs, FakeFsEntry, Fs};
2use anyhow::{Context as _, Result, bail};
3use collections::{HashMap, HashSet};
4use futures::future::{self, BoxFuture, join_all};
5use git::{
6 Oid,
7 blame::Blame,
8 repository::{
9 AskPassDelegate, Branch, CommitDetails, CommitOptions, FetchOptions, GitRepository,
10 GitRepositoryCheckpoint, PushOptions, Remote, RepoPath, ResetMode,
11 },
12 status::{FileStatus, GitStatus, StatusCode, TrackedStatus, UnmergedStatus},
13};
14use gpui::{AsyncApp, BackgroundExecutor, SharedString, Task};
15use ignore::gitignore::GitignoreBuilder;
16use parking_lot::Mutex;
17use rope::Rope;
18use smol::future::FutureExt as _;
19use std::{path::PathBuf, sync::Arc};
20use util::{paths::PathStyle, rel_path::RelPath};
21
22#[derive(Clone)]
23pub struct FakeGitRepository {
24 pub(crate) fs: Arc<FakeFs>,
25 pub(crate) checkpoints: Arc<Mutex<HashMap<Oid, FakeFsEntry>>>,
26 pub(crate) executor: BackgroundExecutor,
27 pub(crate) dot_git_path: PathBuf,
28 pub(crate) repository_dir_path: PathBuf,
29 pub(crate) common_dir_path: PathBuf,
30}
31
32#[derive(Debug, Clone)]
33pub struct FakeGitRepositoryState {
34 pub event_emitter: smol::channel::Sender<PathBuf>,
35 pub unmerged_paths: HashMap<RepoPath, UnmergedStatus>,
36 pub head_contents: HashMap<RepoPath, String>,
37 pub index_contents: HashMap<RepoPath, String>,
38 pub blames: HashMap<RepoPath, Blame>,
39 pub current_branch_name: Option<String>,
40 pub branches: HashSet<String>,
41 pub simulated_index_write_error_message: Option<String>,
42 pub refs: HashMap<String, String>,
43}
44
45impl FakeGitRepositoryState {
46 pub fn new(event_emitter: smol::channel::Sender<PathBuf>) -> Self {
47 FakeGitRepositoryState {
48 event_emitter,
49 head_contents: Default::default(),
50 index_contents: Default::default(),
51 unmerged_paths: Default::default(),
52 blames: Default::default(),
53 current_branch_name: Default::default(),
54 branches: Default::default(),
55 simulated_index_write_error_message: Default::default(),
56 refs: HashMap::from_iter([("HEAD".into(), "abc".into())]),
57 }
58 }
59}
60
61impl FakeGitRepository {
62 fn with_state_async<F, T>(&self, write: bool, f: F) -> BoxFuture<'static, Result<T>>
63 where
64 F: 'static + Send + FnOnce(&mut FakeGitRepositoryState) -> Result<T>,
65 T: Send,
66 {
67 let fs = self.fs.clone();
68 let executor = self.executor.clone();
69 let dot_git_path = self.dot_git_path.clone();
70 async move {
71 executor.simulate_random_delay().await;
72 fs.with_git_state(&dot_git_path, write, f)?
73 }
74 .boxed()
75 }
76}
77
78impl GitRepository for FakeGitRepository {
79 fn reload_index(&self) {}
80
81 fn load_index_text(&self, path: RepoPath) -> BoxFuture<'_, Option<String>> {
82 async {
83 self.with_state_async(false, move |state| {
84 state
85 .index_contents
86 .get(&path)
87 .context("not present in index")
88 .cloned()
89 })
90 .await
91 .ok()
92 }
93 .boxed()
94 }
95
96 fn load_committed_text(&self, path: RepoPath) -> BoxFuture<'_, Option<String>> {
97 async {
98 self.with_state_async(false, move |state| {
99 state
100 .head_contents
101 .get(&path)
102 .context("not present in HEAD")
103 .cloned()
104 })
105 .await
106 .ok()
107 }
108 .boxed()
109 }
110
111 fn load_commit(
112 &self,
113 _commit: String,
114 _cx: AsyncApp,
115 ) -> BoxFuture<'_, Result<git::repository::CommitDiff>> {
116 unimplemented!()
117 }
118
119 fn set_index_text(
120 &self,
121 path: RepoPath,
122 content: Option<String>,
123 _env: Arc<HashMap<String, String>>,
124 ) -> BoxFuture<'_, anyhow::Result<()>> {
125 self.with_state_async(true, move |state| {
126 if let Some(message) = &state.simulated_index_write_error_message {
127 anyhow::bail!("{message}");
128 } else if let Some(content) = content {
129 state.index_contents.insert(path, content);
130 } else {
131 state.index_contents.remove(&path);
132 }
133 Ok(())
134 })
135 }
136
137 fn remote_url(&self, _name: &str) -> Option<String> {
138 None
139 }
140
141 fn revparse_batch(&self, revs: Vec<String>) -> BoxFuture<'_, Result<Vec<Option<String>>>> {
142 self.with_state_async(false, |state| {
143 Ok(revs
144 .into_iter()
145 .map(|rev| state.refs.get(&rev).cloned())
146 .collect())
147 })
148 }
149
150 fn show(&self, commit: String) -> BoxFuture<'_, Result<CommitDetails>> {
151 async {
152 Ok(CommitDetails {
153 sha: commit.into(),
154 ..Default::default()
155 })
156 }
157 .boxed()
158 }
159
160 fn reset(
161 &self,
162 _commit: String,
163 _mode: ResetMode,
164 _env: Arc<HashMap<String, String>>,
165 ) -> BoxFuture<'_, Result<()>> {
166 unimplemented!()
167 }
168
169 fn checkout_files(
170 &self,
171 _commit: String,
172 _paths: Vec<RepoPath>,
173 _env: Arc<HashMap<String, String>>,
174 ) -> BoxFuture<'_, Result<()>> {
175 unimplemented!()
176 }
177
178 fn path(&self) -> PathBuf {
179 self.repository_dir_path.clone()
180 }
181
182 fn main_repository_path(&self) -> PathBuf {
183 self.common_dir_path.clone()
184 }
185
186 fn merge_message(&self) -> BoxFuture<'_, Option<String>> {
187 async move { None }.boxed()
188 }
189
190 fn status(&self, path_prefixes: &[RepoPath]) -> Task<Result<GitStatus>> {
191 let workdir_path = self.dot_git_path.parent().unwrap();
192
193 // Load gitignores
194 let ignores = workdir_path
195 .ancestors()
196 .filter_map(|dir| {
197 let ignore_path = dir.join(".gitignore");
198 let content = self.fs.read_file_sync(ignore_path).ok()?;
199 let content = String::from_utf8(content).ok()?;
200 let mut builder = GitignoreBuilder::new(dir);
201 for line in content.lines() {
202 builder.add_line(Some(dir.into()), line).ok()?;
203 }
204 builder.build().ok()
205 })
206 .collect::<Vec<_>>();
207
208 // Load working copy files.
209 let git_files: HashMap<RepoPath, (String, bool)> = self
210 .fs
211 .files()
212 .iter()
213 .filter_map(|path| {
214 // TODO better simulate git status output in the case of submodules and worktrees
215 let repo_path = path.strip_prefix(workdir_path).ok()?;
216 let mut is_ignored = repo_path.starts_with(".git");
217 for ignore in &ignores {
218 match ignore.matched_path_or_any_parents(path, false) {
219 ignore::Match::None => {}
220 ignore::Match::Ignore(_) => is_ignored = true,
221 ignore::Match::Whitelist(_) => break,
222 }
223 }
224 let content = self
225 .fs
226 .read_file_sync(path)
227 .ok()
228 .map(|content| String::from_utf8(content).unwrap())?;
229 let repo_path = RelPath::new(repo_path, PathStyle::local()).ok()?;
230 Some((repo_path.into(), (content, is_ignored)))
231 })
232 .collect();
233
234 let result = self.fs.with_git_state(&self.dot_git_path, false, |state| {
235 let mut entries = Vec::new();
236 let paths = state
237 .head_contents
238 .keys()
239 .chain(state.index_contents.keys())
240 .chain(git_files.keys())
241 .collect::<HashSet<_>>();
242 for path in paths {
243 if !path_prefixes.iter().any(|prefix| path.starts_with(prefix)) {
244 continue;
245 }
246
247 let head = state.head_contents.get(path);
248 let index = state.index_contents.get(path);
249 let unmerged = state.unmerged_paths.get(path);
250 let fs = git_files.get(path);
251 let status = match (unmerged, head, index, fs) {
252 (Some(unmerged), _, _, _) => FileStatus::Unmerged(*unmerged),
253 (_, Some(head), Some(index), Some((fs, _))) => {
254 FileStatus::Tracked(TrackedStatus {
255 index_status: if head == index {
256 StatusCode::Unmodified
257 } else {
258 StatusCode::Modified
259 },
260 worktree_status: if fs == index {
261 StatusCode::Unmodified
262 } else {
263 StatusCode::Modified
264 },
265 })
266 }
267 (_, Some(head), Some(index), None) => FileStatus::Tracked(TrackedStatus {
268 index_status: if head == index {
269 StatusCode::Unmodified
270 } else {
271 StatusCode::Modified
272 },
273 worktree_status: StatusCode::Deleted,
274 }),
275 (_, Some(_), None, Some(_)) => FileStatus::Tracked(TrackedStatus {
276 index_status: StatusCode::Deleted,
277 worktree_status: StatusCode::Added,
278 }),
279 (_, Some(_), None, None) => FileStatus::Tracked(TrackedStatus {
280 index_status: StatusCode::Deleted,
281 worktree_status: StatusCode::Deleted,
282 }),
283 (_, None, Some(index), Some((fs, _))) => FileStatus::Tracked(TrackedStatus {
284 index_status: StatusCode::Added,
285 worktree_status: if fs == index {
286 StatusCode::Unmodified
287 } else {
288 StatusCode::Modified
289 },
290 }),
291 (_, None, Some(_), None) => FileStatus::Tracked(TrackedStatus {
292 index_status: StatusCode::Added,
293 worktree_status: StatusCode::Deleted,
294 }),
295 (_, None, None, Some((_, is_ignored))) => {
296 if *is_ignored {
297 continue;
298 }
299 FileStatus::Untracked
300 }
301 (_, None, None, None) => {
302 unreachable!();
303 }
304 };
305 if status
306 != FileStatus::Tracked(TrackedStatus {
307 index_status: StatusCode::Unmodified,
308 worktree_status: StatusCode::Unmodified,
309 })
310 {
311 entries.push((path.clone(), status));
312 }
313 }
314 entries.sort_by(|a, b| a.0.cmp(&b.0));
315 anyhow::Ok(GitStatus {
316 entries: entries.into(),
317 })
318 });
319 Task::ready(match result {
320 Ok(result) => result,
321 Err(e) => Err(e),
322 })
323 }
324
325 fn stash_entries(&self) -> BoxFuture<'_, Result<git::stash::GitStash>> {
326 async { Ok(git::stash::GitStash::default()) }.boxed()
327 }
328
329 fn branches(&self) -> BoxFuture<'_, Result<Vec<Branch>>> {
330 self.with_state_async(false, move |state| {
331 let current_branch = &state.current_branch_name;
332 Ok(state
333 .branches
334 .iter()
335 .map(|branch_name| Branch {
336 is_head: Some(branch_name) == current_branch.as_ref(),
337 ref_name: branch_name.into(),
338 most_recent_commit: None,
339 upstream: None,
340 })
341 .collect())
342 })
343 }
344
345 fn change_branch(&self, name: String) -> BoxFuture<'_, Result<()>> {
346 self.with_state_async(true, |state| {
347 state.current_branch_name = Some(name);
348 Ok(())
349 })
350 }
351
352 fn create_branch(&self, name: String) -> BoxFuture<'_, Result<()>> {
353 self.with_state_async(true, move |state| {
354 state.branches.insert(name);
355 Ok(())
356 })
357 }
358
359 fn rename_branch(&self, branch: String, new_name: String) -> BoxFuture<'_, Result<()>> {
360 self.with_state_async(true, move |state| {
361 if !state.branches.remove(&branch) {
362 bail!("no such branch: {branch}");
363 }
364 state.branches.insert(new_name.clone());
365 if state.current_branch_name == Some(branch) {
366 state.current_branch_name = Some(new_name);
367 }
368 Ok(())
369 })
370 }
371
372 fn blame(&self, path: RepoPath, _content: Rope) -> BoxFuture<'_, Result<git::blame::Blame>> {
373 self.with_state_async(false, move |state| {
374 state
375 .blames
376 .get(&path)
377 .with_context(|| format!("failed to get blame for {:?}", path.0))
378 .cloned()
379 })
380 }
381
382 fn stage_paths(
383 &self,
384 paths: Vec<RepoPath>,
385 _env: Arc<HashMap<String, String>>,
386 ) -> BoxFuture<'_, Result<()>> {
387 Box::pin(async move {
388 let contents = paths
389 .into_iter()
390 .map(|path| {
391 let abs_path = self
392 .dot_git_path
393 .parent()
394 .unwrap()
395 .join(&path.as_std_path());
396 Box::pin(async move { (path.clone(), self.fs.load(&abs_path).await.ok()) })
397 })
398 .collect::<Vec<_>>();
399 let contents = join_all(contents).await;
400 self.with_state_async(true, move |state| {
401 for (path, content) in contents {
402 if let Some(content) = content {
403 state.index_contents.insert(path, content);
404 } else {
405 state.index_contents.remove(&path);
406 }
407 }
408 Ok(())
409 })
410 .await
411 })
412 }
413
414 fn unstage_paths(
415 &self,
416 paths: Vec<RepoPath>,
417 _env: Arc<HashMap<String, String>>,
418 ) -> BoxFuture<'_, Result<()>> {
419 self.with_state_async(true, move |state| {
420 for path in paths {
421 match state.head_contents.get(&path) {
422 Some(content) => state.index_contents.insert(path, content.clone()),
423 None => state.index_contents.remove(&path),
424 };
425 }
426 Ok(())
427 })
428 }
429
430 fn stash_paths(
431 &self,
432 _paths: Vec<RepoPath>,
433 _env: Arc<HashMap<String, String>>,
434 ) -> BoxFuture<'_, Result<()>> {
435 unimplemented!()
436 }
437
438 fn stash_pop(
439 &self,
440 _index: Option<usize>,
441 _env: Arc<HashMap<String, String>>,
442 ) -> BoxFuture<'_, Result<()>> {
443 unimplemented!()
444 }
445
446 fn stash_apply(
447 &self,
448 _index: Option<usize>,
449 _env: Arc<HashMap<String, String>>,
450 ) -> BoxFuture<'_, Result<()>> {
451 unimplemented!()
452 }
453
454 fn stash_drop(
455 &self,
456 _index: Option<usize>,
457 _env: Arc<HashMap<String, String>>,
458 ) -> BoxFuture<'_, Result<()>> {
459 unimplemented!()
460 }
461
462 fn commit(
463 &self,
464 _message: gpui::SharedString,
465 _name_and_email: Option<(gpui::SharedString, gpui::SharedString)>,
466 _options: CommitOptions,
467 _env: Arc<HashMap<String, String>>,
468 ) -> BoxFuture<'_, Result<()>> {
469 unimplemented!()
470 }
471
472 fn push(
473 &self,
474 _branch: String,
475 _remote: String,
476 _options: Option<PushOptions>,
477 _askpass: AskPassDelegate,
478 _env: Arc<HashMap<String, String>>,
479 _cx: AsyncApp,
480 ) -> BoxFuture<'_, Result<git::repository::RemoteCommandOutput>> {
481 unimplemented!()
482 }
483
484 fn pull(
485 &self,
486 _branch: String,
487 _remote: String,
488 _askpass: AskPassDelegate,
489 _env: Arc<HashMap<String, String>>,
490 _cx: AsyncApp,
491 ) -> BoxFuture<'_, Result<git::repository::RemoteCommandOutput>> {
492 unimplemented!()
493 }
494
495 fn fetch(
496 &self,
497 _fetch_options: FetchOptions,
498 _askpass: AskPassDelegate,
499 _env: Arc<HashMap<String, String>>,
500 _cx: AsyncApp,
501 ) -> BoxFuture<'_, Result<git::repository::RemoteCommandOutput>> {
502 unimplemented!()
503 }
504
505 fn get_remotes(&self, _branch: Option<String>) -> BoxFuture<'_, Result<Vec<Remote>>> {
506 unimplemented!()
507 }
508
509 fn check_for_pushed_commit(&self) -> BoxFuture<'_, Result<Vec<gpui::SharedString>>> {
510 future::ready(Ok(Vec::new())).boxed()
511 }
512
513 fn diff(&self, _diff: git::repository::DiffType) -> BoxFuture<'_, Result<String>> {
514 unimplemented!()
515 }
516
517 fn checkpoint(&self) -> BoxFuture<'static, Result<GitRepositoryCheckpoint>> {
518 let executor = self.executor.clone();
519 let fs = self.fs.clone();
520 let checkpoints = self.checkpoints.clone();
521 let repository_dir_path = self.repository_dir_path.parent().unwrap().to_path_buf();
522 async move {
523 executor.simulate_random_delay().await;
524 let oid = Oid::random(&mut executor.rng());
525 let entry = fs.entry(&repository_dir_path)?;
526 checkpoints.lock().insert(oid, entry);
527 Ok(GitRepositoryCheckpoint { commit_sha: oid })
528 }
529 .boxed()
530 }
531
532 fn restore_checkpoint(&self, checkpoint: GitRepositoryCheckpoint) -> BoxFuture<'_, Result<()>> {
533 let executor = self.executor.clone();
534 let fs = self.fs.clone();
535 let checkpoints = self.checkpoints.clone();
536 let repository_dir_path = self.repository_dir_path.parent().unwrap().to_path_buf();
537 async move {
538 executor.simulate_random_delay().await;
539 let checkpoints = checkpoints.lock();
540 let entry = checkpoints
541 .get(&checkpoint.commit_sha)
542 .context(format!("invalid checkpoint: {}", checkpoint.commit_sha))?;
543 fs.insert_entry(&repository_dir_path, entry.clone())?;
544 Ok(())
545 }
546 .boxed()
547 }
548
549 fn compare_checkpoints(
550 &self,
551 left: GitRepositoryCheckpoint,
552 right: GitRepositoryCheckpoint,
553 ) -> BoxFuture<'_, Result<bool>> {
554 let executor = self.executor.clone();
555 let checkpoints = self.checkpoints.clone();
556 async move {
557 executor.simulate_random_delay().await;
558 let checkpoints = checkpoints.lock();
559 let left = checkpoints
560 .get(&left.commit_sha)
561 .context(format!("invalid left checkpoint: {}", left.commit_sha))?;
562 let right = checkpoints
563 .get(&right.commit_sha)
564 .context(format!("invalid right checkpoint: {}", right.commit_sha))?;
565
566 Ok(left == right)
567 }
568 .boxed()
569 }
570
571 fn diff_checkpoints(
572 &self,
573 _base_checkpoint: GitRepositoryCheckpoint,
574 _target_checkpoint: GitRepositoryCheckpoint,
575 ) -> BoxFuture<'_, Result<String>> {
576 unimplemented!()
577 }
578
579 fn default_branch(&self) -> BoxFuture<'_, Result<Option<SharedString>>> {
580 unimplemented!()
581 }
582}
583
584#[cfg(test)]
585mod tests {
586 use crate::{FakeFs, Fs};
587 use gpui::BackgroundExecutor;
588 use serde_json::json;
589 use std::path::Path;
590 use util::path;
591
592 #[gpui::test]
593 async fn test_checkpoints(executor: BackgroundExecutor) {
594 let fs = FakeFs::new(executor);
595 fs.insert_tree(
596 path!("/"),
597 json!({
598 "bar": {
599 "baz": "qux"
600 },
601 "foo": {
602 ".git": {},
603 "a": "lorem",
604 "b": "ipsum",
605 },
606 }),
607 )
608 .await;
609 fs.with_git_state(Path::new("/foo/.git"), true, |_git| {})
610 .unwrap();
611 let repository = fs
612 .open_repo(Path::new("/foo/.git"), Some("git".as_ref()))
613 .unwrap();
614
615 let checkpoint_1 = repository.checkpoint().await.unwrap();
616 fs.write(Path::new("/foo/b"), b"IPSUM").await.unwrap();
617 fs.write(Path::new("/foo/c"), b"dolor").await.unwrap();
618 let checkpoint_2 = repository.checkpoint().await.unwrap();
619 let checkpoint_3 = repository.checkpoint().await.unwrap();
620
621 assert!(
622 repository
623 .compare_checkpoints(checkpoint_2.clone(), checkpoint_3.clone())
624 .await
625 .unwrap()
626 );
627 assert!(
628 !repository
629 .compare_checkpoints(checkpoint_1.clone(), checkpoint_2.clone())
630 .await
631 .unwrap()
632 );
633
634 repository.restore_checkpoint(checkpoint_1).await.unwrap();
635 assert_eq!(
636 fs.files_with_contents(Path::new("")),
637 [
638 (Path::new(path!("/bar/baz")).into(), b"qux".into()),
639 (Path::new(path!("/foo/a")).into(), b"lorem".into()),
640 (Path::new(path!("/foo/b")).into(), b"ipsum".into())
641 ]
642 );
643 }
644}