git: Add git stash picker (#35927)

Closes #ISSUE

This PR continues work from #32821 by adding a stash entry picker for
pop/drop operations. Additionally, the stash pop action in the git panel
is now disabled when no stash entries exist, preventing error logs from
attempted pops on empty stashes.

Preview:

<img width="1920" height="1256" alt="Screenshot From 2025-09-11
14-08-31"
src="https://github.com/user-attachments/assets/b2f32974-8c69-4e50-8951-24ab2cf93c12"
/>

<img width="1920" height="1256" alt="Screenshot From 2025-09-11
14-08-12"
src="https://github.com/user-attachments/assets/992ce237-43c9-456e-979c-c2e2149d633e"
/>



Release Notes:

- Added a stash picker to pop and drop a specific stash entry
- Disabled the stash pop action on the git panel when no stash entries
exist
- Added git stash apply command
- Added git stash drop command
This commit is contained in:
Alvaro Parker
2025-09-12 14:45:38 -04:00
committed by GitHub
parent 2143c59fba
commit 4b7595c94c
19 changed files with 1179 additions and 21 deletions
+201 -11
View File
@@ -20,7 +20,7 @@ use futures::{
stream::FuturesOrdered,
};
use git::{
BuildPermalinkParams, GitHostingProviderRegistry, WORK_DIRECTORY_REPO_PATH,
BuildPermalinkParams, GitHostingProviderRegistry, Oid, WORK_DIRECTORY_REPO_PATH,
blame::Blame,
parse_git_remote_url,
repository::{
@@ -28,6 +28,7 @@ use git::{
GitRepository, GitRepositoryCheckpoint, PushOptions, Remote, RemoteCommandOutput, RepoPath,
ResetMode, UpstreamTrackingStatus,
},
stash::{GitStash, StashEntry},
status::{
FileStatus, GitSummary, StatusCode, TrackedStatus, UnmergedStatus, UnmergedStatusCode,
},
@@ -244,6 +245,7 @@ pub struct RepositorySnapshot {
pub merge: MergeDetails,
pub remote_origin_url: Option<String>,
pub remote_upstream_url: Option<String>,
pub stash_entries: GitStash,
}
type JobId = u64;
@@ -404,6 +406,8 @@ impl GitStore {
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_stash_apply);
client.add_entity_request_handler(Self::handle_stash_drop);
client.add_entity_request_handler(Self::handle_commit);
client.add_entity_request_handler(Self::handle_reset);
client.add_entity_request_handler(Self::handle_show);
@@ -1744,16 +1748,53 @@ impl GitStore {
) -> 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 stash_index = envelope.payload.stash_index.map(|i| i as usize);
repository_handle
.update(&mut cx, |repository_handle, cx| {
repository_handle.stash_pop(cx)
repository_handle.stash_pop(stash_index, cx)
})?
.await?;
Ok(proto::Ack {})
}
async fn handle_stash_apply(
this: Entity<Self>,
envelope: TypedEnvelope<proto::StashApply>,
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 stash_index = envelope.payload.stash_index.map(|i| i as usize);
repository_handle
.update(&mut cx, |repository_handle, cx| {
repository_handle.stash_apply(stash_index, cx)
})?
.await?;
Ok(proto::Ack {})
}
async fn handle_stash_drop(
this: Entity<Self>,
envelope: TypedEnvelope<proto::StashDrop>,
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 stash_index = envelope.payload.stash_index.map(|i| i as usize);
repository_handle
.update(&mut cx, |repository_handle, cx| {
repository_handle.stash_drop(stash_index, cx)
})?
.await??;
Ok(proto::Ack {})
}
async fn handle_set_index_text(
this: Entity<Self>,
envelope: TypedEnvelope<proto::SetIndexText>,
@@ -2710,6 +2751,7 @@ impl RepositorySnapshot {
merge: Default::default(),
remote_origin_url: None,
remote_upstream_url: None,
stash_entries: Default::default(),
}
}
@@ -2736,6 +2778,12 @@ impl RepositorySnapshot {
entry_ids: vec![self.id.to_proto()],
scan_id: self.scan_id,
is_last_update: true,
stash_entries: self
.stash_entries
.entries
.iter()
.map(stash_to_proto)
.collect(),
}
}
@@ -2799,6 +2847,12 @@ impl RepositorySnapshot {
entry_ids: vec![],
scan_id: self.scan_id,
is_last_update: true,
stash_entries: self
.stash_entries
.entries
.iter()
.map(stash_to_proto)
.collect(),
}
}
@@ -2855,6 +2909,26 @@ impl RepositorySnapshot {
}
}
pub fn stash_to_proto(entry: &StashEntry) -> proto::StashEntry {
proto::StashEntry {
oid: entry.oid.as_bytes().to_vec(),
message: entry.message.clone(),
branch: entry.branch.clone(),
index: entry.index as u64,
timestamp: entry.timestamp,
}
}
pub fn proto_to_stash(entry: &proto::StashEntry) -> Result<StashEntry> {
Ok(StashEntry {
oid: Oid::from_bytes(&entry.oid)?,
message: entry.message.clone(),
index: entry.index as usize,
branch: entry.branch.clone(),
timestamp: entry.timestamp,
})
}
impl MergeDetails {
async fn load(
backend: &Arc<dyn GitRepository>,
@@ -3230,6 +3304,10 @@ impl Repository {
self.snapshot.status()
}
pub fn cached_stash(&self) -> GitStash {
self.snapshot.stash_entries.clone()
}
pub fn repo_path_to_project_path(&self, path: &RepoPath, cx: &App) -> Option<ProjectPath> {
let git_store = self.git_store.upgrade()?;
let worktree_store = git_store.read(cx).worktree_store.read(cx);
@@ -3665,7 +3743,11 @@ impl Repository {
})
}
pub fn stash_pop(&mut self, cx: &mut Context<Self>) -> Task<anyhow::Result<()>> {
pub fn stash_pop(
&mut self,
index: Option<usize>,
cx: &mut Context<Self>,
) -> Task<anyhow::Result<()>> {
let id = self.id;
cx.spawn(async move |this, cx| {
this.update(cx, |this, _| {
@@ -3675,12 +3757,13 @@ impl Repository {
backend,
environment,
..
} => backend.stash_pop(environment).await,
} => backend.stash_pop(index, environment).await,
RepositoryState::Remote { project_id, client } => {
client
.request(proto::StashPop {
project_id: project_id.0,
repository_id: id.to_proto(),
stash_index: index.map(|i| i as u64),
})
.await
.context("sending stash pop request")?;
@@ -3694,6 +3777,99 @@ impl Repository {
})
}
pub fn stash_apply(
&mut self,
index: Option<usize>,
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_apply(index, environment).await,
RepositoryState::Remote { project_id, client } => {
client
.request(proto::StashApply {
project_id: project_id.0,
repository_id: id.to_proto(),
stash_index: index.map(|i| i as u64),
})
.await
.context("sending stash apply request")?;
Ok(())
}
}
})
})?
.await??;
Ok(())
})
}
pub fn stash_drop(
&mut self,
index: Option<usize>,
cx: &mut Context<Self>,
) -> oneshot::Receiver<anyhow::Result<()>> {
let id = self.id;
let updates_tx = self
.git_store()
.and_then(|git_store| match &git_store.read(cx).state {
GitStoreState::Local { downstream, .. } => downstream
.as_ref()
.map(|downstream| downstream.updates_tx.clone()),
_ => None,
});
let this = cx.weak_entity();
self.send_job(None, move |git_repo, mut cx| async move {
match git_repo {
RepositoryState::Local {
backend,
environment,
..
} => {
let result = backend.stash_drop(index, environment).await;
if result.is_ok()
&& let Ok(stash_entries) = backend.stash_entries().await
{
let snapshot = this.update(&mut cx, |this, cx| {
this.snapshot.stash_entries = stash_entries;
let snapshot = this.snapshot.clone();
cx.emit(RepositoryEvent::Updated {
full_scan: false,
new_instance: false,
});
snapshot
})?;
if let Some(updates_tx) = updates_tx {
updates_tx
.unbounded_send(DownstreamUpdate::UpdateRepository(snapshot))
.ok();
}
}
result
}
RepositoryState::Remote { project_id, client } => {
client
.request(proto::StashDrop {
project_id: project_id.0,
repository_id: id.to_proto(),
stash_index: index.map(|i| i as u64),
})
.await
.context("sending stash pop request")?;
Ok(())
}
}
})
}
pub fn commit(
&mut self,
message: SharedString,
@@ -4219,6 +4395,13 @@ impl Repository {
self.snapshot.merge.conflicted_paths = conflicted_paths;
self.snapshot.merge.message = update.merge_message.map(SharedString::from);
self.snapshot.stash_entries = GitStash {
entries: update
.stash_entries
.iter()
.filter_map(|entry| proto_to_stash(entry).ok())
.collect(),
};
let edits = update
.removed_statuses
@@ -4528,6 +4711,7 @@ impl Repository {
return Ok(());
}
let statuses = backend.status(&paths).await?;
let stash_entries = backend.stash_entries().await?;
let changed_path_statuses = cx
.background_spawn(async move {
@@ -4559,18 +4743,22 @@ impl Repository {
.await;
this.update(&mut cx, |this, cx| {
let needs_update = !changed_path_statuses.is_empty()
|| this.snapshot.stash_entries != stash_entries;
this.snapshot.stash_entries = stash_entries;
if !changed_path_statuses.is_empty() {
this.snapshot
.statuses_by_path
.edit(changed_path_statuses, &());
this.snapshot.scan_id += 1;
if let Some(updates_tx) = updates_tx {
updates_tx
.unbounded_send(DownstreamUpdate::UpdateRepository(
this.snapshot.clone(),
))
.ok();
}
}
if needs_update && let Some(updates_tx) = updates_tx {
updates_tx
.unbounded_send(DownstreamUpdate::UpdateRepository(
this.snapshot.clone(),
))
.ok();
}
cx.emit(RepositoryEvent::Updated {
full_scan: false,
@@ -4821,6 +5009,7 @@ async fn compute_snapshot(
let statuses = backend
.status(std::slice::from_ref(&WORK_DIRECTORY_REPO_PATH))
.await?;
let stash_entries = backend.stash_entries().await?;
let statuses_by_path = SumTree::from_iter(
statuses
.entries
@@ -4871,6 +5060,7 @@ async fn compute_snapshot(
merge: merge_details,
remote_origin_url,
remote_upstream_url,
stash_entries,
};
Ok((snapshot, events))