use std::path::Path; use crate::{FakeFs, FakeFsEntry, Fs, RemoveOptions, RenameOptions}; use anyhow::{Context as _, Result, bail}; use async_channel::Sender; use collections::{HashMap, HashSet}; use futures::FutureExt as _; use futures::future::{self, BoxFuture, join_all}; use git::repository::GitCommitTemplate; use git::{ Oid, RunHook, blame::Blame, repository::{ AskPassDelegate, Branch, CommitData, CommitDataReader, CommitDetails, CommitOptions, CreateWorktreeTarget, FetchOptions, GRAPH_CHUNK_SIZE, GitRepository, GitRepositoryCheckpoint, InitialGraphCommitData, LogOrder, LogSource, PushOptions, RefEdit, Remote, RepoPath, ResetMode, SearchCommitArgs, Worktree, }, stash::GitStash, status::{ DiffTreeType, FileStatus, GitStatus, StatusCode, TrackedStatus, TreeDiff, TreeDiffStatus, UnmergedStatus, }, }; use gpui::{AsyncApp, BackgroundExecutor, SharedString, Task}; use ignore::gitignore::GitignoreBuilder; use parking_lot::Mutex; use rope::Rope; use std::{path::PathBuf, sync::Arc, sync::atomic::AtomicBool}; use text::LineEnding; use util::{paths::PathStyle, rel_path::RelPath}; #[derive(Clone)] pub struct FakeGitRepository { pub(crate) fs: Arc, pub(crate) checkpoints: Arc>>, pub(crate) executor: BackgroundExecutor, pub(crate) dot_git_path: PathBuf, pub(crate) repository_dir_path: PathBuf, pub(crate) common_dir_path: PathBuf, pub(crate) is_trusted: Arc, } #[derive(Debug, Clone)] pub struct FakeCommitSnapshot { pub head_contents: HashMap, pub index_contents: HashMap, pub sha: String, } #[derive(Debug, Clone)] pub enum FakeCommitDataEntry { Success(CommitData), Fail(CommitData), } #[derive(Debug, Clone)] pub struct FakeGitRepositoryState { pub commit_history: Vec, pub event_emitter: async_channel::Sender, pub unmerged_paths: HashMap, pub head_contents: HashMap, pub index_contents: HashMap, // everything in commit contents is in oids pub merge_base_contents: HashMap, pub oids: HashMap, pub blames: HashMap, pub current_branch_name: Option, pub branches: HashSet, /// List of remotes, keys are names and values are URLs pub remotes: HashMap, pub simulated_index_write_error_message: Option, pub simulated_create_worktree_error: Option, pub simulated_graph_error: Option, pub branches_requiring_force_delete: HashSet, pub worktrees_requiring_force_delete: HashSet, pub refs: HashMap, pub graph_commits: Vec>, pub commit_data: HashMap, pub stash_entries: GitStash, } impl FakeGitRepositoryState { pub fn new(event_emitter: async_channel::Sender) -> Self { FakeGitRepositoryState { event_emitter, head_contents: Default::default(), index_contents: Default::default(), unmerged_paths: Default::default(), blames: Default::default(), current_branch_name: Default::default(), branches: Default::default(), simulated_index_write_error_message: Default::default(), simulated_create_worktree_error: Default::default(), simulated_graph_error: None, branches_requiring_force_delete: Default::default(), worktrees_requiring_force_delete: Default::default(), refs: HashMap::from_iter([("HEAD".into(), "abc".into())]), merge_base_contents: Default::default(), oids: Default::default(), remotes: HashMap::default(), graph_commits: Vec::new(), commit_data: Default::default(), commit_history: Vec::new(), stash_entries: Default::default(), } } } impl FakeGitRepository { fn with_state_async(&self, write: bool, f: F) -> BoxFuture<'static, Result> where F: 'static + Send + FnOnce(&mut FakeGitRepositoryState) -> Result, T: Send, { let fs = self.fs.clone(); let executor = self.executor.clone(); let dot_git_path = self.dot_git_path.clone(); async move { executor.simulate_random_delay().await; fs.with_git_state(&dot_git_path, write, f)? } .boxed() } fn edit_ref(&self, edit: RefEdit) -> BoxFuture<'_, Result<()>> { self.with_state_async(true, move |state| { match edit { RefEdit::Update { ref_name, commit } => { state.refs.insert(ref_name, commit); } RefEdit::Delete { ref_name } => { state.refs.remove(&ref_name); } } Ok(()) }) } /// Scans `.git/worktrees/*/gitdir` to find the admin entry directory for a /// worktree at the given checkout path. Used when the working tree directory /// has already been deleted and we can't read its `.git` pointer file. async fn find_worktree_entry_dir_by_path(&self, path: &Path) -> Option { use futures::StreamExt; let worktrees_dir = self.common_dir_path.join("worktrees"); let mut entries = self.fs.read_dir(&worktrees_dir).await.ok()?; while let Some(Ok(entry_path)) = entries.next().await { if let Ok(gitdir_content) = self.fs.load(&entry_path.join("gitdir")).await { let worktree_path = PathBuf::from(gitdir_content.trim()) .parent() .map(PathBuf::from) .unwrap_or_default(); if worktree_path == path { return Some(entry_path); } } } None } } impl GitRepository for FakeGitRepository { fn reload_index(&self) {} fn load_index_text(&self, path: RepoPath) -> BoxFuture<'_, Option> { let fut = self.with_state_async(false, move |state| { state .index_contents .get(&path) .context("not present in index") .cloned() }); self.executor.spawn(async move { fut.await.ok() }).boxed() } fn load_committed_text(&self, path: RepoPath) -> BoxFuture<'_, Option> { let fut = self.with_state_async(false, move |state| { state .head_contents .get(&path) .context("not present in HEAD") .cloned() }); self.executor.spawn(async move { fut.await.ok() }).boxed() } fn load_commit_template(&self) -> BoxFuture<'_, Result>> { async { Ok(None) }.boxed() } fn load_blob_content(&self, oid: git::Oid) -> BoxFuture<'_, Result> { self.with_state_async(false, move |state| { state.oids.get(&oid).cloned().context("oid does not exist") }) .boxed() } fn load_commit( &self, _commit: String, _cx: AsyncApp, ) -> BoxFuture<'_, Result> { async { Ok(git::repository::CommitDiff { files: Vec::new() }) }.boxed() } fn set_index_text( &self, path: RepoPath, content: Option, _env: Arc>, _is_executable: bool, ) -> BoxFuture<'_, anyhow::Result<()>> { self.with_state_async(true, move |state| { if let Some(message) = &state.simulated_index_write_error_message { anyhow::bail!("{message}"); } else if let Some(content) = content { state.index_contents.insert(path, content); } else { state.index_contents.remove(&path); } Ok(()) }) } fn remote_url(&self, name: &str) -> BoxFuture<'_, Option> { let name = name.to_string(); let fut = self.with_state_async(false, move |state| { state .remotes .get(&name) .context("remote not found") .cloned() }); async move { fut.await.ok() }.boxed() } fn diff_tree(&self, _request: DiffTreeType) -> BoxFuture<'_, Result> { let mut entries = HashMap::default(); self.with_state_async(false, |state| { for (path, content) in &state.head_contents { let status = if let Some((oid, original)) = state .merge_base_contents .get(path) .map(|oid| (oid, &state.oids[oid])) { if original == content { continue; } TreeDiffStatus::Modified { old: *oid } } else { TreeDiffStatus::Added }; entries.insert(path.clone(), status); } for (path, oid) in &state.merge_base_contents { if !entries.contains_key(path) { entries.insert(path.clone(), TreeDiffStatus::Deleted { old: *oid }); } } Ok(TreeDiff { entries }) }) .boxed() } fn revparse_batch(&self, revs: Vec) -> BoxFuture<'_, Result>>> { self.with_state_async(false, |state| { Ok(revs .into_iter() .map(|rev| state.refs.get(&rev).cloned()) .collect()) }) } fn show(&self, commit: String) -> BoxFuture<'_, Result> { async { Ok(CommitDetails { sha: commit.into(), message: "initial commit".into(), ..Default::default() }) } .boxed() } fn reset( &self, commit: String, mode: ResetMode, _env: Arc>, ) -> BoxFuture<'_, Result<()>> { self.with_state_async(true, move |state| { let pop_count = if commit == "HEAD~" || commit == "HEAD^" { 1 } else if let Some(suffix) = commit.strip_prefix("HEAD~") { suffix .parse::() .with_context(|| format!("Invalid HEAD~ offset: {commit}"))? } else { match state .commit_history .iter() .rposition(|entry| entry.sha == commit) { Some(index) => state.commit_history.len() - index, None => anyhow::bail!("Unknown commit ref: {commit}"), } }; if pop_count == 0 || pop_count > state.commit_history.len() { anyhow::bail!( "Cannot reset {pop_count} commit(s): only {} in history", state.commit_history.len() ); } let target_index = state.commit_history.len() - pop_count; let snapshot = state.commit_history[target_index].clone(); state.commit_history.truncate(target_index); match mode { ResetMode::Soft => { state.head_contents = snapshot.head_contents; } ResetMode::Mixed => { state.head_contents = snapshot.head_contents; state.index_contents = state.head_contents.clone(); } } state.refs.insert("HEAD".into(), snapshot.sha); Ok(()) }) } fn checkout_files( &self, _commit: String, _paths: Vec, _env: Arc>, ) -> BoxFuture<'_, Result<()>> { unimplemented!() } fn path(&self) -> PathBuf { self.repository_dir_path.clone() } fn main_repository_path(&self) -> PathBuf { self.common_dir_path.clone() } fn merge_message(&self) -> BoxFuture<'_, Option> { async move { None }.boxed() } fn status(&self, path_prefixes: &[RepoPath]) -> Task> { let workdir_path = self.dot_git_path.parent().unwrap(); // Load gitignores let ignores = workdir_path .ancestors() .filter_map(|dir| { let ignore_path = dir.join(".gitignore"); let content = self.fs.read_file_sync(ignore_path).ok()?; let content = String::from_utf8(content).ok()?; let mut builder = GitignoreBuilder::new(dir); for line in content.lines() { builder.add_line(Some(dir.into()), line).ok()?; } builder.build().ok() }) .collect::>(); // Load working copy files. let git_files: HashMap = self .fs .files() .iter() .filter_map(|path| { // TODO better simulate git status output in the case of submodules and worktrees let repo_path = path.strip_prefix(workdir_path).ok()?; let mut is_ignored = repo_path.starts_with(".git"); for ignore in &ignores { match ignore.matched_path_or_any_parents(path, false) { ignore::Match::None => {} ignore::Match::Ignore(_) => is_ignored = true, ignore::Match::Whitelist(_) => break, } } let content = self .fs .read_file_sync(path) .ok() .map(|content| String::from_utf8(content).unwrap())?; let repo_path = RelPath::new(repo_path, PathStyle::local()).ok()?; Some((RepoPath::from_rel_path(&repo_path), (content, is_ignored))) }) .collect(); let result = self.fs.with_git_state(&self.dot_git_path, false, |state| { let mut entries = Vec::new(); let paths = state .head_contents .keys() .chain(state.index_contents.keys()) .chain(git_files.keys()) .collect::>(); for path in paths { if !path_prefixes.iter().any(|prefix| path.starts_with(prefix)) { continue; } let head = state.head_contents.get(path); let index = state.index_contents.get(path); let unmerged = state.unmerged_paths.get(path); let fs = git_files.get(path); let status = match (unmerged, head, index, fs) { (Some(unmerged), _, _, _) => FileStatus::Unmerged(*unmerged), (_, Some(head), Some(index), Some((fs, _))) => { FileStatus::Tracked(TrackedStatus { index_status: if head == index { StatusCode::Unmodified } else { StatusCode::Modified }, worktree_status: if fs == index { StatusCode::Unmodified } else { StatusCode::Modified }, }) } (_, Some(head), Some(index), None) => FileStatus::Tracked(TrackedStatus { index_status: if head == index { StatusCode::Unmodified } else { StatusCode::Modified }, worktree_status: StatusCode::Deleted, }), (_, Some(_), None, Some(_)) => FileStatus::Tracked(TrackedStatus { index_status: StatusCode::Deleted, worktree_status: StatusCode::Added, }), (_, Some(_), None, None) => FileStatus::Tracked(TrackedStatus { index_status: StatusCode::Deleted, worktree_status: StatusCode::Deleted, }), (_, None, Some(index), Some((fs, _))) => FileStatus::Tracked(TrackedStatus { index_status: StatusCode::Added, worktree_status: if fs == index { StatusCode::Unmodified } else { StatusCode::Modified }, }), (_, None, Some(_), None) => FileStatus::Tracked(TrackedStatus { index_status: StatusCode::Added, worktree_status: StatusCode::Deleted, }), (_, None, None, Some((_, is_ignored))) => { if *is_ignored { continue; } FileStatus::Untracked } (_, None, None, None) => { unreachable!(); } }; if status != FileStatus::Tracked(TrackedStatus { index_status: StatusCode::Unmodified, worktree_status: StatusCode::Unmodified, }) { entries.push((path.clone(), status)); } } entries.sort_by(|a, b| a.0.cmp(&b.0)); anyhow::Ok(GitStatus { entries: entries.into(), }) }); Task::ready(match result { Ok(result) => result, Err(e) => Err(e), }) } fn stash_entries(&self) -> BoxFuture<'_, Result> { self.with_state_async(false, |state| Ok(state.stash_entries.clone())) } fn branches(&self) -> BoxFuture<'_, Result>> { self.with_state_async(false, move |state| { let current_branch = &state.current_branch_name; let mut branches = state .branches .iter() .map(|branch_name| { let ref_name = if branch_name.starts_with("refs/") { branch_name.into() } else if branch_name.contains('/') { format!("refs/remotes/{branch_name}").into() } else { format!("refs/heads/{branch_name}").into() }; Branch { is_head: Some(branch_name) == current_branch.as_ref(), ref_name, most_recent_commit: None, upstream: None, } }) .collect::>(); // compute snapshot expects these to be sorted by ref_name // because that's what git itself does branches.sort_by(|a, b| a.ref_name.cmp(&b.ref_name)); Ok(branches) }) } fn worktrees(&self) -> BoxFuture<'_, Result>> { let fs = self.fs.clone(); let common_dir_path = self.common_dir_path.clone(); let executor = self.executor.clone(); async move { executor.simulate_random_delay().await; let (main_worktree, refs) = fs.with_git_state(&common_dir_path, false, |state| { let work_dir = common_dir_path .parent() .map(PathBuf::from) .unwrap_or_else(|| common_dir_path.clone()); let head_sha = state .refs .get("HEAD") .cloned() .unwrap_or_else(|| "0000000".to_string()); let branch_ref = state .current_branch_name .as_ref() .map(|name| format!("refs/heads/{name}")) .unwrap_or_else(|| "refs/heads/main".to_string()); let main_wt = Worktree { path: work_dir, ref_name: Some(branch_ref.into()), sha: head_sha.into(), is_main: true, is_bare: false, }; (main_wt, state.refs.clone()) })?; let mut all = vec![main_worktree]; let worktrees_dir = common_dir_path.join("worktrees"); if let Ok(mut entries) = fs.read_dir(&worktrees_dir).await { use futures::StreamExt; while let Some(Ok(entry_path)) = entries.next().await { let head_content = match fs.load(&entry_path.join("HEAD")).await { Ok(content) => content, Err(_) => continue, }; let gitdir_content = match fs.load(&entry_path.join("gitdir")).await { Ok(content) => content, Err(_) => continue, }; let ref_name = head_content .strip_prefix("ref: ") .map(|s| s.trim().to_string()); let sha = ref_name .as_ref() .and_then(|r| refs.get(r)) .cloned() .unwrap_or_else(|| head_content.trim().to_string()); let worktree_path = PathBuf::from(gitdir_content.trim()) .parent() .map(PathBuf::from) .unwrap_or_default(); all.push(Worktree { path: worktree_path, ref_name: ref_name.map(Into::into), sha: sha.into(), is_main: false, is_bare: false, }); } } Ok(all) } .boxed() } fn create_worktree( &self, target: CreateWorktreeTarget, path: PathBuf, ) -> BoxFuture<'_, Result<()>> { let fs = self.fs.clone(); let executor = self.executor.clone(); let dot_git_path = self.dot_git_path.clone(); let common_dir_path = self.common_dir_path.clone(); async move { executor.simulate_random_delay().await; let branch_name = target.branch_name().map(ToOwned::to_owned); let create_branch_ref = matches!(target, CreateWorktreeTarget::NewBranch { .. }); // Check for simulated error and validate branch state before any side effects. fs.with_git_state(&dot_git_path, false, { let branch_name = branch_name.clone(); move |state| { if let Some(message) = &state.simulated_create_worktree_error { anyhow::bail!("{message}"); } match (create_branch_ref, branch_name.as_ref()) { (true, Some(branch_name)) => { if state.branches.contains(branch_name) { bail!("a branch named '{}' already exists", branch_name); } } (false, Some(branch_name)) => { if !state.branches.contains(branch_name) { bail!("no branch named '{}' exists", branch_name); } } (false, None) => {} (true, None) => bail!("branch name is required to create a branch"), } Ok(()) } })??; let (branch_name, sha, create_branch_ref) = match target { CreateWorktreeTarget::ExistingBranch { branch_name } => { let ref_name = format!("refs/heads/{branch_name}"); let sha = fs.with_git_state(&dot_git_path, false, { move |state| { Ok::<_, anyhow::Error>( state .refs .get(&ref_name) .cloned() .unwrap_or_else(|| "fake-sha".to_string()), ) } })??; (Some(branch_name), sha, false) } CreateWorktreeTarget::NewBranch { branch_name, base_sha: start_point, } => ( Some(branch_name), start_point.unwrap_or_else(|| "fake-sha".to_string()), true, ), CreateWorktreeTarget::Detached { base_sha: start_point, } => ( None, start_point.unwrap_or_else(|| "fake-sha".to_string()), false, ), }; // Create the worktree checkout directory. fs.create_dir(&path).await?; // Create .git/worktrees// directory with HEAD, commondir, gitdir. let worktree_entry_name = branch_name.as_deref().unwrap_or_else(|| { path.file_name() .and_then(|name| name.to_str()) .unwrap_or("detached") }); let worktrees_entry_dir = common_dir_path.join("worktrees").join(worktree_entry_name); fs.create_dir(&worktrees_entry_dir).await?; let head_content = if let Some(ref branch_name) = branch_name { let ref_name = format!("refs/heads/{branch_name}"); format!("ref: {ref_name}") } else { sha.clone() }; fs.write_file_internal( worktrees_entry_dir.join("HEAD"), head_content.into_bytes(), false, )?; fs.write_file_internal( worktrees_entry_dir.join("commondir"), common_dir_path.to_string_lossy().into_owned().into_bytes(), false, )?; let worktree_dot_git = path.join(".git"); fs.write_file_internal( worktrees_entry_dir.join("gitdir"), worktree_dot_git.to_string_lossy().into_owned().into_bytes(), false, )?; // Create .git file in the worktree checkout. fs.write_file_internal( &worktree_dot_git, format!("gitdir: {}", worktrees_entry_dir.display()).into_bytes(), false, )?; // Update git state for newly created branches. if create_branch_ref { fs.with_git_state(&dot_git_path, true, { let branch_name = branch_name.clone(); let sha = sha.clone(); move |state| { if let Some(branch_name) = branch_name { let ref_name = format!("refs/heads/{branch_name}"); state.refs.insert(ref_name, sha); state.branches.insert(branch_name); } Ok::<(), anyhow::Error>(()) } })??; } Ok(()) } .boxed() } fn remove_worktree(&self, path: PathBuf, force: bool) -> BoxFuture<'_, Result<()>> { let fs = self.fs.clone(); let executor = self.executor.clone(); let common_dir_path = self.common_dir_path.clone(); async move { executor.simulate_random_delay().await; if !force { fs.with_git_state(&common_dir_path, false, |state| { if state.worktrees_requiring_force_delete.contains(&path) { bail!( "fatal: '{}' contains modified or untracked files, use --force to delete it", path.display() ); } Ok::<(), anyhow::Error>(()) })??; } // Try to read the worktree's .git file to find its entry // directory. If the working tree is already gone (e.g. the // caller deleted it before asking git to clean up), fall back // to scanning `.git/worktrees/*/gitdir` for a matching path, // mirroring real git's behavior with `--force`. let dot_git_file = path.join(".git"); let worktree_entry_dir = if let Ok(content) = fs.load(&dot_git_file).await { let gitdir = content .strip_prefix("gitdir:") .context("invalid .git file in worktree")? .trim(); PathBuf::from(gitdir) } else { self.find_worktree_entry_dir_by_path(&path) .await .with_context(|| format!("no worktree found at path: {}", path.display()))? }; // Remove the worktree checkout directory if it still exists. fs.remove_dir( &path, RemoveOptions { recursive: true, ignore_if_not_exists: true, }, ) .await?; // Remove the .git/worktrees// directory. fs.remove_dir( &worktree_entry_dir, RemoveOptions { recursive: true, ignore_if_not_exists: false, }, ) .await?; // Emit a git event on the main .git directory so the scanner // notices the change. fs.with_git_state(&common_dir_path, true, |state| { state.worktrees_requiring_force_delete.remove(&path); Ok::<(), anyhow::Error>(()) })??; Ok(()) } .boxed() } fn rename_worktree(&self, old_path: PathBuf, new_path: PathBuf) -> BoxFuture<'_, Result<()>> { let fs = self.fs.clone(); let executor = self.executor.clone(); let common_dir_path = self.common_dir_path.clone(); async move { executor.simulate_random_delay().await; // Read the worktree's .git file to find its entry directory. let dot_git_file = old_path.join(".git"); let content = fs .load(&dot_git_file) .await .with_context(|| format!("no worktree found at path: {}", old_path.display()))?; let gitdir = content .strip_prefix("gitdir:") .context("invalid .git file in worktree")? .trim(); let worktree_entry_dir = PathBuf::from(gitdir); // Move the worktree checkout directory. fs.rename( &old_path, &new_path, RenameOptions { overwrite: false, ignore_if_exists: false, create_parents: true, }, ) .await?; // Update the gitdir file in .git/worktrees// to point to the // new location. let new_dot_git = new_path.join(".git"); fs.write_file_internal( worktree_entry_dir.join("gitdir"), new_dot_git.to_string_lossy().into_owned().into_bytes(), false, )?; // Update the .git file in the moved worktree checkout. fs.write_file_internal( &new_dot_git, format!("gitdir: {}", worktree_entry_dir.display()).into_bytes(), false, )?; // Emit a git event on the main .git directory so the scanner // notices the change. fs.with_git_state(&common_dir_path, true, |_| {})?; Ok(()) } .boxed() } fn checkout_branch_in_worktree( &self, _branch_name: String, _worktree_path: PathBuf, _create: bool, ) -> BoxFuture<'_, Result<()>> { async { Ok(()) }.boxed() } fn change_branch(&self, name: String) -> BoxFuture<'_, Result<()>> { self.with_state_async(true, |state| { state.current_branch_name = Some(name); Ok(()) }) } fn create_branch( &self, name: String, _base_branch: Option, ) -> BoxFuture<'_, Result<()>> { self.with_state_async(true, move |state| { if let Some((remote, _)) = name.split_once('/') && !state.remotes.contains_key(remote) { state.remotes.insert(remote.to_owned(), "".to_owned()); } state.branches.insert(name); Ok(()) }) } fn rename_branch(&self, branch: String, new_name: String) -> BoxFuture<'_, Result<()>> { self.with_state_async(true, move |state| { if !state.branches.remove(&branch) { bail!("no such branch: {branch}"); } state.branches.insert(new_name.clone()); if state.current_branch_name == Some(branch) { state.current_branch_name = Some(new_name); } Ok(()) }) } fn delete_branch( &self, _is_remote: bool, name: String, force: bool, ) -> BoxFuture<'_, Result<()>> { self.with_state_async(true, move |state| { if !force && state.branches_requiring_force_delete.contains(&name) { bail!( "error: The branch '{name}' is not fully merged.\nIf you are sure you want to delete it, run 'git branch -D {name}'." ); } if !state.branches.remove(&name) { bail!("no such branch: {name}"); } state.branches_requiring_force_delete.remove(&name); Ok(()) }) } fn blame( &self, path: RepoPath, _content: Rope, _line_ending: LineEnding, ) -> BoxFuture<'_, Result> { self.with_state_async(false, move |state| { state .blames .get(&path) .with_context(|| format!("failed to get blame for {:?}", path)) .cloned() }) } fn stage_paths( &self, paths: Vec, _env: Arc>, ) -> BoxFuture<'_, Result<()>> { Box::pin(async move { let contents = paths .into_iter() .map(|path| { let abs_path = self .dot_git_path .parent() .unwrap() .join(&path.as_std_path()); Box::pin(async move { (path.clone(), self.fs.load(&abs_path).await.ok()) }) }) .collect::>(); let contents = join_all(contents).await; self.with_state_async(true, move |state| { for (path, content) in contents { if let Some(content) = content { state.index_contents.insert(path, content); } else { state.index_contents.remove(&path); } } Ok(()) }) .await }) } fn unstage_paths( &self, paths: Vec, _env: Arc>, ) -> BoxFuture<'_, Result<()>> { self.with_state_async(true, move |state| { for path in paths { match state.head_contents.get(&path) { Some(content) => state.index_contents.insert(path, content.clone()), None => state.index_contents.remove(&path), }; } Ok(()) }) } fn stash_paths( &self, _paths: Vec, _env: Arc>, ) -> BoxFuture<'_, Result<()>> { unimplemented!() } fn stash_pop( &self, _index: Option, _env: Arc>, ) -> BoxFuture<'_, Result<()>> { unimplemented!() } fn stash_apply( &self, _index: Option, _env: Arc>, ) -> BoxFuture<'_, Result<()>> { unimplemented!() } fn stash_drop( &self, _index: Option, _env: Arc>, ) -> BoxFuture<'_, Result<()>> { unimplemented!() } fn commit( &self, _message: gpui::SharedString, _name_and_email: Option<(gpui::SharedString, gpui::SharedString)>, options: CommitOptions, _askpass: AskPassDelegate, _env: Arc>, ) -> BoxFuture<'_, Result<()>> { self.with_state_async(true, move |state| { if !options.allow_empty && !options.amend && state.index_contents == state.head_contents { anyhow::bail!("nothing to commit (use allow_empty to create an empty commit)"); } let old_sha = state.refs.get("HEAD").cloned().unwrap_or_default(); state.commit_history.push(FakeCommitSnapshot { head_contents: state.head_contents.clone(), index_contents: state.index_contents.clone(), sha: old_sha, }); state.head_contents = state.index_contents.clone(); let new_sha = format!("fake-commit-{}", state.commit_history.len()); state.refs.insert("HEAD".into(), new_sha); Ok(()) }) } fn run_hook( &self, _hook: RunHook, _env: Arc>, ) -> BoxFuture<'_, Result<()>> { async { Ok(()) }.boxed() } fn push( &self, _branch: String, _remote_branch: String, _remote: String, _options: Option, _askpass: AskPassDelegate, _env: Arc>, _cx: AsyncApp, ) -> BoxFuture<'_, Result> { unimplemented!() } fn pull( &self, _branch: Option, _remote: String, _rebase: bool, _askpass: AskPassDelegate, _env: Arc>, _cx: AsyncApp, ) -> BoxFuture<'_, Result> { unimplemented!() } fn fetch( &self, _fetch_options: FetchOptions, _askpass: AskPassDelegate, _env: Arc>, _cx: AsyncApp, ) -> BoxFuture<'_, Result> { unimplemented!() } fn get_all_remotes(&self) -> BoxFuture<'_, Result>> { self.with_state_async(false, move |state| { let remotes = state .remotes .keys() .map(|r| Remote { name: r.clone().into(), }) .collect::>(); Ok(remotes) }) } fn get_push_remote(&self, _branch: String) -> BoxFuture<'_, Result>> { unimplemented!() } fn get_branch_remote(&self, _branch: String) -> BoxFuture<'_, Result>> { unimplemented!() } fn check_for_pushed_commit(&self) -> BoxFuture<'_, Result>> { future::ready(Ok(Vec::new())).boxed() } fn diff(&self, _diff: git::repository::DiffType) -> BoxFuture<'_, Result> { future::ready(Ok(String::new())).boxed() } fn diff_stat( &self, path_prefixes: &[RepoPath], ) -> BoxFuture<'_, Result> { fn count_lines(s: &str) -> u32 { if s.is_empty() { 0 } else { s.lines().count() as u32 } } fn matches_prefixes(path: &RepoPath, prefixes: &[RepoPath]) -> bool { if prefixes.is_empty() { return true; } prefixes.iter().any(|prefix| { let prefix_str = prefix.as_unix_str(); if prefix_str == "." { return true; } path == prefix || path.starts_with(&prefix) }) } let path_prefixes = path_prefixes.to_vec(); let workdir_path = self.dot_git_path.parent().unwrap().to_path_buf(); let worktree_files: HashMap = self .fs .files() .iter() .filter_map(|path| { let repo_path = path.strip_prefix(&workdir_path).ok()?; if repo_path.starts_with(".git") { return None; } let content = self .fs .read_file_sync(path) .ok() .and_then(|bytes| String::from_utf8(bytes).ok())?; let repo_path = RelPath::new(repo_path, PathStyle::local()).ok()?; Some((RepoPath::from_rel_path(&repo_path), content)) }) .collect(); self.with_state_async(false, move |state| { let mut entries = Vec::new(); let all_paths: HashSet<&RepoPath> = state .head_contents .keys() .chain( worktree_files .keys() .filter(|p| state.index_contents.contains_key(*p)), ) .collect(); for path in all_paths { if !matches_prefixes(path, &path_prefixes) { continue; } let head = state.head_contents.get(path); let worktree = worktree_files.get(path); match (head, worktree) { (Some(old), Some(new)) if old != new => { entries.push(( path.clone(), git::status::DiffStat { added: count_lines(new), deleted: count_lines(old), }, )); } (Some(old), None) => { entries.push(( path.clone(), git::status::DiffStat { added: 0, deleted: count_lines(old), }, )); } (None, Some(new)) => { entries.push(( path.clone(), git::status::DiffStat { added: count_lines(new), deleted: 0, }, )); } _ => {} } } entries.sort_by(|(a, _), (b, _)| a.cmp(b)); Ok(git::status::GitDiffStat { entries: entries.into(), }) }) .boxed() } fn checkpoint(&self) -> BoxFuture<'static, Result> { let executor = self.executor.clone(); let fs = self.fs.clone(); let checkpoints = self.checkpoints.clone(); let repository_dir_path = self.repository_dir_path.parent().unwrap().to_path_buf(); async move { executor.simulate_random_delay().await; let oid = git::Oid::random(&mut *executor.rng().lock()); let entry = fs.entry(&repository_dir_path)?; checkpoints.lock().insert(oid, entry); Ok(GitRepositoryCheckpoint { commit_sha: oid }) } .boxed() } fn restore_checkpoint(&self, checkpoint: GitRepositoryCheckpoint) -> BoxFuture<'_, Result<()>> { let executor = self.executor.clone(); let fs = self.fs.clone(); let checkpoints = self.checkpoints.clone(); let repository_dir_path = self.repository_dir_path.parent().unwrap().to_path_buf(); async move { executor.simulate_random_delay().await; let checkpoints = checkpoints.lock(); let entry = checkpoints .get(&checkpoint.commit_sha) .context(format!("invalid checkpoint: {}", checkpoint.commit_sha))?; fs.insert_entry(&repository_dir_path, entry.clone())?; Ok(()) } .boxed() } fn create_archive_checkpoint(&self) -> BoxFuture<'_, Result<(String, String)>> { let executor = self.executor.clone(); let fs = self.fs.clone(); let checkpoints = self.checkpoints.clone(); let repository_dir_path = self.repository_dir_path.parent().unwrap().to_path_buf(); async move { executor.simulate_random_delay().await; let staged_oid = git::Oid::random(&mut *executor.rng().lock()); let unstaged_oid = git::Oid::random(&mut *executor.rng().lock()); let entry = fs.entry(&repository_dir_path)?; checkpoints.lock().insert(staged_oid, entry.clone()); checkpoints.lock().insert(unstaged_oid, entry); Ok((staged_oid.to_string(), unstaged_oid.to_string())) } .boxed() } fn restore_archive_checkpoint( &self, // The fake filesystem doesn't model a separate index, so only the // unstaged (full working directory) snapshot is restored. _staged_sha: String, unstaged_sha: String, ) -> BoxFuture<'_, Result<()>> { match unstaged_sha.parse() { Ok(commit_sha) => self.restore_checkpoint(GitRepositoryCheckpoint { commit_sha }), Err(error) => async move { Err(anyhow::anyhow!(error).context("failed to parse unstaged SHA as Oid")) } .boxed(), } } fn compare_checkpoints( &self, left: GitRepositoryCheckpoint, right: GitRepositoryCheckpoint, ) -> BoxFuture<'_, Result> { let executor = self.executor.clone(); let checkpoints = self.checkpoints.clone(); async move { executor.simulate_random_delay().await; let checkpoints = checkpoints.lock(); let left = checkpoints .get(&left.commit_sha) .context(format!("invalid left checkpoint: {}", left.commit_sha))?; let right = checkpoints .get(&right.commit_sha) .context(format!("invalid right checkpoint: {}", right.commit_sha))?; Ok(left == right) } .boxed() } fn diff_checkpoints( &self, base_checkpoint: GitRepositoryCheckpoint, target_checkpoint: GitRepositoryCheckpoint, ) -> BoxFuture<'_, Result> { let executor = self.executor.clone(); let checkpoints = self.checkpoints.clone(); async move { executor.simulate_random_delay().await; let checkpoints = checkpoints.lock(); let base = checkpoints .get(&base_checkpoint.commit_sha) .context(format!( "invalid base checkpoint: {}", base_checkpoint.commit_sha ))?; let target = checkpoints .get(&target_checkpoint.commit_sha) .context(format!( "invalid target checkpoint: {}", target_checkpoint.commit_sha ))?; fn collect_files( entry: &FakeFsEntry, prefix: String, out: &mut std::collections::BTreeMap, ) { match entry { FakeFsEntry::File { content, .. } => { out.insert(prefix, String::from_utf8_lossy(content).into_owned()); } FakeFsEntry::Dir { entries, .. } => { for (name, child) in entries { let path = if prefix.is_empty() { name.clone() } else { format!("{prefix}/{name}") }; collect_files(child, path, out); } } FakeFsEntry::Symlink { .. } => {} } } let mut base_files = std::collections::BTreeMap::new(); let mut target_files = std::collections::BTreeMap::new(); collect_files(base, String::new(), &mut base_files); collect_files(target, String::new(), &mut target_files); let all_paths: std::collections::BTreeSet<&String> = base_files.keys().chain(target_files.keys()).collect(); let mut diff = String::new(); for path in all_paths { match (base_files.get(path), target_files.get(path)) { (Some(base_content), Some(target_content)) if base_content != target_content => { diff.push_str(&format!("diff --git a/{path} b/{path}\n")); diff.push_str(&format!("--- a/{path}\n")); diff.push_str(&format!("+++ b/{path}\n")); for line in base_content.lines() { diff.push_str(&format!("-{line}\n")); } for line in target_content.lines() { diff.push_str(&format!("+{line}\n")); } } (Some(_), None) => { diff.push_str(&format!("diff --git a/{path} /dev/null\n")); diff.push_str("deleted file\n"); } (None, Some(_)) => { diff.push_str(&format!("diff --git /dev/null b/{path}\n")); diff.push_str("new file\n"); } _ => {} } } Ok(diff) } .boxed() } fn default_branch( &self, include_remote_name: bool, ) -> BoxFuture<'_, Result>> { async move { Ok(Some(if include_remote_name { "origin/main".into() } else { "main".into() })) } .boxed() } fn create_remote(&self, name: String, url: String) -> BoxFuture<'_, Result<()>> { self.with_state_async(true, move |state| { state.remotes.insert(name, url); Ok(()) }) } fn remove_remote(&self, name: String) -> BoxFuture<'_, Result<()>> { self.with_state_async(true, move |state| { state.branches.retain(|branch| { branch .split_once('/') .is_none_or(|(remote, _)| remote != name) }); state.remotes.remove(&name); Ok(()) }) } fn initial_graph_data( &self, _log_source: LogSource, _log_order: LogOrder, request_tx: Sender>>, ) -> BoxFuture<'_, Result<()>> { let fs = self.fs.clone(); let dot_git_path = self.dot_git_path.clone(); async move { let (graph_commits, simulated_error) = fs.with_git_state(&dot_git_path, false, |state| { ( state.graph_commits.clone(), state.simulated_graph_error.clone(), ) })?; if let Some(error) = simulated_error { anyhow::bail!("{}", error); } for chunk in graph_commits.chunks(GRAPH_CHUNK_SIZE) { request_tx.send(chunk.to_vec()).await.ok(); } Ok(()) } .boxed() } fn search_commits( &self, _log_source: LogSource, search_args: SearchCommitArgs, request_tx: Sender, ) -> BoxFuture<'_, Result<()>> { async move { let query = if search_args.case_sensitive { search_args.query.to_string() } else { search_args.query.to_lowercase() }; let matching_shas = self.fs.with_git_state(&self.dot_git_path, false, |state| { state .commit_data .iter() .filter_map(|(sha, entry)| { let FakeCommitDataEntry::Success(commit_data) = entry else { return None; }; let message = if search_args.case_sensitive { commit_data.message.to_string() } else { commit_data.message.to_lowercase() }; message.contains(&query).then_some(*sha) }) .collect::>() })?; for sha in matching_shas { if request_tx.send(sha).await.is_err() { break; } } Ok(()) } .boxed() } fn commit_data_reader(&self) -> Result { let fs = self.fs.clone(); let dot_git_path = self.dot_git_path.clone(); let executor = self.executor.clone(); Ok(CommitDataReader::for_test(executor, move |sha| { fs.with_git_state(&dot_git_path, false, |state| { let commit = state .commit_data .get(&sha) .context(format!("graph commit data not found for {sha}"))?; match commit { FakeCommitDataEntry::Success(data) => Ok(data.clone()), FakeCommitDataEntry::Fail(_) => { bail!("simulated commit data read failure for {sha}") } } })? })) } fn update_ref(&self, ref_name: String, commit: String) -> BoxFuture<'_, Result<()>> { self.edit_ref(RefEdit::Update { ref_name, commit }) } fn delete_ref(&self, ref_name: String) -> BoxFuture<'_, Result<()>> { self.edit_ref(RefEdit::Delete { ref_name }) } fn repair_worktrees(&self) -> BoxFuture<'_, Result<()>> { async { Ok(()) }.boxed() } fn set_trusted(&self, trusted: bool) { self.is_trusted .store(trusted, std::sync::atomic::Ordering::Release); } fn is_trusted(&self) -> bool { self.is_trusted.load(std::sync::atomic::Ordering::Acquire) } }