1use crate::{FakeFs, FakeFsEntry, Fs};
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, 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};
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}
36
37#[derive(Debug, Clone)]
38pub struct FakeGitRepositoryState {
39 pub event_emitter: smol::channel::Sender<PathBuf>,
40 pub unmerged_paths: HashMap<RepoPath, UnmergedStatus>,
41 pub head_contents: HashMap<RepoPath, String>,
42 pub index_contents: HashMap<RepoPath, String>,
43 // everything in commit contents is in oids
44 pub merge_base_contents: HashMap<RepoPath, Oid>,
45 pub oids: HashMap<Oid, String>,
46 pub blames: HashMap<RepoPath, Blame>,
47 pub current_branch_name: Option<String>,
48 pub branches: HashSet<String>,
49 /// List of remotes, keys are names and values are URLs
50 pub remotes: HashMap<String, String>,
51 pub simulated_index_write_error_message: Option<String>,
52 pub refs: HashMap<String, String>,
53 pub graph_commits: Vec<Arc<InitialGraphCommitData>>,
54}
55
56impl FakeGitRepositoryState {
57 pub fn new(event_emitter: smol::channel::Sender<PathBuf>) -> Self {
58 FakeGitRepositoryState {
59 event_emitter,
60 head_contents: Default::default(),
61 index_contents: Default::default(),
62 unmerged_paths: Default::default(),
63 blames: Default::default(),
64 current_branch_name: Default::default(),
65 branches: Default::default(),
66 simulated_index_write_error_message: Default::default(),
67 refs: HashMap::from_iter([("HEAD".into(), "abc".into())]),
68 merge_base_contents: Default::default(),
69 oids: Default::default(),
70 remotes: HashMap::default(),
71 graph_commits: Vec::new(),
72 }
73 }
74}
75
76impl FakeGitRepository {
77 fn with_state_async<F, T>(&self, write: bool, f: F) -> BoxFuture<'static, Result<T>>
78 where
79 F: 'static + Send + FnOnce(&mut FakeGitRepositoryState) -> Result<T>,
80 T: Send,
81 {
82 let fs = self.fs.clone();
83 let executor = self.executor.clone();
84 let dot_git_path = self.dot_git_path.clone();
85 async move {
86 executor.simulate_random_delay().await;
87 fs.with_git_state(&dot_git_path, write, f)?
88 }
89 .boxed()
90 }
91}
92
93impl GitRepository for FakeGitRepository {
94 fn reload_index(&self) {}
95
96 fn load_index_text(&self, path: RepoPath) -> BoxFuture<'_, Option<String>> {
97 let fut = self.with_state_async(false, move |state| {
98 state
99 .index_contents
100 .get(&path)
101 .context("not present in index")
102 .cloned()
103 });
104 self.executor.spawn(async move { fut.await.ok() }).boxed()
105 }
106
107 fn load_committed_text(&self, path: RepoPath) -> BoxFuture<'_, Option<String>> {
108 let fut = self.with_state_async(false, move |state| {
109 state
110 .head_contents
111 .get(&path)
112 .context("not present in HEAD")
113 .cloned()
114 });
115 self.executor.spawn(async move { fut.await.ok() }).boxed()
116 }
117
118 fn load_blob_content(&self, oid: git::Oid) -> BoxFuture<'_, Result<String>> {
119 self.with_state_async(false, move |state| {
120 state.oids.get(&oid).cloned().context("oid does not exist")
121 })
122 .boxed()
123 }
124
125 fn load_commit(
126 &self,
127 _commit: String,
128 _cx: AsyncApp,
129 ) -> BoxFuture<'_, Result<git::repository::CommitDiff>> {
130 unimplemented!()
131 }
132
133 fn set_index_text(
134 &self,
135 path: RepoPath,
136 content: Option<String>,
137 _env: Arc<HashMap<String, String>>,
138 _is_executable: bool,
139 ) -> BoxFuture<'_, anyhow::Result<()>> {
140 self.with_state_async(true, move |state| {
141 if let Some(message) = &state.simulated_index_write_error_message {
142 anyhow::bail!("{message}");
143 } else if let Some(content) = content {
144 state.index_contents.insert(path, content);
145 } else {
146 state.index_contents.remove(&path);
147 }
148 Ok(())
149 })
150 }
151
152 fn remote_url(&self, name: &str) -> BoxFuture<'_, Option<String>> {
153 let name = name.to_string();
154 let fut = self.with_state_async(false, move |state| {
155 state
156 .remotes
157 .get(&name)
158 .context("remote not found")
159 .cloned()
160 });
161 async move { fut.await.ok() }.boxed()
162 }
163
164 fn diff_tree(&self, _request: DiffTreeType) -> BoxFuture<'_, Result<TreeDiff>> {
165 let mut entries = HashMap::default();
166 self.with_state_async(false, |state| {
167 for (path, content) in &state.head_contents {
168 let status = if let Some((oid, original)) = state
169 .merge_base_contents
170 .get(path)
171 .map(|oid| (oid, &state.oids[oid]))
172 {
173 if original == content {
174 continue;
175 }
176 TreeDiffStatus::Modified { old: *oid }
177 } else {
178 TreeDiffStatus::Added
179 };
180 entries.insert(path.clone(), status);
181 }
182 for (path, oid) in &state.merge_base_contents {
183 if !entries.contains_key(path) {
184 entries.insert(path.clone(), TreeDiffStatus::Deleted { old: *oid });
185 }
186 }
187 Ok(TreeDiff { entries })
188 })
189 .boxed()
190 }
191
192 fn revparse_batch(&self, revs: Vec<String>) -> BoxFuture<'_, Result<Vec<Option<String>>>> {
193 self.with_state_async(false, |state| {
194 Ok(revs
195 .into_iter()
196 .map(|rev| state.refs.get(&rev).cloned())
197 .collect())
198 })
199 }
200
201 fn show(&self, commit: String) -> BoxFuture<'_, Result<CommitDetails>> {
202 async {
203 Ok(CommitDetails {
204 sha: commit.into(),
205 message: "initial commit".into(),
206 ..Default::default()
207 })
208 }
209 .boxed()
210 }
211
212 fn reset(
213 &self,
214 _commit: String,
215 _mode: ResetMode,
216 _env: Arc<HashMap<String, String>>,
217 ) -> BoxFuture<'_, Result<()>> {
218 unimplemented!()
219 }
220
221 fn checkout_files(
222 &self,
223 _commit: String,
224 _paths: Vec<RepoPath>,
225 _env: Arc<HashMap<String, String>>,
226 ) -> BoxFuture<'_, Result<()>> {
227 unimplemented!()
228 }
229
230 fn path(&self) -> PathBuf {
231 self.repository_dir_path.clone()
232 }
233
234 fn main_repository_path(&self) -> PathBuf {
235 self.common_dir_path.clone()
236 }
237
238 fn merge_message(&self) -> BoxFuture<'_, Option<String>> {
239 async move { None }.boxed()
240 }
241
242 fn status(&self, path_prefixes: &[RepoPath]) -> Task<Result<GitStatus>> {
243 let workdir_path = self.dot_git_path.parent().unwrap();
244
245 // Load gitignores
246 let ignores = workdir_path
247 .ancestors()
248 .filter_map(|dir| {
249 let ignore_path = dir.join(".gitignore");
250 let content = self.fs.read_file_sync(ignore_path).ok()?;
251 let content = String::from_utf8(content).ok()?;
252 let mut builder = GitignoreBuilder::new(dir);
253 for line in content.lines() {
254 builder.add_line(Some(dir.into()), line).ok()?;
255 }
256 builder.build().ok()
257 })
258 .collect::<Vec<_>>();
259
260 // Load working copy files.
261 let git_files: HashMap<RepoPath, (String, bool)> = self
262 .fs
263 .files()
264 .iter()
265 .filter_map(|path| {
266 // TODO better simulate git status output in the case of submodules and worktrees
267 let repo_path = path.strip_prefix(workdir_path).ok()?;
268 let mut is_ignored = repo_path.starts_with(".git");
269 for ignore in &ignores {
270 match ignore.matched_path_or_any_parents(path, false) {
271 ignore::Match::None => {}
272 ignore::Match::Ignore(_) => is_ignored = true,
273 ignore::Match::Whitelist(_) => break,
274 }
275 }
276 let content = self
277 .fs
278 .read_file_sync(path)
279 .ok()
280 .map(|content| String::from_utf8(content).unwrap())?;
281 let repo_path = RelPath::new(repo_path, PathStyle::local()).ok()?;
282 Some((RepoPath::from_rel_path(&repo_path), (content, is_ignored)))
283 })
284 .collect();
285
286 let result = self.fs.with_git_state(&self.dot_git_path, false, |state| {
287 let mut entries = Vec::new();
288 let paths = state
289 .head_contents
290 .keys()
291 .chain(state.index_contents.keys())
292 .chain(git_files.keys())
293 .collect::<HashSet<_>>();
294 for path in paths {
295 if !path_prefixes.iter().any(|prefix| path.starts_with(prefix)) {
296 continue;
297 }
298
299 let head = state.head_contents.get(path);
300 let index = state.index_contents.get(path);
301 let unmerged = state.unmerged_paths.get(path);
302 let fs = git_files.get(path);
303 let status = match (unmerged, head, index, fs) {
304 (Some(unmerged), _, _, _) => FileStatus::Unmerged(*unmerged),
305 (_, Some(head), Some(index), Some((fs, _))) => {
306 FileStatus::Tracked(TrackedStatus {
307 index_status: if head == index {
308 StatusCode::Unmodified
309 } else {
310 StatusCode::Modified
311 },
312 worktree_status: if fs == index {
313 StatusCode::Unmodified
314 } else {
315 StatusCode::Modified
316 },
317 })
318 }
319 (_, Some(head), Some(index), None) => FileStatus::Tracked(TrackedStatus {
320 index_status: if head == index {
321 StatusCode::Unmodified
322 } else {
323 StatusCode::Modified
324 },
325 worktree_status: StatusCode::Deleted,
326 }),
327 (_, Some(_), None, Some(_)) => FileStatus::Tracked(TrackedStatus {
328 index_status: StatusCode::Deleted,
329 worktree_status: StatusCode::Added,
330 }),
331 (_, Some(_), None, None) => FileStatus::Tracked(TrackedStatus {
332 index_status: StatusCode::Deleted,
333 worktree_status: StatusCode::Deleted,
334 }),
335 (_, None, Some(index), Some((fs, _))) => FileStatus::Tracked(TrackedStatus {
336 index_status: StatusCode::Added,
337 worktree_status: if fs == index {
338 StatusCode::Unmodified
339 } else {
340 StatusCode::Modified
341 },
342 }),
343 (_, None, Some(_), None) => FileStatus::Tracked(TrackedStatus {
344 index_status: StatusCode::Added,
345 worktree_status: StatusCode::Deleted,
346 }),
347 (_, None, None, Some((_, is_ignored))) => {
348 if *is_ignored {
349 continue;
350 }
351 FileStatus::Untracked
352 }
353 (_, None, None, None) => {
354 unreachable!();
355 }
356 };
357 if status
358 != FileStatus::Tracked(TrackedStatus {
359 index_status: StatusCode::Unmodified,
360 worktree_status: StatusCode::Unmodified,
361 })
362 {
363 entries.push((path.clone(), status));
364 }
365 }
366 entries.sort_by(|a, b| a.0.cmp(&b.0));
367 anyhow::Ok(GitStatus {
368 entries: entries.into(),
369 })
370 });
371 Task::ready(match result {
372 Ok(result) => result,
373 Err(e) => Err(e),
374 })
375 }
376
377 fn stash_entries(&self) -> BoxFuture<'_, Result<git::stash::GitStash>> {
378 async { Ok(git::stash::GitStash::default()) }.boxed()
379 }
380
381 fn branches(&self) -> BoxFuture<'_, Result<Vec<Branch>>> {
382 self.with_state_async(false, move |state| {
383 let current_branch = &state.current_branch_name;
384 Ok(state
385 .branches
386 .iter()
387 .map(|branch_name| {
388 let ref_name = if branch_name.starts_with("refs/") {
389 branch_name.into()
390 } else {
391 format!("refs/heads/{branch_name}").into()
392 };
393 Branch {
394 is_head: Some(branch_name) == current_branch.as_ref(),
395 ref_name,
396 most_recent_commit: None,
397 upstream: None,
398 }
399 })
400 .collect())
401 })
402 }
403
404 fn worktrees(&self) -> BoxFuture<'_, Result<Vec<Worktree>>> {
405 unimplemented!()
406 }
407
408 fn create_worktree(
409 &self,
410 _: String,
411 _: PathBuf,
412 _: Option<String>,
413 ) -> BoxFuture<'_, Result<()>> {
414 unimplemented!()
415 }
416
417 fn change_branch(&self, name: String) -> BoxFuture<'_, Result<()>> {
418 self.with_state_async(true, |state| {
419 state.current_branch_name = Some(name);
420 Ok(())
421 })
422 }
423
424 fn create_branch(
425 &self,
426 name: String,
427 _base_branch: Option<String>,
428 ) -> BoxFuture<'_, Result<()>> {
429 self.with_state_async(true, move |state| {
430 state.branches.insert(name);
431 Ok(())
432 })
433 }
434
435 fn rename_branch(&self, branch: String, new_name: String) -> BoxFuture<'_, Result<()>> {
436 self.with_state_async(true, move |state| {
437 if !state.branches.remove(&branch) {
438 bail!("no such branch: {branch}");
439 }
440 state.branches.insert(new_name.clone());
441 if state.current_branch_name == Some(branch) {
442 state.current_branch_name = Some(new_name);
443 }
444 Ok(())
445 })
446 }
447
448 fn delete_branch(&self, name: String) -> BoxFuture<'_, Result<()>> {
449 self.with_state_async(true, move |state| {
450 if !state.branches.remove(&name) {
451 bail!("no such branch: {name}");
452 }
453 Ok(())
454 })
455 }
456
457 fn blame(
458 &self,
459 path: RepoPath,
460 _content: Rope,
461 _line_ending: LineEnding,
462 ) -> BoxFuture<'_, Result<git::blame::Blame>> {
463 self.with_state_async(false, move |state| {
464 state
465 .blames
466 .get(&path)
467 .with_context(|| format!("failed to get blame for {:?}", path))
468 .cloned()
469 })
470 }
471
472 fn file_history(&self, path: RepoPath) -> BoxFuture<'_, Result<git::repository::FileHistory>> {
473 self.file_history_paginated(path, 0, None)
474 }
475
476 fn file_history_paginated(
477 &self,
478 path: RepoPath,
479 _skip: usize,
480 _limit: Option<usize>,
481 ) -> BoxFuture<'_, Result<git::repository::FileHistory>> {
482 async move {
483 Ok(git::repository::FileHistory {
484 entries: Vec::new(),
485 path,
486 })
487 }
488 .boxed()
489 }
490
491 fn stage_paths(
492 &self,
493 paths: Vec<RepoPath>,
494 _env: Arc<HashMap<String, String>>,
495 ) -> BoxFuture<'_, Result<()>> {
496 Box::pin(async move {
497 let contents = paths
498 .into_iter()
499 .map(|path| {
500 let abs_path = self
501 .dot_git_path
502 .parent()
503 .unwrap()
504 .join(&path.as_std_path());
505 Box::pin(async move { (path.clone(), self.fs.load(&abs_path).await.ok()) })
506 })
507 .collect::<Vec<_>>();
508 let contents = join_all(contents).await;
509 self.with_state_async(true, move |state| {
510 for (path, content) in contents {
511 if let Some(content) = content {
512 state.index_contents.insert(path, content);
513 } else {
514 state.index_contents.remove(&path);
515 }
516 }
517 Ok(())
518 })
519 .await
520 })
521 }
522
523 fn unstage_paths(
524 &self,
525 paths: Vec<RepoPath>,
526 _env: Arc<HashMap<String, String>>,
527 ) -> BoxFuture<'_, Result<()>> {
528 self.with_state_async(true, move |state| {
529 for path in paths {
530 match state.head_contents.get(&path) {
531 Some(content) => state.index_contents.insert(path, content.clone()),
532 None => state.index_contents.remove(&path),
533 };
534 }
535 Ok(())
536 })
537 }
538
539 fn stash_paths(
540 &self,
541 _paths: Vec<RepoPath>,
542 _env: Arc<HashMap<String, String>>,
543 ) -> BoxFuture<'_, Result<()>> {
544 unimplemented!()
545 }
546
547 fn stash_pop(
548 &self,
549 _index: Option<usize>,
550 _env: Arc<HashMap<String, String>>,
551 ) -> BoxFuture<'_, Result<()>> {
552 unimplemented!()
553 }
554
555 fn stash_apply(
556 &self,
557 _index: Option<usize>,
558 _env: Arc<HashMap<String, String>>,
559 ) -> BoxFuture<'_, Result<()>> {
560 unimplemented!()
561 }
562
563 fn stash_drop(
564 &self,
565 _index: Option<usize>,
566 _env: Arc<HashMap<String, String>>,
567 ) -> BoxFuture<'_, Result<()>> {
568 unimplemented!()
569 }
570
571 fn commit(
572 &self,
573 _message: gpui::SharedString,
574 _name_and_email: Option<(gpui::SharedString, gpui::SharedString)>,
575 _options: CommitOptions,
576 _askpass: AskPassDelegate,
577 _env: Arc<HashMap<String, String>>,
578 ) -> BoxFuture<'_, Result<()>> {
579 async { Ok(()) }.boxed()
580 }
581
582 fn run_hook(
583 &self,
584 _hook: RunHook,
585 _env: Arc<HashMap<String, String>>,
586 ) -> BoxFuture<'_, Result<()>> {
587 async { Ok(()) }.boxed()
588 }
589
590 fn push(
591 &self,
592 _branch: String,
593 _remote_branch: String,
594 _remote: String,
595 _options: Option<PushOptions>,
596 _askpass: AskPassDelegate,
597 _env: Arc<HashMap<String, String>>,
598 _cx: AsyncApp,
599 ) -> BoxFuture<'_, Result<git::repository::RemoteCommandOutput>> {
600 unimplemented!()
601 }
602
603 fn pull(
604 &self,
605 _branch: Option<String>,
606 _remote: String,
607 _rebase: bool,
608 _askpass: AskPassDelegate,
609 _env: Arc<HashMap<String, String>>,
610 _cx: AsyncApp,
611 ) -> BoxFuture<'_, Result<git::repository::RemoteCommandOutput>> {
612 unimplemented!()
613 }
614
615 fn fetch(
616 &self,
617 _fetch_options: FetchOptions,
618 _askpass: AskPassDelegate,
619 _env: Arc<HashMap<String, String>>,
620 _cx: AsyncApp,
621 ) -> BoxFuture<'_, Result<git::repository::RemoteCommandOutput>> {
622 unimplemented!()
623 }
624
625 fn get_all_remotes(&self) -> BoxFuture<'_, Result<Vec<Remote>>> {
626 self.with_state_async(false, move |state| {
627 let remotes = state
628 .remotes
629 .keys()
630 .map(|r| Remote {
631 name: r.clone().into(),
632 })
633 .collect::<Vec<_>>();
634 Ok(remotes)
635 })
636 }
637
638 fn get_push_remote(&self, _branch: String) -> BoxFuture<'_, Result<Option<Remote>>> {
639 unimplemented!()
640 }
641
642 fn get_branch_remote(&self, _branch: String) -> BoxFuture<'_, Result<Option<Remote>>> {
643 unimplemented!()
644 }
645
646 fn check_for_pushed_commit(&self) -> BoxFuture<'_, Result<Vec<gpui::SharedString>>> {
647 future::ready(Ok(Vec::new())).boxed()
648 }
649
650 fn diff(&self, _diff: git::repository::DiffType) -> BoxFuture<'_, Result<String>> {
651 unimplemented!()
652 }
653
654 fn checkpoint(&self) -> BoxFuture<'static, Result<GitRepositoryCheckpoint>> {
655 let executor = self.executor.clone();
656 let fs = self.fs.clone();
657 let checkpoints = self.checkpoints.clone();
658 let repository_dir_path = self.repository_dir_path.parent().unwrap().to_path_buf();
659 async move {
660 executor.simulate_random_delay().await;
661 let oid = git::Oid::random(&mut *executor.rng().lock());
662 let entry = fs.entry(&repository_dir_path)?;
663 checkpoints.lock().insert(oid, entry);
664 Ok(GitRepositoryCheckpoint { commit_sha: oid })
665 }
666 .boxed()
667 }
668
669 fn restore_checkpoint(&self, checkpoint: GitRepositoryCheckpoint) -> BoxFuture<'_, Result<()>> {
670 let executor = self.executor.clone();
671 let fs = self.fs.clone();
672 let checkpoints = self.checkpoints.clone();
673 let repository_dir_path = self.repository_dir_path.parent().unwrap().to_path_buf();
674 async move {
675 executor.simulate_random_delay().await;
676 let checkpoints = checkpoints.lock();
677 let entry = checkpoints
678 .get(&checkpoint.commit_sha)
679 .context(format!("invalid checkpoint: {}", checkpoint.commit_sha))?;
680 fs.insert_entry(&repository_dir_path, entry.clone())?;
681 Ok(())
682 }
683 .boxed()
684 }
685
686 fn compare_checkpoints(
687 &self,
688 left: GitRepositoryCheckpoint,
689 right: GitRepositoryCheckpoint,
690 ) -> BoxFuture<'_, Result<bool>> {
691 let executor = self.executor.clone();
692 let checkpoints = self.checkpoints.clone();
693 async move {
694 executor.simulate_random_delay().await;
695 let checkpoints = checkpoints.lock();
696 let left = checkpoints
697 .get(&left.commit_sha)
698 .context(format!("invalid left checkpoint: {}", left.commit_sha))?;
699 let right = checkpoints
700 .get(&right.commit_sha)
701 .context(format!("invalid right checkpoint: {}", right.commit_sha))?;
702
703 Ok(left == right)
704 }
705 .boxed()
706 }
707
708 fn diff_checkpoints(
709 &self,
710 _base_checkpoint: GitRepositoryCheckpoint,
711 _target_checkpoint: GitRepositoryCheckpoint,
712 ) -> BoxFuture<'_, Result<String>> {
713 unimplemented!()
714 }
715
716 fn default_branch(
717 &self,
718 include_remote_name: bool,
719 ) -> BoxFuture<'_, Result<Option<SharedString>>> {
720 async move {
721 Ok(Some(if include_remote_name {
722 "origin/main".into()
723 } else {
724 "main".into()
725 }))
726 }
727 .boxed()
728 }
729
730 fn create_remote(&self, name: String, url: String) -> BoxFuture<'_, Result<()>> {
731 self.with_state_async(true, move |state| {
732 state.remotes.insert(name, url);
733 Ok(())
734 })
735 }
736
737 fn remove_remote(&self, name: String) -> BoxFuture<'_, Result<()>> {
738 self.with_state_async(true, move |state| {
739 state.remotes.remove(&name);
740 Ok(())
741 })
742 }
743
744 fn initial_graph_data(
745 &self,
746 _log_source: LogSource,
747 _log_order: LogOrder,
748 request_tx: Sender<Vec<Arc<InitialGraphCommitData>>>,
749 ) -> BoxFuture<'_, Result<()>> {
750 let fs = self.fs.clone();
751 let dot_git_path = self.dot_git_path.clone();
752 async move {
753 let graph_commits =
754 fs.with_git_state(&dot_git_path, false, |state| state.graph_commits.clone())?;
755
756 for chunk in graph_commits.chunks(GRAPH_CHUNK_SIZE) {
757 request_tx.send(chunk.to_vec()).await.ok();
758 }
759 Ok(())
760 }
761 .boxed()
762 }
763
764 fn commit_data_reader(&self) -> Result<CommitDataReader> {
765 anyhow::bail!("commit_data_reader not supported for FakeGitRepository")
766 }
767}