Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions crates/collab/src/rpc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -491,6 +491,12 @@ impl Server {
.add_request_handler(forward_mutating_project_request::<proto::GetBlobContent>)
.add_request_handler(forward_mutating_project_request::<proto::GitCreateBranch>)
.add_request_handler(forward_mutating_project_request::<proto::GitChangeBranch>)
.add_request_handler(forward_mutating_project_request::<proto::GitMerge>)
.add_request_handler(forward_mutating_project_request::<proto::GitCherryPick>)
.add_request_handler(forward_mutating_project_request::<proto::GitRevert>)
.add_request_handler(forward_mutating_project_request::<proto::GitCheckoutCommit>)
.add_request_handler(forward_mutating_project_request::<proto::GitCreateTag>)
.add_request_handler(forward_mutating_project_request::<proto::GitDeleteTag>)
.add_request_handler(forward_mutating_project_request::<proto::GitCreateRemote>)
.add_request_handler(forward_mutating_project_request::<proto::GitRemoveRemote>)
.add_request_handler(forward_read_only_project_request::<proto::GitGetWorktrees>)
Expand Down
32 changes: 32 additions & 0 deletions crates/fs/src/fake_git_repo.rs
Original file line number Diff line number Diff line change
Expand Up @@ -930,6 +930,38 @@ impl GitRepository for FakeGitRepository {
})
}

fn merge(&self, _ref_name: String, _squash: bool) -> BoxFuture<'_, Result<()>> {
async { Ok(()) }.boxed()
}

fn cherry_pick(&self, _commit: String) -> BoxFuture<'_, Result<()>> {
async { Ok(()) }.boxed()
}

fn revert(&self, _commit: String) -> BoxFuture<'_, Result<()>> {
async { Ok(()) }.boxed()
}

fn checkout_commit(&self, _commit: String) -> BoxFuture<'_, Result<()>> {
self.with_state_async(true, |state| {
state.current_branch_name = None;
Ok(())
})
}

fn create_tag(
&self,
_name: String,
_target: String,
_message: Option<String>,
) -> BoxFuture<'_, Result<()>> {
async { Ok(()) }.boxed()
}

fn delete_tag(&self, _name: String) -> BoxFuture<'_, Result<()>> {
async { Ok(()) }.boxed()
}

fn delete_branch(
&self,
_is_remote: bool,
Expand Down
239 changes: 239 additions & 0 deletions crates/git/src/repository.rs
Original file line number Diff line number Diff line change
Expand Up @@ -867,6 +867,31 @@ pub trait GitRepository: Send + Sync {
force: bool,
) -> BoxFuture<'_, Result<()>>;

/// Merges the given commit-ish into the current branch. A squash merge
/// stages the changes without committing them.
fn merge(&self, ref_name: String, squash: bool) -> BoxFuture<'_, Result<()>>;

/// Cherry-picks the given commit onto the current branch.
fn cherry_pick(&self, commit: String) -> BoxFuture<'_, Result<()>>;

/// Creates a new commit reverting the given commit.
fn revert(&self, commit: String) -> BoxFuture<'_, Result<()>>;

/// Checks out the given commit-ish with a detached HEAD.
fn checkout_commit(&self, commit: String) -> BoxFuture<'_, Result<()>>;

/// Creates a tag pointing at the given commit-ish: annotated when a
/// message is provided, lightweight otherwise.
fn create_tag(
&self,
name: String,
target: String,
message: Option<String>,
) -> BoxFuture<'_, Result<()>>;

/// Deletes the given local tag.
fn delete_tag(&self, name: String) -> BoxFuture<'_, Result<()>>;

fn worktrees(&self) -> BoxFuture<'_, Result<Vec<Worktree>>>;

/// Returns the creation time of a linked worktree's git metadata
Expand Down Expand Up @@ -2201,6 +2226,93 @@ impl GitRepository for RealGitRepository {
.boxed()
}

fn merge(&self, ref_name: String, squash: bool) -> BoxFuture<'_, Result<()>> {
let git_binary = self.git_binary_in_worktree();

self.executor
.spawn(async move {
let git_binary = git_binary?;
let flag = if squash { "--squash" } else { "--no-edit" };
git_binary.run(&["merge", flag, &ref_name]).await?;
anyhow::Ok(())
})
.boxed()
}

fn cherry_pick(&self, commit: String) -> BoxFuture<'_, Result<()>> {
let git_binary = self.git_binary_in_worktree();

self.executor
.spawn(async move {
let git_binary = git_binary?;
git_binary.run(&["cherry-pick", &commit]).await?;
anyhow::Ok(())
})
.boxed()
}

fn revert(&self, commit: String) -> BoxFuture<'_, Result<()>> {
let git_binary = self.git_binary_in_worktree();

self.executor
.spawn(async move {
let git_binary = git_binary?;
git_binary.run(&["revert", "--no-edit", &commit]).await?;
anyhow::Ok(())
})
.boxed()
}

fn checkout_commit(&self, commit: String) -> BoxFuture<'_, Result<()>> {
let git_binary = self.git_binary_in_worktree();

self.executor
.spawn(async move {
let git_binary = git_binary?;
git_binary.run(&["checkout", "--detach", &commit]).await?;
anyhow::Ok(())
})
.boxed()
}

fn create_tag(
&self,
name: String,
target: String,
message: Option<String>,
) -> BoxFuture<'_, Result<()>> {
let git_binary = self.git_binary_in_worktree();

self.executor
.spawn(async move {
let git_binary = git_binary?;
match &message {
Some(message) => {
git_binary
.run(&["tag", "-a", &name, "-m", message, &target])
.await?;
}
None => {
git_binary.run(&["tag", &name, &target]).await?;
}
}
anyhow::Ok(())
})
.boxed()
}

fn delete_tag(&self, name: String) -> BoxFuture<'_, Result<()>> {
let git_binary = self.git_binary_in_worktree();

self.executor
.spawn(async move {
let git_binary = git_binary?;
git_binary.run(&["tag", "-d", &name]).await?;
anyhow::Ok(())
})
.boxed()
}

fn delete_branch(
&self,
is_remote: bool,
Expand Down Expand Up @@ -4334,6 +4446,133 @@ mod tests {
);
}

#[gpui::test]
async fn test_merge_cherry_pick_revert_and_checkout_commit(cx: &mut TestAppContext) {
disable_git_global_config();
cx.executor().allow_parking();

let repo_dir = tempfile::tempdir().unwrap();
git_init_repo(repo_dir.path());
git_command(repo_dir.path(), ["config", "user.name", "test"]);
git_command(repo_dir.path(), ["config", "user.email", "test@zed.dev"]);
fs::write(repo_dir.path().join("file.txt"), "one\n").unwrap();
git_command(repo_dir.path(), ["add", "file.txt"]);
git_command(repo_dir.path(), ["commit", "-m", "one"]);

git_command(repo_dir.path(), ["switch", "-c", "feature"]);
fs::write(repo_dir.path().join("feature.txt"), "feature\n").unwrap();
git_command(repo_dir.path(), ["add", "feature.txt"]);
git_command(repo_dir.path(), ["commit", "-m", "feature"]);
git_command(repo_dir.path(), ["switch", "main"]);

let repository = RealGitRepository::new(
&repo_dir.path().join(".git"),
None,
Some("git".into()),
cx.executor(),
)
.unwrap();
let git = repository.git_binary_in_worktree().unwrap();

repository
.merge("feature".to_string(), false)
.await
.unwrap();
assert!(
repo_dir.path().join("feature.txt").exists(),
"merging the feature branch should bring its files into main"
);

git_command(repo_dir.path(), ["switch", "-c", "squashed"]);
fs::write(repo_dir.path().join("squashed.txt"), "squashed\n").unwrap();
git_command(repo_dir.path(), ["add", "squashed.txt"]);
git_command(repo_dir.path(), ["commit", "-m", "squashed"]);
git_command(repo_dir.path(), ["switch", "main"]);

repository
.merge("squashed".to_string(), true)
.await
.unwrap();
assert!(
repo_dir.path().join("squashed.txt").exists(),
"squash merging should bring the branch's files into the working tree"
);
let staged = git.run(&["diff", "--cached", "--name-only"]).await.unwrap();
assert!(
staged.contains("squashed.txt"),
"squash merging should stage the changes without committing them"
);
git_command(repo_dir.path(), ["commit", "-m", "squash feature"]);

repository
.create_tag("v1.0".to_string(), "HEAD".to_string(), None)
.await
.unwrap();
assert_eq!(git.run(&["tag", "-l", "v1.0"]).await.unwrap(), "v1.0");
assert_eq!(
git.run(&["cat-file", "-t", "v1.0"]).await.unwrap(),
"commit",
"a tag without a message should be lightweight"
);

repository
.create_tag(
"v2.0".to_string(),
"HEAD".to_string(),
Some("release v2.0".to_string()),
)
.await
.unwrap();
assert_eq!(
git.run(&["cat-file", "-t", "v2.0"]).await.unwrap(),
"tag",
"a tag with a message should be annotated"
);
assert_eq!(
git.run(&["tag", "-l", "--format=%(contents:subject)", "v2.0"])
.await
.unwrap(),
"release v2.0"
);

repository.delete_tag("v1.0".to_string()).await.unwrap();
repository.delete_tag("v2.0".to_string()).await.unwrap();
assert_eq!(git.run(&["tag", "-l", "v1.0"]).await.unwrap(), "");
assert_eq!(git.run(&["tag", "-l", "v2.0"]).await.unwrap(), "");

git_command(repo_dir.path(), ["switch", "-c", "side"]);
fs::write(repo_dir.path().join("side.txt"), "side\n").unwrap();
git_command(repo_dir.path(), ["add", "side.txt"]);
git_command(repo_dir.path(), ["commit", "-m", "side"]);
let side_sha = git.run(&["rev-parse", "HEAD"]).await.unwrap();
git_command(repo_dir.path(), ["switch", "main"]);

repository.cherry_pick(side_sha).await.unwrap();
assert!(
repo_dir.path().join("side.txt").exists(),
"cherry-picking should apply the side commit onto main"
);

let picked_sha = git.run(&["rev-parse", "HEAD"]).await.unwrap();
repository.revert(picked_sha).await.unwrap();
assert!(
!repo_dir.path().join("side.txt").exists(),
"reverting the cherry-picked commit should remove its files"
);

let first_sha = git
.run(&["rev-list", "--max-parents=0", "HEAD"])
.await
.unwrap();
repository.checkout_commit(first_sha.clone()).await.unwrap();
assert_eq!(
git.run(&["branch", "--show-current"]).await.unwrap(),
"",
"HEAD should be detached after checking out a commit"
);
assert_eq!(git.run(&["rev-parse", "HEAD"]).await.unwrap(), first_sha);
}

#[test]
fn test_initial_graph_commit_data_tag_names() {
let commit = InitialGraphCommitData {
Expand Down
4 changes: 2 additions & 2 deletions crates/git_ui/src/branch_picker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -659,7 +659,7 @@ enum PickerState {
NewBranch,
}

fn delete_branch_command(is_remote: bool, branch_name: &str, force: bool) -> String {
pub(crate) fn delete_branch_command(is_remote: bool, branch_name: &str, force: bool) -> String {
format!(
"branch {} {branch_name}",
delete_branch_flag(is_remote, force)
Expand Down Expand Up @@ -691,7 +691,7 @@ fn unmerged_branch_force_delete_prompt(branch_name: &str) -> String {

// Git only reports these cases via localized stderr, so this best-effort check
// may miss some locales and fall back to the raw error toast.
fn force_delete_prompt_for_branch_delete_error(
pub(crate) fn force_delete_prompt_for_branch_delete_error(
error: &anyhow::Error,
branch_name: &str,
) -> Option<String> {
Expand Down
Loading