1use crate::{FakeFs, FakeFsEntry, Fs, RemoveOptions, RenameOptions};
2use anyhow::{Context as _, Result, bail};
3use collections::{HashMap, HashSet};
4use futures::future::{self, BoxFuture, join_all};
5use git::{
6 Oid, RunHook,
7 blame::Blame,
8 repository::{
9 AskPassDelegate, Branch, CommitDataReader, CommitDetails, CommitOptions, FetchOptions,
10 GRAPH_CHUNK_SIZE, GitRepository, GitRepositoryCheckpoint, InitialGraphCommitData, LogOrder,
11 LogSource, PushOptions, Remote, RepoPath, ResetMode, SearchCommitArgs, Worktree,
12 },
13 status::{
14 DiffTreeType, FileStatus, GitStatus, StatusCode, TrackedStatus, TreeDiff, TreeDiffStatus,
15 UnmergedStatus,
16 },
17};
18use gpui::{AsyncApp, BackgroundExecutor, SharedString, Task};
19use ignore::gitignore::GitignoreBuilder;
20use parking_lot::Mutex;
21use rope::Rope;
22use smol::{channel::Sender, future::FutureExt as _};
23use std::{path::PathBuf, sync::Arc, sync::atomic::AtomicBool};
24use text::LineEnding;
25use util::{paths::PathStyle, rel_path::RelPath};
26
27#[derive(Clone)]
28pub struct FakeGitRepository {
29 pub(crate) fs: Arc<FakeFs>,
30 pub(crate) checkpoints: Arc<Mutex<HashMap<Oid, FakeFsEntry>>>,
31 pub(crate) executor: BackgroundExecutor,
32 pub(crate) dot_git_path: PathBuf,
33 pub(crate) repository_dir_path: PathBuf,
34 pub(crate) common_dir_path: PathBuf,
35 pub(crate) is_trusted: Arc<AtomicBool>,
36}
37
38#[derive(Debug, Clone)]
39pub struct FakeCommitSnapshot {
40 pub head_contents: HashMap<RepoPath, String>,
41 pub index_contents: HashMap<RepoPath, String>,
42 pub sha: String,
43}
44
45#[derive(Debug, Clone)]
46pub struct FakeGitRepositoryState {
47 pub commit_history: Vec<FakeCommitSnapshot>,
48 pub event_emitter: smol::channel::Sender<PathBuf>,
49 pub unmerged_paths: HashMap<RepoPath, UnmergedStatus>,
50 pub head_contents: HashMap<RepoPath, String>,
51 pub index_contents: HashMap<RepoPath, String>,
52 // everything in commit contents is in oids
53 pub merge_base_contents: HashMap<RepoPath, Oid>,
54 pub oids: HashMap<Oid, String>,
55 pub blames: HashMap<RepoPath, Blame>,
56 pub current_branch_name: Option<String>,
57 pub branches: HashSet<String>,
58 /// List of remotes, keys are names and values are URLs
59 pub remotes: HashMap<String, String>,
60 pub simulated_index_write_error_message: Option<String>,
61 pub simulated_create_worktree_error: Option<String>,
62 pub refs: HashMap<String, String>,
63 pub graph_commits: Vec<Arc<InitialGraphCommitData>>,
64}
65
66impl FakeGitRepositoryState {
67 pub fn new(event_emitter: smol::channel::Sender<PathBuf>) -> Self {
68 FakeGitRepositoryState {
69 event_emitter,
70 head_contents: Default::default(),
71 index_contents: Default::default(),
72 unmerged_paths: Default::default(),
73 blames: Default::default(),
74 current_branch_name: Default::default(),
75 branches: Default::default(),
76 simulated_index_write_error_message: Default::default(),
77 simulated_create_worktree_error: Default::default(),
78 refs: HashMap::from_iter([("HEAD".into(), "abc".into())]),
79 merge_base_contents: Default::default(),
80 oids: Default::default(),
81 remotes: HashMap::default(),
82 graph_commits: Vec::new(),
83 commit_history: Vec::new(),
84 }
85 }
86}
87
88impl FakeGitRepository {
89 fn with_state_async<F, T>(&self, write: bool, f: F) -> BoxFuture<'static, Result<T>>
90 where
91 F: 'static + Send + FnOnce(&mut FakeGitRepositoryState) -> Result<T>,
92 T: Send,
93 {
94 let fs = self.fs.clone();
95 let executor = self.executor.clone();
96 let dot_git_path = self.dot_git_path.clone();
97 async move {
98 executor.simulate_random_delay().await;
99 fs.with_git_state(&dot_git_path, write, f)?
100 }
101 .boxed()
102 }
103}
104
105impl GitRepository for FakeGitRepository {
106 fn reload_index(&self) {}
107
108 fn load_index_text(&self, path: RepoPath) -> BoxFuture<'_, Option<String>> {
109 let fut = self.with_state_async(false, move |state| {
110 state
111 .index_contents
112 .get(&path)
113 .context("not present in index")
114 .cloned()
115 });
116 self.executor.spawn(async move { fut.await.ok() }).boxed()
117 }
118
119 fn load_committed_text(&self, path: RepoPath) -> BoxFuture<'_, Option<String>> {
120 let fut = self.with_state_async(false, move |state| {
121 state
122 .head_contents
123 .get(&path)
124 .context("not present in HEAD")
125 .cloned()
126 });
127 self.executor.spawn(async move { fut.await.ok() }).boxed()
128 }
129
130 fn load_blob_content(&self, oid: git::Oid) -> BoxFuture<'_, Result<String>> {
131 self.with_state_async(false, move |state| {
132 state.oids.get(&oid).cloned().context("oid does not exist")
133 })
134 .boxed()
135 }
136
137 fn load_commit(
138 &self,
139 _commit: String,
140 _cx: AsyncApp,
141 ) -> BoxFuture<'_, Result<git::repository::CommitDiff>> {
142 unimplemented!()
143 }
144
145 fn set_index_text(
146 &self,
147 path: RepoPath,
148 content: Option<String>,
149 _env: Arc<HashMap<String, String>>,
150 _is_executable: bool,
151 ) -> BoxFuture<'_, anyhow::Result<()>> {
152 self.with_state_async(true, move |state| {
153 if let Some(message) = &state.simulated_index_write_error_message {
154 anyhow::bail!("{message}");
155 } else if let Some(content) = content {
156 state.index_contents.insert(path, content);
157 } else {
158 state.index_contents.remove(&path);
159 }
160 Ok(())
161 })
162 }
163
164 fn remote_url(&self, name: &str) -> BoxFuture<'_, Option<String>> {
165 let name = name.to_string();
166 let fut = self.with_state_async(false, move |state| {
167 state
168 .remotes
169 .get(&name)
170 .context("remote not found")
171 .cloned()
172 });
173 async move { fut.await.ok() }.boxed()
174 }
175
176 fn diff_tree(&self, _request: DiffTreeType) -> BoxFuture<'_, Result<TreeDiff>> {
177 let mut entries = HashMap::default();
178 self.with_state_async(false, |state| {
179 for (path, content) in &state.head_contents {
180 let status = if let Some((oid, original)) = state
181 .merge_base_contents
182 .get(path)
183 .map(|oid| (oid, &state.oids[oid]))
184 {
185 if original == content {
186 continue;
187 }
188 TreeDiffStatus::Modified { old: *oid }
189 } else {
190 TreeDiffStatus::Added
191 };
192 entries.insert(path.clone(), status);
193 }
194 for (path, oid) in &state.merge_base_contents {
195 if !entries.contains_key(path) {
196 entries.insert(path.clone(), TreeDiffStatus::Deleted { old: *oid });
197 }
198 }
199 Ok(TreeDiff { entries })
200 })
201 .boxed()
202 }
203
204 fn revparse_batch(&self, revs: Vec<String>) -> BoxFuture<'_, Result<Vec<Option<String>>>> {
205 self.with_state_async(false, |state| {
206 Ok(revs
207 .into_iter()
208 .map(|rev| state.refs.get(&rev).cloned())
209 .collect())
210 })
211 }
212
213 fn show(&self, commit: String) -> BoxFuture<'_, Result<CommitDetails>> {
214 async {
215 Ok(CommitDetails {
216 sha: commit.into(),
217 message: "initial commit".into(),
218 ..Default::default()
219 })
220 }
221 .boxed()
222 }
223
224 fn reset(
225 &self,
226 commit: String,
227 mode: ResetMode,
228 _env: Arc<HashMap<String, String>>,
229 ) -> BoxFuture<'_, Result<()>> {
230 self.with_state_async(true, move |state| {
231 let pop_count = if commit == "HEAD~" {
232 1
233 } else if let Some(suffix) = commit.strip_prefix("HEAD~") {
234 suffix
235 .parse::<usize>()
236 .with_context(|| format!("Invalid HEAD~ offset: {commit}"))?
237 } else {
238 match state
239 .commit_history
240 .iter()
241 .rposition(|entry| entry.sha == commit)
242 {
243 Some(index) => state.commit_history.len() - index,
244 None => anyhow::bail!("Unknown commit ref: {commit}"),
245 }
246 };
247
248 if pop_count == 0 || pop_count > state.commit_history.len() {
249 anyhow::bail!(
250 "Cannot reset {pop_count} commit(s): only {} in history",
251 state.commit_history.len()
252 );
253 }
254
255 let target_index = state.commit_history.len() - pop_count;
256 let snapshot = state.commit_history[target_index].clone();
257 state.commit_history.truncate(target_index);
258
259 match mode {
260 ResetMode::Soft => {
261 state.head_contents = snapshot.head_contents;
262 }
263 ResetMode::Mixed => {
264 state.head_contents = snapshot.head_contents;
265 state.index_contents = state.head_contents.clone();
266 }
267 }
268
269 state.refs.insert("HEAD".into(), snapshot.sha);
270 Ok(())
271 })
272 }
273
274 fn checkout_files(
275 &self,
276 _commit: String,
277 _paths: Vec<RepoPath>,
278 _env: Arc<HashMap<String, String>>,
279 ) -> BoxFuture<'_, Result<()>> {
280 unimplemented!()
281 }
282
283 fn path(&self) -> PathBuf {
284 self.repository_dir_path.clone()
285 }
286
287 fn main_repository_path(&self) -> PathBuf {
288 self.common_dir_path.clone()
289 }
290
291 fn merge_message(&self) -> BoxFuture<'_, Option<String>> {
292 async move { None }.boxed()
293 }
294
295 fn status(&self, path_prefixes: &[RepoPath]) -> Task<Result<GitStatus>> {
296 let workdir_path = self.dot_git_path.parent().unwrap();
297
298 // Load gitignores
299 let ignores = workdir_path
300 .ancestors()
301 .filter_map(|dir| {
302 let ignore_path = dir.join(".gitignore");
303 let content = self.fs.read_file_sync(ignore_path).ok()?;
304 let content = String::from_utf8(content).ok()?;
305 let mut builder = GitignoreBuilder::new(dir);
306 for line in content.lines() {
307 builder.add_line(Some(dir.into()), line).ok()?;
308 }
309 builder.build().ok()
310 })
311 .collect::<Vec<_>>();
312
313 // Load working copy files.
314 let git_files: HashMap<RepoPath, (String, bool)> = self
315 .fs
316 .files()
317 .iter()
318 .filter_map(|path| {
319 // TODO better simulate git status output in the case of submodules and worktrees
320 let repo_path = path.strip_prefix(workdir_path).ok()?;
321 let mut is_ignored = repo_path.starts_with(".git");
322 for ignore in &ignores {
323 match ignore.matched_path_or_any_parents(path, false) {
324 ignore::Match::None => {}
325 ignore::Match::Ignore(_) => is_ignored = true,
326 ignore::Match::Whitelist(_) => break,
327 }
328 }
329 let content = self
330 .fs
331 .read_file_sync(path)
332 .ok()
333 .map(|content| String::from_utf8(content).unwrap())?;
334 let repo_path = RelPath::new(repo_path, PathStyle::local()).ok()?;
335 Some((RepoPath::from_rel_path(&repo_path), (content, is_ignored)))
336 })
337 .collect();
338
339 let result = self.fs.with_git_state(&self.dot_git_path, false, |state| {
340 let mut entries = Vec::new();
341 let paths = state
342 .head_contents
343 .keys()
344 .chain(state.index_contents.keys())
345 .chain(git_files.keys())
346 .collect::<HashSet<_>>();
347 for path in paths {
348 if !path_prefixes.iter().any(|prefix| path.starts_with(prefix)) {
349 continue;
350 }
351
352 let head = state.head_contents.get(path);
353 let index = state.index_contents.get(path);
354 let unmerged = state.unmerged_paths.get(path);
355 let fs = git_files.get(path);
356 let status = match (unmerged, head, index, fs) {
357 (Some(unmerged), _, _, _) => FileStatus::Unmerged(*unmerged),
358 (_, Some(head), Some(index), Some((fs, _))) => {
359 FileStatus::Tracked(TrackedStatus {
360 index_status: if head == index {
361 StatusCode::Unmodified
362 } else {
363 StatusCode::Modified
364 },
365 worktree_status: if fs == index {
366 StatusCode::Unmodified
367 } else {
368 StatusCode::Modified
369 },
370 })
371 }
372 (_, Some(head), Some(index), None) => FileStatus::Tracked(TrackedStatus {
373 index_status: if head == index {
374 StatusCode::Unmodified
375 } else {
376 StatusCode::Modified
377 },
378 worktree_status: StatusCode::Deleted,
379 }),
380 (_, Some(_), None, Some(_)) => FileStatus::Tracked(TrackedStatus {
381 index_status: StatusCode::Deleted,
382 worktree_status: StatusCode::Added,
383 }),
384 (_, Some(_), None, None) => FileStatus::Tracked(TrackedStatus {
385 index_status: StatusCode::Deleted,
386 worktree_status: StatusCode::Deleted,
387 }),
388 (_, None, Some(index), Some((fs, _))) => FileStatus::Tracked(TrackedStatus {
389 index_status: StatusCode::Added,
390 worktree_status: if fs == index {
391 StatusCode::Unmodified
392 } else {
393 StatusCode::Modified
394 },
395 }),
396 (_, None, Some(_), None) => FileStatus::Tracked(TrackedStatus {
397 index_status: StatusCode::Added,
398 worktree_status: StatusCode::Deleted,
399 }),
400 (_, None, None, Some((_, is_ignored))) => {
401 if *is_ignored {
402 continue;
403 }
404 FileStatus::Untracked
405 }
406 (_, None, None, None) => {
407 unreachable!();
408 }
409 };
410 if status
411 != FileStatus::Tracked(TrackedStatus {
412 index_status: StatusCode::Unmodified,
413 worktree_status: StatusCode::Unmodified,
414 })
415 {
416 entries.push((path.clone(), status));
417 }
418 }
419 entries.sort_by(|a, b| a.0.cmp(&b.0));
420 anyhow::Ok(GitStatus {
421 entries: entries.into(),
422 })
423 });
424 Task::ready(match result {
425 Ok(result) => result,
426 Err(e) => Err(e),
427 })
428 }
429
430 fn stash_entries(&self) -> BoxFuture<'_, Result<git::stash::GitStash>> {
431 async { Ok(git::stash::GitStash::default()) }.boxed()
432 }
433
434 fn branches(&self) -> BoxFuture<'_, Result<Vec<Branch>>> {
435 self.with_state_async(false, move |state| {
436 let current_branch = &state.current_branch_name;
437 Ok(state
438 .branches
439 .iter()
440 .map(|branch_name| {
441 let ref_name = if branch_name.starts_with("refs/") {
442 branch_name.into()
443 } else if branch_name.contains('/') {
444 format!("refs/remotes/{branch_name}").into()
445 } else {
446 format!("refs/heads/{branch_name}").into()
447 };
448 Branch {
449 is_head: Some(branch_name) == current_branch.as_ref(),
450 ref_name,
451 most_recent_commit: None,
452 upstream: None,
453 }
454 })
455 .collect())
456 })
457 }
458
459 fn worktrees(&self) -> BoxFuture<'_, Result<Vec<Worktree>>> {
460 let fs = self.fs.clone();
461 let common_dir_path = self.common_dir_path.clone();
462 let executor = self.executor.clone();
463
464 async move {
465 executor.simulate_random_delay().await;
466
467 let (main_worktree, refs) = fs.with_git_state(&common_dir_path, false, |state| {
468 let work_dir = common_dir_path
469 .parent()
470 .map(PathBuf::from)
471 .unwrap_or_else(|| common_dir_path.clone());
472 let head_sha = state
473 .refs
474 .get("HEAD")
475 .cloned()
476 .unwrap_or_else(|| "0000000".to_string());
477 let branch_ref = state
478 .current_branch_name
479 .as_ref()
480 .map(|name| format!("refs/heads/{name}"))
481 .unwrap_or_else(|| "refs/heads/main".to_string());
482 let main_wt = Worktree {
483 path: work_dir,
484 ref_name: Some(branch_ref.into()),
485 sha: head_sha.into(),
486 is_main: true,
487 };
488 (main_wt, state.refs.clone())
489 })?;
490
491 let mut all = vec![main_worktree];
492
493 let worktrees_dir = common_dir_path.join("worktrees");
494 if let Ok(mut entries) = fs.read_dir(&worktrees_dir).await {
495 use futures::StreamExt;
496 while let Some(Ok(entry_path)) = entries.next().await {
497 let head_content = match fs.load(&entry_path.join("HEAD")).await {
498 Ok(content) => content,
499 Err(_) => continue,
500 };
501 let gitdir_content = match fs.load(&entry_path.join("gitdir")).await {
502 Ok(content) => content,
503 Err(_) => continue,
504 };
505
506 let ref_name = head_content
507 .strip_prefix("ref: ")
508 .map(|s| s.trim().to_string());
509 let sha = ref_name
510 .as_ref()
511 .and_then(|r| refs.get(r))
512 .cloned()
513 .unwrap_or_else(|| head_content.trim().to_string());
514
515 let worktree_path = PathBuf::from(gitdir_content.trim())
516 .parent()
517 .map(PathBuf::from)
518 .unwrap_or_default();
519
520 all.push(Worktree {
521 path: worktree_path,
522 ref_name: ref_name.map(Into::into),
523 sha: sha.into(),
524 is_main: false,
525 });
526 }
527 }
528
529 Ok(all)
530 }
531 .boxed()
532 }
533
534 fn create_worktree(
535 &self,
536 branch_name: Option<String>,
537 path: PathBuf,
538 from_commit: Option<String>,
539 ) -> BoxFuture<'_, Result<()>> {
540 let fs = self.fs.clone();
541 let executor = self.executor.clone();
542 let dot_git_path = self.dot_git_path.clone();
543 let common_dir_path = self.common_dir_path.clone();
544 async move {
545 executor.simulate_random_delay().await;
546 // Check for simulated error and duplicate branch before any side effects.
547 fs.with_git_state(&dot_git_path, false, |state| {
548 if let Some(message) = &state.simulated_create_worktree_error {
549 anyhow::bail!("{message}");
550 }
551 if let Some(ref name) = branch_name {
552 if state.branches.contains(name) {
553 bail!("a branch named '{}' already exists", name);
554 }
555 }
556 Ok(())
557 })??;
558
559 // Create the worktree checkout directory.
560 fs.create_dir(&path).await?;
561
562 // Create .git/worktrees/<name>/ directory with HEAD, commondir, gitdir.
563 let worktree_entry_name = branch_name
564 .as_deref()
565 .unwrap_or_else(|| path.file_name().unwrap().to_str().unwrap());
566 let worktrees_entry_dir = common_dir_path.join("worktrees").join(worktree_entry_name);
567 fs.create_dir(&worktrees_entry_dir).await?;
568
569 let sha = from_commit.unwrap_or_else(|| "fake-sha".to_string());
570 let head_content = if let Some(ref branch_name) = branch_name {
571 let ref_name = format!("refs/heads/{branch_name}");
572 format!("ref: {ref_name}")
573 } else {
574 sha.clone()
575 };
576 fs.write_file_internal(
577 worktrees_entry_dir.join("HEAD"),
578 head_content.into_bytes(),
579 false,
580 )?;
581 fs.write_file_internal(
582 worktrees_entry_dir.join("commondir"),
583 common_dir_path.to_string_lossy().into_owned().into_bytes(),
584 false,
585 )?;
586 let worktree_dot_git = path.join(".git");
587 fs.write_file_internal(
588 worktrees_entry_dir.join("gitdir"),
589 worktree_dot_git.to_string_lossy().into_owned().into_bytes(),
590 false,
591 )?;
592
593 // Create .git file in the worktree checkout.
594 fs.write_file_internal(
595 &worktree_dot_git,
596 format!("gitdir: {}", worktrees_entry_dir.display()).into_bytes(),
597 false,
598 )?;
599
600 // Update git state: add ref and branch.
601 fs.with_git_state(&dot_git_path, true, move |state| {
602 if let Some(branch_name) = branch_name {
603 let ref_name = format!("refs/heads/{branch_name}");
604 state.refs.insert(ref_name, sha);
605 state.branches.insert(branch_name);
606 } else {
607 state.refs.insert("HEAD".into(), sha);
608 }
609 Ok::<(), anyhow::Error>(())
610 })??;
611 Ok(())
612 }
613 .boxed()
614 }
615
616 fn remove_worktree(&self, path: PathBuf, _force: bool) -> BoxFuture<'_, Result<()>> {
617 let fs = self.fs.clone();
618 let executor = self.executor.clone();
619 let common_dir_path = self.common_dir_path.clone();
620 async move {
621 executor.simulate_random_delay().await;
622
623 // Read the worktree's .git file to find its entry directory.
624 let dot_git_file = path.join(".git");
625 let content = fs
626 .load(&dot_git_file)
627 .await
628 .with_context(|| format!("no worktree found at path: {}", path.display()))?;
629 let gitdir = content
630 .strip_prefix("gitdir:")
631 .context("invalid .git file in worktree")?
632 .trim();
633 let worktree_entry_dir = PathBuf::from(gitdir);
634
635 // Remove the worktree checkout directory.
636 fs.remove_dir(
637 &path,
638 RemoveOptions {
639 recursive: true,
640 ignore_if_not_exists: false,
641 },
642 )
643 .await?;
644
645 // Remove the .git/worktrees/<name>/ directory.
646 fs.remove_dir(
647 &worktree_entry_dir,
648 RemoveOptions {
649 recursive: true,
650 ignore_if_not_exists: false,
651 },
652 )
653 .await?;
654
655 // Emit a git event on the main .git directory so the scanner
656 // notices the change.
657 fs.with_git_state(&common_dir_path, true, |_| {})?;
658
659 Ok(())
660 }
661 .boxed()
662 }
663
664 fn rename_worktree(&self, old_path: PathBuf, new_path: PathBuf) -> BoxFuture<'_, Result<()>> {
665 let fs = self.fs.clone();
666 let executor = self.executor.clone();
667 let common_dir_path = self.common_dir_path.clone();
668 async move {
669 executor.simulate_random_delay().await;
670
671 // Read the worktree's .git file to find its entry directory.
672 let dot_git_file = old_path.join(".git");
673 let content = fs
674 .load(&dot_git_file)
675 .await
676 .with_context(|| format!("no worktree found at path: {}", old_path.display()))?;
677 let gitdir = content
678 .strip_prefix("gitdir:")
679 .context("invalid .git file in worktree")?
680 .trim();
681 let worktree_entry_dir = PathBuf::from(gitdir);
682
683 // Move the worktree checkout directory.
684 fs.rename(
685 &old_path,
686 &new_path,
687 RenameOptions {
688 overwrite: false,
689 ignore_if_exists: false,
690 create_parents: true,
691 },
692 )
693 .await?;
694
695 // Update the gitdir file in .git/worktrees/<name>/ to point to the
696 // new location.
697 let new_dot_git = new_path.join(".git");
698 fs.write_file_internal(
699 worktree_entry_dir.join("gitdir"),
700 new_dot_git.to_string_lossy().into_owned().into_bytes(),
701 false,
702 )?;
703
704 // Update the .git file in the moved worktree checkout.
705 fs.write_file_internal(
706 &new_dot_git,
707 format!("gitdir: {}", worktree_entry_dir.display()).into_bytes(),
708 false,
709 )?;
710
711 // Emit a git event on the main .git directory so the scanner
712 // notices the change.
713 fs.with_git_state(&common_dir_path, true, |_| {})?;
714
715 Ok(())
716 }
717 .boxed()
718 }
719
720 fn change_branch(&self, name: String) -> BoxFuture<'_, Result<()>> {
721 self.with_state_async(true, |state| {
722 state.current_branch_name = Some(name);
723 Ok(())
724 })
725 }
726
727 fn create_branch(
728 &self,
729 name: String,
730 _base_branch: Option<String>,
731 ) -> BoxFuture<'_, Result<()>> {
732 self.with_state_async(true, move |state| {
733 if let Some((remote, _)) = name.split_once('/')
734 && !state.remotes.contains_key(remote)
735 {
736 state.remotes.insert(remote.to_owned(), "".to_owned());
737 }
738 state.branches.insert(name);
739 Ok(())
740 })
741 }
742
743 fn rename_branch(&self, branch: String, new_name: String) -> BoxFuture<'_, Result<()>> {
744 self.with_state_async(true, move |state| {
745 if !state.branches.remove(&branch) {
746 bail!("no such branch: {branch}");
747 }
748 state.branches.insert(new_name.clone());
749 if state.current_branch_name == Some(branch) {
750 state.current_branch_name = Some(new_name);
751 }
752 Ok(())
753 })
754 }
755
756 fn delete_branch(&self, _is_remote: bool, name: String) -> BoxFuture<'_, Result<()>> {
757 self.with_state_async(true, move |state| {
758 if !state.branches.remove(&name) {
759 bail!("no such branch: {name}");
760 }
761 Ok(())
762 })
763 }
764
765 fn blame(
766 &self,
767 path: RepoPath,
768 _content: Rope,
769 _line_ending: LineEnding,
770 ) -> BoxFuture<'_, Result<git::blame::Blame>> {
771 self.with_state_async(false, move |state| {
772 state
773 .blames
774 .get(&path)
775 .with_context(|| format!("failed to get blame for {:?}", path))
776 .cloned()
777 })
778 }
779
780 fn file_history(&self, path: RepoPath) -> BoxFuture<'_, Result<git::repository::FileHistory>> {
781 self.file_history_paginated(path, 0, None)
782 }
783
784 fn file_history_paginated(
785 &self,
786 path: RepoPath,
787 _skip: usize,
788 _limit: Option<usize>,
789 ) -> BoxFuture<'_, Result<git::repository::FileHistory>> {
790 async move {
791 Ok(git::repository::FileHistory {
792 entries: Vec::new(),
793 path,
794 })
795 }
796 .boxed()
797 }
798
799 fn stage_paths(
800 &self,
801 paths: Vec<RepoPath>,
802 _env: Arc<HashMap<String, String>>,
803 ) -> BoxFuture<'_, Result<()>> {
804 Box::pin(async move {
805 let contents = paths
806 .into_iter()
807 .map(|path| {
808 let abs_path = self
809 .dot_git_path
810 .parent()
811 .unwrap()
812 .join(&path.as_std_path());
813 Box::pin(async move { (path.clone(), self.fs.load(&abs_path).await.ok()) })
814 })
815 .collect::<Vec<_>>();
816 let contents = join_all(contents).await;
817 self.with_state_async(true, move |state| {
818 for (path, content) in contents {
819 if let Some(content) = content {
820 state.index_contents.insert(path, content);
821 } else {
822 state.index_contents.remove(&path);
823 }
824 }
825 Ok(())
826 })
827 .await
828 })
829 }
830
831 fn unstage_paths(
832 &self,
833 paths: Vec<RepoPath>,
834 _env: Arc<HashMap<String, String>>,
835 ) -> BoxFuture<'_, Result<()>> {
836 self.with_state_async(true, move |state| {
837 for path in paths {
838 match state.head_contents.get(&path) {
839 Some(content) => state.index_contents.insert(path, content.clone()),
840 None => state.index_contents.remove(&path),
841 };
842 }
843 Ok(())
844 })
845 }
846
847 fn stash_paths(
848 &self,
849 _paths: Vec<RepoPath>,
850 _env: Arc<HashMap<String, String>>,
851 ) -> BoxFuture<'_, Result<()>> {
852 unimplemented!()
853 }
854
855 fn stash_pop(
856 &self,
857 _index: Option<usize>,
858 _env: Arc<HashMap<String, String>>,
859 ) -> BoxFuture<'_, Result<()>> {
860 unimplemented!()
861 }
862
863 fn stash_apply(
864 &self,
865 _index: Option<usize>,
866 _env: Arc<HashMap<String, String>>,
867 ) -> BoxFuture<'_, Result<()>> {
868 unimplemented!()
869 }
870
871 fn stash_drop(
872 &self,
873 _index: Option<usize>,
874 _env: Arc<HashMap<String, String>>,
875 ) -> BoxFuture<'_, Result<()>> {
876 unimplemented!()
877 }
878
879 fn commit(
880 &self,
881 _message: gpui::SharedString,
882 _name_and_email: Option<(gpui::SharedString, gpui::SharedString)>,
883 options: CommitOptions,
884 _askpass: AskPassDelegate,
885 _env: Arc<HashMap<String, String>>,
886 ) -> BoxFuture<'_, Result<()>> {
887 self.with_state_async(true, move |state| {
888 if !options.allow_empty && !options.amend && state.index_contents == state.head_contents
889 {
890 anyhow::bail!("nothing to commit (use allow_empty to create an empty commit)");
891 }
892
893 let old_sha = state.refs.get("HEAD").cloned().unwrap_or_default();
894 state.commit_history.push(FakeCommitSnapshot {
895 head_contents: state.head_contents.clone(),
896 index_contents: state.index_contents.clone(),
897 sha: old_sha,
898 });
899
900 state.head_contents = state.index_contents.clone();
901
902 let new_sha = format!("fake-commit-{}", state.commit_history.len());
903 state.refs.insert("HEAD".into(), new_sha);
904
905 Ok(())
906 })
907 }
908
909 fn run_hook(
910 &self,
911 _hook: RunHook,
912 _env: Arc<HashMap<String, String>>,
913 ) -> BoxFuture<'_, Result<()>> {
914 async { Ok(()) }.boxed()
915 }
916
917 fn push(
918 &self,
919 _branch: String,
920 _remote_branch: String,
921 _remote: String,
922 _options: Option<PushOptions>,
923 _askpass: AskPassDelegate,
924 _env: Arc<HashMap<String, String>>,
925 _cx: AsyncApp,
926 ) -> BoxFuture<'_, Result<git::repository::RemoteCommandOutput>> {
927 unimplemented!()
928 }
929
930 fn pull(
931 &self,
932 _branch: Option<String>,
933 _remote: String,
934 _rebase: bool,
935 _askpass: AskPassDelegate,
936 _env: Arc<HashMap<String, String>>,
937 _cx: AsyncApp,
938 ) -> BoxFuture<'_, Result<git::repository::RemoteCommandOutput>> {
939 unimplemented!()
940 }
941
942 fn fetch(
943 &self,
944 _fetch_options: FetchOptions,
945 _askpass: AskPassDelegate,
946 _env: Arc<HashMap<String, String>>,
947 _cx: AsyncApp,
948 ) -> BoxFuture<'_, Result<git::repository::RemoteCommandOutput>> {
949 unimplemented!()
950 }
951
952 fn get_all_remotes(&self) -> BoxFuture<'_, Result<Vec<Remote>>> {
953 self.with_state_async(false, move |state| {
954 let remotes = state
955 .remotes
956 .keys()
957 .map(|r| Remote {
958 name: r.clone().into(),
959 })
960 .collect::<Vec<_>>();
961 Ok(remotes)
962 })
963 }
964
965 fn get_push_remote(&self, _branch: String) -> BoxFuture<'_, Result<Option<Remote>>> {
966 unimplemented!()
967 }
968
969 fn get_branch_remote(&self, _branch: String) -> BoxFuture<'_, Result<Option<Remote>>> {
970 unimplemented!()
971 }
972
973 fn check_for_pushed_commit(&self) -> BoxFuture<'_, Result<Vec<gpui::SharedString>>> {
974 future::ready(Ok(Vec::new())).boxed()
975 }
976
977 fn diff(&self, _diff: git::repository::DiffType) -> BoxFuture<'_, Result<String>> {
978 future::ready(Ok(String::new())).boxed()
979 }
980
981 fn diff_stat(
982 &self,
983 path_prefixes: &[RepoPath],
984 ) -> BoxFuture<'_, Result<git::status::GitDiffStat>> {
985 fn count_lines(s: &str) -> u32 {
986 if s.is_empty() {
987 0
988 } else {
989 s.lines().count() as u32
990 }
991 }
992
993 fn matches_prefixes(path: &RepoPath, prefixes: &[RepoPath]) -> bool {
994 if prefixes.is_empty() {
995 return true;
996 }
997 prefixes.iter().any(|prefix| {
998 let prefix_str = prefix.as_unix_str();
999 if prefix_str == "." {
1000 return true;
1001 }
1002 path == prefix || path.starts_with(&prefix)
1003 })
1004 }
1005
1006 let path_prefixes = path_prefixes.to_vec();
1007
1008 let workdir_path = self.dot_git_path.parent().unwrap().to_path_buf();
1009 let worktree_files: HashMap<RepoPath, String> = self
1010 .fs
1011 .files()
1012 .iter()
1013 .filter_map(|path| {
1014 let repo_path = path.strip_prefix(&workdir_path).ok()?;
1015 if repo_path.starts_with(".git") {
1016 return None;
1017 }
1018 let content = self
1019 .fs
1020 .read_file_sync(path)
1021 .ok()
1022 .and_then(|bytes| String::from_utf8(bytes).ok())?;
1023 let repo_path = RelPath::new(repo_path, PathStyle::local()).ok()?;
1024 Some((RepoPath::from_rel_path(&repo_path), content))
1025 })
1026 .collect();
1027
1028 self.with_state_async(false, move |state| {
1029 let mut entries = Vec::new();
1030 let all_paths: HashSet<&RepoPath> = state
1031 .head_contents
1032 .keys()
1033 .chain(
1034 worktree_files
1035 .keys()
1036 .filter(|p| state.index_contents.contains_key(*p)),
1037 )
1038 .collect();
1039 for path in all_paths {
1040 if !matches_prefixes(path, &path_prefixes) {
1041 continue;
1042 }
1043 let head = state.head_contents.get(path);
1044 let worktree = worktree_files.get(path);
1045 match (head, worktree) {
1046 (Some(old), Some(new)) if old != new => {
1047 entries.push((
1048 path.clone(),
1049 git::status::DiffStat {
1050 added: count_lines(new),
1051 deleted: count_lines(old),
1052 },
1053 ));
1054 }
1055 (Some(old), None) => {
1056 entries.push((
1057 path.clone(),
1058 git::status::DiffStat {
1059 added: 0,
1060 deleted: count_lines(old),
1061 },
1062 ));
1063 }
1064 (None, Some(new)) => {
1065 entries.push((
1066 path.clone(),
1067 git::status::DiffStat {
1068 added: count_lines(new),
1069 deleted: 0,
1070 },
1071 ));
1072 }
1073 _ => {}
1074 }
1075 }
1076 entries.sort_by(|(a, _), (b, _)| a.cmp(b));
1077 Ok(git::status::GitDiffStat {
1078 entries: entries.into(),
1079 })
1080 })
1081 .boxed()
1082 }
1083
1084 fn checkpoint(&self) -> BoxFuture<'static, Result<GitRepositoryCheckpoint>> {
1085 let executor = self.executor.clone();
1086 let fs = self.fs.clone();
1087 let checkpoints = self.checkpoints.clone();
1088 let repository_dir_path = self.repository_dir_path.parent().unwrap().to_path_buf();
1089 async move {
1090 executor.simulate_random_delay().await;
1091 let oid = git::Oid::random(&mut *executor.rng().lock());
1092 let entry = fs.entry(&repository_dir_path)?;
1093 checkpoints.lock().insert(oid, entry);
1094 Ok(GitRepositoryCheckpoint { commit_sha: oid })
1095 }
1096 .boxed()
1097 }
1098
1099 fn restore_checkpoint(&self, checkpoint: GitRepositoryCheckpoint) -> BoxFuture<'_, Result<()>> {
1100 let executor = self.executor.clone();
1101 let fs = self.fs.clone();
1102 let checkpoints = self.checkpoints.clone();
1103 let repository_dir_path = self.repository_dir_path.parent().unwrap().to_path_buf();
1104 async move {
1105 executor.simulate_random_delay().await;
1106 let checkpoints = checkpoints.lock();
1107 let entry = checkpoints
1108 .get(&checkpoint.commit_sha)
1109 .context(format!("invalid checkpoint: {}", checkpoint.commit_sha))?;
1110 fs.insert_entry(&repository_dir_path, entry.clone())?;
1111 Ok(())
1112 }
1113 .boxed()
1114 }
1115
1116 fn compare_checkpoints(
1117 &self,
1118 left: GitRepositoryCheckpoint,
1119 right: GitRepositoryCheckpoint,
1120 ) -> BoxFuture<'_, Result<bool>> {
1121 let executor = self.executor.clone();
1122 let checkpoints = self.checkpoints.clone();
1123 async move {
1124 executor.simulate_random_delay().await;
1125 let checkpoints = checkpoints.lock();
1126 let left = checkpoints
1127 .get(&left.commit_sha)
1128 .context(format!("invalid left checkpoint: {}", left.commit_sha))?;
1129 let right = checkpoints
1130 .get(&right.commit_sha)
1131 .context(format!("invalid right checkpoint: {}", right.commit_sha))?;
1132
1133 Ok(left == right)
1134 }
1135 .boxed()
1136 }
1137
1138 fn diff_checkpoints(
1139 &self,
1140 base_checkpoint: GitRepositoryCheckpoint,
1141 target_checkpoint: GitRepositoryCheckpoint,
1142 ) -> BoxFuture<'_, Result<String>> {
1143 let executor = self.executor.clone();
1144 let checkpoints = self.checkpoints.clone();
1145 async move {
1146 executor.simulate_random_delay().await;
1147 let checkpoints = checkpoints.lock();
1148 let base = checkpoints
1149 .get(&base_checkpoint.commit_sha)
1150 .context(format!(
1151 "invalid base checkpoint: {}",
1152 base_checkpoint.commit_sha
1153 ))?;
1154 let target = checkpoints
1155 .get(&target_checkpoint.commit_sha)
1156 .context(format!(
1157 "invalid target checkpoint: {}",
1158 target_checkpoint.commit_sha
1159 ))?;
1160
1161 fn collect_files(
1162 entry: &FakeFsEntry,
1163 prefix: String,
1164 out: &mut std::collections::BTreeMap<String, String>,
1165 ) {
1166 match entry {
1167 FakeFsEntry::File { content, .. } => {
1168 out.insert(prefix, String::from_utf8_lossy(content).into_owned());
1169 }
1170 FakeFsEntry::Dir { entries, .. } => {
1171 for (name, child) in entries {
1172 let path = if prefix.is_empty() {
1173 name.clone()
1174 } else {
1175 format!("{prefix}/{name}")
1176 };
1177 collect_files(child, path, out);
1178 }
1179 }
1180 FakeFsEntry::Symlink { .. } => {}
1181 }
1182 }
1183
1184 let mut base_files = std::collections::BTreeMap::new();
1185 let mut target_files = std::collections::BTreeMap::new();
1186 collect_files(base, String::new(), &mut base_files);
1187 collect_files(target, String::new(), &mut target_files);
1188
1189 let all_paths: std::collections::BTreeSet<&String> =
1190 base_files.keys().chain(target_files.keys()).collect();
1191
1192 let mut diff = String::new();
1193 for path in all_paths {
1194 match (base_files.get(path), target_files.get(path)) {
1195 (Some(base_content), Some(target_content))
1196 if base_content != target_content =>
1197 {
1198 diff.push_str(&format!("diff --git a/{path} b/{path}\n"));
1199 diff.push_str(&format!("--- a/{path}\n"));
1200 diff.push_str(&format!("+++ b/{path}\n"));
1201 for line in base_content.lines() {
1202 diff.push_str(&format!("-{line}\n"));
1203 }
1204 for line in target_content.lines() {
1205 diff.push_str(&format!("+{line}\n"));
1206 }
1207 }
1208 (Some(_), None) => {
1209 diff.push_str(&format!("diff --git a/{path} /dev/null\n"));
1210 diff.push_str("deleted file\n");
1211 }
1212 (None, Some(_)) => {
1213 diff.push_str(&format!("diff --git /dev/null b/{path}\n"));
1214 diff.push_str("new file\n");
1215 }
1216 _ => {}
1217 }
1218 }
1219 Ok(diff)
1220 }
1221 .boxed()
1222 }
1223
1224 fn default_branch(
1225 &self,
1226 include_remote_name: bool,
1227 ) -> BoxFuture<'_, Result<Option<SharedString>>> {
1228 async move {
1229 Ok(Some(if include_remote_name {
1230 "origin/main".into()
1231 } else {
1232 "main".into()
1233 }))
1234 }
1235 .boxed()
1236 }
1237
1238 fn create_remote(&self, name: String, url: String) -> BoxFuture<'_, Result<()>> {
1239 self.with_state_async(true, move |state| {
1240 state.remotes.insert(name, url);
1241 Ok(())
1242 })
1243 }
1244
1245 fn remove_remote(&self, name: String) -> BoxFuture<'_, Result<()>> {
1246 self.with_state_async(true, move |state| {
1247 state.branches.retain(|branch| {
1248 branch
1249 .split_once('/')
1250 .is_none_or(|(remote, _)| remote != name)
1251 });
1252 state.remotes.remove(&name);
1253 Ok(())
1254 })
1255 }
1256
1257 fn initial_graph_data(
1258 &self,
1259 _log_source: LogSource,
1260 _log_order: LogOrder,
1261 request_tx: Sender<Vec<Arc<InitialGraphCommitData>>>,
1262 ) -> BoxFuture<'_, Result<()>> {
1263 let fs = self.fs.clone();
1264 let dot_git_path = self.dot_git_path.clone();
1265 async move {
1266 let graph_commits =
1267 fs.with_git_state(&dot_git_path, false, |state| state.graph_commits.clone())?;
1268
1269 for chunk in graph_commits.chunks(GRAPH_CHUNK_SIZE) {
1270 request_tx.send(chunk.to_vec()).await.ok();
1271 }
1272 Ok(())
1273 }
1274 .boxed()
1275 }
1276
1277 fn search_commits(
1278 &self,
1279 _log_source: LogSource,
1280 _search_args: SearchCommitArgs,
1281 _request_tx: Sender<Oid>,
1282 ) -> BoxFuture<'_, Result<()>> {
1283 async { bail!("search_commits not supported for FakeGitRepository") }.boxed()
1284 }
1285
1286 fn commit_data_reader(&self) -> Result<CommitDataReader> {
1287 anyhow::bail!("commit_data_reader not supported for FakeGitRepository")
1288 }
1289
1290 fn update_ref(&self, ref_name: String, commit: String) -> BoxFuture<'_, Result<()>> {
1291 self.with_state_async(true, move |state| {
1292 state.refs.insert(ref_name, commit);
1293 Ok(())
1294 })
1295 }
1296
1297 fn delete_ref(&self, ref_name: String) -> BoxFuture<'_, Result<()>> {
1298 self.with_state_async(true, move |state| {
1299 state.refs.remove(&ref_name);
1300 Ok(())
1301 })
1302 }
1303
1304 fn stage_all_including_untracked(&self) -> BoxFuture<'_, Result<()>> {
1305 let workdir_path = self.dot_git_path.parent().unwrap();
1306 let git_files: Vec<(RepoPath, String)> = self
1307 .fs
1308 .files()
1309 .iter()
1310 .filter_map(|path| {
1311 let repo_path = path.strip_prefix(workdir_path).ok()?;
1312 if repo_path.starts_with(".git") {
1313 return None;
1314 }
1315 let content = self
1316 .fs
1317 .read_file_sync(path)
1318 .ok()
1319 .and_then(|bytes| String::from_utf8(bytes).ok())?;
1320 let rel_path = RelPath::new(repo_path, PathStyle::local()).ok()?;
1321 Some((RepoPath::from_rel_path(&rel_path), content))
1322 })
1323 .collect();
1324
1325 self.with_state_async(true, move |state| {
1326 // Stage all filesystem contents, mirroring `git add -A`.
1327 let fs_paths: HashSet<RepoPath> = git_files.iter().map(|(p, _)| p.clone()).collect();
1328 for (path, content) in git_files {
1329 state.index_contents.insert(path, content);
1330 }
1331 // Remove index entries for files that no longer exist on disk.
1332 state
1333 .index_contents
1334 .retain(|path, _| fs_paths.contains(path));
1335 Ok(())
1336 })
1337 }
1338
1339 fn set_trusted(&self, trusted: bool) {
1340 self.is_trusted
1341 .store(trusted, std::sync::atomic::Ordering::Release);
1342 }
1343
1344 fn is_trusted(&self) -> bool {
1345 self.is_trusted.load(std::sync::atomic::Ordering::Acquire)
1346 }
1347}