Skip to main content

git_cli/
ci.rs

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}