From 07252c33095d06f7599da67c955a7bc5230c5e9f Mon Sep 17 00:00:00 2001 From: Alvaro Parker <64918109+AlvaroParker@users.noreply.github.com> Date: Fri, 25 Jul 2025 19:15:54 -0400 Subject: [PATCH] git: Enable git stash in git panel (#32821) Related discussion #31484 Release Notes: - Added a menu entry on the git panel to git stash and git pop stash. Preview: ![Screenshot-2025-06-17_08:26:36](https://github.com/user-attachments/assets/d3699ba4-511f-4c7b-a7cc-00a295d01f64) --------- Co-authored-by: Cole Miller --- crates/collab/src/rpc.rs | 2 + crates/fs/src/fake_git_repo.rs | 12 ++++ crates/git/src/git.rs | 4 ++ crates/git/src/repository.rs | 57 +++++++++++++++ crates/git_ui/src/git_panel.rs | 60 +++++++++++++++- crates/git_ui/src/git_ui.rs | 16 +++++ crates/project/src/git_store.rs | 120 ++++++++++++++++++++++++++++++++ crates/proto/proto/git.proto | 11 +++ crates/proto/proto/zed.proto | 4 +- crates/proto/src/proto.rs | 6 ++ 10 files changed, 290 insertions(+), 2 deletions(-) diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index 5c5de2f36e..515647f97d 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -433,6 +433,8 @@ impl Server { .add_request_handler(forward_mutating_project_request::) .add_request_handler(forward_mutating_project_request::) .add_request_handler(forward_mutating_project_request::) + .add_request_handler(forward_mutating_project_request::) + .add_request_handler(forward_mutating_project_request::) .add_request_handler(forward_mutating_project_request::) .add_request_handler(forward_mutating_project_request::) .add_request_handler(forward_read_only_project_request::) diff --git a/crates/fs/src/fake_git_repo.rs b/crates/fs/src/fake_git_repo.rs index 8a4f7c03bb..378a8fb7df 100644 --- a/crates/fs/src/fake_git_repo.rs +++ b/crates/fs/src/fake_git_repo.rs @@ -398,6 +398,18 @@ impl GitRepository for FakeGitRepository { }) } + fn stash_paths( + &self, + _paths: Vec, + _env: Arc>, + ) -> BoxFuture> { + unimplemented!() + } + + fn stash_pop(&self, _env: Arc>) -> BoxFuture> { + unimplemented!() + } + fn commit( &self, _message: gpui::SharedString, diff --git a/crates/git/src/git.rs b/crates/git/src/git.rs index 3714086dd0..553361e673 100644 --- a/crates/git/src/git.rs +++ b/crates/git/src/git.rs @@ -55,6 +55,10 @@ actions!( StageAll, /// Unstages all changes in the repository. UnstageAll, + /// Stashes all changes in the repository, including untracked files. + StashAll, + /// Pops the most recent stash. + StashPop, /// Restores all tracked files to their last committed state. RestoreTrackedFiles, /// Moves all untracked files to trash. diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs index 9cc3442392..a63315e69e 100644 --- a/crates/git/src/repository.rs +++ b/crates/git/src/repository.rs @@ -395,6 +395,14 @@ pub trait GitRepository: Send + Sync { env: Arc>, ) -> BoxFuture<'_, Result<()>>; + fn stash_paths( + &self, + paths: Vec, + env: Arc>, + ) -> BoxFuture>; + + fn stash_pop(&self, env: Arc>) -> BoxFuture>; + fn push( &self, branch_name: String, @@ -1189,6 +1197,55 @@ impl GitRepository for RealGitRepository { .boxed() } + fn stash_paths( + &self, + paths: Vec, + env: Arc>, + ) -> BoxFuture> { + let working_directory = self.working_directory(); + self.executor + .spawn(async move { + let mut cmd = new_smol_command("git"); + cmd.current_dir(&working_directory?) + .envs(env.iter()) + .args(["stash", "push", "--quiet"]) + .arg("--include-untracked"); + + cmd.args(paths.iter().map(|p| p.as_ref())); + + let output = cmd.output().await?; + + anyhow::ensure!( + output.status.success(), + "Failed to stash:\n{}", + String::from_utf8_lossy(&output.stderr) + ); + Ok(()) + }) + .boxed() + } + + fn stash_pop(&self, env: Arc>) -> BoxFuture> { + let working_directory = self.working_directory(); + self.executor + .spawn(async move { + let mut cmd = new_smol_command("git"); + cmd.current_dir(&working_directory?) + .envs(env.iter()) + .args(["stash", "pop"]); + + let output = cmd.output().await?; + + anyhow::ensure!( + output.status.success(), + "Failed to stash pop:\n{}", + String::from_utf8_lossy(&output.stderr) + ); + Ok(()) + }) + .boxed() + } + fn commit( &self, message: SharedString, diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index a8d1da7daf..725a1b6db5 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -27,7 +27,10 @@ use git::repository::{ }; use git::status::StageStatus; use git::{Amend, Signoff, ToggleStaged, repository::RepoPath, status::FileStatus}; -use git::{ExpandCommitEditor, RestoreTrackedFiles, StageAll, TrashUntrackedFiles, UnstageAll}; +use git::{ + ExpandCommitEditor, RestoreTrackedFiles, StageAll, StashAll, StashPop, TrashUntrackedFiles, + UnstageAll, +}; use gpui::{ Action, Animation, AnimationExt as _, AsyncApp, AsyncWindowContext, Axis, ClickEvent, Corner, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, KeyContext, @@ -140,6 +143,13 @@ fn git_panel_context_menu( UnstageAll.boxed_clone(), ) .separator() + .action_disabled_when( + !(state.has_new_changes || state.has_tracked_changes), + "Stash All", + StashAll.boxed_clone(), + ) + .action("Stash Pop", StashPop.boxed_clone()) + .separator() .action("Open Diff", project_diff::Diff.boxed_clone()) .separator() .action_disabled_when( @@ -1415,6 +1425,52 @@ impl GitPanel { self.tracked_staged_count + self.new_staged_count + self.conflicted_staged_count } + pub fn stash_pop(&mut self, _: &StashPop, _window: &mut Window, cx: &mut Context) { + let Some(active_repository) = self.active_repository.clone() else { + return; + }; + + cx.spawn({ + async move |this, cx| { + let stash_task = active_repository + .update(cx, |repo, cx| repo.stash_pop(cx))? + .await; + this.update(cx, |this, cx| { + stash_task + .map_err(|e| { + this.show_error_toast("stash pop", e, cx); + }) + .ok(); + cx.notify(); + }) + } + }) + .detach(); + } + + pub fn stash_all(&mut self, _: &StashAll, _window: &mut Window, cx: &mut Context) { + let Some(active_repository) = self.active_repository.clone() else { + return; + }; + + cx.spawn({ + async move |this, cx| { + let stash_task = active_repository + .update(cx, |repo, cx| repo.stash_all(cx))? + .await; + this.update(cx, |this, cx| { + stash_task + .map_err(|e| { + this.show_error_toast("stash", e, cx); + }) + .ok(); + cx.notify(); + }) + } + }) + .detach(); + } + pub fn commit_message_buffer(&self, cx: &App) -> Entity { self.commit_editor .read(cx) @@ -4365,6 +4421,8 @@ impl Render for GitPanel { .on_action(cx.listener(Self::revert_selected)) .on_action(cx.listener(Self::clean_all)) .on_action(cx.listener(Self::generate_commit_message_action)) + .on_action(cx.listener(Self::stash_all)) + .on_action(cx.listener(Self::stash_pop)) }) .on_action(cx.listener(Self::select_first)) .on_action(cx.listener(Self::select_next)) diff --git a/crates/git_ui/src/git_ui.rs b/crates/git_ui/src/git_ui.rs index 2d7fba13c5..0163175eda 100644 --- a/crates/git_ui/src/git_ui.rs +++ b/crates/git_ui/src/git_ui.rs @@ -114,6 +114,22 @@ pub fn init(cx: &mut App) { }); }); } + workspace.register_action(|workspace, action: &git::StashAll, window, cx| { + let Some(panel) = workspace.panel::(cx) else { + return; + }; + panel.update(cx, |panel, cx| { + panel.stash_all(action, window, cx); + }); + }); + workspace.register_action(|workspace, action: &git::StashPop, window, cx| { + let Some(panel) = workspace.panel::(cx) else { + return; + }; + panel.update(cx, |panel, cx| { + panel.stash_pop(action, window, cx); + }); + }); workspace.register_action(|workspace, action: &git::StageAll, window, cx| { let Some(panel) = workspace.panel::(cx) else { return; diff --git a/crates/project/src/git_store.rs b/crates/project/src/git_store.rs index d131b6dd41..28dd0e91e3 100644 --- a/crates/project/src/git_store.rs +++ b/crates/project/src/git_store.rs @@ -420,6 +420,8 @@ impl GitStore { client.add_entity_request_handler(Self::handle_fetch); client.add_entity_request_handler(Self::handle_stage); client.add_entity_request_handler(Self::handle_unstage); + client.add_entity_request_handler(Self::handle_stash); + client.add_entity_request_handler(Self::handle_stash_pop); client.add_entity_request_handler(Self::handle_commit); client.add_entity_request_handler(Self::handle_reset); client.add_entity_request_handler(Self::handle_show); @@ -1696,6 +1698,48 @@ impl GitStore { Ok(proto::Ack {}) } + async fn handle_stash( + this: Entity, + envelope: TypedEnvelope, + mut cx: AsyncApp, + ) -> Result { + let repository_id = RepositoryId::from_proto(envelope.payload.repository_id); + let repository_handle = Self::repository_for_request(&this, repository_id, &mut cx)?; + + let entries = envelope + .payload + .paths + .into_iter() + .map(PathBuf::from) + .map(RepoPath::new) + .collect(); + + repository_handle + .update(&mut cx, |repository_handle, cx| { + repository_handle.stash_entries(entries, cx) + })? + .await?; + + Ok(proto::Ack {}) + } + + async fn handle_stash_pop( + this: Entity, + envelope: TypedEnvelope, + mut cx: AsyncApp, + ) -> Result { + let repository_id = RepositoryId::from_proto(envelope.payload.repository_id); + let repository_handle = Self::repository_for_request(&this, repository_id, &mut cx)?; + + repository_handle + .update(&mut cx, |repository_handle, cx| { + repository_handle.stash_pop(cx) + })? + .await?; + + Ok(proto::Ack {}) + } + async fn handle_set_index_text( this: Entity, envelope: TypedEnvelope, @@ -3540,6 +3584,82 @@ impl Repository { self.unstage_entries(to_unstage, cx) } + pub fn stash_all(&mut self, cx: &mut Context) -> Task> { + let to_stash = self + .cached_status() + .map(|entry| entry.repo_path.clone()) + .collect(); + + self.stash_entries(to_stash, cx) + } + + pub fn stash_entries( + &mut self, + entries: Vec, + cx: &mut Context, + ) -> Task> { + let id = self.id; + + cx.spawn(async move |this, cx| { + this.update(cx, |this, _| { + this.send_job(None, move |git_repo, _cx| async move { + match git_repo { + RepositoryState::Local { + backend, + environment, + .. + } => backend.stash_paths(entries, environment).await, + RepositoryState::Remote { project_id, client } => { + client + .request(proto::Stash { + project_id: project_id.0, + repository_id: id.to_proto(), + paths: entries + .into_iter() + .map(|repo_path| repo_path.as_ref().to_proto()) + .collect(), + }) + .await + .context("sending stash request")?; + Ok(()) + } + } + }) + })? + .await??; + Ok(()) + }) + } + + pub fn stash_pop(&mut self, cx: &mut Context) -> Task> { + let id = self.id; + cx.spawn(async move |this, cx| { + this.update(cx, |this, _| { + this.send_job(None, move |git_repo, _cx| async move { + match git_repo { + RepositoryState::Local { + backend, + environment, + .. + } => backend.stash_pop(environment).await, + RepositoryState::Remote { project_id, client } => { + client + .request(proto::StashPop { + project_id: project_id.0, + repository_id: id.to_proto(), + }) + .await + .context("sending stash pop request")?; + Ok(()) + } + } + }) + })? + .await??; + Ok(()) + }) + } + pub fn commit( &mut self, message: SharedString, diff --git a/crates/proto/proto/git.proto b/crates/proto/proto/git.proto index 1d544b15ff..ea08d36371 100644 --- a/crates/proto/proto/git.proto +++ b/crates/proto/proto/git.proto @@ -286,6 +286,17 @@ message Unstage { repeated string paths = 4; } +message Stash { + uint64 project_id = 1; + uint64 repository_id = 2; + repeated string paths = 3; +} + +message StashPop { + uint64 project_id = 1; + uint64 repository_id = 2; +} + message Commit { uint64 project_id = 1; reserved 2; diff --git a/crates/proto/proto/zed.proto b/crates/proto/proto/zed.proto index 31f929ec90..29ab2b1e90 100644 --- a/crates/proto/proto/zed.proto +++ b/crates/proto/proto/zed.proto @@ -396,8 +396,10 @@ message Envelope { GetDocumentColor get_document_color = 353; GetDocumentColorResponse get_document_color_response = 354; GetColorPresentation get_color_presentation = 355; - GetColorPresentationResponse get_color_presentation_response = 356; // current max + GetColorPresentationResponse get_color_presentation_response = 356; + Stash stash = 357; + StashPop stash_pop = 358; // current max } reserved 87 to 88; diff --git a/crates/proto/src/proto.rs b/crates/proto/src/proto.rs index 918ac9e935..9f586a7839 100644 --- a/crates/proto/src/proto.rs +++ b/crates/proto/src/proto.rs @@ -261,6 +261,8 @@ messages!( (Unfollow, Foreground), (UnshareProject, Foreground), (Unstage, Background), + (Stash, Background), + (StashPop, Background), (UpdateBuffer, Foreground), (UpdateBufferFile, Foreground), (UpdateChannelBuffer, Foreground), @@ -419,6 +421,8 @@ request_messages!( (TaskContextForLocation, TaskContext), (Test, Test), (Unstage, Ack), + (Stash, Ack), + (StashPop, Ack), (UpdateBuffer, Ack), (UpdateParticipantLocation, Ack), (UpdateProject, Ack), @@ -549,6 +553,8 @@ entity_messages!( TaskContextForLocation, UnshareProject, Unstage, + Stash, + StashPop, UpdateBuffer, UpdateBufferFile, UpdateDiagnosticSummary,