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