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