1use crate::FakeFs;
2use anyhow::{Context as _, Result, anyhow};
3use collections::{HashMap, HashSet};
4use futures::future::{self, BoxFuture};
5use git::{
6 blame::Blame,
7 repository::{
8 AskPassDelegate, Branch, CommitDetails, CommitOptions, GitRepository,
9 GitRepositoryCheckpoint, 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 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 merge_head_shas: Vec<String>,
38 pub simulated_index_write_error_message: Option<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 merge_head_shas: Default::default(),
52 simulated_index_write_error_message: Default::default(),
53 }
54 }
55}
56
57impl FakeGitRepository {
58 fn with_state<F, T>(&self, write: bool, f: F) -> Result<T>
59 where
60 F: FnOnce(&mut FakeGitRepositoryState) -> T,
61 {
62 self.fs.with_git_state(&self.dot_git_path, write, f)
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(&self, path: RepoPath) -> 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) -> 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 load_commit(
115 &self,
116 _commit: String,
117 _cx: AsyncApp,
118 ) -> BoxFuture<Result<git::repository::CommitDiff>> {
119 unimplemented!()
120 }
121
122 fn set_index_text(
123 &self,
124 path: RepoPath,
125 content: Option<String>,
126 _env: Arc<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 self.with_state(false, |state| state.merge_head_shas.clone())
150 .unwrap()
151 }
152
153 fn show(&self, commit: String) -> BoxFuture<Result<CommitDetails>> {
154 async {
155 Ok(CommitDetails {
156 sha: commit.into(),
157 ..Default::default()
158 })
159 }
160 .boxed()
161 }
162
163 fn reset(
164 &self,
165 _commit: String,
166 _mode: ResetMode,
167 _env: Arc<HashMap<String, String>>,
168 ) -> BoxFuture<Result<()>> {
169 unimplemented!()
170 }
171
172 fn checkout_files(
173 &self,
174 _commit: String,
175 _paths: Vec<RepoPath>,
176 _env: Arc<HashMap<String, String>>,
177 ) -> BoxFuture<Result<()>> {
178 unimplemented!()
179 }
180
181 fn path(&self) -> PathBuf {
182 self.repository_dir_path.clone()
183 }
184
185 fn main_repository_path(&self) -> PathBuf {
186 self.common_dir_path.clone()
187 }
188
189 fn merge_message(&self) -> BoxFuture<Option<String>> {
190 async move { None }.boxed()
191 }
192
193 fn status(&self, path_prefixes: &[RepoPath]) -> BoxFuture<Result<GitStatus>> {
194 let workdir_path = self.dot_git_path.parent().unwrap();
195
196 // Load gitignores
197 let ignores = workdir_path
198 .ancestors()
199 .filter_map(|dir| {
200 let ignore_path = dir.join(".gitignore");
201 let content = self.fs.read_file_sync(ignore_path).ok()?;
202 let content = String::from_utf8(content).ok()?;
203 let mut builder = GitignoreBuilder::new(dir);
204 for line in content.lines() {
205 builder.add_line(Some(dir.into()), line).ok()?;
206 }
207 builder.build().ok()
208 })
209 .collect::<Vec<_>>();
210
211 // Load working copy files.
212 let git_files: HashMap<RepoPath, (String, bool)> = self
213 .fs
214 .files()
215 .iter()
216 .filter_map(|path| {
217 // TODO better simulate git status output in the case of submodules and worktrees
218 let repo_path = path.strip_prefix(workdir_path).ok()?;
219 let mut is_ignored = repo_path.starts_with(".git");
220 for ignore in &ignores {
221 match ignore.matched_path_or_any_parents(path, false) {
222 ignore::Match::None => {}
223 ignore::Match::Ignore(_) => is_ignored = true,
224 ignore::Match::Whitelist(_) => break,
225 }
226 }
227 let content = self
228 .fs
229 .read_file_sync(path)
230 .ok()
231 .map(|content| String::from_utf8(content).unwrap())?;
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 async move { result? }.boxed()
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: Arc<HashMap<String, String>>,
368 ) -> BoxFuture<Result<()>> {
369 unimplemented!()
370 }
371
372 fn unstage_paths(
373 &self,
374 _paths: Vec<RepoPath>,
375 _env: Arc<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 _options: CommitOptions,
385 _env: Arc<HashMap<String, String>>,
386 ) -> BoxFuture<Result<()>> {
387 unimplemented!()
388 }
389
390 fn push(
391 &self,
392 _branch: String,
393 _remote: String,
394 _options: Option<PushOptions>,
395 _askpass: AskPassDelegate,
396 _env: Arc<HashMap<String, String>>,
397 _cx: AsyncApp,
398 ) -> BoxFuture<Result<git::repository::RemoteCommandOutput>> {
399 unimplemented!()
400 }
401
402 fn pull(
403 &self,
404 _branch: String,
405 _remote: String,
406 _askpass: AskPassDelegate,
407 _env: Arc<HashMap<String, String>>,
408 _cx: AsyncApp,
409 ) -> BoxFuture<Result<git::repository::RemoteCommandOutput>> {
410 unimplemented!()
411 }
412
413 fn fetch(
414 &self,
415 _askpass: AskPassDelegate,
416 _env: Arc<HashMap<String, String>>,
417 _cx: AsyncApp,
418 ) -> BoxFuture<Result<git::repository::RemoteCommandOutput>> {
419 unimplemented!()
420 }
421
422 fn get_remotes(&self, _branch: Option<String>) -> BoxFuture<Result<Vec<Remote>>> {
423 unimplemented!()
424 }
425
426 fn check_for_pushed_commit(&self) -> BoxFuture<Result<Vec<gpui::SharedString>>> {
427 future::ready(Ok(Vec::new())).boxed()
428 }
429
430 fn diff(&self, _diff: git::repository::DiffType) -> BoxFuture<Result<String>> {
431 unimplemented!()
432 }
433
434 fn checkpoint(&self) -> BoxFuture<'static, Result<GitRepositoryCheckpoint>> {
435 unimplemented!()
436 }
437
438 fn restore_checkpoint(&self, _checkpoint: GitRepositoryCheckpoint) -> BoxFuture<Result<()>> {
439 unimplemented!()
440 }
441
442 fn compare_checkpoints(
443 &self,
444 _left: GitRepositoryCheckpoint,
445 _right: GitRepositoryCheckpoint,
446 ) -> BoxFuture<Result<bool>> {
447 unimplemented!()
448 }
449
450 fn diff_checkpoints(
451 &self,
452 _base_checkpoint: GitRepositoryCheckpoint,
453 _target_checkpoint: GitRepositoryCheckpoint,
454 ) -> BoxFuture<Result<String>> {
455 unimplemented!()
456 }
457}