gex 0.6.7

Git workflow improvement CLI tool inspired by Magit
use std::{
    fmt::{self, Write},
    fs,
    io::stdout,
    process::{Command, Stdio},
    rc::Rc,
    sync::atomic::Ordering,
};

use anyhow::{Context, Result};
use crossterm::{cursor, terminal};

use crate::{
    branch::BranchList, config::Options, git_process, minibuffer::MiniBuffer, status, State, View,
};

macro_rules! commands {
    ($($key:literal: $cmd:tt => [$($subkey:literal: $subcmd:tt),+$(,)?]),*$(,)?) => {
        paste::paste! {
            #[derive(Clone, Copy, Debug)]
            pub enum GexCommand { $($cmd),* }
            impl GexCommand {
                pub const fn commands() -> &'static [(char, Self)] {
                    &[$(($key, Self::$cmd)),*]
                }
                pub const fn subcommands(&self) -> &[(char, SubCommand)] {
                    match self {
                        $(Self::$cmd => {
                            &[$((
                                $subkey,
                                SubCommand::$cmd([<$cmd:lower>]::SubCommand::$subcmd)
                            )),*]
                        }),*
                    }
                }
            }

            #[derive(Clone, Copy)]
            pub enum SubCommand { $($cmd([<$cmd:lower>]::SubCommand)),* }
            impl fmt::Display for SubCommand {
                fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
                    match self { $(Self::$cmd(subcmd) => write!(f, "{subcmd}")),* }
                }
            }

            $(
                pub mod [<$cmd:lower>] {
                    use std::fmt;
                    #[derive(Debug, Clone, Copy)]
                    pub enum SubCommand { $($subcmd),* }
                    impl fmt::Display for SubCommand {
                        fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
                            match self {
                                $(Self::$subcmd => write!(f, stringify!([<$subcmd:lower>]))),*
                            }
                        }
                    }
                }
            )*
        }
    }
}

commands! {
    'b': Branch => ['b': Checkout, 'n': New],
    'c': Commit => ['c': Commit, 'a': Amend, 'e': Extend],
    'p': Push => ['p': Remote, 'f': Force],
    'z': Stash => ['s': Stash, 'p': Pop],
}

const COMMIT_TEMPLATE: &str = "
# Please enter the commit message for your changes. Lines starting
# with '#' will be ignored, and an empty message aborts the commit.
#";

fn get_commit_message(editor: &str, amend: bool) -> Result<String> {
    let output = git_process(&["status"])?.stdout;
    let status = str::from_utf8(&output)?
        .lines()
        .fold(String::new(), |mut output, l| {
            let _ = writeln!(output, "# {l}");
            output
        });
    if amend {
        let output = git_process(&["log", "-1", "--pretty=%B"])?.stdout;
        let last_commit = str::from_utf8(&output)?;
        fs::write(
            ".git/COMMIT_EDITMSG",
            format!("{last_commit}{COMMIT_TEMPLATE}\n{status}"),
        )
        .context("failed to write .git/COMMIT_EDITMSG")?;
    } else {
        fs::write(
            ".git/COMMIT_EDITMSG",
            format!("{COMMIT_TEMPLATE}\n{status}"),
        )
        .context("failed to write .git/COMMIT_EDITMSG")?;
    }
    Command::new(editor)
        .args([".git/COMMIT_EDITMSG"])
        .stdout(Stdio::inherit())
        .stdin(Stdio::inherit())
        .output()
        .with_context(|| format!("failed to open editor ({editor})"))?;
    Ok(fs::read_to_string(".git/COMMIT_EDITMSG")
        .context("failed to read .git/COMMIT_EDITMSG")?
        .lines()
        .filter(|l| !l.starts_with('#'))
        .fold(String::new(), |mut output, l| {
            let _ = writeln!(output, "{l}");
            output
        }))
}

impl GexCommand {
    #[allow(clippy::enum_glob_use)]
    pub fn handle_input(self, key: char, state: &mut State, options: &Options) -> Result<()> {
        use SubCommand::*;
        let State {
            ref mut view,
            ref mut minibuffer,
            ..
        } = state;
        let Some((_, cmd)) = self.subcommands().iter().find(|(c, _)| key == *c) else {
            return Ok(());
        };

        match cmd {
            Branch(subcmd) => {
                use branch::SubCommand;
                match subcmd {
                    SubCommand::New => {
                        minibuffer.get_input(
                            Rc::new(|input| {
                                if let Some(input) = input {
                                    BranchList::checkout_new(input)?;
                                    status::REFRESH_FLAG.store(true, Ordering::Release);
                                }
                                print!("{}", cursor::Hide);
                                Ok(())
                            }),
                            Some("Name for the new branch: "),
                            view,
                            View::Status,
                        );
                    }
                    SubCommand::Checkout => {
                        state.branch_list.fetch()?;
                        *view = View::BranchList;
                    }
                }
            }
            Commit(subcmd) => {
                use commit::SubCommand;
                match subcmd {
                    SubCommand::Commit => {
                        crossterm::execute!(stdout(), terminal::LeaveAlternateScreen)
                            .context("failed to leave alternate screen")?;
                        let commit_msg = get_commit_message(&options.editor, false)
                            .context("failed to get commit message")?;
                        MiniBuffer::push_command_output(&git_process(&[
                            "commit",
                            "-m",
                            &commit_msg,
                        ])?);
                        status::REFRESH_FLAG.store(true, Ordering::Release);
                        crossterm::execute!(stdout(), terminal::EnterAlternateScreen, cursor::Hide)
                            .context("failed to enter alternate screen")?;
                    }
                    SubCommand::Extend => {
                        MiniBuffer::push_command_output(
                            &Command::new("git")
                                .args(["commit", "--amend", "--no-edit"])
                                .stdout(Stdio::inherit())
                                .stdin(Stdio::inherit())
                                .output()
                                .context("failed to run `git commit`")?,
                        );
                        status::REFRESH_FLAG.store(true, Ordering::Release);
                    }
                    SubCommand::Amend => {
                        crossterm::execute!(stdout(), terminal::LeaveAlternateScreen)
                            .context("failed to leave alternate screen")?;
                        let commit_msg = get_commit_message(&options.editor, true)
                            .context("failed to get commit message")?;
                        MiniBuffer::push_command_output(&git_process(&[
                            "commit",
                            "--amend",
                            "-m",
                            &commit_msg,
                        ])?);
                        status::REFRESH_FLAG.store(true, Ordering::Release);
                        crossterm::execute!(stdout(), terminal::EnterAlternateScreen, cursor::Hide)
                            .context("failed to enter alternate screen")?;
                    }
                }
                *view = View::Status;
            }
            Push(subcmd) => {
                use push::SubCommand;
                // For now we are just temporarily disabling the raw mode so that if the user is
                // aksed for credentials then they can provide them that way.
                crossterm::execute!(stdout(), cursor::MoveToColumn(0), cursor::Show)?;
                terminal::disable_raw_mode().context("failed to disable raw mode")?;
                match subcmd {
                    SubCommand::Remote => MiniBuffer::push_command_output(&git_process(&["push"])?),
                    SubCommand::Force => {
                        MiniBuffer::push_command_output(&git_process(&["push", "--force"])?);
                    }
                }
                crossterm::execute!(stdout(), cursor::Hide)?;
                terminal::enable_raw_mode().context("failed to enable raw mode")?;
                *view = View::Status;
            }
            Stash(subcmd) => {
                use stash::SubCommand;
                match subcmd {
                    SubCommand::Stash => MiniBuffer::push_command_output(&git_process(&["stash"])?),
                    SubCommand::Pop => {
                        MiniBuffer::push_command_output(&git_process(&["stash", "pop"])?);
                    }
                }
                status::REFRESH_FLAG.store(true, Ordering::Release);
                *view = View::Status;
            }
        }

        Ok(())
    }
}