mirror of
https://github.com/ultraworkers/claw-code-parity.git
synced 2026-06-22 01:31:10 +00:00
feat(cli): add claw branch delete command
Add a top-level `claw branch delete` command that deletes merged local branches while protecting the current branch, the default branch, and branches checked out in linked worktrees. The CLI now validates the new subcommand explicitly and covers the behavior with parser, help, and git integration tests. Constraint: Keep existing dirty workspace changes outside this CLI command untouched Rejected: Reusing the unimplemented /branch slash command | user requested a direct top-level command Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep branch protection aligned with worktree-aware git behavior before broadening deletion rules Tested: cargo build --workspace; cargo test --workspace Not-tested: Real remote origin/HEAD configurations beyond local main/master fallback
This commit is contained in:
parent
21d823f597
commit
4331fdeb6a
@ -134,6 +134,7 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
CliAction::Login => run_login()?,
|
CliAction::Login => run_login()?,
|
||||||
CliAction::Logout => run_logout()?,
|
CliAction::Logout => run_logout()?,
|
||||||
CliAction::Init => run_init()?,
|
CliAction::Init => run_init()?,
|
||||||
|
CliAction::BranchDelete => print_branch_delete_report()?,
|
||||||
CliAction::Repl {
|
CliAction::Repl {
|
||||||
model,
|
model,
|
||||||
allowed_tools,
|
allowed_tools,
|
||||||
@ -181,6 +182,7 @@ enum CliAction {
|
|||||||
Login,
|
Login,
|
||||||
Logout,
|
Logout,
|
||||||
Init,
|
Init,
|
||||||
|
BranchDelete,
|
||||||
Repl {
|
Repl {
|
||||||
model: String,
|
model: String,
|
||||||
allowed_tools: Option<AllowedToolSet>,
|
allowed_tools: Option<AllowedToolSet>,
|
||||||
@ -364,6 +366,7 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
|||||||
"login" => Ok(CliAction::Login),
|
"login" => Ok(CliAction::Login),
|
||||||
"logout" => Ok(CliAction::Logout),
|
"logout" => Ok(CliAction::Logout),
|
||||||
"init" => Ok(CliAction::Init),
|
"init" => Ok(CliAction::Init),
|
||||||
|
"branch" => parse_branch_args(&rest[1..]),
|
||||||
"prompt" => {
|
"prompt" => {
|
||||||
let prompt = rest[1..].join(" ");
|
let prompt = rest[1..].join(" ");
|
||||||
if prompt.trim().is_empty() {
|
if prompt.trim().is_empty() {
|
||||||
@ -393,7 +396,7 @@ fn parse_single_word_command_alias(
|
|||||||
model: &str,
|
model: &str,
|
||||||
permission_mode_override: Option<PermissionMode>,
|
permission_mode_override: Option<PermissionMode>,
|
||||||
) -> Option<Result<CliAction, String>> {
|
) -> Option<Result<CliAction, String>> {
|
||||||
if rest.len() != 1 {
|
if rest.len() != 1 || rest[0] == "branch" {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -698,6 +701,16 @@ fn parse_system_prompt_args(args: &[String]) -> Result<CliAction, String> {
|
|||||||
Ok(CliAction::PrintSystemPrompt { cwd, date })
|
Ok(CliAction::PrintSystemPrompt { cwd, date })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn parse_branch_args(args: &[String]) -> Result<CliAction, String> {
|
||||||
|
match args {
|
||||||
|
[] => Err("Usage: claw branch delete".to_string()),
|
||||||
|
[action] if action == "delete" => Ok(CliAction::BranchDelete),
|
||||||
|
[action, ..] => Err(format!(
|
||||||
|
"unknown branch action: {action}. Usage: claw branch delete"
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn parse_resume_args(args: &[String]) -> Result<CliAction, String> {
|
fn parse_resume_args(args: &[String]) -> Result<CliAction, String> {
|
||||||
let (session_path, command_tokens): (PathBuf, &[String]) = match args.first() {
|
let (session_path, command_tokens): (PathBuf, &[String]) = match args.first() {
|
||||||
None => (PathBuf::from(LATEST_SESSION_REFERENCE), &[]),
|
None => (PathBuf::from(LATEST_SESSION_REFERENCE), &[]),
|
||||||
@ -3951,6 +3964,122 @@ fn format_issue_report(context: Option<&str>) -> String {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn format_branch_delete_report(
|
||||||
|
repo_root: &Path,
|
||||||
|
current_branch: &str,
|
||||||
|
default_branch: Option<&str>,
|
||||||
|
deleted_branches: &[String],
|
||||||
|
) -> String {
|
||||||
|
let result = if deleted_branches.is_empty() {
|
||||||
|
"no merged local branches were eligible for deletion".to_string()
|
||||||
|
} else {
|
||||||
|
format!("deleted {} merged local branch(es)", deleted_branches.len())
|
||||||
|
};
|
||||||
|
let deleted = if deleted_branches.is_empty() {
|
||||||
|
"none".to_string()
|
||||||
|
} else {
|
||||||
|
deleted_branches.join(", ")
|
||||||
|
};
|
||||||
|
|
||||||
|
format!(
|
||||||
|
"Branch cleanup
|
||||||
|
Repository {}
|
||||||
|
Current branch {}
|
||||||
|
Protected branch {}
|
||||||
|
Deleted {}
|
||||||
|
Result {}",
|
||||||
|
repo_root.display(),
|
||||||
|
current_branch,
|
||||||
|
default_branch.unwrap_or("none"),
|
||||||
|
deleted,
|
||||||
|
result,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_branch_delete_report() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let cwd = env::current_dir()?;
|
||||||
|
println!("{}", delete_merged_local_branches_in(&cwd)?);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delete_merged_local_branches_in(cwd: &Path) -> Result<String, Box<dyn std::error::Error>> {
|
||||||
|
let repo_root = find_git_root_in(cwd)?;
|
||||||
|
let current_branch =
|
||||||
|
resolve_git_branch_for(cwd).ok_or("unable to resolve the current git branch")?;
|
||||||
|
if current_branch == "detached HEAD" {
|
||||||
|
return Err("cannot delete merged branches from detached HEAD".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let default_branch = resolve_default_branch_name(&repo_root);
|
||||||
|
let mut protected_branches = branches_checked_out_in_worktrees(&repo_root);
|
||||||
|
protected_branches.insert(current_branch.clone());
|
||||||
|
if let Some(branch) = default_branch.as_ref() {
|
||||||
|
protected_branches.insert(branch.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
let merged_branches = list_merged_local_branches(&repo_root)?;
|
||||||
|
let deleted_branches = merged_branches
|
||||||
|
.into_iter()
|
||||||
|
.filter(|branch| !protected_branches.contains(branch))
|
||||||
|
.map(|branch| {
|
||||||
|
git_status_ok_in(&repo_root, &["branch", "-d", &branch])?;
|
||||||
|
Ok(branch)
|
||||||
|
})
|
||||||
|
.collect::<Result<Vec<_>, Box<dyn std::error::Error>>>()?;
|
||||||
|
|
||||||
|
Ok(format_branch_delete_report(
|
||||||
|
&repo_root,
|
||||||
|
¤t_branch,
|
||||||
|
default_branch.as_deref(),
|
||||||
|
&deleted_branches,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_default_branch_name(cwd: &Path) -> Option<String> {
|
||||||
|
run_git_capture_in(
|
||||||
|
cwd,
|
||||||
|
&["symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"],
|
||||||
|
)
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|remote_head| {
|
||||||
|
remote_head
|
||||||
|
.trim()
|
||||||
|
.strip_prefix("refs/remotes/origin/")
|
||||||
|
.map(str::to_string)
|
||||||
|
})
|
||||||
|
.or_else(|| {
|
||||||
|
["main", "master"]
|
||||||
|
.into_iter()
|
||||||
|
.find(|branch| git_ref_exists_in(cwd, &format!("refs/heads/{branch}")))
|
||||||
|
.map(str::to_string)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn branches_checked_out_in_worktrees(cwd: &Path) -> std::collections::HashSet<String> {
|
||||||
|
let Some(output) = run_git_capture_in(cwd, &["worktree", "list", "--porcelain"]) else {
|
||||||
|
return std::collections::HashSet::new();
|
||||||
|
};
|
||||||
|
|
||||||
|
parse_git_worktrees(&output, cwd)
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|entry| match entry.branch {
|
||||||
|
Some(branch) if branch != "detached HEAD" => Some(branch),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list_merged_local_branches(cwd: &Path) -> Result<Vec<String>, Box<dyn std::error::Error>> {
|
||||||
|
let output = run_git_capture_in(cwd, &["branch", "--format=%(refname:short)", "--merged"])
|
||||||
|
.ok_or("failed to enumerate merged local branches")?;
|
||||||
|
Ok(output
|
||||||
|
.lines()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|branch| !branch.is_empty())
|
||||||
|
.map(str::to_string)
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
fn git_output(args: &[&str]) -> Result<String, Box<dyn std::error::Error>> {
|
fn git_output(args: &[&str]) -> Result<String, Box<dyn std::error::Error>> {
|
||||||
let output = Command::new("git")
|
let output = Command::new("git")
|
||||||
.args(args)
|
.args(args)
|
||||||
@ -3964,10 +4093,12 @@ fn git_output(args: &[&str]) -> Result<String, Box<dyn std::error::Error>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn git_status_ok(args: &[&str]) -> Result<(), Box<dyn std::error::Error>> {
|
fn git_status_ok(args: &[&str]) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let output = Command::new("git")
|
let cwd = env::current_dir()?;
|
||||||
.args(args)
|
git_status_ok_in(&cwd, args)
|
||||||
.current_dir(env::current_dir()?)
|
}
|
||||||
.output()?;
|
|
||||||
|
fn git_status_ok_in(cwd: &Path, args: &[&str]) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let output = Command::new("git").args(args).current_dir(cwd).output()?;
|
||||||
if !output.status.success() {
|
if !output.status.success() {
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||||
return Err(format!("git {} failed: {stderr}", args.join(" ")).into());
|
return Err(format!("git {} failed: {stderr}", args.join(" ")).into());
|
||||||
@ -5794,6 +5925,11 @@ fn print_help_to(out: &mut impl Write) -> io::Result<()> {
|
|||||||
writeln!(out, " claw login")?;
|
writeln!(out, " claw login")?;
|
||||||
writeln!(out, " claw logout")?;
|
writeln!(out, " claw logout")?;
|
||||||
writeln!(out, " claw init")?;
|
writeln!(out, " claw init")?;
|
||||||
|
writeln!(out, " claw branch delete")?;
|
||||||
|
writeln!(
|
||||||
|
out,
|
||||||
|
" Delete merged local git branches except the current/default worktree branches"
|
||||||
|
)?;
|
||||||
writeln!(out)?;
|
writeln!(out)?;
|
||||||
writeln!(out, "Flags:")?;
|
writeln!(out, "Flags:")?;
|
||||||
writeln!(
|
writeln!(
|
||||||
@ -5859,6 +5995,7 @@ fn print_help_to(out: &mut impl Write) -> io::Result<()> {
|
|||||||
out,
|
out,
|
||||||
" claw --resume {LATEST_SESSION_REFERENCE} /status /diff /export notes.txt"
|
" claw --resume {LATEST_SESSION_REFERENCE} /status /diff /export notes.txt"
|
||||||
)?;
|
)?;
|
||||||
|
writeln!(out, " claw branch delete")?;
|
||||||
writeln!(out, " claw agents")?;
|
writeln!(out, " claw agents")?;
|
||||||
writeln!(out, " claw mcp show my-server")?;
|
writeln!(out, " claw mcp show my-server")?;
|
||||||
writeln!(out, " claw /skills")?;
|
writeln!(out, " claw /skills")?;
|
||||||
@ -5875,25 +6012,24 @@ fn print_help() {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
build_runtime_plugin_state_with_loader, build_runtime_with_plugin_state,
|
build_runtime_plugin_state_with_loader, build_runtime_with_plugin_state,
|
||||||
create_managed_session_handle, describe_tool_progress, filter_tool_specs,
|
create_managed_session_handle, delete_merged_local_branches_in, describe_tool_progress,
|
||||||
format_bughunter_report, format_commit_preflight_report, format_commit_skipped_report,
|
filter_tool_specs, format_bughunter_report, format_commit_preflight_report,
|
||||||
format_compact_report, format_cost_report, format_internal_prompt_progress_line,
|
format_commit_skipped_report, format_compact_report, format_cost_report,
|
||||||
format_issue_report, format_model_report, format_model_switch_report,
|
format_internal_prompt_progress_line, format_issue_report, format_model_report,
|
||||||
format_permissions_report, format_permissions_switch_report, format_pr_report,
|
format_model_switch_report, format_permissions_report, format_permissions_switch_report,
|
||||||
format_resume_report, format_status_report, format_tool_call_start, format_tool_result,
|
format_pr_report, format_resume_report, format_status_report, format_tool_call_start,
|
||||||
format_ultraplan_report, format_unknown_slash_command,
|
format_tool_result, format_ultraplan_report, format_unknown_slash_command,
|
||||||
format_unknown_slash_command_message, normalize_permission_mode, parse_args,
|
format_unknown_slash_command_message, git_ref_exists_in, normalize_permission_mode,
|
||||||
parse_git_status_branch, parse_git_status_metadata_for, parse_git_workspace_summary,
|
parse_args, parse_git_status_branch, parse_git_status_metadata_for,
|
||||||
parse_git_worktrees, parse_recent_commits, permission_policy, print_help_to,
|
parse_git_workspace_summary, parse_git_worktrees, parse_recent_commits, permission_policy,
|
||||||
push_output_block, render_config_report,
|
print_help_to, push_output_block, render_config_report, render_diff_report,
|
||||||
render_diff_report, render_diff_report_for, render_memory_report, render_repl_help,
|
render_diff_report_for, render_memory_report, render_repl_help, render_resume_usage,
|
||||||
render_resume_usage, resolve_model_alias, resolve_session_reference, response_to_events,
|
resolve_model_alias, resolve_session_reference, response_to_events,
|
||||||
resume_supported_slash_commands, run_resume_command,
|
resume_supported_slash_commands, run_resume_command,
|
||||||
slash_command_completion_candidates_with_sessions, status_context, validate_no_args,
|
slash_command_completion_candidates_with_sessions, status_context, validate_no_args,
|
||||||
write_mcp_server_fixture, CliAction, CliOutputFormat, CliToolExecutor,
|
write_mcp_server_fixture, CliAction, CliOutputFormat, CliToolExecutor, GitBranchFreshness,
|
||||||
GitBranchFreshness, GitCommitEntry, GitWorkspaceSummary, GitWorktreeEntry,
|
GitCommitEntry, GitWorkspaceSummary, GitWorktreeEntry, InternalPromptProgressEvent,
|
||||||
InternalPromptProgressEvent, InternalPromptProgressState, LiveCli, SlashCommand,
|
InternalPromptProgressState, LiveCli, SlashCommand, StatusUsage, DEFAULT_MODEL,
|
||||||
StatusUsage, DEFAULT_MODEL,
|
|
||||||
};
|
};
|
||||||
use api::{MessageResponse, OutputContentBlock, Usage};
|
use api::{MessageResponse, OutputContentBlock, Usage};
|
||||||
use plugins::{
|
use plugins::{
|
||||||
@ -6284,6 +6420,27 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_branch_delete_subcommand() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_args(&["branch".to_string(), "delete".to_string()])
|
||||||
|
.expect("branch delete should parse"),
|
||||||
|
CliAction::BranchDelete
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn branch_subcommand_requires_delete_action() {
|
||||||
|
let usage_error =
|
||||||
|
parse_args(&["branch".to_string()]).expect_err("branch should require an action");
|
||||||
|
assert!(usage_error.contains("Usage: claw branch delete"));
|
||||||
|
|
||||||
|
let unknown_error = parse_args(&["branch".to_string(), "prune".to_string()])
|
||||||
|
.expect_err("unknown branch action should fail");
|
||||||
|
assert!(unknown_error.contains("unknown branch action: prune"));
|
||||||
|
assert!(unknown_error.contains("Usage: claw branch delete"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_single_word_command_aliases_without_falling_back_to_prompt_mode() {
|
fn parses_single_word_command_aliases_without_falling_back_to_prompt_mode() {
|
||||||
let _guard = env_lock();
|
let _guard = env_lock();
|
||||||
@ -7123,6 +7280,92 @@ UU conflicted.rs",
|
|||||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn branch_delete_removes_only_merged_unprotected_local_branches() {
|
||||||
|
let _guard = cwd_lock()
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||||
|
let workspace = temp_workspace("branch-delete");
|
||||||
|
std::fs::create_dir_all(&workspace).expect("workspace should create");
|
||||||
|
let previous = std::env::current_dir().expect("cwd");
|
||||||
|
std::env::set_current_dir(&workspace).expect("switch cwd");
|
||||||
|
|
||||||
|
git(&["init", "--quiet", "-b", "main"], &workspace);
|
||||||
|
git(&["config", "user.email", "tests@example.com"], &workspace);
|
||||||
|
git(&["config", "user.name", "Rusty Claude Tests"], &workspace);
|
||||||
|
std::fs::write(workspace.join("tracked.txt"), "base\n").expect("write tracked file");
|
||||||
|
git(&["add", "tracked.txt"], &workspace);
|
||||||
|
git(&["commit", "-m", "init", "--quiet"], &workspace);
|
||||||
|
|
||||||
|
git(&["checkout", "-b", "delete-me"], &workspace);
|
||||||
|
std::fs::write(workspace.join("tracked.txt"), "base\ndelete me\n")
|
||||||
|
.expect("update delete-me");
|
||||||
|
git(&["commit", "-am", "delete-me", "--quiet"], &workspace);
|
||||||
|
git(&["checkout", "main"], &workspace);
|
||||||
|
git(
|
||||||
|
&[
|
||||||
|
"merge",
|
||||||
|
"--no-ff",
|
||||||
|
"delete-me",
|
||||||
|
"-m",
|
||||||
|
"merge delete-me",
|
||||||
|
"--quiet",
|
||||||
|
],
|
||||||
|
&workspace,
|
||||||
|
);
|
||||||
|
|
||||||
|
git(&["checkout", "-b", "keep-worktree"], &workspace);
|
||||||
|
std::fs::write(workspace.join("tracked.txt"), "base\ndelete me\nkeep me\n")
|
||||||
|
.expect("update keep-worktree");
|
||||||
|
git(&["commit", "-am", "keep-worktree", "--quiet"], &workspace);
|
||||||
|
git(&["checkout", "main"], &workspace);
|
||||||
|
git(
|
||||||
|
&[
|
||||||
|
"merge",
|
||||||
|
"--no-ff",
|
||||||
|
"keep-worktree",
|
||||||
|
"-m",
|
||||||
|
"merge keep-worktree",
|
||||||
|
"--quiet",
|
||||||
|
],
|
||||||
|
&workspace,
|
||||||
|
);
|
||||||
|
|
||||||
|
let linked_worktree = workspace.join("keep-worktree-linked");
|
||||||
|
git(
|
||||||
|
&[
|
||||||
|
"worktree",
|
||||||
|
"add",
|
||||||
|
"--force",
|
||||||
|
linked_worktree.to_str().expect("utf8 worktree path"),
|
||||||
|
"keep-worktree",
|
||||||
|
],
|
||||||
|
&workspace,
|
||||||
|
);
|
||||||
|
|
||||||
|
let report =
|
||||||
|
delete_merged_local_branches_in(&workspace).expect("branch delete should succeed");
|
||||||
|
assert!(report.contains("Deleted delete-me"));
|
||||||
|
assert!(report.contains("Protected branch main"));
|
||||||
|
assert!(git_ref_exists_in(&workspace, "refs/heads/main"));
|
||||||
|
assert!(!git_ref_exists_in(&workspace, "refs/heads/delete-me"));
|
||||||
|
assert!(git_ref_exists_in(&workspace, "refs/heads/keep-worktree"));
|
||||||
|
|
||||||
|
std::env::set_current_dir(previous).expect("restore cwd");
|
||||||
|
if linked_worktree.exists() {
|
||||||
|
git(
|
||||||
|
&[
|
||||||
|
"worktree",
|
||||||
|
"remove",
|
||||||
|
"--force",
|
||||||
|
linked_worktree.to_str().expect("utf8 worktree path"),
|
||||||
|
],
|
||||||
|
&workspace,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
std::fs::remove_dir_all(workspace).expect("workspace should clean up");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn status_context_reads_real_workspace_metadata() {
|
fn status_context_reads_real_workspace_metadata() {
|
||||||
let context = status_context(None).expect("status context should load");
|
let context = status_context(None).expect("status context should load");
|
||||||
@ -7198,6 +7441,7 @@ UU conflicted.rs",
|
|||||||
let mut help = Vec::new();
|
let mut help = Vec::new();
|
||||||
print_help_to(&mut help).expect("help should render");
|
print_help_to(&mut help).expect("help should render");
|
||||||
let help = String::from_utf8(help).expect("help should be utf8");
|
let help = String::from_utf8(help).expect("help should be utf8");
|
||||||
|
assert!(help.contains("claw branch delete"));
|
||||||
assert!(help.contains("claw --resume [SESSION.jsonl|session-id|latest]"));
|
assert!(help.contains("claw --resume [SESSION.jsonl|session-id|latest]"));
|
||||||
assert!(help.contains("Use `latest` with --resume, /resume, or /session switch"));
|
assert!(help.contains("Use `latest` with --resume, /resume, or /session switch"));
|
||||||
assert!(help.contains("claw --resume latest"));
|
assert!(help.contains("claw --resume latest"));
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user