gcu/
main.rs

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