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