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, GitRepository, GitRepositoryCheckpoint, PushOptions,
9 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<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(&self, path: RepoPath, _cx: AsyncApp) -> BoxFuture<Option<String>> {
85 async {
86 self.with_state_async(false, move |state| {
87 state
88 .index_contents
89 .get(path.as_ref())
90 .ok_or_else(|| anyhow!("not present in index"))
91 .cloned()
92 })
93 .await
94 .ok()
95 }
96 .boxed()
97 }
98
99 fn load_committed_text(&self, path: RepoPath, _cx: AsyncApp) -> BoxFuture<Option<String>> {
100 async {
101 self.with_state_async(false, move |state| {
102 state
103 .head_contents
104 .get(path.as_ref())
105 .ok_or_else(|| anyhow!("not present in HEAD"))
106 .cloned()
107 })
108 .await
109 .ok()
110 }
111 .boxed()
112 }
113
114 fn set_index_text(
115 &self,
116 path: RepoPath,
117 content: Option<String>,
118 _env: HashMap<String, String>,
119 _cx: AsyncApp,
120 ) -> BoxFuture<anyhow::Result<()>> {
121 self.with_state_async(true, move |state| {
122 if let Some(message) = state.simulated_index_write_error_message.clone() {
123 return Err(anyhow!("{}", 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 head_sha(&self) -> Option<String> {
138 None
139 }
140
141 fn merge_head_shas(&self) -> Vec<String> {
142 vec![]
143 }
144
145 fn show(&self, _commit: String, _cx: AsyncApp) -> BoxFuture<Result<CommitDetails>> {
146 unimplemented!()
147 }
148
149 fn reset(
150 &self,
151 _commit: String,
152 _mode: ResetMode,
153 _env: HashMap<String, String>,
154 ) -> BoxFuture<Result<()>> {
155 unimplemented!()
156 }
157
158 fn checkout_files(
159 &self,
160 _commit: String,
161 _paths: Vec<RepoPath>,
162 _env: HashMap<String, String>,
163 ) -> BoxFuture<Result<()>> {
164 unimplemented!()
165 }
166
167 fn path(&self) -> PathBuf {
168 self.with_state(|state| state.path.clone())
169 }
170
171 fn main_repository_path(&self) -> PathBuf {
172 self.path()
173 }
174
175 fn status(&self, path_prefixes: &[RepoPath]) -> Result<GitStatus> {
176 let workdir_path = self.dot_git_path.parent().unwrap();
177
178 // Load gitignores
179 let ignores = workdir_path
180 .ancestors()
181 .filter_map(|dir| {
182 let ignore_path = dir.join(".gitignore");
183 let content = self.fs.read_file_sync(ignore_path).ok()?;
184 let content = String::from_utf8(content).ok()?;
185 let mut builder = GitignoreBuilder::new(dir);
186 for line in content.lines() {
187 builder.add_line(Some(dir.into()), line).ok()?;
188 }
189 builder.build().ok()
190 })
191 .collect::<Vec<_>>();
192
193 // Load working copy files.
194 let git_files: HashMap<RepoPath, (String, bool)> = self
195 .fs
196 .files()
197 .iter()
198 .filter_map(|path| {
199 let repo_path = path.strip_prefix(workdir_path).ok()?;
200 let mut is_ignored = false;
201 for ignore in &ignores {
202 match ignore.matched_path_or_any_parents(path, false) {
203 ignore::Match::None => {}
204 ignore::Match::Ignore(_) => is_ignored = true,
205 ignore::Match::Whitelist(_) => break,
206 }
207 }
208 let content = self
209 .fs
210 .read_file_sync(path)
211 .ok()
212 .map(|content| String::from_utf8(content).unwrap())?;
213 Some((repo_path.into(), (content, is_ignored)))
214 })
215 .collect();
216
217 self.fs.with_git_state(&self.dot_git_path, false, |state| {
218 let mut entries = Vec::new();
219 let paths = state
220 .head_contents
221 .keys()
222 .chain(state.index_contents.keys())
223 .chain(git_files.keys())
224 .collect::<HashSet<_>>();
225 for path in paths {
226 if !path_prefixes.iter().any(|prefix| path.starts_with(prefix)) {
227 continue;
228 }
229
230 let head = state.head_contents.get(path);
231 let index = state.index_contents.get(path);
232 let unmerged = state.unmerged_paths.get(path);
233 let fs = git_files.get(path);
234 let status = match (unmerged, head, index, fs) {
235 (Some(unmerged), _, _, _) => FileStatus::Unmerged(*unmerged),
236 (_, Some(head), Some(index), Some((fs, _))) => {
237 FileStatus::Tracked(TrackedStatus {
238 index_status: if head == index {
239 StatusCode::Unmodified
240 } else {
241 StatusCode::Modified
242 },
243 worktree_status: if fs == index {
244 StatusCode::Unmodified
245 } else {
246 StatusCode::Modified
247 },
248 })
249 }
250 (_, Some(head), Some(index), None) => FileStatus::Tracked(TrackedStatus {
251 index_status: if head == index {
252 StatusCode::Unmodified
253 } else {
254 StatusCode::Modified
255 },
256 worktree_status: StatusCode::Deleted,
257 }),
258 (_, Some(_), None, Some(_)) => FileStatus::Tracked(TrackedStatus {
259 index_status: StatusCode::Deleted,
260 worktree_status: StatusCode::Added,
261 }),
262 (_, Some(_), None, None) => FileStatus::Tracked(TrackedStatus {
263 index_status: StatusCode::Deleted,
264 worktree_status: StatusCode::Deleted,
265 }),
266 (_, None, Some(index), Some((fs, _))) => FileStatus::Tracked(TrackedStatus {
267 index_status: StatusCode::Added,
268 worktree_status: if fs == index {
269 StatusCode::Unmodified
270 } else {
271 StatusCode::Modified
272 },
273 }),
274 (_, None, Some(_), None) => FileStatus::Tracked(TrackedStatus {
275 index_status: StatusCode::Added,
276 worktree_status: StatusCode::Deleted,
277 }),
278 (_, None, None, Some((_, is_ignored))) => {
279 if *is_ignored {
280 continue;
281 }
282 FileStatus::Untracked
283 }
284 (_, None, None, None) => {
285 unreachable!();
286 }
287 };
288 if status
289 != FileStatus::Tracked(TrackedStatus {
290 index_status: StatusCode::Unmodified,
291 worktree_status: StatusCode::Unmodified,
292 })
293 {
294 entries.push((path.clone(), status));
295 }
296 }
297 entries.sort_by(|a, b| a.0.cmp(&b.0));
298 Ok(GitStatus {
299 entries: entries.into(),
300 })
301 })?
302 }
303
304 fn branches(&self) -> BoxFuture<Result<Vec<Branch>>> {
305 self.with_state_async(false, move |state| {
306 let current_branch = &state.current_branch_name;
307 Ok(state
308 .branches
309 .iter()
310 .map(|branch_name| Branch {
311 is_head: Some(branch_name) == current_branch.as_ref(),
312 name: branch_name.into(),
313 most_recent_commit: None,
314 upstream: None,
315 })
316 .collect())
317 })
318 }
319
320 fn change_branch(&self, name: String, _cx: AsyncApp) -> BoxFuture<Result<()>> {
321 self.with_state_async(true, |state| {
322 state.current_branch_name = Some(name);
323 Ok(())
324 })
325 }
326
327 fn create_branch(&self, name: String, _: AsyncApp) -> BoxFuture<Result<()>> {
328 self.with_state_async(true, move |state| {
329 state.branches.insert(name.to_owned());
330 Ok(())
331 })
332 }
333
334 fn blame(
335 &self,
336 path: RepoPath,
337 _content: Rope,
338 _cx: &mut AsyncApp,
339 ) -> BoxFuture<Result<git::blame::Blame>> {
340 self.with_state_async(false, move |state| {
341 state
342 .blames
343 .get(&path)
344 .with_context(|| format!("failed to get blame for {:?}", path.0))
345 .cloned()
346 })
347 }
348
349 fn stage_paths(
350 &self,
351 _paths: Vec<RepoPath>,
352 _env: HashMap<String, String>,
353 _cx: AsyncApp,
354 ) -> BoxFuture<Result<()>> {
355 unimplemented!()
356 }
357
358 fn unstage_paths(
359 &self,
360 _paths: Vec<RepoPath>,
361 _env: HashMap<String, String>,
362 _cx: AsyncApp,
363 ) -> BoxFuture<Result<()>> {
364 unimplemented!()
365 }
366
367 fn commit(
368 &self,
369 _message: gpui::SharedString,
370 _name_and_email: Option<(gpui::SharedString, gpui::SharedString)>,
371 _env: HashMap<String, String>,
372 _cx: AsyncApp,
373 ) -> BoxFuture<Result<()>> {
374 unimplemented!()
375 }
376
377 fn push(
378 &self,
379 _branch: String,
380 _remote: String,
381 _options: Option<PushOptions>,
382 _askpass: AskPassSession,
383 _env: HashMap<String, String>,
384 _cx: AsyncApp,
385 ) -> BoxFuture<Result<git::repository::RemoteCommandOutput>> {
386 unimplemented!()
387 }
388
389 fn pull(
390 &self,
391 _branch: String,
392 _remote: String,
393 _askpass: AskPassSession,
394 _env: HashMap<String, String>,
395 _cx: AsyncApp,
396 ) -> BoxFuture<Result<git::repository::RemoteCommandOutput>> {
397 unimplemented!()
398 }
399
400 fn fetch(
401 &self,
402 _askpass: AskPassSession,
403 _env: HashMap<String, String>,
404 _cx: AsyncApp,
405 ) -> BoxFuture<Result<git::repository::RemoteCommandOutput>> {
406 unimplemented!()
407 }
408
409 fn get_remotes(
410 &self,
411 _branch: Option<String>,
412 _cx: AsyncApp,
413 ) -> BoxFuture<Result<Vec<Remote>>> {
414 unimplemented!()
415 }
416
417 fn check_for_pushed_commit(
418 &self,
419 _cx: gpui::AsyncApp,
420 ) -> BoxFuture<Result<Vec<gpui::SharedString>>> {
421 future::ready(Ok(Vec::new())).boxed()
422 }
423
424 fn diff(
425 &self,
426 _diff: git::repository::DiffType,
427 _cx: gpui::AsyncApp,
428 ) -> BoxFuture<Result<String>> {
429 unimplemented!()
430 }
431
432 fn checkpoint(&self, _cx: AsyncApp) -> BoxFuture<Result<GitRepositoryCheckpoint>> {
433 unimplemented!()
434 }
435
436 fn restore_checkpoint(
437 &self,
438 _checkpoint: GitRepositoryCheckpoint,
439 _cx: AsyncApp,
440 ) -> BoxFuture<Result<()>> {
441 unimplemented!()
442 }
443
444 fn compare_checkpoints(
445 &self,
446 _left: GitRepositoryCheckpoint,
447 _right: GitRepositoryCheckpoint,
448 _cx: AsyncApp,
449 ) -> BoxFuture<Result<bool>> {
450 unimplemented!()
451 }
452
453 fn delete_checkpoint(
454 &self,
455 _checkpoint: GitRepositoryCheckpoint,
456 _cx: AsyncApp,
457 ) -> BoxFuture<Result<()>> {
458 unimplemented!()
459 }
460}