Detailed changes
@@ -35,8 +35,16 @@ pub struct FakeGitRepository {
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 struct FakeGitRepositoryState {
+ pub commit_history: Vec<FakeCommitSnapshot>,
pub event_emitter: smol::channel::Sender<PathBuf>,
pub unmerged_paths: HashMap<RepoPath, UnmergedStatus>,
pub head_contents: HashMap<RepoPath, String>,
@@ -72,6 +80,7 @@ impl FakeGitRepositoryState {
oids: Default::default(),
remotes: HashMap::default(),
graph_commits: Vec::new(),
+ commit_history: Vec::new(),
}
}
}
@@ -214,11 +223,52 @@ impl GitRepository for FakeGitRepository {
fn reset(
&self,
- _commit: String,
- _mode: ResetMode,
+ commit: String,
+ mode: ResetMode,
_env: Arc<HashMap<String, String>>,
) -> BoxFuture<'_, Result<()>> {
- unimplemented!()
+ self.with_state_async(true, move |state| {
+ let pop_count = if 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(
@@ -483,7 +533,7 @@ impl GitRepository for FakeGitRepository {
fn create_worktree(
&self,
- branch_name: String,
+ branch_name: Option<String>,
path: PathBuf,
from_commit: Option<String>,
) -> BoxFuture<'_, Result<()>> {
@@ -498,8 +548,10 @@ impl GitRepository for FakeGitRepository {
if let Some(message) = &state.simulated_create_worktree_error {
anyhow::bail!("{message}");
}
- if state.branches.contains(&branch_name) {
- bail!("a branch named '{}' already exists", branch_name);
+ if let Some(ref name) = branch_name {
+ if state.branches.contains(name) {
+ bail!("a branch named '{}' already exists", name);
+ }
}
Ok(())
})??;
@@ -508,13 +560,22 @@ impl GitRepository for FakeGitRepository {
fs.create_dir(&path).await?;
// Create .git/worktrees/<name>/ directory with HEAD, commondir, gitdir.
- let ref_name = format!("refs/heads/{branch_name}");
- let worktrees_entry_dir = common_dir_path.join("worktrees").join(&branch_name);
+ let worktree_entry_name = branch_name
+ .as_deref()
+ .unwrap_or_else(|| path.file_name().unwrap().to_str().unwrap());
+ let worktrees_entry_dir = common_dir_path.join("worktrees").join(worktree_entry_name);
fs.create_dir(&worktrees_entry_dir).await?;
+ let sha = from_commit.unwrap_or_else(|| "fake-sha".to_string());
+ 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"),
- format!("ref: {ref_name}").into_bytes(),
+ head_content.into_bytes(),
false,
)?;
fs.write_file_internal(
@@ -537,10 +598,14 @@ impl GitRepository for FakeGitRepository {
)?;
// Update git state: add ref and branch.
- let sha = from_commit.unwrap_or_else(|| "fake-sha".to_string());
fs.with_git_state(&dot_git_path, true, move |state| {
- state.refs.insert(ref_name, sha);
- state.branches.insert(branch_name);
+ 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);
+ } else {
+ state.refs.insert("HEAD".into(), sha);
+ }
Ok::<(), anyhow::Error>(())
})??;
Ok(())
@@ -815,11 +880,30 @@ impl GitRepository for FakeGitRepository {
&self,
_message: gpui::SharedString,
_name_and_email: Option<(gpui::SharedString, gpui::SharedString)>,
- _options: CommitOptions,
+ options: CommitOptions,
_askpass: AskPassDelegate,
_env: Arc<HashMap<String, String>>,
) -> BoxFuture<'_, Result<()>> {
- async { Ok(()) }.boxed()
+ 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(
@@ -1203,6 +1287,55 @@ impl GitRepository for FakeGitRepository {
anyhow::bail!("commit_data_reader not supported for FakeGitRepository")
}
+ fn update_ref(&self, ref_name: String, commit: String) -> BoxFuture<'_, Result<()>> {
+ self.with_state_async(true, move |state| {
+ state.refs.insert(ref_name, commit);
+ Ok(())
+ })
+ }
+
+ fn delete_ref(&self, ref_name: String) -> BoxFuture<'_, Result<()>> {
+ self.with_state_async(true, move |state| {
+ state.refs.remove(&ref_name);
+ Ok(())
+ })
+ }
+
+ fn stage_all_including_untracked(&self) -> BoxFuture<'_, Result<()>> {
+ let workdir_path = self.dot_git_path.parent().unwrap();
+ let git_files: Vec<(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 rel_path = RelPath::new(repo_path, PathStyle::local()).ok()?;
+ Some((RepoPath::from_rel_path(&rel_path), content))
+ })
+ .collect();
+
+ self.with_state_async(true, move |state| {
+ // Stage all filesystem contents, mirroring `git add -A`.
+ let fs_paths: HashSet<RepoPath> = git_files.iter().map(|(p, _)| p.clone()).collect();
+ for (path, content) in git_files {
+ state.index_contents.insert(path, content);
+ }
+ // Remove index entries for files that no longer exist on disk.
+ state
+ .index_contents
+ .retain(|path, _| fs_paths.contains(path));
+ Ok(())
+ })
+ }
+
fn set_trusted(&self, trusted: bool) {
self.is_trusted
.store(trusted, std::sync::atomic::Ordering::Release);
@@ -24,7 +24,7 @@ async fn test_fake_worktree_lifecycle(cx: &mut TestAppContext) {
// Create a worktree
let worktree_1_dir = worktrees_dir.join("feature-branch");
repo.create_worktree(
- "feature-branch".to_string(),
+ Some("feature-branch".to_string()),
worktree_1_dir.clone(),
Some("abc123".to_string()),
)
@@ -47,9 +47,13 @@ async fn test_fake_worktree_lifecycle(cx: &mut TestAppContext) {
// Create a second worktree (without explicit commit)
let worktree_2_dir = worktrees_dir.join("bugfix-branch");
- repo.create_worktree("bugfix-branch".to_string(), worktree_2_dir.clone(), None)
- .await
- .unwrap();
+ repo.create_worktree(
+ Some("bugfix-branch".to_string()),
+ worktree_2_dir.clone(),
+ None,
+ )
+ .await
+ .unwrap();
let worktrees = repo.worktrees().await.unwrap();
assert_eq!(worktrees.len(), 3);
@@ -329,6 +329,7 @@ impl Upstream {
pub struct CommitOptions {
pub amend: bool,
pub signoff: bool,
+ pub allow_empty: bool,
}
#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
@@ -715,7 +716,7 @@ pub trait GitRepository: Send + Sync {
fn create_worktree(
&self,
- branch_name: String,
+ branch_name: Option<String>,
path: PathBuf,
from_commit: Option<String>,
) -> BoxFuture<'_, Result<()>>;
@@ -916,6 +917,12 @@ pub trait GitRepository: Send + Sync {
fn commit_data_reader(&self) -> Result<CommitDataReader>;
+ fn update_ref(&self, ref_name: String, commit: String) -> BoxFuture<'_, Result<()>>;
+
+ fn delete_ref(&self, ref_name: String) -> BoxFuture<'_, Result<()>>;
+
+ fn stage_all_including_untracked(&self) -> BoxFuture<'_, Result<()>>;
+
fn set_trusted(&self, trusted: bool);
fn is_trusted(&self) -> bool;
}
@@ -1660,19 +1667,20 @@ impl GitRepository for RealGitRepository {
fn create_worktree(
&self,
- branch_name: String,
+ branch_name: Option<String>,
path: PathBuf,
from_commit: Option<String>,
) -> BoxFuture<'_, Result<()>> {
let git_binary = self.git_binary();
- let mut args = vec![
- OsString::from("worktree"),
- OsString::from("add"),
- OsString::from("-b"),
- OsString::from(branch_name.as_str()),
- OsString::from("--"),
- OsString::from(path.as_os_str()),
- ];
+ let mut args = vec![OsString::from("worktree"), OsString::from("add")];
+ if let Some(branch_name) = &branch_name {
+ args.push(OsString::from("-b"));
+ args.push(OsString::from(branch_name.as_str()));
+ } else {
+ args.push(OsString::from("--detach"));
+ }
+ args.push(OsString::from("--"));
+ args.push(OsString::from(path.as_os_str()));
if let Some(from_commit) = from_commit {
args.push(OsString::from(from_commit));
} else {
@@ -2165,6 +2173,10 @@ impl GitRepository for RealGitRepository {
cmd.arg("--signoff");
}
+ if options.allow_empty {
+ cmd.arg("--allow-empty");
+ }
+
if let Some((name, email)) = name_and_email {
cmd.arg("--author").arg(&format!("{name} <{email}>"));
}
@@ -2176,6 +2188,50 @@ impl GitRepository for RealGitRepository {
.boxed()
}
+ fn update_ref(&self, ref_name: String, commit: String) -> BoxFuture<'_, Result<()>> {
+ let git_binary = self.git_binary();
+ self.executor
+ .spawn(async move {
+ let args: Vec<OsString> = vec![
+ "--no-optional-locks".into(),
+ "update-ref".into(),
+ ref_name.into(),
+ commit.into(),
+ ];
+ git_binary?.run(&args).await?;
+ Ok(())
+ })
+ .boxed()
+ }
+
+ fn delete_ref(&self, ref_name: String) -> BoxFuture<'_, Result<()>> {
+ let git_binary = self.git_binary();
+ self.executor
+ .spawn(async move {
+ let args: Vec<OsString> = vec![
+ "--no-optional-locks".into(),
+ "update-ref".into(),
+ "-d".into(),
+ ref_name.into(),
+ ];
+ git_binary?.run(&args).await?;
+ Ok(())
+ })
+ .boxed()
+ }
+
+ fn stage_all_including_untracked(&self) -> BoxFuture<'_, Result<()>> {
+ let git_binary = self.git_binary();
+ self.executor
+ .spawn(async move {
+ let args: Vec<OsString> =
+ vec!["--no-optional-locks".into(), "add".into(), "-A".into()];
+ git_binary?.run(&args).await?;
+ Ok(())
+ })
+ .boxed()
+ }
+
fn push(
&self,
branch_name: String,
@@ -4009,7 +4065,7 @@ mod tests {
// Create a new worktree
repo.create_worktree(
- "test-branch".to_string(),
+ Some("test-branch".to_string()),
worktree_path.clone(),
Some("HEAD".to_string()),
)
@@ -4068,7 +4124,7 @@ mod tests {
// Create a worktree
let worktree_path = worktrees_dir.join("worktree-to-remove");
repo.create_worktree(
- "to-remove".to_string(),
+ Some("to-remove".to_string()),
worktree_path.clone(),
Some("HEAD".to_string()),
)
@@ -4092,7 +4148,7 @@ mod tests {
// Create a worktree
let worktree_path = worktrees_dir.join("dirty-wt");
repo.create_worktree(
- "dirty-wt".to_string(),
+ Some("dirty-wt".to_string()),
worktree_path.clone(),
Some("HEAD".to_string()),
)
@@ -4162,7 +4218,7 @@ mod tests {
// Create a worktree
let old_path = worktrees_dir.join("old-worktree-name");
repo.create_worktree(
- "old-name".to_string(),
+ Some("old-name".to_string()),
old_path.clone(),
Some("HEAD".to_string()),
)
@@ -453,6 +453,7 @@ impl CommitModal {
CommitOptions {
amend: is_amend_pending,
signoff: is_signoff_enabled,
+ allow_empty: false,
},
window,
cx,
@@ -2155,6 +2155,7 @@ impl GitPanel {
CommitOptions {
amend: false,
signoff: self.signoff_enabled,
+ allow_empty: false,
},
window,
cx,
@@ -2195,6 +2196,7 @@ impl GitPanel {
CommitOptions {
amend: true,
signoff: self.signoff_enabled,
+ allow_empty: false,
},
window,
cx,
@@ -4454,7 +4456,11 @@ impl GitPanel {
git_panel
.update(cx, |git_panel, cx| {
git_panel.commit_changes(
- CommitOptions { amend, signoff },
+ CommitOptions {
+ amend,
+ signoff,
+ allow_empty: false,
+ },
window,
cx,
);
@@ -2338,6 +2338,7 @@ impl GitStore {
CommitOptions {
amend: options.amend,
signoff: options.signoff,
+ allow_empty: options.allow_empty,
},
askpass,
cx,
@@ -5484,6 +5485,7 @@ impl Repository {
options: Some(proto::commit::CommitOptions {
amend: options.amend,
signoff: options.signoff,
+ allow_empty: options.allow_empty,
}),
askpass_id,
})
@@ -5977,7 +5979,9 @@ impl Repository {
move |repo, _cx| async move {
match repo {
RepositoryState::Local(LocalRepositoryState { backend, .. }) => {
- backend.create_worktree(branch_name, path, commit).await
+ backend
+ .create_worktree(Some(branch_name), path, commit)
+ .await
}
RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => {
client
@@ -5997,6 +6001,86 @@ impl Repository {
)
}
+ pub fn create_worktree_detached(
+ &mut self,
+ path: PathBuf,
+ commit: String,
+ ) -> oneshot::Receiver<Result<()>> {
+ self.send_job(
+ Some("git worktree add (detached)".into()),
+ move |repo, _cx| async move {
+ match repo {
+ RepositoryState::Local(LocalRepositoryState { backend, .. }) => {
+ backend.create_worktree(None, path, Some(commit)).await
+ }
+ RepositoryState::Remote(_) => {
+ anyhow::bail!(
+ "create_worktree_detached is not supported for remote repositories"
+ )
+ }
+ }
+ },
+ )
+ }
+
+ pub fn head_sha(&mut self) -> oneshot::Receiver<Result<Option<String>>> {
+ self.send_job(None, move |repo, _cx| async move {
+ match repo {
+ RepositoryState::Local(LocalRepositoryState { backend, .. }) => {
+ Ok(backend.head_sha().await)
+ }
+ RepositoryState::Remote(_) => {
+ anyhow::bail!("head_sha is not supported for remote repositories")
+ }
+ }
+ })
+ }
+
+ pub fn update_ref(
+ &mut self,
+ ref_name: String,
+ commit: String,
+ ) -> oneshot::Receiver<Result<()>> {
+ self.send_job(None, move |repo, _cx| async move {
+ match repo {
+ RepositoryState::Local(LocalRepositoryState { backend, .. }) => {
+ backend.update_ref(ref_name, commit).await
+ }
+ RepositoryState::Remote(_) => {
+ anyhow::bail!("update_ref is not supported for remote repositories")
+ }
+ }
+ })
+ }
+
+ pub fn delete_ref(&mut self, ref_name: String) -> oneshot::Receiver<Result<()>> {
+ self.send_job(None, move |repo, _cx| async move {
+ match repo {
+ RepositoryState::Local(LocalRepositoryState { backend, .. }) => {
+ backend.delete_ref(ref_name).await
+ }
+ RepositoryState::Remote(_) => {
+ anyhow::bail!("delete_ref is not supported for remote repositories")
+ }
+ }
+ })
+ }
+
+ pub fn stage_all_including_untracked(&mut self) -> oneshot::Receiver<Result<()>> {
+ self.send_job(None, move |repo, _cx| async move {
+ match repo {
+ RepositoryState::Local(LocalRepositoryState { backend, .. }) => {
+ backend.stage_all_including_untracked().await
+ }
+ RepositoryState::Remote(_) => {
+ anyhow::bail!(
+ "stage_all_including_untracked is not supported for remote repositories"
+ )
+ }
+ }
+ })
+ }
+
pub fn remove_worktree(&mut self, path: PathBuf, force: bool) -> oneshot::Receiver<Result<()>> {
let id = self.id;
self.send_job(
@@ -403,6 +403,7 @@ message Commit {
message CommitOptions {
bool amend = 1;
bool signoff = 2;
+ bool allow_empty = 3;
}
}