use nils_common::git as common_git;
use std::process::Output;
pub fn dispatch(cmd: &str, args: &[String]) -> Option<i32> {
match cmd {
"pick" => Some(run_pick(args)),
_ => None,
}
}
struct PickArgs {
target: String,
commit_spec: String,
name: String,
remote_opt: Option<String>,
want_force: bool,
want_fetch: bool,
want_stay: bool,
}
enum ParseResult {
Help,
Usage,
Ok(PickArgs),
}
fn run_pick(args: &[String]) -> i32 {
let parsed = match parse_pick_args(args) {
ParseResult::Help => {
print_pick_help();
return 0;
}
ParseResult::Usage => {
print_pick_usage_error();
return 2;
}
ParseResult::Ok(value) => value,
};
if !git_success(&["rev-parse", "--git-dir"]) {
eprintln!("❌ Not inside a Git repository.");
return 1;
}
let op_warnings = detect_in_progress_ops();
if !op_warnings.is_empty() {
eprintln!("❌ Refusing to run during an in-progress Git operation:");
for warning in op_warnings {
eprintln!(" - {warning}");
}
return 1;
}
if !git_success_quiet(&["diff", "--quiet", "--no-ext-diff"]) {
eprintln!("❌ Unstaged changes detected. Commit or stash before running git-pick.");
return 1;
}
if !git_success_quiet(&["diff", "--cached", "--quiet", "--no-ext-diff"]) {
eprintln!("❌ Staged changes detected. Commit or stash before running git-pick.");
return 1;
}
let remotes = git_remotes();
if remotes.is_empty() {
eprintln!("❌ No git remotes found (need a remote to push CI branches).");
return 1;
}
let mut remote = parsed.remote_opt.clone().unwrap_or_default();
if remote.is_empty() {
if remotes.iter().any(|name| name == "origin") {
remote = "origin".to_string();
} else {
remote = remotes[0].clone();
}
}
let mut target_branch = parsed.target.clone();
let mut target_branch_for_name = parsed.target.clone();
let mut target_is_remote = false;
if let Some((maybe_remote, rest)) = parsed.target.split_once('/')
&& remotes.iter().any(|name| name == maybe_remote)
{
let target_remote = maybe_remote.to_string();
target_branch = rest.to_string();
target_branch_for_name = target_branch.clone();
target_is_remote = true;
if parsed.remote_opt.is_none() {
remote = target_remote;
} else if remote != target_remote {
eprintln!(
"❌ Target ref looks like '{}' (remote '{}') but --remote is '{}'.",
parsed.target, target_remote, remote
);
return 2;
}
}
if parsed.want_fetch {
let status = git_status_quiet(&["fetch", "--prune", "--", &remote, &target_branch]);
if status.unwrap_or(1) != 0 {
eprintln!("⚠️ Fetch failed: git fetch --prune -- {remote} {target_branch}");
eprintln!(" Continuing with local refs (or re-run with --no-fetch).");
}
}
let base_ref = resolve_base_ref(&remote, &target_branch, &parsed.target, target_is_remote)
.unwrap_or_else(|| {
eprintln!("❌ Cannot resolve target ref: {}", parsed.target);
String::new()
});
if base_ref.is_empty() {
return 1;
}
let ci_branch = format!("ci/{target_branch_for_name}/{}", parsed.name);
if !git_success_quiet(&["check-ref-format", "--branch", &ci_branch]) {
eprintln!("❌ Invalid CI branch name: {ci_branch}");
return 2;
}
let pick_commits = match resolve_pick_commits(&parsed.commit_spec) {
Some(value) => value,
None => return 1,
};
let orig_branch = git_stdout_trimmed_optional(&["symbolic-ref", "--quiet", "--short", "HEAD"]);
let orig_sha = git_stdout_trimmed_optional(&["rev-parse", "--verify", "HEAD"]);
let local_branch_exists = git_success_quiet(&[
"show-ref",
"--verify",
"--quiet",
&format!("refs/heads/{ci_branch}"),
]);
if local_branch_exists && !parsed.want_force {
eprintln!("❌ Local branch already exists: {ci_branch}");
eprintln!(" Use --force to reset/rebuild it.");
return 1;
}
if !parsed.want_force && !local_branch_exists && remote_branch_exists(&remote, &ci_branch) {
eprintln!("❌ Remote branch already exists: {remote}/{ci_branch}");
eprintln!(" Use --force to reset/rebuild it.");
return 1;
}
println!("🌿 CI branch: {ci_branch}");
println!("🔧 Base : {base_ref}");
println!(
"🍒 Pick : {} ({} commit(s))",
parsed.commit_spec,
pick_commits.len()
);
if local_branch_exists {
if git_status_inherit(&["switch", "--quiet", "--", &ci_branch]).unwrap_or(1) != 0 {
return 1;
}
if git_status_inherit(&["reset", "--hard", &base_ref]).unwrap_or(1) != 0 {
return 1;
}
} else if git_status_inherit(&["switch", "--quiet", "-c", &ci_branch, &base_ref]).unwrap_or(1)
!= 0
{
return 1;
}
if git_status_inherit(&build_cherry_pick_args(&pick_commits)).unwrap_or(1) != 0 {
eprintln!("❌ Cherry-pick failed on branch: {ci_branch}");
eprintln!("🧠 Resolve conflicts then run: git cherry-pick --continue");
eprintln!(" Or abort and retry: git cherry-pick --abort");
return 1;
}
let push_status = if parsed.want_force {
git_status_inherit(&[
"push",
"-u",
"--force-with-lease",
"--",
&remote,
&ci_branch,
])
} else {
git_status_inherit(&["push", "-u", "--", &remote, &ci_branch])
};
if push_status.unwrap_or(1) != 0 {
return 1;
}
println!("✅ Pushed: {remote}/{ci_branch} (CI should run on branch push)");
println!("🧹 Cleanup:");
println!(" git push --delete -- {remote} {ci_branch}");
println!(" git branch -D -- {ci_branch}");
if parsed.want_stay {
return 0;
}
if let Some(branch) = orig_branch {
let _ = git_status_inherit(&["switch", "--quiet", "--", &branch]);
} else if let Some(sha) = orig_sha {
let _ = git_status_inherit(&["switch", "--quiet", "--detach", &sha]);
}
0
}
fn parse_pick_args(args: &[String]) -> ParseResult {
let mut remote_opt: Option<String> = None;
let mut want_force = false;
let mut want_fetch = true;
let mut want_stay = false;
let mut positional: Vec<String> = Vec::new();
let mut idx = 0;
while idx < args.len() {
let arg = &args[idx];
if arg == "--" {
positional.extend(args.iter().skip(idx + 1).cloned());
break;
}
match arg.as_str() {
"-h" | "--help" => return ParseResult::Help,
"-f" | "--force" => {
want_force = true;
idx += 1;
}
"--no-fetch" => {
want_fetch = false;
idx += 1;
}
"--stay" => {
want_stay = true;
idx += 1;
}
"-r" | "--remote" => {
let Some(value) = args.get(idx + 1) else {
return ParseResult::Usage;
};
remote_opt = Some(value.to_string());
idx += 2;
}
_ => {
if let Some(value) = arg.strip_prefix("--remote=") {
remote_opt = Some(value.to_string());
idx += 1;
} else if arg.starts_with('-') {
return ParseResult::Usage;
} else {
positional.push(arg.to_string());
idx += 1;
}
}
}
}
if positional.len() != 3 {
return ParseResult::Usage;
}
ParseResult::Ok(PickArgs {
target: positional[0].clone(),
commit_spec: positional[1].clone(),
name: positional[2].clone(),
remote_opt,
want_force,
want_fetch,
want_stay,
})
}
fn print_pick_help() {
println!("git-pick: create and push a CI branch with cherry-picked commits");
println!();
println!("Usage:");
println!(" git-pick <target> <commit-or-range> <name>");
println!();
println!("Args:");
println!(" <target> Base branch/ref (e.g. main, release/x, origin/main)");
println!(" <commit-or-range> Passed to 'git cherry-pick' (e.g. abc123, A..B, A^..B)");
println!(" <name> Suffix for CI branch: ci/<target>/<name>");
println!();
println!("Options:");
println!(" -r, --remote <name> Remote to fetch/push (default: origin, else first remote)");
println!(" --no-fetch Skip 'git fetch' (uses existing local refs)");
println!(
" -f, --force Reset existing ci/<target>/<name> and force-push (with lease)"
);
println!(" --stay Keep checked out on the CI branch");
}
fn print_pick_usage_error() {
eprintln!("❌ Usage: git-pick <target> <commit-or-range> <name>");
eprintln!(" Try: git-pick --help");
}
fn resolve_base_ref(
remote: &str,
target_branch: &str,
target: &str,
target_is_remote: bool,
) -> Option<String> {
if target_is_remote
&& git_success_quiet(&[
"show-ref",
"--verify",
"--quiet",
&format!("refs/remotes/{remote}/{target_branch}"),
])
{
return Some(format!("{remote}/{target_branch}"));
}
if git_success_quiet(&[
"show-ref",
"--verify",
"--quiet",
&format!("refs/heads/{target_branch}"),
]) {
return Some(target_branch.to_string());
}
if git_success_quiet(&[
"show-ref",
"--verify",
"--quiet",
&format!("refs/remotes/{remote}/{target_branch}"),
]) {
return Some(format!("{remote}/{target_branch}"));
}
let target_commit = format!("{target}^{{commit}}");
if git_success_quiet(&["rev-parse", "--verify", "--quiet", &target_commit]) {
return Some(target.to_string());
}
None
}
fn resolve_pick_commits(commit_spec: &str) -> Option<Vec<String>> {
if commit_spec.contains("..") {
let output = git_output(&["rev-list", "--reverse", commit_spec]);
let mut commits: Vec<String> = Vec::new();
if let Some(output) = output
&& output.status.success()
{
let stdout = String::from_utf8_lossy(&output.stdout);
commits = stdout
.lines()
.map(|line| line.trim())
.filter(|line| !line.is_empty())
.map(|line| line.to_string())
.collect();
}
if commits.is_empty() {
eprintln!("❌ No commits resolved from range: {commit_spec}");
return None;
}
return Some(commits);
}
let commit_ref = format!("{commit_spec}^{{commit}}");
let commit_sha = git_stdout_trimmed_optional(&["rev-parse", "--verify", &commit_ref]);
if let Some(commit_sha) = commit_sha {
return Some(vec![commit_sha]);
}
eprintln!("❌ Cannot resolve commit: {commit_spec}");
None
}
fn remote_branch_exists(remote: &str, branch: &str) -> bool {
let output = git_output(&["ls-remote", "--heads", remote, branch]);
let Some(output) = output else {
return false;
};
if !output.status.success() {
return false;
}
!String::from_utf8_lossy(&output.stdout).trim().is_empty()
}
fn git_remotes() -> Vec<String> {
let output = git_output(&["remote"]);
let Some(output) = output else {
return Vec::new();
};
if !output.status.success() {
return Vec::new();
}
String::from_utf8_lossy(&output.stdout)
.lines()
.map(|line| line.trim())
.filter(|line| !line.is_empty())
.map(|line| line.to_string())
.collect()
}
fn detect_in_progress_ops() -> Vec<String> {
let mut warnings = Vec::new();
if git_path_exists("MERGE_HEAD", true) {
warnings.push("merge in progress".to_string());
}
if git_path_exists("rebase-apply", false) || git_path_exists("rebase-merge", false) {
warnings.push("rebase in progress".to_string());
}
if git_path_exists("CHERRY_PICK_HEAD", true) {
warnings.push("cherry-pick in progress".to_string());
}
if git_path_exists("REVERT_HEAD", true) {
warnings.push("revert in progress".to_string());
}
warnings
}
fn git_path_exists(name: &str, is_file: bool) -> bool {
let output = git_stdout_trimmed_optional(&["rev-parse", "--git-path", name]);
let Some(path) = output else {
return false;
};
let path = std::path::Path::new(&path);
if is_file {
path.is_file()
} else {
path.is_dir()
}
}
fn build_cherry_pick_args(commits: &[String]) -> Vec<&str> {
let mut args: Vec<&str> = Vec::with_capacity(commits.len() + 2);
args.push("cherry-pick");
args.push("--");
for commit in commits {
args.push(commit);
}
args
}
fn git_output(args: &[&str]) -> Option<Output> {
common_git::run_output(args).ok()
}
fn git_status_inherit(args: &[&str]) -> Option<i32> {
common_git::run_status_inherit(args)
.ok()
.map(|status| status.code().unwrap_or(1))
}
fn git_status_quiet(args: &[&str]) -> Option<i32> {
common_git::run_status_quiet(args)
.ok()
.map(|status| status.code().unwrap_or(1))
}
fn git_success(args: &[&str]) -> bool {
matches!(git_output(args), Some(output) if output.status.success())
}
fn git_success_quiet(args: &[&str]) -> bool {
matches!(git_status_quiet(args), Some(code) if code == 0)
}
fn git_stdout_trimmed_optional(args: &[&str]) -> Option<String> {
let output = git_output(args)?;
if !output.status.success() {
return None;
}
let value = trim_trailing_newlines(&String::from_utf8_lossy(&output.stdout));
if value.is_empty() { None } else { Some(value) }
}
fn trim_trailing_newlines(input: &str) -> String {
input.trim_end_matches(['\n', '\r']).to_string()
}