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