git: Enable git stash in git panel (#32821)

Alvaro Parker and Cole Miller created

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 <cole@zed.dev>

Change summary

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(-)

Detailed changes

crates/collab/src/rpc.rs 🔗

@@ -433,6 +433,8 @@ impl Server {
             .add_request_handler(forward_mutating_project_request::<proto::SynchronizeContexts>)
             .add_request_handler(forward_mutating_project_request::<proto::Stage>)
             .add_request_handler(forward_mutating_project_request::<proto::Unstage>)
+            .add_request_handler(forward_mutating_project_request::<proto::Stash>)
+            .add_request_handler(forward_mutating_project_request::<proto::StashPop>)
             .add_request_handler(forward_mutating_project_request::<proto::Commit>)
             .add_request_handler(forward_mutating_project_request::<proto::GitInit>)
             .add_request_handler(forward_read_only_project_request::<proto::GetRemotes>)

crates/fs/src/fake_git_repo.rs 🔗

@@ -398,6 +398,18 @@ impl GitRepository for FakeGitRepository {
         })
     }
 
+    fn stash_paths(
+        &self,
+        _paths: Vec<RepoPath>,
+        _env: Arc<HashMap<String, String>>,
+    ) -> BoxFuture<Result<()>> {
+        unimplemented!()
+    }
+
+    fn stash_pop(&self, _env: Arc<HashMap<String, String>>) -> BoxFuture<Result<()>> {
+        unimplemented!()
+    }
+
     fn commit(
         &self,
         _message: gpui::SharedString,

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.

crates/git/src/repository.rs 🔗

@@ -395,6 +395,14 @@ pub trait GitRepository: Send + Sync {
         env: Arc<HashMap<String, String>>,
     ) -> BoxFuture<'_, Result<()>>;
 
+    fn stash_paths(
+        &self,
+        paths: Vec<RepoPath>,
+        env: Arc<HashMap<String, String>>,
+    ) -> BoxFuture<Result<()>>;
+
+    fn stash_pop(&self, env: Arc<HashMap<String, String>>) -> BoxFuture<Result<()>>;
+
     fn push(
         &self,
         branch_name: String,
@@ -1189,6 +1197,55 @@ impl GitRepository for RealGitRepository {
             .boxed()
     }
 
+    fn stash_paths(
+        &self,
+        paths: Vec<RepoPath>,
+        env: Arc<HashMap<String, String>>,
+    ) -> BoxFuture<Result<()>> {
+        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<HashMap<String, String>>) -> BoxFuture<Result<()>> {
+        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,

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<Self>) {
+        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<Self>) {
+        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<Buffer> {
         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))

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::<git_panel::GitPanel>(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::<git_panel::GitPanel>(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::<git_panel::GitPanel>(cx) else {
                 return;

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<Self>,
+        envelope: TypedEnvelope<proto::Stash>,
+        mut cx: AsyncApp,
+    ) -> Result<proto::Ack> {
+        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<Self>,
+        envelope: TypedEnvelope<proto::StashPop>,
+        mut cx: AsyncApp,
+    ) -> Result<proto::Ack> {
+        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<Self>,
         envelope: TypedEnvelope<proto::SetIndexText>,
@@ -3540,6 +3584,82 @@ impl Repository {
         self.unstage_entries(to_unstage, cx)
     }
 
+    pub fn stash_all(&mut self, cx: &mut Context<Self>) -> Task<anyhow::Result<()>> {
+        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<RepoPath>,
+        cx: &mut Context<Self>,
+    ) -> Task<anyhow::Result<()>> {
+        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<Self>) -> Task<anyhow::Result<()>> {
+        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,

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;

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;

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,