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