feat(git): add tag management and cherry-pick workflow
This update extends the Git backend and UI to support listing, creating, deleting, and pushing tags. It also adds cherry-pick commands with proper in-progress detection and conflict handling, and prevents other operations while a cherry-pick is active. - Add GitTag model, tag listing, and tag CRUD/push commands - Implement cherry-pick start/continue/abort with status tracking - Update UI to display tags and gate actions during cherry-pick
This commit is contained in:
@@ -48,6 +48,7 @@ pub struct GitStatus {
|
||||
pub files: Vec<GitFileStatus>,
|
||||
pub clean: bool,
|
||||
pub rebase_in_progress: bool,
|
||||
pub cherry_pick_in_progress: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
@@ -57,6 +58,16 @@ pub struct GitBranch {
|
||||
pub remote: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct GitTag {
|
||||
pub name: String,
|
||||
pub hash: String,
|
||||
pub short_hash: String,
|
||||
pub message: Option<String>,
|
||||
pub date: String,
|
||||
pub annotated: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct GitStash {
|
||||
pub selector: String,
|
||||
@@ -245,6 +256,7 @@ pub fn open_repository_file(path: String, file: String) -> Result<(), String> {
|
||||
pub struct RepositoryBundle {
|
||||
pub status: GitStatus,
|
||||
pub branches: Vec<GitBranch>,
|
||||
pub tags: Vec<GitTag>,
|
||||
pub stashes: Vec<GitStash>,
|
||||
pub commits: Vec<GitCommit>,
|
||||
pub files: Vec<GitRepositoryFile>,
|
||||
@@ -287,12 +299,14 @@ pub async fn open_repository_bundle(
|
||||
let repo = resolve_repo(&path)?;
|
||||
let status = status_for_repo(&repo)?;
|
||||
let branches = branches_for_repo(&repo)?;
|
||||
let tags = tags_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,
|
||||
tags,
|
||||
stashes,
|
||||
commits,
|
||||
files,
|
||||
@@ -320,6 +334,63 @@ pub fn list_stashes(path: String) -> Result<Vec<GitStash>, String> {
|
||||
stashes_for_repo(&repo)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn list_tags(path: String) -> Result<Vec<GitTag>, String> {
|
||||
let repo = resolve_repo(&path)?;
|
||||
tags_for_repo(&repo)
|
||||
}
|
||||
|
||||
fn tags_for_repo(repo: &Path) -> Result<Vec<GitTag>, String> {
|
||||
let output = run_git(
|
||||
repo,
|
||||
[
|
||||
"for-each-ref",
|
||||
"--sort=-creatordate",
|
||||
"refs/tags",
|
||||
"--format=%(refname:short)%00%(objectname)%00%(objectname:short)%00%(*objectname)%00%(*objectname:short)%00%(contents:subject)%00%(creatordate:iso-strict)",
|
||||
],
|
||||
)?;
|
||||
let text = String::from_utf8_lossy(&output);
|
||||
let mut tags = Vec::new();
|
||||
|
||||
for line in text.lines() {
|
||||
let mut parts = line.splitn(7, '\0');
|
||||
let name = parts.next().unwrap_or_default().trim();
|
||||
let object_hash = parts.next().unwrap_or_default().trim();
|
||||
let object_short = parts.next().unwrap_or_default().trim();
|
||||
let deref_hash = parts.next().unwrap_or_default().trim();
|
||||
let deref_short = parts.next().unwrap_or_default().trim();
|
||||
let subject = parts.next().unwrap_or_default().trim();
|
||||
let date = parts.next().unwrap_or_default().trim();
|
||||
|
||||
if name.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Annotated tags are their own object with a `taggerdate`/subject and
|
||||
// dereference (`*...`) to the commit they point at; lightweight tags
|
||||
// point straight at the commit, so the dereferenced fields are empty.
|
||||
let annotated = !deref_hash.is_empty();
|
||||
let hash = if annotated { deref_hash } else { object_hash };
|
||||
let short_hash = if annotated { deref_short } else { object_short };
|
||||
|
||||
tags.push(GitTag {
|
||||
name: name.to_string(),
|
||||
hash: hash.to_string(),
|
||||
short_hash: short_hash.to_string(),
|
||||
message: if annotated && !subject.is_empty() {
|
||||
Some(subject.to_string())
|
||||
} else {
|
||||
None
|
||||
},
|
||||
date: date.to_string(),
|
||||
annotated,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(tags)
|
||||
}
|
||||
|
||||
fn branches_for_repo(repo: &Path) -> Result<Vec<GitBranch>, String> {
|
||||
let output = run_git(
|
||||
repo,
|
||||
@@ -570,6 +641,123 @@ pub fn delete_branch(
|
||||
status_for_repo(&repo)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn create_tag(
|
||||
path: String,
|
||||
name: String,
|
||||
target: Option<String>,
|
||||
message: Option<String>,
|
||||
) -> Result<Vec<GitTag>, String> {
|
||||
let repo = resolve_repo(&path)?;
|
||||
let name = validate_new_tag_name(&repo, &name)?;
|
||||
let target = match target {
|
||||
Some(target) if !target.trim().is_empty() => verify_commit(&repo, &target)?,
|
||||
_ => verify_commit(&repo, "HEAD")?,
|
||||
};
|
||||
let message = message
|
||||
.map(|message| message.trim().to_string())
|
||||
.filter(|message| !message.is_empty());
|
||||
|
||||
match message {
|
||||
Some(message) => {
|
||||
run_git(
|
||||
&repo,
|
||||
["tag", "-a", name.as_str(), "-m", message.as_str(), target.as_str()],
|
||||
)?;
|
||||
}
|
||||
None => {
|
||||
run_git(&repo, ["tag", name.as_str(), target.as_str()])?;
|
||||
}
|
||||
}
|
||||
|
||||
tags_for_repo(&repo)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn delete_tag(path: String, name: String) -> Result<Vec<GitTag>, String> {
|
||||
let repo = resolve_repo(&path)?;
|
||||
let name = validate_existing_tag_name(&repo, &name)?;
|
||||
run_git(&repo, ["tag", "-d", name.as_str()])?;
|
||||
tags_for_repo(&repo)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn push_tag(
|
||||
path: String,
|
||||
name: String,
|
||||
username: Option<String>,
|
||||
password: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
let repo = resolve_repo(&path)?;
|
||||
let name = validate_existing_tag_name(&repo, &name)?;
|
||||
let remote = initial_push_remote_name(&repo)?;
|
||||
let tag_ref = format!("refs/tags/{name}");
|
||||
let push_args = ["push", remote.as_str(), tag_ref.as_str()];
|
||||
|
||||
match (username.as_deref(), password.as_deref()) {
|
||||
(Some(u), Some(p)) if !u.is_empty() || !p.is_empty() => {
|
||||
run_git_authenticated(&repo, push_args, u, p)?;
|
||||
}
|
||||
_ => {
|
||||
run_git(&repo, push_args)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_new_tag_name(repo: &Path, name: &str) -> Result<String, String> {
|
||||
let name = name.trim();
|
||||
if name.is_empty() {
|
||||
return Err("Tag name must not be empty.".to_string());
|
||||
}
|
||||
|
||||
let normalized = validate_tag_ref_name(name)?;
|
||||
if ref_exists(repo, &format!("refs/tags/{normalized}"))? {
|
||||
return Err(format!("Tag '{normalized}' already exists."));
|
||||
}
|
||||
|
||||
Ok(normalized)
|
||||
}
|
||||
|
||||
fn validate_existing_tag_name(repo: &Path, name: &str) -> Result<String, String> {
|
||||
let name = name.trim();
|
||||
if name.is_empty() {
|
||||
return Err("Tag name must not be empty.".to_string());
|
||||
}
|
||||
|
||||
let normalized = validate_tag_ref_name(name)?;
|
||||
if !ref_exists(repo, &format!("refs/tags/{normalized}"))? {
|
||||
return Err(format!("Tag '{normalized}' was not found."));
|
||||
}
|
||||
|
||||
Ok(normalized)
|
||||
}
|
||||
|
||||
fn validate_tag_ref_name(name: &str) -> Result<String, String> {
|
||||
let output = git_command()
|
||||
.args(["check-ref-format", "--allow-onelevel", &format!("refs/tags/{name}")])
|
||||
.output()
|
||||
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let details = command_output_details(&output);
|
||||
return Err(format!("Invalid tag name: {details}"));
|
||||
}
|
||||
|
||||
let normalized = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
let normalized = normalized
|
||||
.strip_prefix("refs/tags/")
|
||||
.map(str::to_string)
|
||||
.unwrap_or(normalized);
|
||||
|
||||
Ok(if normalized.is_empty() {
|
||||
name.to_string()
|
||||
} else {
|
||||
normalized
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn stage_files(path: String, files: Vec<String>) -> Result<GitStatus, String> {
|
||||
let repo = resolve_repo(&path)?;
|
||||
@@ -1214,6 +1402,68 @@ fn rebase_status_or_error(
|
||||
Err(format!("{context}: {}", command_output_details(&output)))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn cherry_pick_commit(path: String, commit: String) -> Result<GitStatus, String> {
|
||||
let repo = resolve_repo(&path)?;
|
||||
let commit_hash = verify_commit(&repo, &commit)?;
|
||||
|
||||
let output = git_command()
|
||||
.arg("-C")
|
||||
.arg(&repo)
|
||||
.args(["cherry-pick", commit_hash.as_str()])
|
||||
.env("GIT_EDITOR", "true")
|
||||
.output()
|
||||
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?;
|
||||
|
||||
cherry_pick_status_or_error(&repo, output, "Cherry-pick failed")
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn cherry_pick_continue(path: String) -> Result<GitStatus, String> {
|
||||
let repo = resolve_repo(&path)?;
|
||||
if !cherry_pick_in_progress(&repo) {
|
||||
return Err("No cherry-pick is currently in progress.".to_string());
|
||||
}
|
||||
|
||||
let output = git_command()
|
||||
.arg("-C")
|
||||
.arg(&repo)
|
||||
.args(["cherry-pick", "--continue"])
|
||||
.env("GIT_EDITOR", "true")
|
||||
.output()
|
||||
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?;
|
||||
|
||||
cherry_pick_status_or_error(&repo, output, "Cherry-pick continue failed")
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn cherry_pick_abort(path: String) -> Result<GitStatus, String> {
|
||||
let repo = resolve_repo(&path)?;
|
||||
if !cherry_pick_in_progress(&repo) {
|
||||
return Err("No cherry-pick is currently in progress.".to_string());
|
||||
}
|
||||
|
||||
run_git(&repo, ["cherry-pick", "--abort"])?;
|
||||
status_for_repo(&repo)
|
||||
}
|
||||
|
||||
fn cherry_pick_status_or_error(
|
||||
repo: &Path,
|
||||
output: Output,
|
||||
context: &str,
|
||||
) -> Result<GitStatus, String> {
|
||||
if output.status.success() {
|
||||
return status_for_repo(repo);
|
||||
}
|
||||
|
||||
let status = status_for_repo(repo)?;
|
||||
if has_unresolved_conflicts(&status) || status.cherry_pick_in_progress {
|
||||
return Ok(status);
|
||||
}
|
||||
|
||||
Err(format!("{context}: {}", command_output_details(&output)))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn list_commits(path: String, limit: Option<u32>) -> Result<Vec<GitCommit>, String> {
|
||||
let repo = resolve_repo(&path)?;
|
||||
@@ -2049,6 +2299,7 @@ fn status_for_repo(repo: &Path) -> Result<GitStatus, String> {
|
||||
clean: files.is_empty(),
|
||||
files,
|
||||
rebase_in_progress: rebase_in_progress(repo),
|
||||
cherry_pick_in_progress: cherry_pick_in_progress(repo),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2056,6 +2307,10 @@ fn rebase_in_progress(repo: &Path) -> bool {
|
||||
git_path_exists(repo, "rebase-merge") || git_path_exists(repo, "rebase-apply")
|
||||
}
|
||||
|
||||
fn cherry_pick_in_progress(repo: &Path) -> bool {
|
||||
git_path_exists(repo, "CHERRY_PICK_HEAD")
|
||||
}
|
||||
|
||||
fn git_path_exists(repo: &Path, name: &str) -> bool {
|
||||
let Ok(output) = run_git(repo, ["rev-parse", "--git-path", name]) else {
|
||||
return false;
|
||||
@@ -2245,6 +2500,7 @@ fn clone_repository_core(
|
||||
let repo = resolve_repo(&target.to_string_lossy())?;
|
||||
let status = status_for_repo(&repo)?;
|
||||
let branches = branches_for_repo(&repo)?;
|
||||
let tags = tags_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)?;
|
||||
@@ -2252,6 +2508,7 @@ fn clone_repository_core(
|
||||
Ok(RepositoryBundle {
|
||||
status,
|
||||
branches,
|
||||
tags,
|
||||
stashes,
|
||||
commits,
|
||||
files,
|
||||
|
||||
Reference in New Issue
Block a user