#![warn(clippy::pedantic, clippy::nursery)]
#![allow(
clippy::cast_possible_truncation,
clippy::too_many_lines,
clippy::missing_errors_doc,
clippy::redundant_closure_for_method_calls,
clippy::module_name_repetitions
)]
use std::{
cmp, env,
io::{stdin, stdout, BufRead, Write},
path::Path,
process::{self, Command, Output, Stdio},
};
use anyhow::{Context, Result};
use clap::Arg;
use crossterm::{
cursor,
event::{self, Event, KeyCode},
style::{Attribute, Color, SetForegroundColor},
terminal::{self, ClearType},
};
use git2::Repository;
use crate::minibuffer::{MessageType, MiniBuffer};
mod branch;
mod minibuffer;
mod parse;
mod status;
use branch::BranchList;
use status::Status;
#[derive(PartialEq)]
enum State {
Status,
Commit,
Branch,
}
const COMMIT_CMDS: [(char, &str); 3] = [('c', "commit"), ('e', "extend"), ('a', "amend")];
pub fn git_process(args: &[&str]) -> Result<Output> {
Command::new("git").args(args).output().with_context(|| {
format!(
"failed to run `git{}`",
args.iter().map(|a| " ".to_string() + a).collect::<String>()
)
})
}
fn run(path: &Path) -> Result<()> {
let repo = if let Ok(repo) = Repository::discover(path) {
repo
} else {
print!("Not a git repository. Initialise one? [y/N]");
drop(stdout().flush());
let input = stdin()
.lock()
.lines()
.next()
.context("couldn't read stdin")?
.context("malformed stdin")?;
if input.to_lowercase() != "y" {
process::exit(0);
}
Repository::init(path).context("failed to initialise git repository")?
};
std::env::set_current_dir(repo.path().parent().context("`.git` cannot be root dir")?)
.context("failed to set working directory")?;
let mut status = Status::new(&repo)?;
let mut branch_list = BranchList::new()?;
let mut mini_buffer = MiniBuffer::new();
if !env::var("LANG")
.map(|s| s.starts_with("en"))
.unwrap_or(true)
{
mini_buffer.push("WARNING: Non-English locale detected. For now, Gex only supports English locale setting.
Set locale to English, e.g.:
$ LANG=en_GB gex
See https://github.com/Piturnah/gex/issues/13.".to_string(), MessageType::Error);
}
crossterm::execute!(stdout(), terminal::EnterAlternateScreen)
.context("failed to enter alternate screen")?;
terminal::enable_raw_mode().context("failed to put terminal in raw mode")?;
print!("{}", cursor::Hide);
let mut state = State::Status;
loop {
let (term_width, term_height) =
terminal::size().context("failed to query terminal dimensions")?;
match state {
State::Status | State::Commit => {
print!(
"{}{}{}\r",
cursor::MoveToRow(0),
terminal::Clear(ClearType::All),
status,
);
}
State::Branch => {
print!(
"{}{}{}",
cursor::MoveToRow(0),
terminal::Clear(ClearType::All),
branch_list
);
drop(stdout().flush());
}
}
if state == State::Commit {
print!(
"{}{:═^term_width$}{}{}{}",
cursor::MoveTo(0, term_height - 1 - COMMIT_CMDS.len() as u16),
"Commit Options",
terminal::Clear(ClearType::FromCursorDown),
COMMIT_CMDS
.into_iter()
.map(|(k, v)| format!(
"\r\n {}{}{}{} => {}",
SetForegroundColor(Color::Green),
Attribute::Bold,
k,
Attribute::Reset,
v
))
.collect::<String>(),
SetForegroundColor(Color::Reset),
term_width = term_width as usize,
);
drop(stdout().flush());
}
mini_buffer.render(term_width, term_height)?;
if let Event::Key(event) = event::read().context("failed to read a terminal event")? {
match state {
State::Status => match event.code {
KeyCode::Char('j') | KeyCode::Down => status.down()?,
KeyCode::Char('k') | KeyCode::Up => status.up()?,
KeyCode::Char('G' | 'J') => status.cursor_last()?,
KeyCode::Char('g' | 'K') => status.cursor_first()?,
KeyCode::Char('s') => {
status.stage()?;
status.fetch(&repo)?;
}
KeyCode::Char('S') => {
mini_buffer.push_command_output(&git_process(&["add", "."])?);
status.fetch(&repo)?;
}
KeyCode::Char('u') => {
status.unstage()?;
status.fetch(&repo)?;
}
KeyCode::Char('U') => {
mini_buffer.push_command_output(&git_process(&["reset"])?);
status.fetch(&repo)?;
}
KeyCode::Tab => status.expand()?,
KeyCode::Char('c') => {
state = State::Commit;
}
KeyCode::Char('F') => {
mini_buffer.push_command_output(&git_process(&["pull"])?);
status.fetch(&repo)?;
}
KeyCode::Char('b') => {
branch_list.fetch()?;
state = State::Branch;
}
KeyCode::Char('r') => status.fetch(&repo)?,
KeyCode::Char(':') => {
mini_buffer.git_command(term_width, term_height)?;
status.fetch(&repo)?;
}
KeyCode::Char('q') => {
terminal::disable_raw_mode().context("failed to disable raw mode")?;
crossterm::execute!(
stdout(),
terminal::LeaveAlternateScreen,
cursor::Show,
cursor::MoveToColumn(0)
)
.context("failed to leave alternate screen")?;
process::exit(0);
}
_ => {}
},
State::Commit => match event.code {
KeyCode::Char('c') => {
crossterm::execute!(stdout(), terminal::LeaveAlternateScreen)
.context("failed to leave alternate screen")?;
mini_buffer.push_command_output(
&Command::new("git")
.arg("commit")
.stdout(Stdio::inherit())
.stdin(Stdio::inherit())
.output()
.context("failed to run `git commit`")?,
);
status.fetch(&repo)?;
crossterm::execute!(stdout(), terminal::EnterAlternateScreen, cursor::Hide)
.context("failed to enter alternate screen")?;
state = State::Status;
}
KeyCode::Char('e') => {
mini_buffer.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.fetch(&repo)?;
state = State::Status;
}
KeyCode::Char('a') => {
crossterm::execute!(stdout(), terminal::LeaveAlternateScreen)
.context("failed to leave alternate screen")?;
mini_buffer.push_command_output(
&Command::new("git")
.args(["commit", "--amend"])
.stdout(Stdio::inherit())
.stdin(Stdio::inherit())
.output()
.context("failed to run `git commit`")?,
);
status.fetch(&repo)?;
crossterm::execute!(stdout(), terminal::EnterAlternateScreen, cursor::Hide)
.context("failed to enter alternate screen")?;
state = State::Status;
}
KeyCode::Esc => state = State::Status,
KeyCode::Char('q') => {
terminal::disable_raw_mode().context("failed to exit raw mode")?;
crossterm::execute!(
stdout(),
terminal::LeaveAlternateScreen,
cursor::Show,
cursor::MoveToColumn(0)
)
.context("failed to leave alternate screen")?;
process::exit(0);
}
_ => {}
},
State::Branch => match event.code {
KeyCode::Char('k') | KeyCode::Up => {
branch_list.cursor = branch_list.cursor.saturating_sub(1);
}
KeyCode::Char('j') | KeyCode::Down => {
branch_list.cursor =
cmp::min(branch_list.cursor + 1, branch_list.branches.len() - 1);
}
KeyCode::Char('g' | 'K') => branch_list.cursor = 0,
KeyCode::Char('G' | 'J') => {
branch_list.cursor = branch_list.branches.len() - 1;
}
KeyCode::Char(' ') | KeyCode::Enter => {
mini_buffer.push_command_output(&branch_list.checkout()?);
status.fetch(&repo)?;
state = State::Status;
}
KeyCode::Char('b') => {
mini_buffer.push_command_output(&BranchList::checkout_new()?);
status.fetch(&repo)?;
state = State::Status;
}
KeyCode::Esc => state = State::Status,
KeyCode::Char('q') => {
terminal::disable_raw_mode().context("failed to disable raw mode")?;
crossterm::execute!(
stdout(),
terminal::LeaveAlternateScreen,
cursor::Show,
cursor::MoveToColumn(0)
)
.context("failed to leave alternate screen")?;
process::exit(0);
}
_ => {}
},
};
}
}
}
fn main() -> Result<()> {
let matches = clap::command!()
.version(env!("GEX_VERSION"))
.arg(
Arg::new("path")
.default_value(".")
.value_name("PATH")
.help("The path to the repository"),
)
.get_matches();
let path = matches
.get_one::<String>("path")
.expect("default value provided");
run(Path::new(path)).map_err(|e| {
drop(terminal::disable_raw_mode());
drop(crossterm::execute!(
stdout(),
terminal::LeaveAlternateScreen,
cursor::Show,
cursor::MoveToColumn(0)
));
e
})
}