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} |