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