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 <cole@zed.dev>
This commit is contained in:
Alvaro Parker 2025-07-25 19:15:54 -04:00 committed by GitHub
parent 4d00d07df1
commit 07252c3309
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 290 additions and 2 deletions

View file

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

View file

@ -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,

View file

@ -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.

View file

@ -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,

View file

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

View file

@ -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;

View file

@ -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,

View file

@ -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;

View file

@ -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;

View file

@ -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,