From f652da59e8d4c75a9bcba00e426643eb876fbb11 Mon Sep 17 00:00:00 2001
From: Priyadharshan <91417662+Priyadharshan0903@users.noreply.github.com>
Date: Tue, 18 Aug 2026 05:36:16 +0000
Subject: [PATCH] Added Tracked , Staged options to stash (#62254)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
# Objective
Closes #62252
The Git Panel could only stash *everything* — `Stash All` runs
`git stash push --include-untracked`, sweeping tracked edits and
untracked files
into a single entry. There was no way to stash a subset, so the common
workflows
of "park my tracked edits but keep my new scratch files" and "park what
I've
staged and keep working on the rest" required dropping to the terminal.
## Images
## Solution
Add two stash variants alongside `Stash All`, surfaced in the Git
Panel's
overflow menu based on how the list is currently grouped, so the menu
mirrors the
sections the user can actually see:
| Group By | Stash entries offered |
| --- | --- |
| None | Stash All |
| Tracked & Untracked | Stash All, **Stash Tracked** |
| Staged & Unstaged | Stash All, **Stash Staged** |
- **`git::StashTracked`** stashes tracked changes and leaves untracked
files in
place. It reuses the existing pathspec plumbing
(`Repository::stash_entries`),
filtering the status list down to the paths to stash.
- **`git::StashStaged`** stashes the index only, leaving unstaged
changes in
place. This *cannot* be expressed as a pathspec — a partially staged
file would
have its unstaged hunks stashed too — so it needs git's own `--staged`
flag.
That meant a new `GitRepository::stash_staged` backend method and an
`optional bool staged` field on `proto::Stash` so remote projects work
too.
Both actions are unbound by default and are dispatchable from the
command palette
when the panel is focused.
One subtlety worth calling out for review: `Stash Tracked` filters on
`FileStatus::is_created()`, not `is_untracked()`. Staging a new file
flips it from
`Untracked` to `Tracked { Added }`, but the panel still lists it under
**Untracked** — using `is_untracked()` meant staged-new files were
silently
stashed. `is_created()` is the same predicate the panel uses to build
that section
(`git_panel.rs`), so the menu item and the list can no longer disagree.
This branch also includes a separate commit adding **per-section
staging**
(`git::StageSection` / `git::UnstageSection`) — right-click a file to
stage or
unstage every entry in its section. Happy to split that into its own PR
if
preferred.
## Testing
Manually tested on macOS against a scratch repo with a mix of states:
modified
tracked files, untracked files, and untracked files that had been
staged.
- `Stash Tracked` with tracked edits + untracked files → only tracked
edits
stashed; untracked files remain.
- `Stash Tracked` with untracked files **staged** → they remain, staged.
This was
broken in an earlier revision and drove the `is_created()` fix above.
- `Stash Staged` with one file staged and another modified-but-unstaged
→ only the
staged file is stashed; the unstaged edit and untracked files survive.
- `Stash Pop` round-trips both cases back to the original state, with no
conflicts.
- Menu contents and disabled states verified in all three Group By
modes.
- Per-section staging covered by a new unit test,
`test_stage_section_scopes_to_selected_section`.
Not covered by automated tests: the stash actions themselves.
`FakeGitRepository`
leaves every stash method `unimplemented!()`, so stash behavior isn't
reachable
from GPUI tests today — consistent with the existing untested
`StashAll`. Adding
fake-repo stash support looks like a worthwhile follow-up but felt out
of scope here.
Reviewers on non-macOS platforms: nothing here is platform-specific.
Note that
`Stash Staged` requires **git 2.35+** (Jan 2022) for `git stash push
--staged`;
older git surfaces a clear error toast rather than failing opaquely. The
remote
path (`proto::Stash.staged`) has not been exercised against a live
collab session.
## Self-Review Checklist:
- [x] I've reviewed my own diff for quality, security, and reliability
- [ ] Unsafe blocks (if any) have justifying comments
- [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)
- [x] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable
Release Notes:
- Added `Stash Tracked` and `Stash Staged` options to the Git Panel,
letting you stash only tracked changes or only staged changes.
---------
Co-authored-by: Christopher Biscardi
---
crates/fs/src/fake_git_repo.rs | 8 +
crates/git/src/git.rs | 9 +
crates/git/src/repository.rs | 33 +++
crates/git_ui/src/git_panel.rs | 385 +++++++++++++++++++++++++++++++-
crates/git_ui/src/git_ui.rs | 16 ++
crates/project/src/git_store.rs | 67 ++++++
crates/proto/proto/git.proto | 2 +
docs/src/git.md | 14 +-
8 files changed, 520 insertions(+), 14 deletions(-)
diff --git a/crates/fs/src/fake_git_repo.rs b/crates/fs/src/fake_git_repo.rs
index 95209c5a8f..a5f6cfe812 100644
--- a/crates/fs/src/fake_git_repo.rs
+++ b/crates/fs/src/fake_git_repo.rs
@@ -1070,6 +1070,14 @@ impl GitRepository for FakeGitRepository {
unimplemented!()
}
+ fn stash_staged(
+ &self,
+ _message: Option,
+ _env: Arc>,
+ ) -> BoxFuture<'_, Result<()>> {
+ unimplemented!()
+ }
+
fn stash_pop(
&self,
_index: Option,
diff --git a/crates/git/src/git.rs b/crates/git/src/git.rs
index 9d8f658a6b..fbe1d936c6 100644
--- a/crates/git/src/git.rs
+++ b/crates/git/src/git.rs
@@ -67,6 +67,11 @@ actions!(
StageFile,
/// Unstages the current file.
UnstageFile,
+ // per-section
+ /// Stages every entry in the section containing the selected entry.
+ StageSection,
+ /// Unstages every entry in the section containing the selected entry.
+ UnstageSection,
// repo-wide
/// Stages all changes in the repository.
StageAll,
@@ -74,6 +79,10 @@ actions!(
UnstageAll,
/// Stashes all changes in the repository, including untracked files.
StashAll,
+ /// Stashes tracked changes in the repository, leaving untracked files in place.
+ StashTracked,
+ /// Stashes staged changes in the repository, leaving unstaged changes in place.
+ StashStaged,
/// Pops the most recent stash.
StashPop,
/// Apply the most recent stash.
diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs
index 4b52142d56..bfa5b8a44c 100644
--- a/crates/git/src/repository.rs
+++ b/crates/git/src/repository.rs
@@ -941,6 +941,12 @@ pub trait GitRepository: Send + Sync {
env: Arc>,
) -> BoxFuture<'_, Result<()>>;
+ fn stash_staged(
+ &self,
+ message: Option,
+ env: Arc>,
+ ) -> BoxFuture<'_, Result<()>>;
+
fn stash_pop(
&self,
index: Option,
@@ -2561,6 +2567,33 @@ impl GitRepository for RealGitRepository {
.boxed()
}
+ fn stash_staged(
+ &self,
+ message: Option,
+ env: Arc>,
+ ) -> BoxFuture<'_, Result<()>> {
+ let git = self.git_binary_in_worktree();
+ self.executor
+ .spawn(async move {
+ let git = git?;
+ // `--staged` cannot be expressed as a pathspec: a partially staged
+ // file would otherwise have its unstaged hunks stashed too.
+ let mut args = vec!["stash", "push", "--quiet", "--staged"];
+ if let Some(message) = message.as_deref() {
+ args.extend_from_slice(&["--message", message]);
+ }
+ let output = git.build_command(&args).envs(env.iter()).output().await?;
+
+ anyhow::ensure!(
+ output.status.success(),
+ "Failed to stash staged changes (requires git 2.35 or newer):\n{}",
+ String::from_utf8_lossy(&output.stderr)
+ );
+ Ok(())
+ })
+ .boxed()
+ }
+
fn stash_pop(
&self,
index: Option,
diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs
index 8e800ffb50..16ea1c3914 100644
--- a/crates/git_ui/src/git_panel.rs
+++ b/crates/git_ui/src/git_panel.rs
@@ -43,8 +43,8 @@ use git::{
};
use git::{
ExpandCommitEditor, GitHostingProviderRegistry, GitRemote, RestoreTrackedFiles, StageAll,
- StashAll, StashApply, StashPop, ToggleFillCommitEditor, TrashUntrackedFiles, UnstageAll,
- ViewFile, parse_git_remote_url,
+ StashAll, StashApply, StashPop, StashStaged, StashTracked, ToggleFillCommitEditor,
+ TrashUntrackedFiles, UnstageAll, ViewFile, parse_git_remote_url,
};
use gpui::{
AbsoluteLength, Action, Anchor, AnyElement, AsyncApp, AsyncWindowContext, ClickEvent,
@@ -201,21 +201,56 @@ struct GitPanelViewOptionsMenuState {
tree_view: bool,
}
+#[derive(Clone, Copy, PartialEq, Eq)]
+enum StashKind {
+ All,
+ Tracked,
+ Staged,
+}
+
+impl StashKind {
+ fn title(self) -> &'static str {
+ match self {
+ StashKind::All => "Stash All",
+ StashKind::Tracked => "Stash Tracked",
+ StashKind::Staged => "Stash Staged",
+ }
+ }
+
+ fn error_action(self) -> &'static str {
+ match self {
+ StashKind::All => "stash",
+ StashKind::Tracked => "stash tracked",
+ StashKind::Staged => "stash staged",
+ }
+ }
+}
+
/// 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,
+ kind: StashKind,
}
impl StashMessageModal {
- fn new(panel: WeakEntity, window: &mut Window, cx: &mut Context) -> Self {
+ fn new(
+ panel: WeakEntity,
+ kind: StashKind,
+ 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 }
+ Self {
+ editor,
+ panel,
+ kind,
+ }
}
fn cancel(&mut self, _: &menu::Cancel, _window: &mut Window, cx: &mut Context) {
@@ -225,8 +260,9 @@ impl StashMessageModal {
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);
+ let kind = self.kind;
self.panel
- .update(cx, |panel, cx| panel.perform_stash(message, cx))
+ .update(cx, |panel, cx| panel.perform_stash(kind, message, cx))
.ok();
cx.emit(DismissEvent);
}
@@ -256,7 +292,7 @@ impl Render for StashMessageModal {
.w_full()
.gap_1p5()
.child(Icon::new(IconName::GitBranch).size(IconSize::XSmall))
- .child(Headline::new("Stash All").size(HeadlineSize::XSmall)),
+ .child(Headline::new(self.kind.title()).size(HeadlineSize::XSmall)),
)
.child(div().px_3().pb_3().w_full().child(self.editor.clone()))
}
@@ -268,6 +304,7 @@ fn git_panel_context_menu(
has_unstaged_changes: bool,
has_new_changes: bool,
has_stash_items: bool,
+ group_by: GitPanelGroupBy,
include_copy_paths: bool,
focus_handle: FocusHandle,
window: &mut Window,
@@ -289,6 +326,22 @@ fn git_panel_context_menu(
"Stash All",
StashAll.boxed_clone(),
)
+ // Offer the stash variant that matches how the list is currently grouped,
+ // so the menu mirrors the sections the user can actually see.
+ .when(group_by == GitPanelGroupBy::Status, |context_menu| {
+ context_menu.action_disabled_when(
+ !has_tracked_changes,
+ "Stash Tracked",
+ StashTracked.boxed_clone(),
+ )
+ })
+ .when(group_by == GitPanelGroupBy::Staging, |context_menu| {
+ context_menu.action_disabled_when(
+ !has_staged_changes,
+ "Stash Staged",
+ StashStaged.boxed_clone(),
+ )
+ })
.action_disabled_when(!has_stash_items, "Stash Pop", StashPop.boxed_clone())
.action("View Stash", zed_actions::git::ViewStash.boxed_clone())
.when(include_copy_paths, |context_menu| {
@@ -2800,24 +2853,41 @@ impl GitPanel {
}
pub fn stash_all(&mut self, _: &StashAll, window: &mut Window, cx: &mut Context) {
+ self.prompt_for_stash_message(StashKind::All, window, cx);
+ }
+
+ pub fn stash_tracked(&mut self, _: &StashTracked, window: &mut Window, cx: &mut Context) {
+ self.prompt_for_stash_message(StashKind::Tracked, window, cx);
+ }
+
+ pub fn stash_staged(&mut self, _: &StashStaged, window: &mut Window, cx: &mut Context) {
+ self.prompt_for_stash_message(StashKind::Staged, window, cx);
+ }
+
+ fn prompt_for_stash_message(
+ &mut self,
+ kind: StashKind,
+ 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| {
+ cx.defer_in(window, move |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)
+ StashMessageModal::new(panel, kind, window, cx)
});
})
.ok();
});
}
- fn perform_stash(&mut self, message: Option, cx: &mut Context) {
+ fn perform_stash(&mut self, kind: StashKind, message: Option, cx: &mut Context) {
let Some(active_repository) = self.active_repository.clone() else {
return;
};
@@ -2825,12 +2895,16 @@ impl GitPanel {
cx.spawn({
async move |this, cx| {
let stash_task = active_repository
- .update(cx, |repo, cx| repo.stash_all(message, cx))
+ .update(cx, |repo, cx| match kind {
+ StashKind::All => repo.stash_all(message, cx),
+ StashKind::Tracked => repo.stash_tracked(message, cx),
+ StashKind::Staged => repo.stash_staged(message, cx),
+ })
.await;
this.update(cx, |this, cx| {
stash_task
.map_err(|e| {
- this.show_error_toast("stash", e, cx);
+ this.show_error_toast(kind.error_action(), e, cx);
})
.ok();
cx.notify();
@@ -2907,6 +2981,44 @@ impl GitPanel {
}
}
+ fn stage_section(
+ &mut self,
+ _: &git::StageSection,
+ window: &mut Window,
+ cx: &mut Context,
+ ) {
+ self.change_selected_section_stage(StageIntent::Stage, window, cx);
+ }
+
+ fn unstage_section(
+ &mut self,
+ _: &git::UnstageSection,
+ window: &mut Window,
+ cx: &mut Context,
+ ) {
+ self.change_selected_section_stage(StageIntent::Unstage, window, cx);
+ }
+
+ fn change_selected_section_stage(
+ &mut self,
+ intent: StageIntent,
+ window: &mut Window,
+ cx: &mut Context,
+ ) {
+ let Some(selected_index) = self.selected_entry else {
+ return;
+ };
+ let Some(section) = self.section_for_entry_index(selected_index) else {
+ return;
+ };
+ self.toggle_staged_for_entry(
+ &GitListEntry::Header(GitHeaderEntry { header: section }),
+ intent,
+ window,
+ cx,
+ );
+ }
+
fn on_commit(&mut self, _: &Commit, window: &mut Window, cx: &mut Context) {
let is_amend = self.amend_pending;
if self.commit(&self.commit_editor.focus_handle(cx), window, cx) {
@@ -5706,13 +5818,14 @@ impl GitPanel {
fn render_git_changes_actions_menu(
&self,
id: impl Into,
- _cx: &mut Context,
+ cx: &mut Context,
) -> impl IntoElement {
let has_tracked_changes = self.has_tracked_changes();
let has_staged_changes = self.has_staged_changes();
let has_unstaged_changes = self.has_unstaged_changes();
let has_new_changes = self.new_count > 0;
let has_stash_items = self.stash_entries.entries.len() > 0;
+ let group_by = GitPanelSettings::get_global(cx).group_by;
let focus_handle = self.focus_handle.clone();
let menu_open = self.changes_actions_menu_handle.is_deployed();
@@ -5730,6 +5843,7 @@ impl GitPanel {
has_unstaged_changes,
has_new_changes,
has_stash_items,
+ group_by,
false,
focus_handle.clone(),
window,
@@ -7498,6 +7612,7 @@ impl GitPanel {
has_unstaged_changes,
has_new_changes,
has_stash_items,
+ GitPanelSettings::get_global(cx).group_by,
include_copy_paths,
self.focus_handle.clone(),
window,
@@ -8287,6 +8402,8 @@ impl Render for GitPanel {
.on_action(cx.listener(Self::unstage_all))
.on_action(cx.listener(Self::stage_selected))
.on_action(cx.listener(Self::unstage_selected))
+ .on_action(cx.listener(Self::stage_section))
+ .on_action(cx.listener(Self::unstage_section))
.on_action(cx.listener(Self::restore_tracked_files))
.on_action(cx.listener(Self::revert_selected))
.on_action(cx.listener(Self::add_to_gitignore))
@@ -8294,6 +8411,8 @@ impl Render for GitPanel {
.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_tracked))
+ .on_action(cx.listener(Self::stash_staged))
.on_action(cx.listener(Self::stash_pop))
})
.on_action(cx.listener(Self::collapse_selected_entry))
@@ -12874,4 +12993,246 @@ mod tests {
"prompt should NOT list unstaged.rs, got: {detail}"
);
}
+
+ #[gpui::test]
+ async fn test_stage_section_scopes_to_selected_section(cx: &mut TestAppContext) {
+ use GitListEntry::*;
+
+ init_test(cx);
+ let fs = FakeFs::new(cx.background_executor.clone());
+ fs.insert_tree(
+ "/root",
+ json!({
+ "project": {
+ ".git": {},
+ "src": {
+ "main.rs": "fn main() {}",
+ "lib.rs": "pub fn hello() {}",
+ },
+ "new_file.txt": "new content",
+ "another_new.rs": "// new file",
+ }
+ }),
+ )
+ .await;
+
+ fs.set_status_for_repo(
+ Path::new(path!("/root/project/.git")),
+ &[
+ ("src/main.rs", StatusCode::Modified.worktree()),
+ ("src/lib.rs", StatusCode::Modified.worktree()),
+ ("new_file.txt", FileStatus::Untracked),
+ ("another_new.rs", FileStatus::Untracked),
+ ],
+ );
+
+ let project = Project::test(fs.clone(), [Path::new(path!("/root/project"))], cx).await;
+ let window_handle =
+ cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+ let workspace = window_handle
+ .read_with(cx, |mw, _| mw.workspace().clone())
+ .unwrap();
+ let cx = &mut VisualTestContext::from_window(window_handle.into(), cx);
+
+ cx.read(|cx| {
+ project
+ .read(cx)
+ .worktrees(cx)
+ .next()
+ .unwrap()
+ .read(cx)
+ .as_local()
+ .unwrap()
+ .scan_complete()
+ })
+ .await;
+
+ cx.executor().run_until_parked();
+
+ let panel = workspace.update_in(cx, GitPanel::new);
+
+ let handle = cx.update_window_entity(&panel, |panel, _, _| {
+ std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
+ });
+ cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
+ handle.await;
+
+ let entries = panel.read_with(cx, |panel, _| panel.entries.clone());
+ #[rustfmt::skip]
+ pretty_assertions::assert_matches!(
+ entries.as_slice(),
+ &[
+ Header(GitHeaderEntry { header: Section::Tracked }),
+ Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
+ Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
+ Header(GitHeaderEntry { header: Section::New }),
+ Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
+ Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
+ ],
+ );
+
+ // Staging from an untracked row must leave the tracked section alone.
+ panel.update_in(cx, |panel, window, cx| {
+ panel.selected_entry = Some(4);
+ panel.stage_section(&git::StageSection, window, cx);
+ });
+
+ cx.read(|cx| {
+ project
+ .read(cx)
+ .worktrees(cx)
+ .next()
+ .unwrap()
+ .read(cx)
+ .as_local()
+ .unwrap()
+ .scan_complete()
+ })
+ .await;
+
+ cx.executor().run_until_parked();
+
+ let handle = cx.update_window_entity(&panel, |panel, _, _| {
+ std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
+ });
+ cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
+ handle.await;
+
+ let entries = panel.read_with(cx, |panel, _| panel.entries.clone());
+ #[rustfmt::skip]
+ pretty_assertions::assert_matches!(
+ entries.as_slice(),
+ &[
+ Header(GitHeaderEntry { header: Section::Tracked }),
+ Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
+ Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
+ Header(GitHeaderEntry { header: Section::New }),
+ Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
+ Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
+ ],
+ );
+
+ panel.update_in(cx, |panel, window, cx| {
+ panel.selected_entry = Some(1);
+ panel.stage_section(&git::StageSection, window, cx);
+ });
+
+ cx.read(|cx| {
+ project
+ .read(cx)
+ .worktrees(cx)
+ .next()
+ .unwrap()
+ .read(cx)
+ .as_local()
+ .unwrap()
+ .scan_complete()
+ })
+ .await;
+
+ cx.executor().run_until_parked();
+
+ let handle = cx.update_window_entity(&panel, |panel, _, _| {
+ std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
+ });
+ cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
+ handle.await;
+
+ let entries = panel.read_with(cx, |panel, _| panel.entries.clone());
+ #[rustfmt::skip]
+ pretty_assertions::assert_matches!(
+ entries.as_slice(),
+ &[
+ Header(GitHeaderEntry { header: Section::Tracked }),
+ Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
+ Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
+ Header(GitHeaderEntry { header: Section::New }),
+ Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
+ Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
+ ],
+ );
+
+ // Unstaging is likewise section-scoped, and unlike the header checkbox it
+ // has a fixed direction rather than toggling.
+ panel.update_in(cx, |panel, window, cx| {
+ panel.selected_entry = Some(4);
+ panel.unstage_section(&git::UnstageSection, window, cx);
+ });
+
+ cx.read(|cx| {
+ project
+ .read(cx)
+ .worktrees(cx)
+ .next()
+ .unwrap()
+ .read(cx)
+ .as_local()
+ .unwrap()
+ .scan_complete()
+ })
+ .await;
+
+ cx.executor().run_until_parked();
+
+ let handle = cx.update_window_entity(&panel, |panel, _, _| {
+ std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
+ });
+ cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
+ handle.await;
+
+ let entries = panel.read_with(cx, |panel, _| panel.entries.clone());
+ #[rustfmt::skip]
+ pretty_assertions::assert_matches!(
+ entries.as_slice(),
+ &[
+ Header(GitHeaderEntry { header: Section::Tracked }),
+ Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
+ Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
+ Header(GitHeaderEntry { header: Section::New }),
+ Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
+ Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
+ ],
+ );
+
+ // Re-issuing the same direction is idempotent, where a toggle would flip back.
+ panel.update_in(cx, |panel, window, cx| {
+ panel.selected_entry = Some(4);
+ panel.unstage_section(&git::UnstageSection, window, cx);
+ });
+
+ cx.read(|cx| {
+ project
+ .read(cx)
+ .worktrees(cx)
+ .next()
+ .unwrap()
+ .read(cx)
+ .as_local()
+ .unwrap()
+ .scan_complete()
+ })
+ .await;
+
+ cx.executor().run_until_parked();
+
+ let handle = cx.update_window_entity(&panel, |panel, _, _| {
+ std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
+ });
+ cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
+ handle.await;
+
+ let entries = panel.read_with(cx, |panel, _| panel.entries.clone());
+ #[rustfmt::skip]
+ pretty_assertions::assert_matches!(
+ entries.as_slice(),
+ &[
+ Header(GitHeaderEntry { header: Section::Tracked }),
+ Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
+ Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
+ Header(GitHeaderEntry { header: Section::New }),
+ Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
+ Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
+ ],
+ );
+ }
}
diff --git a/crates/git_ui/src/git_ui.rs b/crates/git_ui/src/git_ui.rs
index e33871266e..7038a7a8e9 100644
--- a/crates/git_ui/src/git_ui.rs
+++ b/crates/git_ui/src/git_ui.rs
@@ -255,6 +255,22 @@ pub fn init(cx: &mut App) {
panel.stash_all(action, window, cx);
});
});
+ workspace.register_action(|workspace, action: &git::StashStaged, window, cx| {
+ let Some(panel) = workspace.panel::(cx) else {
+ return;
+ };
+ panel.update(cx, |panel, cx| {
+ panel.stash_staged(action, window, cx);
+ });
+ });
+ workspace.register_action(|workspace, action: &git::StashTracked, window, cx| {
+ let Some(panel) = workspace.panel::(cx) else {
+ return;
+ };
+ panel.update(cx, |panel, cx| {
+ panel.stash_tracked(action, window, cx);
+ });
+ });
workspace.register_action(|workspace, action: &git::StashPop, 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 1df75d5192..2bd6847205 100644
--- a/crates/project/src/git_store.rs
+++ b/crates/project/src/git_store.rs
@@ -3446,6 +3446,15 @@ impl GitStore {
let message = envelope.payload.message;
+ if envelope.payload.staged.unwrap_or(false) {
+ repository_handle
+ .update(&mut cx, |repository_handle, cx| {
+ repository_handle.stash_staged(message, cx)
+ })
+ .await?;
+ return Ok(proto::Ack {});
+ }
+
let entries = envelope
.payload
.paths
@@ -7921,6 +7930,27 @@ impl Repository {
self.stash_entries(to_stash, message, cx)
}
+ pub fn stash_tracked(
+ &mut self,
+ message: Option,
+ cx: &mut Context,
+ ) -> Task> {
+ // `is_created` rather than `is_untracked`, so that staging a new file does not
+ // move it out of the untracked set: `git add` makes it `Tracked { Added }`, but
+ // the panel still lists it under "Untracked" and the user expects it left alone.
+ let to_stash: Vec<_> = self
+ .cached_status()
+ .filter(|entry| !entry.status.is_created())
+ .map(|entry| entry.repo_path)
+ .collect();
+
+ // An empty pathspec would make `git stash push` stash everything.
+ if to_stash.is_empty() {
+ return Task::ready(Ok(()));
+ }
+ self.stash_entries(to_stash, message, cx)
+ }
+
pub fn stash_entries(
&mut self,
entries: Vec,
@@ -7948,6 +7978,43 @@ impl Repository {
.map(|repo_path| repo_path.as_unix_str().to_owned())
.collect(),
message,
+ staged: None,
+ })
+ .await?;
+ Ok(())
+ }
+ }
+ })
+ })?
+ .await??;
+ Ok(())
+ })
+ }
+
+ pub fn stash_staged(
+ &mut self,
+ message: Option,
+ cx: &mut Context,
+ ) -> Task> {
+ let id = self.id;
+
+ cx.spawn(async move |this, cx| {
+ this.update(cx, |this, _| {
+ this.send_job("stash_staged", None, move |git_repo, _cx| async move {
+ match git_repo {
+ RepositoryState::Local(LocalRepositoryState {
+ backend,
+ environment,
+ ..
+ }) => backend.stash_staged(message, environment).await,
+ RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => {
+ client
+ .request(proto::Stash {
+ project_id: project_id.0,
+ repository_id: id.to_proto(),
+ paths: Vec::new(),
+ message,
+ staged: Some(true),
})
.await?;
Ok(())
diff --git a/crates/proto/proto/git.proto b/crates/proto/proto/git.proto
index 1d0ff18b5d..cf5560d783 100644
--- a/crates/proto/proto/git.proto
+++ b/crates/proto/proto/git.proto
@@ -379,6 +379,8 @@ message Stash {
uint64 repository_id = 2;
repeated string paths = 3;
optional string message = 4;
+ // When set, stash the index only (`git stash push --staged`) and ignore `paths`.
+ optional bool staged = 5;
}
message StashPop {
diff --git a/docs/src/git.md b/docs/src/git.md
index 4ef0b638f6..d1e2ad3cbf 100644
--- a/docs/src/git.md
+++ b/docs/src/git.md
@@ -155,6 +155,8 @@ From the panel, you can simply type a commit message and hit the commit button,
Entries can be staged using each individual entry's checkbox. All changes can be staged using the button at the top of the panel, or {#action git::StageAll}.
+Entries are grouped into sections (**Tracked** and **Untracked** by default), and each section can be staged on its own without touching the others. Click the checkbox on a section header, or use {#action git::StageSection} / {#action git::UnstageSection}, which act on the section containing the selected entry. These are also available by right-clicking an entry. Neither is bound by default; bind them in your keymap under the `GitPanel` context if you use them often.
+
To open a changed file in the editor without a diff view, right-click on the file in the Git Panel and select **View File**. Use **Open Diff** ({#kb menu::Confirm}) or **Open Diff (File)** to review changes in a diff view instead.
@@ -255,9 +257,13 @@ Git stash allows you to temporarily save your uncommitted changes and revert you
### Creating Stashes
-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.
+To stash all your current changes, use the {#action git::StashAll} action. This will save both staged and unstaged changes, including untracked files, 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.
+To stash only tracked changes and leave untracked files in place, use the {#action git::StashTracked} action. To stash only staged changes and leave unstaged changes in place, use the {#action git::StashStaged} action; this requires git 2.35 or newer.
+
+The Git Panel's overflow menu offers whichever of these matches the current **Group By** mode: **Stash Tracked** when grouping by Tracked & Untracked, and **Stash Staged** when grouping by Staged & Unstaged. **Stash All** is always available.
+
+Each of these actions first prompts for an optional stash name. Confirming with an empty prompt lets git generate its usual `WIP on ...` description.
### Managing Stashes
@@ -385,6 +391,8 @@ When viewing files with changes, Zed displays diff hunks that can be expanded or
| {#action git::Add} | {#kb git::Add} |
| {#action git::StageAll} | {#kb git::StageAll} |
| {#action git::UnstageAll} | {#kb git::UnstageAll} |
+| {#action git::StageSection} | {#kb git::StageSection} |
+| {#action git::UnstageSection} | {#kb git::UnstageSection} |
| {#action git::ToggleStaged} | {#kb git::ToggleStaged} |
| {#action git::StageAndNext} | {#kb git::StageAndNext} |
| {#action git::UnstageAndNext} | {#kb git::UnstageAndNext} |
@@ -404,6 +412,8 @@ When viewing files with changes, Zed displays diff hunks that can be expanded or
| {#action git::Worktree} | {#kb git::Worktree} |
| {#action git::Blame} | {#kb git::Blame} |
| {#action git::StashAll} | {#kb git::StashAll} |
+| {#action git::StashTracked} | {#kb git::StashTracked} |
+| {#action git::StashStaged} | {#kb git::StashStaged} |
| {#action git::StashPop} | {#kb git::StashPop} |
| {#action git::StashApply} | {#kb git::StashApply} |
| {#action git::ViewStash} | {#kb git::ViewStash} |