Skip to main content

git_cli/
pr_shortcut.rs

1use crate::context::GitContext;
2
3const PROTECTED_BRANCHES: &[&str] = &["main", "master", "develop"];
4
5/// Simple "create a PR" tasks get deterministic git/gh commands (no LLM).
6pub fn try_simple_pr_create(task: &str, ctx: &GitContext) -> Option<Result<String, String>> {
7    if !ctx.is_repo {
8        return None;
9    }
10
11    let lower = task.trim().to_lowercase();
12    if !is_simple_create_intent(&lower) {
13        return None;
14    }
15
16    let head = match ctx.branch.clone() {
17        Some(b) => b,
18        None => {
19            return Some(Err(
20                "Could not determine current branch. Run this inside a git repository."
21                    .to_string(),
22            ));
23        }
24    };
25
26    if PROTECTED_BRANCHES.contains(&head.as_str()) {
27        return Some(Err(format!(
28            "You are on `{head}`. PRs need a feature branch.\n\n\
29             Example:\n  \
30             git checkout -b feature/my-change\n  \
31             git push origin feature/my-change\n  \
32             git-cli \"create a PR to main\" --execute"
33        )));
34    }
35
36    let base = parse_target_base(&lower).unwrap_or_else(|| default_base_branch(ctx));
37    let title = title_from_branch(&head);
38    let body = format!("PR for branch {head}");
39
40    let mut lines = vec![
41        format!("# Push branch `{head}` to remote"),
42        format!("git push origin {head}"),
43        format!("# Create PR: {head} → {base}"),
44        format!(
45            "gh pr create --base {base} --head {head} --title \"{title}\" --body \"{body}\""
46        ),
47    ];
48
49    if let Some(ref prs) = ctx.open_prs {
50        let pattern = format!("{head} → {base}");
51        if prs.contains(&pattern) {
52            lines.insert(
53                0,
54                format!("# PR already exists for {head} → {base} (see Open PRs in context)"),
55            );
56            return Some(Ok(lines.join("\n")));
57        }
58    }
59
60    Some(Ok(lines.join("\n")))
61}
62
63fn is_simple_create_intent(lower: &str) -> bool {
64    const EXACT: &[&str] = &[
65        "create a pr",
66        "create pr",
67        "create a pull request",
68        "create pull request",
69        "open a pr",
70        "open pr",
71    ];
72    const PREFIX: &[&str] = &[
73        "create a pr to ",
74        "create pr to ",
75        "create a pull request to ",
76        "create pull request to ",
77        "open a pr to ",
78        "open pr to ",
79    ];
80
81    if EXACT.contains(&lower) {
82        return true;
83    }
84    if PREFIX.iter().any(|p| lower.starts_with(p)) {
85        // exclude multi-target / merge workflows
86        return !lower.contains(" and ")
87            && !lower.contains("merge")
88            && !lower.contains("all ");
89    }
90    false
91}
92
93fn parse_target_base(lower: &str) -> Option<String> {
94    const PREFIXES: &[&str] = &[
95        "create a pr to ",
96        "create pr to ",
97        "create a pull request to ",
98        "create pull request to ",
99        "open a pr to ",
100        "open pr to ",
101    ];
102    for prefix in PREFIXES {
103        if let Some(rest) = lower.strip_prefix(prefix) {
104            let base = rest.trim();
105            if !base.is_empty() {
106                return Some(base.to_string());
107            }
108        }
109    }
110    None
111}
112
113fn default_base_branch(ctx: &GitContext) -> String {
114    if let Some(ref branches) = ctx.branches {
115        if branches.lines().any(|l| l.trim().trim_start_matches('*').trim() == "main") {
116            return "main".to_string();
117        }
118        if branches.lines().any(|l| l.trim().trim_start_matches('*').trim() == "master") {
119            return "master".to_string();
120        }
121    }
122    "main".to_string()
123}
124
125fn title_from_branch(head: &str) -> String {
126    if let Some(name) = head.strip_prefix("feature/") {
127        format!("feat: {name}")
128    } else if let Some(name) = head.strip_prefix("fix/") {
129        format!("fix: {name}")
130    } else {
131        format!("feat: {head}")
132    }
133}