Skip to main content

gcu/
main.rs

1//! Switch, create, and derive Git branches (including from GitHub PR URLs).
2//!
3//! # Errors
4//! - Git operations, GitHub API calls, or user interaction fails.
5
6use std::io::Write;
7
8use owo_colors::OwoColorize;
9use rootcause::prelude::ResultExt;
10use rootcause::report;
11use url::Url;
12use ytil_git::CmdError;
13use ytil_sys::cli::Args;
14
15/// Switch, create, and derive Git branches (including from GitHub PR URLs).
16#[ytil_sys::main]
17fn main() -> rootcause::Result<()> {
18    let args = ytil_sys::cli::get();
19    if args.has_help() {
20        println!("{}", include_str!("../help.txt"));
21        return Ok(());
22    }
23    let args: Vec<_> = args.iter().map(String::as_str).collect();
24
25    match args.split_first() {
26        None => autocomplete_git_branches_and_switch(),
27        // Assumption: cannot create a branch with a name that starts with -
28        Some((hd, _)) if *hd == "-" => ytil_git::branch::switch(hd)
29            .inspect(|()| report_branch_switch(hd))
30            .map_err(From::from),
31        Some((hd, tail)) if *hd == "-b" => create_branch_and_switch(&build_branch_name(tail)?),
32        Some((hd, &[])) => handle_single_input_argument(hd),
33        _ => create_branch_and_switch(&build_branch_name(&args)?),
34    }?;
35
36    Ok(())
37}
38
39/// Interactive selection and switching of Git branches.
40///
41/// Presents a minimal TUI listing the provided branches (or fetches recent local / remote branches
42/// if none provided), with redundant remotes removed.
43///
44/// Selecting an empty line or "-" triggers previous-branch switching.
45///
46/// # Errors
47/// - Branch enumeration via [`ytil_git::branch::get_all_no_redundant`] fails (if `branches` is empty).
48/// - UI rendering via [`ytil_tui::minimal_select`] fails.
49/// - Branch switching via [`ytil_git::branch::switch`] fails.
50fn autocomplete_git_branches_and_switch() -> rootcause::Result<()> {
51    let Some(branch) = ytil_tui::git_branch::select()? else {
52        return Ok(());
53    };
54
55    let branch_name_no_origin = branch.name_no_origin();
56    ytil_git::branch::switch(branch_name_no_origin).inspect(|()| report_branch_switch(branch_name_no_origin))?;
57
58    Ok(())
59}
60
61/// Handles a single input argument, either a GitHub PR URL or a branch name, and switches to the corresponding branch.
62///
63/// Behaviour:
64/// - If `arg` parses as a GitHub PR URL, authenticate then derive the branch name and switch to it.
65/// - Otherwise, use `arg` as the branch name and switch to it.
66///
67/// # Errors
68/// - GitHub authentication via [`ytil_gh::log_into_github`] fails (if URL).
69/// - Pull request branch name derivation via [`ytil_gh::get_branch_name_from_url`] fails (if URL).
70/// - Branch switching via [`ytil_git::branch::switch`] fails.
71fn handle_single_input_argument(arg: &str) -> rootcause::Result<()> {
72    let branch_name = if let Ok(url) = Url::parse(arg) {
73        ytil_gh::log_into_github()?;
74        ytil_gh::get_branch_name_from_url(&url)?
75    } else {
76        arg.to_string()
77    };
78
79    match ytil_git::branch::switch(&branch_name).map_err(|e| *e) {
80        Err(CmdError::CmdFailure { stderr, .. }) if stderr.contains("invalid reference: ") => {
81            create_branch_and_switch(&branch_name)
82        }
83        other => Ok(other?),
84    }
85}
86
87/// Creates a new local branch (if desired) and switches to it.
88///
89/// Behaviour:
90/// - if both the current branch and the target branch are non‑default (not `main` / `master`) user confirmation is
91///   required.
92///
93/// # Errors
94/// - Current branch discovery via [`ytil_git::branch::get_current`] fails.
95/// - Branch creation via [`ytil_git::branch::create_from_default_branch`] or subsequent switching via
96///   [`ytil_git::branch::switch`] fails.
97/// - Reading user confirmation input fails.
98fn create_branch_and_switch(branch_name: &str) -> rootcause::Result<()> {
99    if !should_create_new_branch(branch_name)? {
100        return Ok(());
101    }
102    if let Err(err) = ytil_git::branch::create_from_default_branch(branch_name, None) {
103        if err.to_string().contains("already exists") {
104            ytil_git::branch::switch(branch_name).inspect(|()| report_branch_exists(branch_name))?;
105            return Ok(());
106        }
107        return Err(err);
108    }
109    ytil_git::branch::switch(branch_name).inspect(|()| report_branch_new(branch_name))?;
110    Ok(())
111}
112
113/// Returns `true` if a new branch may be created following the desired behavior.
114///
115/// Behaviour:
116/// - Always allowed when target is a default branch (`main`/`master`).
117/// - Always allowed when current branch is a default branch.
118/// - Otherwise, requires user confirmation via empty line input (non‑empty aborts).
119///
120/// # Errors
121/// - Current branch discovery via [`ytil_git::branch::get_current`] fails.
122/// - Reading user confirmation input fails.
123fn should_create_new_branch(branch_name: &str) -> rootcause::Result<bool> {
124    let default_branch = ytil_git::branch::get_default()?;
125    if default_branch == branch_name {
126        return Ok(true);
127    }
128    let curr_branch = ytil_git::branch::get_current()?;
129    if default_branch == curr_branch {
130        return Ok(true);
131    }
132    ask_branching_from_not_default(branch_name, &curr_branch);
133    std::io::stdout().flush()?;
134    let mut input = String::new();
135    std::io::stdin().read_line(&mut input)?;
136    if !input.trim().is_empty() {
137        report_branch_not_created(branch_name);
138        return Ok(false);
139    }
140    Ok(true)
141}
142
143/// Builds a sanitized, lowercased Git branch name from raw arguments.
144///
145/// Transformation:
146/// - Split each argument by ASCII whitespace into tokens.
147/// - Replace unsupported characters with spaces (only alphanumeric plus '.', '/', '_').
148/// - Collapse contiguous spaces inside each token into `-` separators.
149/// - Discard empty tokens.
150/// - Join resulting tokens with `-`.
151///
152/// # Errors
153/// - sanitization produces an empty string.
154fn build_branch_name(args: &[&str]) -> rootcause::Result<String> {
155    fn is_permitted(c: char) -> bool {
156        const PERMITTED_CHARS: [char; 3] = ['.', '/', '_'];
157        c.is_alphanumeric() || PERMITTED_CHARS.contains(&c)
158    }
159
160    // Single-pass approach: walk every char once, push permitted chars lowercased directly into the
161    // output buffer, and collapse runs of non-permitted chars / whitespace boundaries into a single
162    // '-' separator. This avoids the previous 5+ intermediate allocations per token.
163    let mut branch_name = String::new();
164    let mut need_separator = false;
165
166    for arg in args {
167        for token in arg.split_whitespace() {
168            for c in token.chars() {
169                if is_permitted(c) {
170                    if need_separator && !branch_name.is_empty() {
171                        branch_name.push('-');
172                    }
173                    need_separator = false;
174                    for lc in c.to_lowercase() {
175                        branch_name.push(lc);
176                    }
177                } else {
178                    // Non-permitted chars collapse into a pending separator.
179                    need_separator = true;
180                }
181            }
182            // Boundary between whitespace-separated tokens is also a separator.
183            need_separator = true;
184        }
185    }
186
187    if branch_name.is_empty() {
188        Err(report!("branch name construction produced empty string")).attach_with(|| format!("args={args:#?}"))?;
189    }
190
191    Ok(branch_name)
192}
193
194/// Prints a styled indication of a successful branch switch.
195fn report_branch_switch(branch_name: &str) {
196    println!("{} {}", ">".magenta().bold(), branch_name.bold());
197}
198
199/// Prints a styled indication that a new branch was created.
200fn report_branch_new(branch_name: &str) {
201    println!("{} {}", "+".green().bold(), branch_name.bold());
202}
203
204/// Prints a styled indication that the branch already exists; then indicates switch.
205fn report_branch_exists(branch_name: &str) {
206    println!("{}{} {}", "!".blue().bold(), ">".magenta().bold(), branch_name.bold());
207}
208
209/// Prints a styled indication that branch creation was aborted (no newline).
210fn report_branch_not_created(branch_name: &str) {
211    print!("{} {} not created", "x".red().bold(), branch_name.bold());
212}
213
214/// Prints a styled notice that a new branch is being created from a non-default branch.
215fn ask_branching_from_not_default(branch_name: &str, default_branch_name: &str) {
216    print!(
217        "{} {} from {}",
218        "*".cyan().bold(),
219        branch_name.bold(),
220        default_branch_name.bold()
221    );
222}
223
224#[cfg(test)]
225mod tests {
226    use rstest::rstest;
227
228    use super::*;
229
230    #[rstest]
231    #[case::empty_input("", "branch name construction produced empty string")]
232    #[case::invalid_characters_only("❌", "branch name construction produced empty string")]
233    fn test_build_branch_name_fails_as_expected(#[case] input: &str, #[case] expected_ctx: &str) {
234        assert2::assert!(let Err(err) = build_branch_name(&[input]));
235        assert_eq!(err.format_current_context().to_string(), expected_ctx);
236    }
237
238    #[rstest]
239    #[case::single_word(&["HelloWorld"], "helloworld")]
240    #[case::space_separated(&["Hello World"], "hello-world")]
241    #[case::special_characters(&["Feature: Implement User Login!"], "feature-implement-user-login")]
242    #[case::version_number(&["Version 2.0"], "version-2.0")]
243    #[case::multiple_separators(&["This---is...a_test"], "this-is...a_test")]
244    #[case::leading_trailing_spaces(&["  Leading and trailing   "], "leading-and-trailing")]
245    #[case::emoji(&["Hello 🌎 World"], "hello-world")]
246    #[case::emoji_at_start_end(&["🚀Launch🚀Day"], "launch-day")]
247    #[case::multiple_emojis(&["Smile 😊 and 🤖 code"], "smile-and-code")]
248    #[case::multiple_args(&["Hello", "World"], "hello-world")]
249    #[case::args_with_spaces(&["Hello World", "World"], "hello-world-world")]
250    #[case::mixed_args(&["Hello World", "🌎", "42"], "hello-world-42")]
251    #[case::special_chars_in_args(&["This", "---is.", "..a_test"], "this-is.-..a_test")]
252    #[case::dependabot_path(&["dependabot/cargo/opentelemetry-0.27.1"], "dependabot/cargo/opentelemetry-0.27.1")]
253    fn test_build_branch_name_succeeds_as_expected(#[case] input: &[&str], #[case] expected_output: &str) {
254        assert2::assert!(let Ok(actual_output) = build_branch_name(input));
255        assert_eq!(actual_output, expected_output);
256    }
257}