From c6b01d8a203a6dc8c06a89e05ee4dec69de55cf0 Mon Sep 17 00:00:00 2001
From: Priyadharshan <91417662+Priyadharshan0903@users.noreply.github.com>
Date: Tue, 11 Aug 2026 04:38:21 +0000
Subject: [PATCH] Add optional message support to git stash (#62439)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Show a modal when invoking the stash action to allow users to provide an
optional custom message for the stash entry.
Closes #62430
# Image
# Objective
Zed's stash action runs `git stash push --quiet --include-untracked --`
with no `-m`, so every stash is labelled with git's auto-generated `WIP
on : `. That text describes the commit you were
sitting on, not what you stashed — so two stashes taken from the same
commit are indistinguishable.
This undercuts the stash picker (`git::ViewStash`), which lists entries
as `#: ` and fuzzy-searches over exactly that string.
The search box already exists; there is just nothing meaningful to
search, because every candidate is a variation of the same
auto-generated line.
## Solution
`git::StashAll` now opens a single-line modal ("Optionally provide a
stash message") before stashing.
- Confirming with text passes `--message ` to `git stash push`.
- Confirming with the field empty omits the flag entirely, keeping git's
default description — so the prompt is a one-keystroke pass-through and
existing muscle memory still works.
- Cancelling aborts the stash, so the prompt doubles as a confirmation
step.
Implementation:
- `StashMessageModal` (`Editor::single_line`) in `git_panel.rs`, toggled
from `GitPanel::stash_all`. `menu::Confirm` trims the input and maps
empty to `None`.
- `message: Option` threaded through `Repository::stash_all` →
`stash_entries` → `GitRepository::stash_paths`. The flag is appended
before the `--` separator so a message is never parsed as a pathspec.
- New `message` field on the `Stash` proto message, so remote and collab
projects behave identically.
One non-obvious detail: the modal is opened via `cx.defer_in` rather
than inline. `git::StashAll` is registered on the workspace
(`git_ui.rs`) as well as on the panel element, and
`Workspace::register_action` dispatches while `Workspace` is leased — so
opening the modal inline re-enters that update and hits GPUI's
`double_lease_panic`. This only reproduces when focus is *outside* the
Git Panel, which makes it easy to miss.
`Option` rather than `String` is deliberate: `--message ""`
produces a blank stash description, which is strictly worse than git's
default.
## Testing
Manually verified the modal in a local build on macOS: the prompt
appears on `git::StashAll`, accepts a message, and the named entry shows
up in the stash picker.
Also verified at the git level by replaying the exact argument vector
`stash_paths` builds against a scratch repo with mixed staged / unstaged
/ untracked changes:
| Case | Result |
|---|---|
| `stash push --quiet --include-untracked --message "my named stash" --
` | `stash@{0}: my named stash`; worktree clean, untracked file
included |
| same, without `--message` | `stash@{0}: ` — git's
default text |
| `--message "x" --` with no paths (clean repo) | exit 0, no stash
created — the empty pathspec does **not** stash everything |
`cargo fmt --check` clean, `./script/clippy -p git -p fs -p project -p
git_ui` passes with `--deny warnings`, and the existing suites pass
(`cargo test -p project -p git_ui`, 436 tests).
Worth a reviewer's attention: trigger `git::StashAll` with focus in the
**editor** rather than the Git Panel. That routes through the workspace
action registration and is the case the `cx.defer_in` deferral exists to
keep from panicking.
No new automated tests — the behavior is testable with the existing
`git_panel.rs` harness (`init_test`, `GitPanel::new`) if reviewers would
prefer coverage over a manual check.
## Self-Review Checklist:
- [x] I've reviewed my own diff for quality, security, and reliability
- [x] Unsafe blocks (if any) have justifying comments — n/a, no unsafe
added
- [x] The content adheres to Zed's UI standards
([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
and
[icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md)
guidelines)
- [ ] Tests cover the new/changed behavior — no new tests; see Testing
- [x] Performance impact has been considered and is acceptable — one
extra process argument; no new work on any hot path
---
Release Notes:
- Added an optional stash message prompt when stashing changes
`
---------
Co-authored-by: Chris Biscardi
---
crates/fs/src/fake_git_repo.rs | 1 +
crates/git/src/repository.rs | 9 +++-
crates/git_ui/src/git_panel.rs | 85 +++++++++++++++++++++++++++++++--
crates/project/src/git_store.rs | 16 +++++--
crates/proto/proto/git.proto | 1 +
docs/src/git.md | 2 +
6 files changed, 106 insertions(+), 8 deletions(-)
diff --git a/crates/fs/src/fake_git_repo.rs b/crates/fs/src/fake_git_repo.rs
index 268fdc7e6c..0a87c4584d 100644
--- a/crates/fs/src/fake_git_repo.rs
+++ b/crates/fs/src/fake_git_repo.rs
@@ -1040,6 +1040,7 @@ impl GitRepository for FakeGitRepository {
fn stash_paths(
&self,
_paths: Vec,
+ _message: Option,
_env: Arc>,
) -> BoxFuture<'_, Result<()>> {
unimplemented!()
diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs
index b6393d9093..9d3e4086f3 100644
--- a/crates/git/src/repository.rs
+++ b/crates/git/src/repository.rs
@@ -934,6 +934,7 @@ pub trait GitRepository: Send + Sync {
fn stash_paths(
&self,
paths: Vec,
+ message: Option,
env: Arc>,
) -> BoxFuture<'_, Result<()>>;
@@ -2490,14 +2491,20 @@ impl GitRepository for RealGitRepository {
fn stash_paths(
&self,
paths: Vec,
+ message: Option,
env: Arc>,
) -> BoxFuture<'_, Result<()>> {
let git = self.git_binary_in_worktree();
self.executor
.spawn(async move {
let git = git?;
+ let mut args = vec!["stash", "push", "--quiet", "--include-untracked"];
+ if let Some(message) = message.as_deref() {
+ args.extend_from_slice(&["--message", message]);
+ }
+ args.push("--");
let output = git
- .build_command(&["stash", "push", "--quiet", "--include-untracked", "--"])
+ .build_command(&args)
.envs(env.iter())
.args(paths.iter().map(|p| p.as_unix_str()))
.output()
diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs
index cb92cacfcd..1f880c59d3 100644
--- a/crates/git_ui/src/git_panel.rs
+++ b/crates/git_ui/src/git_panel.rs
@@ -98,7 +98,7 @@ use util::paths::PathStyle;
use util::{ResultExt, TryFutureExt, markdown::MarkdownInlineCode, maybe, rel_path::RelPath};
use workspace::SERIALIZATION_THROTTLE_TIME;
use workspace::{
- Item, Workspace,
+ Item, ModalView, Workspace,
dock::{DockPosition, Panel, PanelEvent},
notifications::{DetachAndPromptErr, NotificationId, NotifyTaskExt},
};
@@ -201,6 +201,67 @@ struct GitPanelViewOptionsMenuState {
tree_view: bool,
}
+/// Prompts for an optional stash name. Confirming with an empty editor stashes
+/// without `--message`, letting git generate its usual "WIP on ..." description.
+struct StashMessageModal {
+ editor: Entity,
+ panel: WeakEntity,
+}
+
+impl StashMessageModal {
+ fn new(panel: WeakEntity, window: &mut Window, cx: &mut Context) -> Self {
+ let editor = cx.new(|cx| {
+ let mut editor = Editor::single_line(window, cx);
+ editor.set_placeholder_text("Optionally provide a stash message", window, cx);
+ editor
+ });
+ Self { editor, panel }
+ }
+
+ fn cancel(&mut self, _: &menu::Cancel, _window: &mut Window, cx: &mut Context) {
+ cx.emit(DismissEvent);
+ }
+
+ fn confirm(&mut self, _: &menu::Confirm, _window: &mut Window, cx: &mut Context) {
+ let message = self.editor.read(cx).text(cx).trim().to_owned();
+ let message = (!message.is_empty()).then_some(message);
+ self.panel
+ .update(cx, |panel, cx| panel.perform_stash(message, cx))
+ .ok();
+ cx.emit(DismissEvent);
+ }
+}
+
+impl EventEmitter for StashMessageModal {}
+impl ModalView for StashMessageModal {}
+impl Focusable for StashMessageModal {
+ fn focus_handle(&self, cx: &App) -> FocusHandle {
+ self.editor.focus_handle(cx)
+ }
+}
+
+impl Render for StashMessageModal {
+ fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement {
+ v_flex()
+ .key_context("StashMessageModal")
+ .on_action(cx.listener(Self::cancel))
+ .on_action(cx.listener(Self::confirm))
+ .elevation_2(cx)
+ .w(rems(34.))
+ .child(
+ h_flex()
+ .px_3()
+ .pt_2()
+ .pb_1()
+ .w_full()
+ .gap_1p5()
+ .child(Icon::new(IconName::GitBranch).size(IconSize::XSmall))
+ .child(Headline::new("Stash All").size(HeadlineSize::XSmall)),
+ )
+ .child(div().px_3().pb_3().w_full().child(self.editor.clone()))
+ }
+}
+
fn git_panel_context_menu(
has_tracked_changes: bool,
has_staged_changes: bool,
@@ -2731,7 +2792,25 @@ impl GitPanel {
.detach();
}
- pub fn stash_all(&mut self, _: &StashAll, _window: &mut Window, cx: &mut Context) {
+ pub fn stash_all(&mut self, _: &StashAll, window: &mut Window, cx: &mut Context) {
+ if self.active_repository.is_none() {
+ return;
+ }
+ // `git::StashAll` is also registered on the workspace, which dispatches it while
+ // `Workspace` is leased, so opening the modal inline would re-enter that update.
+ cx.defer_in(window, |this, window, cx| {
+ let panel = cx.entity().downgrade();
+ this.workspace
+ .update(cx, |workspace, cx| {
+ workspace.toggle_modal(window, cx, |window, cx| {
+ StashMessageModal::new(panel, window, cx)
+ });
+ })
+ .ok();
+ });
+ }
+
+ fn perform_stash(&mut self, message: Option, cx: &mut Context) {
let Some(active_repository) = self.active_repository.clone() else {
return;
};
@@ -2739,7 +2818,7 @@ impl GitPanel {
cx.spawn({
async move |this, cx| {
let stash_task = active_repository
- .update(cx, |repo, cx| repo.stash_all(cx))
+ .update(cx, |repo, cx| repo.stash_all(message, cx))
.await;
this.update(cx, |this, cx| {
stash_task
diff --git a/crates/project/src/git_store.rs b/crates/project/src/git_store.rs
index a25fd88bec..6e88c80912 100644
--- a/crates/project/src/git_store.rs
+++ b/crates/project/src/git_store.rs
@@ -3063,6 +3063,8 @@ impl GitStore {
let repository_id = RepositoryId::from_proto(envelope.payload.repository_id);
let repository_handle = Self::repository_for_request(&this, repository_id, &mut cx)?;
+ let message = envelope.payload.message;
+
let entries = envelope
.payload
.paths
@@ -3072,7 +3074,7 @@ impl GitStore {
repository_handle
.update(&mut cx, |repository_handle, cx| {
- repository_handle.stash_entries(entries, cx)
+ repository_handle.stash_entries(entries, message, cx)
})
.await?;
@@ -7473,15 +7475,20 @@ impl Repository {
})
}
- pub fn stash_all(&mut self, cx: &mut Context) -> Task> {
+ pub fn stash_all(
+ &mut self,
+ message: Option,
+ cx: &mut Context,
+ ) -> Task> {
let to_stash = self.cached_status().map(|entry| entry.repo_path).collect();
- self.stash_entries(to_stash, cx)
+ self.stash_entries(to_stash, message, cx)
}
pub fn stash_entries(
&mut self,
entries: Vec,
+ message: Option,
cx: &mut Context,
) -> Task> {
let id = self.id;
@@ -7494,7 +7501,7 @@ impl Repository {
backend,
environment,
..
- }) => backend.stash_paths(entries, environment).await,
+ }) => backend.stash_paths(entries, message, environment).await,
RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => {
client
.request(proto::Stash {
@@ -7504,6 +7511,7 @@ impl Repository {
.into_iter()
.map(|repo_path| repo_path.as_unix_str().to_owned())
.collect(),
+ message,
})
.await?;
Ok(())
diff --git a/crates/proto/proto/git.proto b/crates/proto/proto/git.proto
index 5320328f76..eb5bb54323 100644
--- a/crates/proto/proto/git.proto
+++ b/crates/proto/proto/git.proto
@@ -378,6 +378,7 @@ message Stash {
uint64 project_id = 1;
uint64 repository_id = 2;
repeated string paths = 3;
+ optional string message = 4;
}
message StashPop {
diff --git a/docs/src/git.md b/docs/src/git.md
index 07f2a00556..e599c86034 100644
--- a/docs/src/git.md
+++ b/docs/src/git.md
@@ -244,6 +244,8 @@ Git stash allows you to temporarily save your uncommitted changes and revert you
To stash all your current changes, use the {#action git::StashAll} action. This will save both staged and unstaged changes to a new stash entry and clean your working directory.
+The action first prompts for an optional stash name. Confirming with an empty prompt lets git generate its usual `WIP on ...` description.
+
### Managing Stashes
Zed provides a stash picker accessible via {#action git::ViewStash} or from the Git Panel's overflow menu. From the stash picker, you can: