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