feat(git stash): add stash listing and push/apply/pop/drop UI

This change introduces Git stash support end-to-end, including a new
backend command to list stashes and operations to push, apply, pop, and
drop them. The frontend now fetches stashes as part of the repository
bundle and provides a dedicated panel to manage shelved changes.

- Add GitStash model and Tauri commands for stash operations
- Implement StashPanel component and wire it into the app
- Adjust sidebar layout and add stash-specific styling
This commit is contained in:
Christoph Brandau
2026-07-04 00:16:27 +02:00
parent 201bb90bf7
commit 1d67312ee4
7 changed files with 497 additions and 5 deletions
+142
View File
@@ -56,6 +56,16 @@ pub struct GitBranch {
pub remote: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct GitStash {
pub selector: String,
pub index: u32,
pub hash: String,
pub branch: Option<String>,
pub message: String,
pub date: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct GitCommit {
pub hash: String,
@@ -234,6 +244,7 @@ pub fn open_repository_file(path: String, file: String) -> Result<(), String> {
pub struct RepositoryBundle {
pub status: GitStatus,
pub branches: Vec<GitBranch>,
pub stashes: Vec<GitStash>,
pub commits: Vec<GitCommit>,
pub files: Vec<GitRepositoryFile>,
}
@@ -252,11 +263,13 @@ pub async fn open_repository_bundle(
let repo = resolve_repo(&path)?;
let status = status_for_repo(&repo)?;
let branches = branches_for_repo(&repo)?;
let stashes = stashes_for_repo(&repo)?;
let commits = commits_for_repo(&repo, commit_limit)?;
let files = repository_files_with_status(&repo, &status)?;
Ok(RepositoryBundle {
status,
branches,
stashes,
commits,
files,
})
@@ -277,6 +290,12 @@ pub fn list_branches(path: String) -> Result<Vec<GitBranch>, String> {
branches_for_repo(&repo)
}
#[tauri::command]
pub fn list_stashes(path: String) -> Result<Vec<GitStash>, String> {
let repo = resolve_repo(&path)?;
stashes_for_repo(&repo)
}
fn branches_for_repo(repo: &Path) -> Result<Vec<GitBranch>, String> {
let output = run_git(
repo,
@@ -316,6 +335,129 @@ fn branches_for_repo(repo: &Path) -> Result<Vec<GitBranch>, String> {
Ok(branches)
}
fn stashes_for_repo(repo: &Path) -> Result<Vec<GitStash>, String> {
let output = run_git(repo, ["stash", "list", "--format=%gd%x00%H%x00%cr%x00%gs"])?;
let text = String::from_utf8_lossy(&output);
let mut stashes = Vec::new();
for line in text.lines() {
let mut parts = line.splitn(4, '\0');
let selector = parts.next().unwrap_or_default().trim();
let hash = parts.next().unwrap_or_default().trim();
let date = parts.next().unwrap_or_default().trim();
let subject = parts.next().unwrap_or_default().trim();
if selector.is_empty() {
continue;
}
let index = stash_index_from_selector(selector).unwrap_or(stashes.len() as u32);
let (branch, message) = parse_stash_subject(subject);
stashes.push(GitStash {
selector: selector.to_string(),
index,
hash: hash.to_string(),
branch,
message,
date: date.to_string(),
});
}
Ok(stashes)
}
fn stash_index_from_selector(selector: &str) -> Option<u32> {
selector
.strip_prefix("stash@{")
.and_then(|value| value.strip_suffix('}'))
.and_then(|value| value.parse::<u32>().ok())
}
fn parse_stash_subject(subject: &str) -> (Option<String>, String) {
for prefix in ["WIP on ", "On "] {
if let Some(value) = subject.strip_prefix(prefix) {
if let Some((branch, rest)) = value.split_once(": ") {
let message = if prefix == "WIP on " {
rest.split_once(' ').map(|(_, msg)| msg).unwrap_or(rest)
} else {
rest
};
return (Some(branch.to_string()), message.to_string());
}
}
}
(None, subject.to_string())
}
#[tauri::command]
pub fn stash_push(
path: String,
message: Option<String>,
include_untracked: bool,
) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
let trimmed_message = message.unwrap_or_default().trim().to_string();
let mut args: Vec<OsString> = vec![OsString::from("stash"), OsString::from("push")];
if include_untracked {
args.push(OsString::from("--include-untracked"));
}
if !trimmed_message.is_empty() {
args.push(OsString::from("-m"));
args.push(OsString::from(trimmed_message));
}
run_git(&repo, args)?;
status_for_repo(&repo)
}
#[tauri::command]
pub fn stash_apply(path: String, selector: String) -> Result<GitStatus, String> {
run_stash_update(path, "apply", selector)
}
#[tauri::command]
pub fn stash_pop(path: String, selector: String) -> Result<GitStatus, String> {
run_stash_update(path, "pop", selector)
}
#[tauri::command]
pub fn stash_drop(path: String, selector: String) -> Result<GitStatus, String> {
run_stash_update(path, "drop", selector)
}
fn run_stash_update(path: String, action: &str, selector: String) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
let selector = validate_stash_selector(&selector)?;
let output = git_command()
.arg("-C")
.arg(&repo)
.args(["stash", action, selector.as_str()])
.output()
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?;
if output.status.success() {
return status_for_repo(&repo);
}
let status = status_for_repo(&repo)?;
if has_unresolved_conflicts(&status) {
return Ok(status);
}
Err(format!(
"Git command failed: {}",
command_output_details(&output)
))
}
fn validate_stash_selector(selector: &str) -> Result<String, String> {
let selector = selector.trim();
let Some(index) = stash_index_from_selector(selector) else {
return Err("Invalid stash selector.".to_string());
};
Ok(format!("stash@{{{index}}}"))
}
#[tauri::command]
pub fn checkout_branch(path: String, branch: String) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
+8 -2
View File
@@ -10,10 +10,11 @@ use git::{
commit_ai_status, compare_commits, compare_file_to_head, compare_file_to_parent, create_branch,
cred_delete, cred_load, cred_save, delete_branch, diff_file_against_working_tree, fetch,
get_file_patch, get_remote_url, get_status, list_branches, list_commits, list_file_history,
list_repository_files, merge_branch, open_repo_in_explorer, open_repository,
list_repository_files, list_stashes, merge_branch, open_repo_in_explorer, open_repository,
open_repository_bundle, open_repository_file, pull, push, read_conflict, rename_branch,
resolve_conflict, resolve_conflict_side, restore_file_from_commit, restore_files,
restore_to_commit, search_code_introductions, stage_files, unstage_files,
restore_to_commit, search_code_introductions, stage_files, stash_apply, stash_drop, stash_pop,
stash_push, unstage_files,
};
fn main() {
@@ -28,12 +29,17 @@ fn main() {
open_repository_file,
get_status,
list_branches,
list_stashes,
checkout_branch,
create_branch,
rename_branch,
delete_branch,
stage_files,
unstage_files,
stash_push,
stash_apply,
stash_pop,
stash_drop,
restore_files,
get_file_patch,
apply_file_patch,