Includes prior-session patches (carry forward so the app compiles): - crates/gpui/build.rs: cross-compile manifest fix - crates/gpui/src/platform.rs: PlatformWindow::activate_with_token trait method - crates/gpui/src/window.rs: Window::activate_with_token public API - crates/gpui_linux/src/linux/wayland/window.rs: WaylandWindow::activate_with_token + activate() keyboard-serial fix Plus the focus-serial fix: - serial.rs: SerialKind::KeyboardEnter - client.rs: store wl_keyboard.enter serial; latest_serial_of() Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1549 lines
55 KiB
Rust
1549 lines
55 KiB
Rust
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<FakeFs>,
|
|
pub(crate) checkpoints: Arc<Mutex<HashMap<Oid, FakeFsEntry>>>,
|
|
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<AtomicBool>,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct FakeCommitSnapshot {
|
|
pub head_contents: HashMap<RepoPath, String>,
|
|
pub index_contents: HashMap<RepoPath, String>,
|
|
pub sha: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub enum FakeCommitDataEntry {
|
|
Success(CommitData),
|
|
Fail(CommitData),
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct FakeGitRepositoryState {
|
|
pub commit_history: Vec<FakeCommitSnapshot>,
|
|
pub event_emitter: async_channel::Sender<PathBuf>,
|
|
pub unmerged_paths: HashMap<RepoPath, UnmergedStatus>,
|
|
pub head_contents: HashMap<RepoPath, String>,
|
|
pub index_contents: HashMap<RepoPath, String>,
|
|
// everything in commit contents is in oids
|
|
pub merge_base_contents: HashMap<RepoPath, Oid>,
|
|
pub oids: HashMap<Oid, String>,
|
|
pub blames: HashMap<RepoPath, Blame>,
|
|
pub current_branch_name: Option<String>,
|
|
pub branches: HashSet<String>,
|
|
/// List of remotes, keys are names and values are URLs
|
|
pub remotes: HashMap<String, String>,
|
|
pub simulated_index_write_error_message: Option<String>,
|
|
pub simulated_create_worktree_error: Option<String>,
|
|
pub simulated_graph_error: Option<String>,
|
|
pub branches_requiring_force_delete: HashSet<String>,
|
|
pub worktrees_requiring_force_delete: HashSet<PathBuf>,
|
|
pub refs: HashMap<String, String>,
|
|
pub graph_commits: Vec<Arc<InitialGraphCommitData>>,
|
|
pub commit_data: HashMap<Oid, FakeCommitDataEntry>,
|
|
pub stash_entries: GitStash,
|
|
}
|
|
|
|
impl FakeGitRepositoryState {
|
|
pub fn new(event_emitter: async_channel::Sender<PathBuf>) -> 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<F, T>(&self, write: bool, f: F) -> BoxFuture<'static, Result<T>>
|
|
where
|
|
F: 'static + Send + FnOnce(&mut FakeGitRepositoryState) -> Result<T>,
|
|
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<PathBuf> {
|
|
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<String>> {
|
|
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<String>> {
|
|
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<Option<GitCommitTemplate>>> {
|
|
async { Ok(None) }.boxed()
|
|
}
|
|
|
|
fn load_blob_content(&self, oid: git::Oid) -> BoxFuture<'_, Result<String>> {
|
|
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<git::repository::CommitDiff>> {
|
|
async { Ok(git::repository::CommitDiff { files: Vec::new() }) }.boxed()
|
|
}
|
|
|
|
fn set_index_text(
|
|
&self,
|
|
path: RepoPath,
|
|
content: Option<String>,
|
|
_env: Arc<HashMap<String, String>>,
|
|
_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<String>> {
|
|
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<TreeDiff>> {
|
|
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<String>) -> BoxFuture<'_, Result<Vec<Option<String>>>> {
|
|
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<CommitDetails>> {
|
|
async {
|
|
Ok(CommitDetails {
|
|
sha: commit.into(),
|
|
message: "initial commit".into(),
|
|
..Default::default()
|
|
})
|
|
}
|
|
.boxed()
|
|
}
|
|
|
|
fn reset(
|
|
&self,
|
|
commit: String,
|
|
mode: ResetMode,
|
|
_env: Arc<HashMap<String, String>>,
|
|
) -> 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::<usize>()
|
|
.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<RepoPath>,
|
|
_env: Arc<HashMap<String, String>>,
|
|
) -> 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<String>> {
|
|
async move { None }.boxed()
|
|
}
|
|
|
|
fn status(&self, path_prefixes: &[RepoPath]) -> Task<Result<GitStatus>> {
|
|
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::<Vec<_>>();
|
|
|
|
// Load working copy files.
|
|
let git_files: HashMap<RepoPath, (String, bool)> = 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::<HashSet<_>>();
|
|
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<git::stash::GitStash>> {
|
|
self.with_state_async(false, |state| Ok(state.stash_entries.clone()))
|
|
}
|
|
|
|
fn branches(&self) -> BoxFuture<'_, Result<Vec<Branch>>> {
|
|
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::<Vec<_>>();
|
|
// 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<Vec<Worktree>>> {
|
|
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/<name>/ 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/<name>/ 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/<name>/ 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<String>,
|
|
) -> 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<git::blame::Blame>> {
|
|
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<RepoPath>,
|
|
_env: Arc<HashMap<String, String>>,
|
|
) -> 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::<Vec<_>>();
|
|
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<RepoPath>,
|
|
_env: Arc<HashMap<String, String>>,
|
|
) -> 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<RepoPath>,
|
|
_env: Arc<HashMap<String, String>>,
|
|
) -> BoxFuture<'_, Result<()>> {
|
|
unimplemented!()
|
|
}
|
|
|
|
fn stash_pop(
|
|
&self,
|
|
_index: Option<usize>,
|
|
_env: Arc<HashMap<String, String>>,
|
|
) -> BoxFuture<'_, Result<()>> {
|
|
unimplemented!()
|
|
}
|
|
|
|
fn stash_apply(
|
|
&self,
|
|
_index: Option<usize>,
|
|
_env: Arc<HashMap<String, String>>,
|
|
) -> BoxFuture<'_, Result<()>> {
|
|
unimplemented!()
|
|
}
|
|
|
|
fn stash_drop(
|
|
&self,
|
|
_index: Option<usize>,
|
|
_env: Arc<HashMap<String, String>>,
|
|
) -> BoxFuture<'_, Result<()>> {
|
|
unimplemented!()
|
|
}
|
|
|
|
fn commit(
|
|
&self,
|
|
_message: gpui::SharedString,
|
|
_name_and_email: Option<(gpui::SharedString, gpui::SharedString)>,
|
|
options: CommitOptions,
|
|
_askpass: AskPassDelegate,
|
|
_env: Arc<HashMap<String, String>>,
|
|
) -> 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<HashMap<String, String>>,
|
|
) -> BoxFuture<'_, Result<()>> {
|
|
async { Ok(()) }.boxed()
|
|
}
|
|
|
|
fn push(
|
|
&self,
|
|
_branch: String,
|
|
_remote_branch: String,
|
|
_remote: String,
|
|
_options: Option<PushOptions>,
|
|
_askpass: AskPassDelegate,
|
|
_env: Arc<HashMap<String, String>>,
|
|
_cx: AsyncApp,
|
|
) -> BoxFuture<'_, Result<git::repository::RemoteCommandOutput>> {
|
|
unimplemented!()
|
|
}
|
|
|
|
fn pull(
|
|
&self,
|
|
_branch: Option<String>,
|
|
_remote: String,
|
|
_rebase: bool,
|
|
_askpass: AskPassDelegate,
|
|
_env: Arc<HashMap<String, String>>,
|
|
_cx: AsyncApp,
|
|
) -> BoxFuture<'_, Result<git::repository::RemoteCommandOutput>> {
|
|
unimplemented!()
|
|
}
|
|
|
|
fn fetch(
|
|
&self,
|
|
_fetch_options: FetchOptions,
|
|
_askpass: AskPassDelegate,
|
|
_env: Arc<HashMap<String, String>>,
|
|
_cx: AsyncApp,
|
|
) -> BoxFuture<'_, Result<git::repository::RemoteCommandOutput>> {
|
|
unimplemented!()
|
|
}
|
|
|
|
fn get_all_remotes(&self) -> BoxFuture<'_, Result<Vec<Remote>>> {
|
|
self.with_state_async(false, move |state| {
|
|
let remotes = state
|
|
.remotes
|
|
.keys()
|
|
.map(|r| Remote {
|
|
name: r.clone().into(),
|
|
})
|
|
.collect::<Vec<_>>();
|
|
Ok(remotes)
|
|
})
|
|
}
|
|
|
|
fn get_push_remote(&self, _branch: String) -> BoxFuture<'_, Result<Option<Remote>>> {
|
|
unimplemented!()
|
|
}
|
|
|
|
fn get_branch_remote(&self, _branch: String) -> BoxFuture<'_, Result<Option<Remote>>> {
|
|
unimplemented!()
|
|
}
|
|
|
|
fn check_for_pushed_commit(&self) -> BoxFuture<'_, Result<Vec<gpui::SharedString>>> {
|
|
future::ready(Ok(Vec::new())).boxed()
|
|
}
|
|
|
|
fn diff(&self, _diff: git::repository::DiffType) -> BoxFuture<'_, Result<String>> {
|
|
future::ready(Ok(String::new())).boxed()
|
|
}
|
|
|
|
fn diff_stat(
|
|
&self,
|
|
path_prefixes: &[RepoPath],
|
|
) -> BoxFuture<'_, Result<git::status::GitDiffStat>> {
|
|
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<RepoPath, String> = 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<GitRepositoryCheckpoint>> {
|
|
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<bool>> {
|
|
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<String>> {
|
|
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<String, String>,
|
|
) {
|
|
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<Option<SharedString>>> {
|
|
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<Vec<Arc<InitialGraphCommitData>>>,
|
|
) -> 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<Oid>,
|
|
) -> 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::<Vec<_>>()
|
|
})?;
|
|
|
|
for sha in matching_shas {
|
|
if request_tx.send(sha).await.is_err() {
|
|
break;
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
.boxed()
|
|
}
|
|
|
|
fn commit_data_reader(&self) -> Result<CommitDataReader> {
|
|
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)
|
|
}
|
|
}
|