1use nils_common::git as common_git;
2use std::process::Output;
3
4pub fn dispatch(cmd: &str, args: &[String]) -> Option<i32> {
5 match cmd {
6 "pick" => Some(run_pick(args)),
7 _ => None,
8 }
9}
10
11struct PickArgs {
12 target: String,
13 commit_spec: String,
14 name: String,
15 remote_opt: Option<String>,
16 want_force: bool,
17 want_fetch: bool,
18 want_stay: bool,
19}
20
21enum ParseResult {
22 Help,
23 Usage,
24 Ok(PickArgs),
25}
26
27fn run_pick(args: &[String]) -> i32 {
28 let parsed = match parse_pick_args(args) {
29 ParseResult::Help => {
30 print_pick_help();
31 return 0;
32 }
33 ParseResult::Usage => {
34 print_pick_usage_error();
35 return 2;
36 }
37 ParseResult::Ok(value) => value,
38 };
39
40 if !git_success(&["rev-parse", "--git-dir"]) {
41 eprintln!("❌ Not inside a Git repository.");
42 return 1;
43 }
44
45 let op_warnings = detect_in_progress_ops();
46 if !op_warnings.is_empty() {
47 eprintln!("❌ Refusing to run during an in-progress Git operation:");
48 for warning in op_warnings {
49 eprintln!(" - {warning}");
50 }
51 return 1;
52 }
53
54 if !git_success_quiet(&["diff", "--quiet", "--no-ext-diff"]) {
55 eprintln!("❌ Unstaged changes detected. Commit or stash before running git-pick.");
56 return 1;
57 }
58 if !git_success_quiet(&["diff", "--cached", "--quiet", "--no-ext-diff"]) {
59 eprintln!("❌ Staged changes detected. Commit or stash before running git-pick.");
60 return 1;
61 }
62
63 let remotes = git_remotes();
64 if remotes.is_empty() {
65 eprintln!("❌ No git remotes found (need a remote to push CI branches).");
66 return 1;
67 }
68
69 let mut remote = parsed.remote_opt.clone().unwrap_or_default();
70 if remote.is_empty() {
71 if remotes.iter().any(|name| name == "origin") {
72 remote = "origin".to_string();
73 } else {
74 remote = remotes[0].clone();
75 }
76 }
77
78 let mut target_branch = parsed.target.clone();
79 let mut target_branch_for_name = parsed.target.clone();
80 let mut target_is_remote = false;
81
82 if let Some((maybe_remote, rest)) = parsed.target.split_once('/')
83 && remotes.iter().any(|name| name == maybe_remote)
84 {
85 let target_remote = maybe_remote.to_string();
86 target_branch = rest.to_string();
87 target_branch_for_name = target_branch.clone();
88
89 target_is_remote = true;
90 if parsed.remote_opt.is_none() {
91 remote = target_remote;
92 } else if remote != target_remote {
93 eprintln!(
94 "❌ Target ref looks like '{}' (remote '{}') but --remote is '{}'.",
95 parsed.target, target_remote, remote
96 );
97 return 2;
98 }
99 }
100
101 if parsed.want_fetch {
102 let status = git_status_quiet(&["fetch", "--prune", "--", &remote, &target_branch]);
103 if status.unwrap_or(1) != 0 {
104 eprintln!("⚠️ Fetch failed: git fetch --prune -- {remote} {target_branch}");
105 eprintln!(" Continuing with local refs (or re-run with --no-fetch).");
106 }
107 }
108
109 let base_ref = resolve_base_ref(&remote, &target_branch, &parsed.target, target_is_remote)
110 .unwrap_or_else(|| {
111 eprintln!("❌ Cannot resolve target ref: {}", parsed.target);
112 String::new()
113 });
114 if base_ref.is_empty() {
115 return 1;
116 }
117
118 let ci_branch = format!("ci/{target_branch_for_name}/{}", parsed.name);
119 if !git_success_quiet(&["check-ref-format", "--branch", &ci_branch]) {
120 eprintln!("❌ Invalid CI branch name: {ci_branch}");
121 return 2;
122 }
123
124 let pick_commits = match resolve_pick_commits(&parsed.commit_spec) {
125 Some(value) => value,
126 None => return 1,
127 };
128
129 let orig_branch = git_stdout_trimmed_optional(&["symbolic-ref", "--quiet", "--short", "HEAD"]);
130 let orig_sha = git_stdout_trimmed_optional(&["rev-parse", "--verify", "HEAD"]);
131
132 let local_branch_exists = git_success_quiet(&[
133 "show-ref",
134 "--verify",
135 "--quiet",
136 &format!("refs/heads/{ci_branch}"),
137 ]);
138
139 if local_branch_exists && !parsed.want_force {
140 eprintln!("❌ Local branch already exists: {ci_branch}");
141 eprintln!(" Use --force to reset/rebuild it.");
142 return 1;
143 }
144
145 if !parsed.want_force && !local_branch_exists && remote_branch_exists(&remote, &ci_branch) {
146 eprintln!("❌ Remote branch already exists: {remote}/{ci_branch}");
147 eprintln!(" Use --force to reset/rebuild it.");
148 return 1;
149 }
150
151 println!("🌿 CI branch: {ci_branch}");
152 println!("🔧 Base : {base_ref}");
153 println!(
154 "🍒 Pick : {} ({} commit(s))",
155 parsed.commit_spec,
156 pick_commits.len()
157 );
158
159 if local_branch_exists {
160 if git_status_inherit(&["switch", "--quiet", "--", &ci_branch]).unwrap_or(1) != 0 {
161 return 1;
162 }
163 if git_status_inherit(&["reset", "--hard", &base_ref]).unwrap_or(1) != 0 {
164 return 1;
165 }
166 } else if git_status_inherit(&["switch", "--quiet", "-c", &ci_branch, &base_ref]).unwrap_or(1)
167 != 0
168 {
169 return 1;
170 }
171
172 if git_status_inherit(&build_cherry_pick_args(&pick_commits)).unwrap_or(1) != 0 {
173 eprintln!("❌ Cherry-pick failed on branch: {ci_branch}");
174 eprintln!("🧠 Resolve conflicts then run: git cherry-pick --continue");
175 eprintln!(" Or abort and retry: git cherry-pick --abort");
176 return 1;
177 }
178
179 let push_status = if parsed.want_force {
180 git_status_inherit(&[
181 "push",
182 "-u",
183 "--force-with-lease",
184 "--",
185 &remote,
186 &ci_branch,
187 ])
188 } else {
189 git_status_inherit(&["push", "-u", "--", &remote, &ci_branch])
190 };
191 if push_status.unwrap_or(1) != 0 {
192 return 1;
193 }
194
195 println!("✅ Pushed: {remote}/{ci_branch} (CI should run on branch push)");
196 println!("🧹 Cleanup:");
197 println!(" git push --delete -- {remote} {ci_branch}");
198 println!(" git branch -D -- {ci_branch}");
199
200 if parsed.want_stay {
201 return 0;
202 }
203
204 if let Some(branch) = orig_branch {
205 let _ = git_status_inherit(&["switch", "--quiet", "--", &branch]);
206 } else if let Some(sha) = orig_sha {
207 let _ = git_status_inherit(&["switch", "--quiet", "--detach", &sha]);
208 }
209
210 0
211}
212
213fn parse_pick_args(args: &[String]) -> ParseResult {
214 let mut remote_opt: Option<String> = None;
215 let mut want_force = false;
216 let mut want_fetch = true;
217 let mut want_stay = false;
218 let mut positional: Vec<String> = Vec::new();
219
220 let mut idx = 0;
221 while idx < args.len() {
222 let arg = &args[idx];
223 if arg == "--" {
224 positional.extend(args.iter().skip(idx + 1).cloned());
225 break;
226 }
227
228 match arg.as_str() {
229 "-h" | "--help" => return ParseResult::Help,
230 "-f" | "--force" => {
231 want_force = true;
232 idx += 1;
233 }
234 "--no-fetch" => {
235 want_fetch = false;
236 idx += 1;
237 }
238 "--stay" => {
239 want_stay = true;
240 idx += 1;
241 }
242 "-r" | "--remote" => {
243 let Some(value) = args.get(idx + 1) else {
244 return ParseResult::Usage;
245 };
246 remote_opt = Some(value.to_string());
247 idx += 2;
248 }
249 _ => {
250 if let Some(value) = arg.strip_prefix("--remote=") {
251 remote_opt = Some(value.to_string());
252 idx += 1;
253 } else if arg.starts_with('-') {
254 return ParseResult::Usage;
255 } else {
256 positional.push(arg.to_string());
257 idx += 1;
258 }
259 }
260 }
261 }
262
263 if positional.len() != 3 {
264 return ParseResult::Usage;
265 }
266
267 ParseResult::Ok(PickArgs {
268 target: positional[0].clone(),
269 commit_spec: positional[1].clone(),
270 name: positional[2].clone(),
271 remote_opt,
272 want_force,
273 want_fetch,
274 want_stay,
275 })
276}
277
278fn print_pick_help() {
279 println!("git-pick: create and push a CI branch with cherry-picked commits");
280 println!();
281 println!("Usage:");
282 println!(" git-pick <target> <commit-or-range> <name>");
283 println!();
284 println!("Args:");
285 println!(" <target> Base branch/ref (e.g. main, release/x, origin/main)");
286 println!(" <commit-or-range> Passed to 'git cherry-pick' (e.g. abc123, A..B, A^..B)");
287 println!(" <name> Suffix for CI branch: ci/<target>/<name>");
288 println!();
289 println!("Options:");
290 println!(" -r, --remote <name> Remote to fetch/push (default: origin, else first remote)");
291 println!(" --no-fetch Skip 'git fetch' (uses existing local refs)");
292 println!(
293 " -f, --force Reset existing ci/<target>/<name> and force-push (with lease)"
294 );
295 println!(" --stay Keep checked out on the CI branch");
296}
297
298fn print_pick_usage_error() {
299 eprintln!("❌ Usage: git-pick <target> <commit-or-range> <name>");
300 eprintln!(" Try: git-pick --help");
301}
302
303fn resolve_base_ref(
304 remote: &str,
305 target_branch: &str,
306 target: &str,
307 target_is_remote: bool,
308) -> Option<String> {
309 if target_is_remote
310 && git_success_quiet(&[
311 "show-ref",
312 "--verify",
313 "--quiet",
314 &format!("refs/remotes/{remote}/{target_branch}"),
315 ])
316 {
317 return Some(format!("{remote}/{target_branch}"));
318 }
319
320 if git_success_quiet(&[
321 "show-ref",
322 "--verify",
323 "--quiet",
324 &format!("refs/heads/{target_branch}"),
325 ]) {
326 return Some(target_branch.to_string());
327 }
328
329 if git_success_quiet(&[
330 "show-ref",
331 "--verify",
332 "--quiet",
333 &format!("refs/remotes/{remote}/{target_branch}"),
334 ]) {
335 return Some(format!("{remote}/{target_branch}"));
336 }
337
338 let target_commit = format!("{target}^{{commit}}");
339 if git_success_quiet(&["rev-parse", "--verify", "--quiet", &target_commit]) {
340 return Some(target.to_string());
341 }
342
343 None
344}
345
346fn resolve_pick_commits(commit_spec: &str) -> Option<Vec<String>> {
347 if commit_spec.contains("..") {
348 let output = git_output(&["rev-list", "--reverse", commit_spec]);
349 let mut commits: Vec<String> = Vec::new();
350 if let Some(output) = output
351 && output.status.success()
352 {
353 let stdout = String::from_utf8_lossy(&output.stdout);
354 commits = stdout
355 .lines()
356 .map(|line| line.trim())
357 .filter(|line| !line.is_empty())
358 .map(|line| line.to_string())
359 .collect();
360 }
361 if commits.is_empty() {
362 eprintln!("❌ No commits resolved from range: {commit_spec}");
363 return None;
364 }
365 return Some(commits);
366 }
367
368 let commit_ref = format!("{commit_spec}^{{commit}}");
369 let commit_sha = git_stdout_trimmed_optional(&["rev-parse", "--verify", &commit_ref]);
370 if let Some(commit_sha) = commit_sha {
371 return Some(vec![commit_sha]);
372 }
373
374 eprintln!("❌ Cannot resolve commit: {commit_spec}");
375 None
376}
377
378fn remote_branch_exists(remote: &str, branch: &str) -> bool {
379 let output = git_output(&["ls-remote", "--heads", remote, branch]);
380 let Some(output) = output else {
381 return false;
382 };
383 if !output.status.success() {
384 return false;
385 }
386 !String::from_utf8_lossy(&output.stdout).trim().is_empty()
387}
388
389fn git_remotes() -> Vec<String> {
390 let output = git_output(&["remote"]);
391 let Some(output) = output else {
392 return Vec::new();
393 };
394 if !output.status.success() {
395 return Vec::new();
396 }
397 String::from_utf8_lossy(&output.stdout)
398 .lines()
399 .map(|line| line.trim())
400 .filter(|line| !line.is_empty())
401 .map(|line| line.to_string())
402 .collect()
403}
404
405fn detect_in_progress_ops() -> Vec<String> {
406 let mut warnings = Vec::new();
407 if git_path_exists("MERGE_HEAD", true) {
408 warnings.push("merge in progress".to_string());
409 }
410 if git_path_exists("rebase-apply", false) || git_path_exists("rebase-merge", false) {
411 warnings.push("rebase in progress".to_string());
412 }
413 if git_path_exists("CHERRY_PICK_HEAD", true) {
414 warnings.push("cherry-pick in progress".to_string());
415 }
416 if git_path_exists("REVERT_HEAD", true) {
417 warnings.push("revert in progress".to_string());
418 }
419 warnings
420}
421
422fn git_path_exists(name: &str, is_file: bool) -> bool {
423 let output = git_stdout_trimmed_optional(&["rev-parse", "--git-path", name]);
424 let Some(path) = output else {
425 return false;
426 };
427 let path = std::path::Path::new(&path);
428 if is_file {
429 path.is_file()
430 } else {
431 path.is_dir()
432 }
433}
434
435fn build_cherry_pick_args(commits: &[String]) -> Vec<&str> {
436 let mut args: Vec<&str> = Vec::with_capacity(commits.len() + 2);
437 args.push("cherry-pick");
438 args.push("--");
439 for commit in commits {
440 args.push(commit);
441 }
442 args
443}
444
445fn git_output(args: &[&str]) -> Option<Output> {
446 common_git::run_output(args).ok()
447}
448
449fn git_status_inherit(args: &[&str]) -> Option<i32> {
450 common_git::run_status_inherit(args)
451 .ok()
452 .map(|status| status.code().unwrap_or(1))
453}
454
455fn git_status_quiet(args: &[&str]) -> Option<i32> {
456 common_git::run_status_quiet(args)
457 .ok()
458 .map(|status| status.code().unwrap_or(1))
459}
460
461fn git_success(args: &[&str]) -> bool {
462 matches!(git_output(args), Some(output) if output.status.success())
463}
464
465fn git_success_quiet(args: &[&str]) -> bool {
466 matches!(git_status_quiet(args), Some(code) if code == 0)
467}
468
469fn git_stdout_trimmed_optional(args: &[&str]) -> Option<String> {
470 let output = git_output(args)?;
471 if !output.status.success() {
472 return None;
473 }
474 let value = trim_trailing_newlines(&String::from_utf8_lossy(&output.stdout));
475 if value.is_empty() { None } else { Some(value) }
476}
477
478fn trim_trailing_newlines(input: &str) -> String {
479 input.trim_end_matches(['\n', '\r']).to_string()
480}