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 Screenshot 2026-08-10 at 9 14
00 PM Screenshot 2026-08-10 at 9 14
11 PM # 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: