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:
@@ -3,6 +3,7 @@ pub mod commit;
|
||||
mod hosting_provider;
|
||||
mod remote;
|
||||
pub mod repository;
|
||||
pub mod stash;
|
||||
pub mod status;
|
||||
|
||||
pub use crate::hosting_provider::*;
|
||||
@@ -59,6 +60,8 @@ actions!(
|
||||
StashAll,
|
||||
/// Pops the most recent stash.
|
||||
StashPop,
|
||||
/// Apply the most recent stash.
|
||||
StashApply,
|
||||
/// Restores all tracked files to their last committed state.
|
||||
RestoreTrackedFiles,
|
||||
/// Moves all untracked files to trash.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use crate::commit::parse_git_diff_name_status;
|
||||
use crate::stash::GitStash;
|
||||
use crate::status::{GitStatus, StatusCode};
|
||||
use crate::{Oid, SHORT_SHA_LENGTH};
|
||||
use anyhow::{Context as _, Result, anyhow, bail};
|
||||
@@ -339,6 +340,8 @@ pub trait GitRepository: Send + Sync {
|
||||
|
||||
fn status(&self, path_prefixes: &[RepoPath]) -> Task<Result<GitStatus>>;
|
||||
|
||||
fn stash_entries(&self) -> BoxFuture<'_, Result<GitStash>>;
|
||||
|
||||
fn branches(&self) -> BoxFuture<'_, Result<Vec<Branch>>>;
|
||||
|
||||
fn change_branch(&self, name: String) -> BoxFuture<'_, Result<()>>;
|
||||
@@ -400,7 +403,23 @@ pub trait GitRepository: Send + Sync {
|
||||
env: Arc<HashMap<String, String>>,
|
||||
) -> BoxFuture<'_, Result<()>>;
|
||||
|
||||
fn stash_pop(&self, env: Arc<HashMap<String, String>>) -> BoxFuture<'_, Result<()>>;
|
||||
fn stash_pop(
|
||||
&self,
|
||||
index: Option<usize>,
|
||||
env: Arc<HashMap<String, String>>,
|
||||
) -> BoxFuture<'_, Result<()>>;
|
||||
|
||||
fn stash_apply(
|
||||
&self,
|
||||
index: Option<usize>,
|
||||
env: Arc<HashMap<String, String>>,
|
||||
) -> BoxFuture<'_, Result<()>>;
|
||||
|
||||
fn stash_drop(
|
||||
&self,
|
||||
index: Option<usize>,
|
||||
env: Arc<HashMap<String, String>>,
|
||||
) -> BoxFuture<'_, Result<()>>;
|
||||
|
||||
fn push(
|
||||
&self,
|
||||
@@ -975,6 +994,26 @@ impl GitRepository for RealGitRepository {
|
||||
})
|
||||
}
|
||||
|
||||
fn stash_entries(&self) -> BoxFuture<'_, Result<GitStash>> {
|
||||
let git_binary_path = self.git_binary_path.clone();
|
||||
let working_directory = self.working_directory();
|
||||
self.executor
|
||||
.spawn(async move {
|
||||
let output = new_std_command(&git_binary_path)
|
||||
.current_dir(working_directory?)
|
||||
.args(&["stash", "list", "--pretty=format:%gd%x00%H%x00%ct%x00%s"])
|
||||
.output()?;
|
||||
if output.status.success() {
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
stdout.parse()
|
||||
} else {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
anyhow::bail!("git status failed: {stderr}");
|
||||
}
|
||||
})
|
||||
.boxed()
|
||||
}
|
||||
|
||||
fn branches(&self) -> BoxFuture<'_, Result<Vec<Branch>>> {
|
||||
let working_directory = self.working_directory();
|
||||
let git_binary_path = self.git_binary_path.clone();
|
||||
@@ -1229,14 +1268,22 @@ impl GitRepository for RealGitRepository {
|
||||
.boxed()
|
||||
}
|
||||
|
||||
fn stash_pop(&self, env: Arc<HashMap<String, String>>) -> BoxFuture<'_, Result<()>> {
|
||||
fn stash_pop(
|
||||
&self,
|
||||
index: Option<usize>,
|
||||
env: Arc<HashMap<String, String>>,
|
||||
) -> BoxFuture<'_, Result<()>> {
|
||||
let working_directory = self.working_directory();
|
||||
self.executor
|
||||
.spawn(async move {
|
||||
let mut cmd = new_smol_command("git");
|
||||
let mut args = vec!["stash".to_string(), "pop".to_string()];
|
||||
if let Some(index) = index {
|
||||
args.push(format!("stash@{{{}}}", index));
|
||||
}
|
||||
cmd.current_dir(&working_directory?)
|
||||
.envs(env.iter())
|
||||
.args(["stash", "pop"]);
|
||||
.args(args);
|
||||
|
||||
let output = cmd.output().await?;
|
||||
|
||||
@@ -1250,6 +1297,64 @@ impl GitRepository for RealGitRepository {
|
||||
.boxed()
|
||||
}
|
||||
|
||||
fn stash_apply(
|
||||
&self,
|
||||
index: Option<usize>,
|
||||
env: Arc<HashMap<String, String>>,
|
||||
) -> BoxFuture<'_, Result<()>> {
|
||||
let working_directory = self.working_directory();
|
||||
self.executor
|
||||
.spawn(async move {
|
||||
let mut cmd = new_smol_command("git");
|
||||
let mut args = vec!["stash".to_string(), "apply".to_string()];
|
||||
if let Some(index) = index {
|
||||
args.push(format!("stash@{{{}}}", index));
|
||||
}
|
||||
cmd.current_dir(&working_directory?)
|
||||
.envs(env.iter())
|
||||
.args(args);
|
||||
|
||||
let output = cmd.output().await?;
|
||||
|
||||
anyhow::ensure!(
|
||||
output.status.success(),
|
||||
"Failed to apply stash:\n{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
Ok(())
|
||||
})
|
||||
.boxed()
|
||||
}
|
||||
|
||||
fn stash_drop(
|
||||
&self,
|
||||
index: Option<usize>,
|
||||
env: Arc<HashMap<String, String>>,
|
||||
) -> BoxFuture<'_, Result<()>> {
|
||||
let working_directory = self.working_directory();
|
||||
self.executor
|
||||
.spawn(async move {
|
||||
let mut cmd = new_smol_command("git");
|
||||
let mut args = vec!["stash".to_string(), "drop".to_string()];
|
||||
if let Some(index) = index {
|
||||
args.push(format!("stash@{{{}}}", index));
|
||||
}
|
||||
cmd.current_dir(&working_directory?)
|
||||
.envs(env.iter())
|
||||
.args(args);
|
||||
|
||||
let output = cmd.output().await?;
|
||||
|
||||
anyhow::ensure!(
|
||||
output.status.success(),
|
||||
"Failed to stash drop:\n{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
Ok(())
|
||||
})
|
||||
.boxed()
|
||||
}
|
||||
|
||||
fn commit(
|
||||
&self,
|
||||
message: SharedString,
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
use crate::Oid;
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use std::{str::FromStr, sync::Arc};
|
||||
|
||||
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
|
||||
pub struct StashEntry {
|
||||
pub index: usize,
|
||||
pub oid: Oid,
|
||||
pub message: String,
|
||||
pub branch: Option<String>,
|
||||
pub timestamp: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Eq, Hash, PartialEq)]
|
||||
pub struct GitStash {
|
||||
pub entries: Arc<[StashEntry]>,
|
||||
}
|
||||
|
||||
impl GitStash {
|
||||
pub fn apply(&mut self, other: GitStash) {
|
||||
self.entries = other.entries;
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for GitStash {
|
||||
type Err = anyhow::Error;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self> {
|
||||
if s.trim().is_empty() {
|
||||
return Ok(Self::default());
|
||||
}
|
||||
|
||||
let mut entries = Vec::new();
|
||||
let mut errors = Vec::new();
|
||||
|
||||
for (line_num, line) in s.lines().enumerate() {
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
match parse_stash_line(line) {
|
||||
Ok(entry) => entries.push(entry),
|
||||
Err(e) => {
|
||||
errors.push(format!("Line {}: {}", line_num + 1, e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we have some valid entries but also some errors, log the errors but continue
|
||||
if !errors.is_empty() && !entries.is_empty() {
|
||||
log::warn!("Failed to parse some stash entries: {}", errors.join(", "));
|
||||
} else if !errors.is_empty() {
|
||||
return Err(anyhow!(
|
||||
"Failed to parse stash entries: {}",
|
||||
errors.join(", ")
|
||||
));
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
entries: entries.into(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a single stash line in the format: "stash@{N}\0<oid>\0<timestamp>\0<message>"
|
||||
fn parse_stash_line(line: &str) -> Result<StashEntry> {
|
||||
let parts: Vec<&str> = line.splitn(4, '\0').collect();
|
||||
|
||||
if parts.len() != 4 {
|
||||
return Err(anyhow!(
|
||||
"Expected 4 null-separated parts, got {}",
|
||||
parts.len()
|
||||
));
|
||||
}
|
||||
|
||||
let index = parse_stash_index(parts[0])
|
||||
.with_context(|| format!("Failed to parse stash index from '{}'", parts[0]))?;
|
||||
|
||||
let oid = Oid::from_str(parts[1])
|
||||
.with_context(|| format!("Failed to parse OID from '{}'", parts[1]))?;
|
||||
|
||||
let timestamp = parts[2]
|
||||
.parse::<i64>()
|
||||
.with_context(|| format!("Failed to parse timestamp from '{}'", parts[2]))?;
|
||||
|
||||
let (branch, message) = parse_stash_message(parts[3]);
|
||||
|
||||
Ok(StashEntry {
|
||||
index,
|
||||
oid,
|
||||
message: message.to_string(),
|
||||
branch: branch.map(Into::into),
|
||||
timestamp,
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse stash index from format "stash@{N}" where N is the index
|
||||
fn parse_stash_index(input: &str) -> Result<usize> {
|
||||
let trimmed = input.trim();
|
||||
|
||||
if !trimmed.starts_with("stash@{") || !trimmed.ends_with('}') {
|
||||
return Err(anyhow!(
|
||||
"Invalid stash index format: expected 'stash@{{N}}'"
|
||||
));
|
||||
}
|
||||
|
||||
let index_str = trimmed
|
||||
.strip_prefix("stash@{")
|
||||
.and_then(|s| s.strip_suffix('}'))
|
||||
.ok_or_else(|| anyhow!("Failed to extract index from stash reference"))?;
|
||||
|
||||
index_str
|
||||
.parse::<usize>()
|
||||
.with_context(|| format!("Invalid stash index number: '{}'", index_str))
|
||||
}
|
||||
|
||||
/// Parse stash message and extract branch information if present
|
||||
///
|
||||
/// Handles the following formats:
|
||||
/// - "WIP on <branch>: <message>" -> (Some(branch), message)
|
||||
/// - "On <branch>: <message>" -> (Some(branch), message)
|
||||
/// - "<message>" -> (None, message)
|
||||
fn parse_stash_message(input: &str) -> (Option<&str>, &str) {
|
||||
// Handle "WIP on <branch>: <message>" pattern
|
||||
if let Some(stripped) = input.strip_prefix("WIP on ")
|
||||
&& let Some(colon_pos) = stripped.find(": ")
|
||||
{
|
||||
let branch = &stripped[..colon_pos];
|
||||
let message = &stripped[colon_pos + 2..];
|
||||
if !branch.is_empty() && !message.is_empty() {
|
||||
return (Some(branch), message);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle "On <branch>: <message>" pattern
|
||||
if let Some(stripped) = input.strip_prefix("On ")
|
||||
&& let Some(colon_pos) = stripped.find(": ")
|
||||
{
|
||||
let branch = &stripped[..colon_pos];
|
||||
let message = &stripped[colon_pos + 2..];
|
||||
if !branch.is_empty() && !message.is_empty() {
|
||||
return (Some(branch), message);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: treat entire input as message with no branch
|
||||
(None, input)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_stash_index() {
|
||||
assert_eq!(parse_stash_index("stash@{0}").unwrap(), 0);
|
||||
assert_eq!(parse_stash_index("stash@{42}").unwrap(), 42);
|
||||
assert_eq!(parse_stash_index(" stash@{5} ").unwrap(), 5);
|
||||
|
||||
assert!(parse_stash_index("invalid").is_err());
|
||||
assert!(parse_stash_index("stash@{not_a_number}").is_err());
|
||||
assert!(parse_stash_index("stash@{0").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_stash_message() {
|
||||
// WIP format
|
||||
let (branch, message) = parse_stash_message("WIP on main: working on feature");
|
||||
assert_eq!(branch, Some("main"));
|
||||
assert_eq!(message, "working on feature");
|
||||
|
||||
// On format
|
||||
let (branch, message) = parse_stash_message("On feature-branch: some changes");
|
||||
assert_eq!(branch, Some("feature-branch"));
|
||||
assert_eq!(message, "some changes");
|
||||
|
||||
// No branch format
|
||||
let (branch, message) = parse_stash_message("just a regular message");
|
||||
assert_eq!(branch, None);
|
||||
assert_eq!(message, "just a regular message");
|
||||
|
||||
// Edge cases
|
||||
let (branch, message) = parse_stash_message("WIP on : empty message");
|
||||
assert_eq!(branch, None);
|
||||
assert_eq!(message, "WIP on : empty message");
|
||||
|
||||
let (branch, message) = parse_stash_message("On branch-name:");
|
||||
assert_eq!(branch, None);
|
||||
assert_eq!(message, "On branch-name:");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_stash_line() {
|
||||
let line = "stash@{0}\u{0000}abc123\u{0000}1234567890\u{0000}WIP on main: test commit";
|
||||
let entry = parse_stash_line(line).unwrap();
|
||||
|
||||
assert_eq!(entry.index, 0);
|
||||
assert_eq!(entry.message, "test commit");
|
||||
assert_eq!(entry.branch, Some("main".to_string()));
|
||||
assert_eq!(entry.timestamp, 1234567890);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_git_stash_from_str() {
|
||||
let input = "stash@{0}\u{0000}abc123\u{0000}1234567890\u{0000}WIP on main: first stash\nstash@{1}\u{0000}def456\u{0000}1234567891\u{0000}On feature: second stash";
|
||||
let stash = GitStash::from_str(input).unwrap();
|
||||
|
||||
assert_eq!(stash.entries.len(), 2);
|
||||
assert_eq!(stash.entries[0].index, 0);
|
||||
assert_eq!(stash.entries[0].branch, Some("main".to_string()));
|
||||
assert_eq!(stash.entries[1].index, 1);
|
||||
assert_eq!(stash.entries[1].branch, Some("feature".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_git_stash_empty_input() {
|
||||
let stash = GitStash::from_str("").unwrap();
|
||||
assert_eq!(stash.entries.len(), 0);
|
||||
|
||||
let stash = GitStash::from_str(" \n \n ").unwrap();
|
||||
assert_eq!(stash.entries.len(), 0);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user