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