1use crate::{FakeFs, Fs};
2use anyhow::{Context as _, Result};
3use collections::{HashMap, HashSet};
4use futures::future::{self, BoxFuture, join_all};
5use git::{
6 blame::Blame,
7 repository::{
8 AskPassDelegate, Branch, CommitDetails, CommitOptions, FetchOptions, GitRepository,
9 GitRepositoryCheckpoint, PushOptions, Remote, RepoPath, ResetMode,
10 },
11 status::{FileStatus, GitStatus, StatusCode, TrackedStatus, UnmergedStatus},
12};
13use gpui::{AsyncApp, BackgroundExecutor, SharedString, Task};
14use ignore::gitignore::GitignoreBuilder;
15use rope::Rope;
16use smol::future::FutureExt as _;
17use std::{path::PathBuf, sync::Arc};
18
19#[derive(Clone)]
20pub struct FakeGitRepository {
21 pub(crate) fs: Arc<FakeFs>,
22 pub(crate) executor: BackgroundExecutor,
23 pub(crate) dot_git_path: PathBuf,
24 pub(crate) repository_dir_path: PathBuf,
25 pub(crate) common_dir_path: PathBuf,
26}
27
28#[derive(Debug, Clone)]
29pub struct FakeGitRepositoryState {
30 pub event_emitter: smol::channel::Sender<PathBuf>,
31 pub unmerged_paths: HashMap<RepoPath, UnmergedStatus>,
32 pub head_contents: HashMap<RepoPath, String>,
33 pub index_contents: HashMap<RepoPath, String>,
34 pub blames: HashMap<RepoPath, Blame>,
35 pub current_branch_name: Option<String>,
36 pub branches: HashSet<String>,
37 pub simulated_index_write_error_message: Option<String>,
38 pub refs: HashMap<String, String>,
39}
40
41impl FakeGitRepositoryState {
42 pub fn new(event_emitter: smol::channel::Sender<PathBuf>) -> Self {
43 FakeGitRepositoryState {
44 event_emitter,
45 head_contents: Default::default(),
46 index_contents: Default::default(),
47 unmerged_paths: Default::default(),
48 blames: Default::default(),
49 current_branch_name: Default::default(),
50 branches: Default::default(),
51 simulated_index_write_error_message: Default::default(),
52 refs: HashMap::from_iter([("HEAD".into(), "abc".into())]),
53 }
54 }
55}
56
57impl FakeGitRepository {
58 fn with_state_async<F, T>(&self, write: bool, f: F) -> BoxFuture<'static, Result<T>>
59 where
60 F: 'static + Send + FnOnce(&mut FakeGitRepositoryState) -> Result<T>,
61 T: Send,
62 {
63 let fs = self.fs.clone();
64 let executor = self.executor.clone();
65 let dot_git_path = self.dot_git_path.clone();
66 async move {
67 executor.simulate_random_delay().await;
68 fs.with_git_state(&dot_git_path, write, f)?
69 }
70 .boxed()
71 }
72}
73
74impl GitRepository for FakeGitRepository {
75 fn reload_index(&self) {}
76
77 fn load_index_text(&self, path: RepoPath) -> BoxFuture<'_, Option<String>> {
78 async {
79 self.with_state_async(false, move |state| {
80 state
81 .index_contents
82 .get(path.as_ref())
83 .context("not present in index")
84 .cloned()
85 })
86 .await
87 .ok()
88 }
89 .boxed()
90 }
91
92 fn load_committed_text(&self, path: RepoPath) -> BoxFuture<'_, Option<String>> {
93 async {
94 self.with_state_async(false, move |state| {
95 state
96 .head_contents
97 .get(path.as_ref())
98 .context("not present in HEAD")
99 .cloned()
100 })
101 .await
102 .ok()
103 }
104 .boxed()
105 }
106
107 fn load_commit(
108 &self,
109 _commit: String,
110 _cx: AsyncApp,
111 ) -> BoxFuture<'_, Result<git::repository::CommitDiff>> {
112 unimplemented!()
113 }
114
115 fn set_index_text(
116 &self,
117 path: RepoPath,
118 content: Option<String>,
119 _env: Arc<HashMap<String, String>>,
120 ) -> BoxFuture<'_, anyhow::Result<()>> {
121 self.with_state_async(true, move |state| {
122 if let Some(message) = &state.simulated_index_write_error_message {
123 anyhow::bail!("{message}");
124 } else if let Some(content) = content {
125 state.index_contents.insert(path, content);
126 } else {
127 state.index_contents.remove(&path);
128 }
129 Ok(())
130 })
131 }
132
133 fn remote_url(&self, _name: &str) -> Option<String> {
134 None
135 }
136
137 fn revparse_batch(&self, revs: Vec<String>) -> BoxFuture<'_, Result<Vec<Option<String>>>> {
138 self.with_state_async(false, |state| {
139 Ok(revs
140 .into_iter()
141 .map(|rev| state.refs.get(&rev).cloned())
142 .collect())
143 })
144 }
145
146 fn show(&self, commit: String) -> BoxFuture<'_, Result<CommitDetails>> {
147 async {
148 Ok(CommitDetails {
149 sha: commit.into(),
150 ..Default::default()
151 })
152 }
153 .boxed()
154 }
155
156 fn reset(
157 &self,
158 _commit: String,
159 _mode: ResetMode,
160 _env: Arc<HashMap<String, String>>,
161 ) -> BoxFuture<'_, Result<()>> {
162 unimplemented!()
163 }
164
165 fn checkout_files(
166 &self,
167 _commit: String,
168 _paths: Vec<RepoPath>,
169 _env: Arc<HashMap<String, String>>,
170 ) -> BoxFuture<'_, Result<()>> {
171 unimplemented!()
172 }
173
174 fn path(&self) -> PathBuf {
175 self.repository_dir_path.clone()
176 }
177
178 fn main_repository_path(&self) -> PathBuf {
179 self.common_dir_path.clone()
180 }
181
182 fn merge_message(&self) -> BoxFuture<'_, Option<String>> {
183 async move { None }.boxed()
184 }
185
186 fn status(&self, path_prefixes: &[RepoPath]) -> Task<Result<GitStatus>> {
187 let workdir_path = self.dot_git_path.parent().unwrap();
188
189 // Load gitignores
190 let ignores = workdir_path
191 .ancestors()
192 .filter_map(|dir| {
193 let ignore_path = dir.join(".gitignore");
194 let content = self.fs.read_file_sync(ignore_path).ok()?;
195 let content = String::from_utf8(content).ok()?;
196 let mut builder = GitignoreBuilder::new(dir);
197 for line in content.lines() {
198 builder.add_line(Some(dir.into()), line).ok()?;
199 }
200 builder.build().ok()
201 })
202 .collect::<Vec<_>>();
203
204 // Load working copy files.
205 let git_files: HashMap<RepoPath, (String, bool)> = self
206 .fs
207 .files()
208 .iter()
209 .filter_map(|path| {
210 // TODO better simulate git status output in the case of submodules and worktrees
211 let repo_path = path.strip_prefix(workdir_path).ok()?;
212 let mut is_ignored = repo_path.starts_with(".git");
213 for ignore in &ignores {
214 match ignore.matched_path_or_any_parents(path, false) {
215 ignore::Match::None => {}
216 ignore::Match::Ignore(_) => is_ignored = true,
217 ignore::Match::Whitelist(_) => break,
218 }
219 }
220 let content = self
221 .fs
222 .read_file_sync(path)
223 .ok()
224 .map(|content| String::from_utf8(content).unwrap())?;
225 Some((repo_path.into(), (content, is_ignored)))
226 })
227 .collect();
228
229 let result = self.fs.with_git_state(&self.dot_git_path, false, |state| {
230 let mut entries = Vec::new();
231 let paths = state
232 .head_contents
233 .keys()
234 .chain(state.index_contents.keys())
235 .chain(git_files.keys())
236 .collect::<HashSet<_>>();
237 for path in paths {
238 if !path_prefixes.iter().any(|prefix| path.starts_with(prefix)) {
239 continue;
240 }
241
242 let head = state.head_contents.get(path);
243 let index = state.index_contents.get(path);
244 let unmerged = state.unmerged_paths.get(path);
245 let fs = git_files.get(path);
246 let status = match (unmerged, head, index, fs) {
247 (Some(unmerged), _, _, _) => FileStatus::Unmerged(*unmerged),
248 (_, Some(head), Some(index), Some((fs, _))) => {
249 FileStatus::Tracked(TrackedStatus {
250 index_status: if head == index {
251 StatusCode::Unmodified
252 } else {
253 StatusCode::Modified
254 },
255 worktree_status: if fs == index {
256 StatusCode::Unmodified
257 } else {
258 StatusCode::Modified
259 },
260 })
261 }
262 (_, Some(head), Some(index), None) => FileStatus::Tracked(TrackedStatus {
263 index_status: if head == index {
264 StatusCode::Unmodified
265 } else {
266 StatusCode::Modified
267 },
268 worktree_status: StatusCode::Deleted,
269 }),
270 (_, Some(_), None, Some(_)) => FileStatus::Tracked(TrackedStatus {
271 index_status: StatusCode::Deleted,
272 worktree_status: StatusCode::Added,
273 }),
274 (_, Some(_), None, None) => FileStatus::Tracked(TrackedStatus {
275 index_status: StatusCode::Deleted,
276 worktree_status: StatusCode::Deleted,
277 }),
278 (_, None, Some(index), Some((fs, _))) => FileStatus::Tracked(TrackedStatus {
279 index_status: StatusCode::Added,
280 worktree_status: if fs == index {
281 StatusCode::Unmodified
282 } else {
283 StatusCode::Modified
284 },
285 }),
286 (_, None, Some(_), None) => FileStatus::Tracked(TrackedStatus {
287 index_status: StatusCode::Added,
288 worktree_status: StatusCode::Deleted,
289 }),
290 (_, None, None, Some((_, is_ignored))) => {
291 if *is_ignored {
292 continue;
293 }
294 FileStatus::Untracked
295 }
296 (_, None, None, None) => {
297 unreachable!();
298 }
299 };
300 if status
301 != FileStatus::Tracked(TrackedStatus {
302 index_status: StatusCode::Unmodified,
303 worktree_status: StatusCode::Unmodified,
304 })
305 {
306 entries.push((path.clone(), status));
307 }
308 }
309 entries.sort_by(|a, b| a.0.cmp(&b.0));
310 anyhow::Ok(GitStatus {
311 entries: entries.into(),
312 })
313 });
314 Task::ready(match result {
315 Ok(result) => result,
316 Err(e) => Err(e),
317 })
318 }
319
320 fn branches(&self) -> BoxFuture<'_, Result<Vec<Branch>>> {
321 self.with_state_async(false, move |state| {
322 let current_branch = &state.current_branch_name;
323 Ok(state
324 .branches
325 .iter()
326 .map(|branch_name| Branch {
327 is_head: Some(branch_name) == current_branch.as_ref(),
328 ref_name: branch_name.into(),
329 most_recent_commit: None,
330 upstream: None,
331 })
332 .collect())
333 })
334 }
335
336 fn change_branch(&self, name: String) -> BoxFuture<'_, Result<()>> {
337 self.with_state_async(true, |state| {
338 state.current_branch_name = Some(name);
339 Ok(())
340 })
341 }
342
343 fn create_branch(&self, name: String) -> BoxFuture<'_, Result<()>> {
344 self.with_state_async(true, move |state| {
345 state.branches.insert(name.to_owned());
346 Ok(())
347 })
348 }
349
350 fn blame(&self, path: RepoPath, _content: Rope) -> BoxFuture<'_, Result<git::blame::Blame>> {
351 self.with_state_async(false, move |state| {
352 state
353 .blames
354 .get(&path)
355 .with_context(|| format!("failed to get blame for {:?}", path.0))
356 .cloned()
357 })
358 }
359
360 fn stage_paths(
361 &self,
362 paths: Vec<RepoPath>,
363 _env: Arc<HashMap<String, String>>,
364 ) -> BoxFuture<'_, Result<()>> {
365 Box::pin(async move {
366 let contents = paths
367 .into_iter()
368 .map(|path| {
369 let abs_path = self.dot_git_path.parent().unwrap().join(&path);
370 Box::pin(async move { (path.clone(), self.fs.load(&abs_path).await.ok()) })
371 })
372 .collect::<Vec<_>>();
373 let contents = join_all(contents).await;
374 self.with_state_async(true, move |state| {
375 for (path, content) in contents {
376 if let Some(content) = content {
377 state.index_contents.insert(path, content);
378 } else {
379 state.index_contents.remove(&path);
380 }
381 }
382 Ok(())
383 })
384 .await
385 })
386 }
387
388 fn unstage_paths(
389 &self,
390 paths: Vec<RepoPath>,
391 _env: Arc<HashMap<String, String>>,
392 ) -> BoxFuture<'_, Result<()>> {
393 self.with_state_async(true, move |state| {
394 for path in paths {
395 match state.head_contents.get(&path) {
396 Some(content) => state.index_contents.insert(path, content.clone()),
397 None => state.index_contents.remove(&path),
398 };
399 }
400 Ok(())
401 })
402 }
403
404 fn stash_paths(
405 &self,
406 _paths: Vec<RepoPath>,
407 _env: Arc<HashMap<String, String>>,
408 ) -> BoxFuture<'_, Result<()>> {
409 unimplemented!()
410 }
411
412 fn stash_pop(&self, _env: Arc<HashMap<String, String>>) -> BoxFuture<'_, Result<()>> {
413 unimplemented!()
414 }
415
416 fn commit(
417 &self,
418 _message: gpui::SharedString,
419 _name_and_email: Option<(gpui::SharedString, gpui::SharedString)>,
420 _options: CommitOptions,
421 _env: Arc<HashMap<String, String>>,
422 ) -> BoxFuture<'_, Result<()>> {
423 unimplemented!()
424 }
425
426 fn push(
427 &self,
428 _branch: String,
429 _remote: String,
430 _options: Option<PushOptions>,
431 _askpass: AskPassDelegate,
432 _env: Arc<HashMap<String, String>>,
433 _cx: AsyncApp,
434 ) -> BoxFuture<'_, Result<git::repository::RemoteCommandOutput>> {
435 unimplemented!()
436 }
437
438 fn pull(
439 &self,
440 _branch: String,
441 _remote: String,
442 _askpass: AskPassDelegate,
443 _env: Arc<HashMap<String, String>>,
444 _cx: AsyncApp,
445 ) -> BoxFuture<'_, Result<git::repository::RemoteCommandOutput>> {
446 unimplemented!()
447 }
448
449 fn fetch(
450 &self,
451 _fetch_options: FetchOptions,
452 _askpass: AskPassDelegate,
453 _env: Arc<HashMap<String, String>>,
454 _cx: AsyncApp,
455 ) -> BoxFuture<'_, Result<git::repository::RemoteCommandOutput>> {
456 unimplemented!()
457 }
458
459 fn get_remotes(&self, _branch: Option<String>) -> BoxFuture<'_, Result<Vec<Remote>>> {
460 unimplemented!()
461 }
462
463 fn check_for_pushed_commit(&self) -> BoxFuture<'_, Result<Vec<gpui::SharedString>>> {
464 future::ready(Ok(Vec::new())).boxed()
465 }
466
467 fn diff(&self, _diff: git::repository::DiffType) -> BoxFuture<'_, Result<String>> {
468 unimplemented!()
469 }
470
471 fn checkpoint(&self) -> BoxFuture<'static, Result<GitRepositoryCheckpoint>> {
472 unimplemented!()
473 }
474
475 fn restore_checkpoint(
476 &self,
477 _checkpoint: GitRepositoryCheckpoint,
478 ) -> BoxFuture<'_, Result<()>> {
479 unimplemented!()
480 }
481
482 fn compare_checkpoints(
483 &self,
484 _left: GitRepositoryCheckpoint,
485 _right: GitRepositoryCheckpoint,
486 ) -> BoxFuture<'_, Result<bool>> {
487 unimplemented!()
488 }
489
490 fn diff_checkpoints(
491 &self,
492 _base_checkpoint: GitRepositoryCheckpoint,
493 _target_checkpoint: GitRepositoryCheckpoint,
494 ) -> BoxFuture<'_, Result<String>> {
495 unimplemented!()
496 }
497
498 fn default_branch(&self) -> BoxFuture<'_, Result<Option<SharedString>>> {
499 unimplemented!()
500 }
501}