This commit is contained in:
JMARyA 2025-01-22 17:45:12 +01:00
commit 0a48113d38
Signed by: jmarya
GPG key ID: 901B2ADDF27C2263
10 changed files with 1660 additions and 0 deletions

89
src/args.rs Normal file
View file

@ -0,0 +1,89 @@
use clap::{arg, command};
pub fn get_args() -> clap::ArgMatches {
command!()
.about("Git wrapper")
.subcommand(
command!("bootstrap")
.about("Bootstrap a new repository from base repository")
.arg(arg!(<BASE> "Base repository").required(true))
.arg(arg!(<NEW> "Repository name").required(true))
.alias("bs"),
)
.subcommand(
command!("todo")
.about("Show open TODOs in repository")
.alias("t"),
)
.subcommand(
command!("new")
.about("Create a new branch")
.arg(arg!(<BRANCH> "Branch name").required(true))
.alias("n"),
)
.subcommand(
command!("commit")
.about("Commit current changes")
.alias("c")
.arg(arg!(-a --all "Add all changed files to commit"))
.arg(arg!(-d --done "Only allow commiting if no TODOs are present"))
.arg(
arg!(-i --interactive "Write interactive commit message with gitmoji standard")
.conflicts_with("message"),
)
.arg(arg!(-m --message <MSG> "Commit message").required(false)),
)
.subcommand(
command!("remove")
.about("Remove a branch")
.arg(arg!(<BRANCH> "Branch name").required(true))
.alias("r"),
)
.subcommand(command!("branch").about("Show branches").alias("b"))
.subcommand(
command!()
.name("push")
.about("Push to a remote")
.arg(arg!(-f --force "Force push"))
.alias("p"),
)
.subcommand(
command!()
.name("status")
.about("Show git status")
.alias("stat")
.alias("stats"),
)
.subcommand(
command!()
.name("add")
.about("Add files to git")
.alias("a")
.arg(arg!(<FILE> "File to add").required(true)),
)
.subcommand(
command!()
.name("fetch")
.about("Run git fetch --prune")
.alias("f"),
)
.subcommand(
command!()
.name("pull")
.about("Run git pull --prune")
.alias("p"),
)
.subcommand(
command!()
.name("log")
.about("Show git commit log")
.alias("l"),
)
.subcommand(
command!()
.name("save")
.about("Create a WIP commit")
.alias("s"),
)
.get_matches()
}

260
src/git.rs Normal file
View file

@ -0,0 +1,260 @@
use subprocess::Exec;
use yansi::{Color, Paint};
use crate::{
NO_COMMIT_REGEX, no_commit_amount, precommit::rust_pre_commit, read_stdout, show_rg,
todos_amount,
};
/// Check if the current repository has a remote set up
pub fn has_remote() -> bool {
let e = Exec::cmd("git").arg("remote").arg("-v");
let str = read_stdout(e);
!str.is_empty()
}
/// Create a new branch and switch to it in the current repository
pub fn create_new_branch(branch: &str) {
let mut git = Exec::cmd("git")
.arg("checkout")
.arg("-b")
.arg(branch)
.popen()
.unwrap();
git.wait().unwrap();
if has_remote() {
let mut git = Exec::cmd("git")
.arg("push")
.arg("-u")
.arg("origin")
.arg(branch)
.popen()
.unwrap();
git.wait().unwrap();
}
}
/// Push the current repository to remote
pub fn push(force: bool) {
let mut cmd = Exec::cmd("git")
.arg("push")
.arg("--set-upstream")
.arg("origin");
if force {
cmd = cmd.arg("--force-with-lease");
}
cmd.popen().unwrap().wait().unwrap();
}
/// Delete a branch from the current repository
pub fn delete_branch(branch: &str) {
let mut git = Exec::cmd("git")
.arg("branch")
.arg("-d")
.arg(branch)
.popen()
.unwrap();
git.wait().unwrap();
if has_remote() {
let mut git = Exec::cmd("git")
.arg("push")
.arg("origin")
.arg("--delete")
.arg(branch)
.popen()
.unwrap();
git.wait().unwrap();
}
}
/// Get the title of the last commit
pub fn last_commit() -> String {
let e = Exec::cmd("git").arg("log").arg("-1").arg("--pretty=%B");
read_stdout(e)
}
/// Get all files affected by last commit
pub fn last_commit_files() -> Vec<String> {
let file_str = read_stdout(
Exec::cmd("git")
.arg("diff-tree")
.arg("--no-commit-id")
.arg("--name-only")
.arg("-r")
.arg("HEAD"),
);
file_str.split('\n').map(|x| x.to_string()).collect()
}
/// Add a file to git
pub fn git_add(file: &str) {
Exec::cmd("git")
.arg("add")
.arg(file)
.popen()
.unwrap()
.wait()
.unwrap();
}
/// Get a Vec of programming languages used in this repository
pub fn get_languages() -> Vec<String> {
let e: Exec = Exec::cmd("onefetch").arg("-o").arg("json");
let json_str = read_stdout(e);
if let Result::<serde_json::Value, _>::Ok(json) = serde_json::from_str(&json_str) {
let languages = (|| {
let fields = json.as_object()?.get("infoFields")?.as_array()?;
let langs = fields
.iter()
.find(|x| x.as_object().unwrap().contains_key("LanguagesInfo"))?;
let langs = langs
.as_object()?
.get("LanguagesInfo")?
.as_object()?
.get("languagesWithPercentage")?
.as_array()?;
Some(
langs
.iter()
.map(|x| {
x.as_object()
.unwrap()
.get("language")
.unwrap()
.as_str()
.unwrap()
.to_string()
})
.collect(),
)
})();
languages.unwrap_or_default()
} else {
Vec::new()
}
}
/// Commit changes to the repository.
pub fn commit(all: bool, done: bool, msg: &str) {
// Work In Progress Save Commit
if last_commit().as_str() == "WIP" {
// Get files affected by commit
let last_commit_files = last_commit_files();
// Reset last commit
Exec::cmd("git")
.arg("reset")
.arg("HEAD~1")
.popen()
.unwrap()
.wait()
.unwrap();
// Readd files affected by commit
for file in last_commit_files {
git_add(&file);
}
}
// Check TODOs
let todos = todos_amount();
if todos > 0 {
println!(
"{}: Repository still contains {todos} TODO entries.",
"Warning".paint(Color::Yellow)
);
if done {
println!(
"{}",
"💀 Make sure to close them before commiting or we can't rest in peace..."
.paint(Color::Red)
);
std::process::exit(1);
}
}
// Impossible commits
if no_commit_amount() > 0 {
println!("{}", "Unable to commit because of:".paint(Color::Red));
show_rg(NO_COMMIT_REGEX);
std::process::exit(1);
}
// Laguage specific pre-commit hooks
for lang in get_languages() {
match lang.as_str() {
"Rust" => {
rust_pre_commit();
}
_ => {}
}
}
if all {
Exec::cmd("git")
.arg("add")
.arg("-A")
.popen()
.unwrap()
.wait()
.unwrap();
}
Exec::cmd("git")
.arg("commit")
.arg("-m")
.arg(msg)
.popen()
.unwrap()
.wait()
.unwrap();
}
/// Fetch remote state
pub fn fetch() {
Exec::cmd("git")
.arg("fetch")
.arg("--prune")
.popen()
.unwrap()
.wait()
.unwrap();
}
/// Pull remote changes
pub fn pull() {
Exec::cmd("git")
.arg("pull")
.arg("--prune")
.popen()
.unwrap()
.wait()
.unwrap();
}
/// Bootstrap a git repository using a base template
pub fn bootstrap(base: &str, name: &str) {
Exec::cmd("git")
.arg("clone")
.arg(base)
.arg(name)
.popen()
.unwrap()
.wait()
.unwrap();
std::fs::remove_dir_all(std::path::Path::new(name).join(".git")).unwrap();
Exec::cmd("git")
.arg("init")
.arg("--quiet")
.cwd(std::path::Path::new(name))
.popen()
.unwrap()
.wait()
.unwrap();
}

360
src/gitmoji.rs Normal file
View file

@ -0,0 +1,360 @@
pub fn select_from_list<F: FnOnce(&str) -> String>(
prompt: &str,
lst: Vec<&str>,
quit: bool,
then: F,
) -> String {
match inquire::Select::new(prompt, lst).prompt() {
Ok(val) => then(val),
Err(_) => {
if quit {
eprintln!("Nothing selected");
std::process::exit(1);
}
String::new()
}
}
}
pub fn select_gitmoji() -> String {
let topics = vec![
"Fix",
"Refactor",
"Improve",
"Add/Update",
"Remove",
"Git",
"UI",
"Dependency",
"Security",
"Text",
"Test",
"Fun",
"Misc",
];
let select = select_from_list(
"Gitmoji Intention Topic",
topics,
true,
|topic| match topic {
"Fix" => select_gitmoji_fix(),
"Improve" => select_gitmoji_improvement(),
"Remove" => select_gitmoji_remove(),
"Dependency" => select_gitmoji_dependency(),
"Test" => select_gitmoji_test(),
"Git" => select_gitmoji_git(),
"Refactor" => select_gitmoji_refactor(),
"UI" => select_gitmoji_ui(),
"Text" => select_gitmoji_text(),
"Fun" => select_gitmoji_fun(),
"Security" => select_gitmoji_security(),
"Add/Update" => select_gitmoji_add_update(),
_ => select_gitmoji_misc(),
},
);
if select.is_empty() {
return select_gitmoji();
}
select
}
pub fn select_gitmoji_fix() -> String {
let options = vec![
"🐛 Fix a bug",
"🩹 Simple fix for a non-critical issue",
"🚑️ Critical hotfix",
"✏️ Fix typos",
"💚 Fix CI Build",
"🔒️ Fix security or privacy issues",
"🚨 Fix compiler / linter warnings",
"🥅 Catch errors",
];
let res = select_from_list("Gitmoji Intention", options, false, |selected| {
selected.split_whitespace().next().unwrap().to_string()
});
if res.is_empty() {
return select_gitmoji();
}
res
}
pub fn select_gitmoji_improvement() -> String {
let options = vec![
"✨ Introduce new features",
"🎨 Improve structure / format of the code",
"⚡️ Improve performance",
"🧑‍💻 Improve developer experience",
"🚸 Improve user experience / usability",
"♿️ Improve accessibility",
"🔍️ Improve SEO",
];
let res = select_from_list("Gitmoji Intention", options, false, |selected| {
selected.split_whitespace().next().unwrap().to_string()
});
if res.is_empty() {
return select_gitmoji();
}
res
}
pub fn select_gitmoji_remove() -> String {
let options = vec!["🔥 Remove code or files", "⚰️ Remove dead code"];
let res = select_from_list("Gitmoji Intention", options, false, |selected| {
selected.split_whitespace().next().unwrap().to_string()
});
if res.is_empty() {
return select_gitmoji();
}
res
}
pub fn select_gitmoji_dependency() -> String {
let options = vec![
" Remove a dependency",
" Add a dependency",
"⬇️ Downgrade dependencies",
"⬆️ Upgrade dependencies",
"📌 Pin dependencies to specific versions",
];
let res = select_from_list("Gitmoji Intention", options, false, |selected| {
selected.split_whitespace().next().unwrap().to_string()
});
if res.is_empty() {
return select_gitmoji();
}
res
}
pub fn select_gitmoji_test() -> String {
let options = vec![
"✅ Add, update, or pass tests",
"🧪 Add a failing test",
"🤡 Mock things",
"⚗️ Perform experiments",
];
let res = select_from_list("Gitmoji Intention", options, false, |selected| {
selected.split_whitespace().next().unwrap().to_string()
});
if res.is_empty() {
return select_gitmoji();
}
res
}
pub fn select_gitmoji_fun() -> String {
let options = vec![
"🍻 Write code drunkenly",
"🍃 Write code high",
"❄ Write code on coke",
"🍄 Write code on psychedelics",
"🥚 Add or update an easter egg",
"💩 Write bad code that needs to be improved",
];
let res = select_from_list("Gitmoji Intention", options, false, |selected| {
selected.split_whitespace().next().unwrap().to_string()
});
if res.is_empty() {
return select_gitmoji();
}
res
}
pub fn select_gitmoji_git() -> String {
let options = vec![
"⏪️ Revert changes",
"🔀 Merge branches",
"🔖 Release / Version tags",
"🙈 Add or update a .gitignore file",
];
let res = select_from_list("Gitmoji Intention", options, false, |selected| {
selected.split_whitespace().next().unwrap().to_string()
});
if res.is_empty() {
return select_gitmoji();
}
res
}
pub fn select_gitmoji_refactor() -> String {
let options = vec![
"♻️ Refactor code",
"🗑️ Deprecate code that needs to be cleaned up",
"🚚 Move or rename resources (e.g.: files, paths, routes)",
"🍱 Add or update assets",
"👽️ Update code due to external API changes",
"💡 Add or update comments in source code",
"🏷️ Add or update types",
];
let res = select_from_list("Gitmoji Intention", options, false, |selected| {
selected.split_whitespace().next().unwrap().to_string()
});
if res.is_empty() {
return select_gitmoji();
}
res
}
pub fn select_gitmoji_ui() -> String {
let options = vec![
"💄 Add or update the UI and style files",
"📱 Work on responsive design",
"💫 Add or update animations and transitions",
];
let res = select_from_list("Gitmoji Intention", options, false, |selected| {
selected.split_whitespace().next().unwrap().to_string()
});
if res.is_empty() {
return select_gitmoji();
}
res
}
pub fn select_gitmoji_text() -> String {
let options = vec![
"📝 Add or update documentation",
"🌐 Internationalization and localization",
"💬 Add or update text and literals",
"👥 Add or update contributor(s)",
"📄 Add or update license",
"🔊 Add or update logs",
"🔇 Remove logs",
];
let res = select_from_list("Gitmoji Intention", options, false, |selected| {
selected.split_whitespace().next().unwrap().to_string()
});
if res.is_empty() {
return select_gitmoji();
}
res
}
pub fn select_gitmoji_security() -> String {
let options = vec![
"🛂 Work on code related to authorization, roles and permissions",
"🔐 Add or update secrets",
"🦺 Add or update code related to validation",
];
let res = select_from_list("Gitmoji Intention", options, false, |selected| {
selected.split_whitespace().next().unwrap().to_string()
});
if res.is_empty() {
return select_gitmoji();
}
res
}
pub fn select_gitmoji_add_update() -> String {
let options = vec![
"👷 Add or update CI build system",
"🔧 Add or update configuration files",
"🔨 Add or update development scripts",
"📦️ Add or update compiled files or packages",
"🚩 Add, update, or remove feature flags",
"🩺 Add or update healthcheck",
"🧵 Add or update code related to multithreading or concurrency",
"🌱 Add or update seed files",
"📸 Add or update snapshots",
"📈 Add or update analytics or track code",
"👔 Add or update business logic",
];
let res = select_from_list("Gitmoji Intention", options, false, |selected| {
selected.split_whitespace().next().unwrap().to_string()
});
if res.is_empty() {
return select_gitmoji();
}
res
}
pub fn select_gitmoji_misc() -> String {
let options = vec![
"🎉 Begin a project",
"🚀 Deploy stuff",
"🚧 Work in progress",
"💥 Introduce breaking changes",
"🗃️ Perform database related changes",
"🏗️ Make architectural changes",
"🧱 Infrastructure related changes",
"💸 Add sponsorships or money related infrastructure",
"🧐 Data exploration/inspection",
];
let res = select_from_list("Gitmoji Intention", options, false, |selected| {
selected.split_whitespace().next().unwrap().to_string()
});
if res.is_empty() {
return select_gitmoji();
}
res
}
pub struct CommitMessage {
gitmoji: String,
title: String,
body: Option<String>,
}
impl CommitMessage {
pub const fn new(gitmoji: String, title: String, body: Option<String>) -> Self {
CommitMessage {
gitmoji,
title,
body,
}
}
pub fn to_msg(&self) -> String {
let mut msg = self.gitmoji.clone();
msg.push_str(&format!(" {}", self.title));
if let Some(body) = &self.body {
if !body.is_empty() {
msg.push_str(&format!("\n\n{body}"));
}
}
msg
}
}

154
src/main.rs Normal file
View file

@ -0,0 +1,154 @@
use git::{bootstrap, commit, create_new_branch, delete_branch, fetch, git_add, pull, push};
use gitmoji::{CommitMessage, select_gitmoji};
use std::io::Read;
use subprocess::{Exec, Redirection};
mod args;
mod git;
mod gitmoji;
mod precommit;
pub fn read_stdout(e: Exec) -> String {
let mut p = e.stdout(Redirection::Pipe).popen().unwrap();
let mut str = String::new();
p.stdout.as_mut().unwrap().read_to_string(&mut str).unwrap();
str.trim().to_string()
}
const TODO_REGEX: &str = r" (todo|unimplemented|refactor|wip|fix)(:|!|\n|\ :)";
const NO_COMMIT_REGEX: &str = r"(NOCOMMIT|ENSURE: )";
pub fn no_commit_amount() -> u64 {
rg_matches(NO_COMMIT_REGEX)
}
pub fn todos_amount() -> u64 {
rg_matches(TODO_REGEX)
}
pub fn rg_matches(regex: &str) -> u64 {
let mut cmd = Exec::cmd("rg")
.arg("-i")
.arg("--json")
.arg("--multiline")
.arg(regex)
.stdout(Redirection::Pipe)
.popen()
.unwrap();
cmd.wait().unwrap();
let mut str = String::new();
cmd.stdout
.as_mut()
.unwrap()
.read_to_string(&mut str)
.unwrap();
let last_line = str.lines().last().unwrap();
let val: serde_json::Value = serde_json::from_str(last_line).unwrap();
let ret = (|| {
val.as_object()
.unwrap()
.get("data")?
.as_object()?
.get("stats")?
.as_object()?
.get("matches")?
.as_number()
.unwrap()
.as_i64()
})();
ret.unwrap_or(0) as u64
}
pub fn show_rg(regex: &str) {
Exec::cmd("rg")
.arg("-i")
.arg("--multiline")
.arg(regex)
.popen()
.unwrap()
.wait()
.unwrap();
}
pub fn show_todos() {
println!("{} todos found.", todos_amount());
show_rg(TODO_REGEX);
}
fn main() {
let args = args::get_args();
match args.subcommand() {
Some(("bootstrap", bs_args)) => {
let base = bs_args.get_one::<String>("BASE").unwrap();
let repo = bs_args.get_one::<String>("NEW").unwrap();
bootstrap(base, repo);
}
Some(("new", new_args)) => {
let branch: &String = new_args.get_one("BRANCH").unwrap();
create_new_branch(branch);
}
Some(("remove", rm_args)) => {
let branch: &String = rm_args.get_one("BRANCH").unwrap();
delete_branch(branch);
}
Some(("branch", _)) => {
Exec::cmd("git")
.arg("branch")
.popen()
.unwrap()
.wait()
.unwrap();
}
Some(("push", push_args)) => {
let force = push_args.get_flag("force");
push(force);
}
Some(("commit", commit_args)) => {
let all = commit_args.get_flag("all");
let done = commit_args.get_flag("done");
let msg: Option<&String> = commit_args.get_one("message");
let interactive = commit_args.get_flag("interactive");
if interactive || msg.is_none() {
let gitmoji = select_gitmoji();
let title = inquire::prompt_text("Commit Title").unwrap();
let body = inquire::Text::new("Commit Description")
.with_page_size(5)
.prompt();
let msg = CommitMessage::new(gitmoji, title, body.ok()).to_msg();
commit(all, done, &msg);
} else {
commit(all, done, msg.unwrap());
}
}
Some(("save", _)) => {
commit(true, false, "WIP");
}
Some(("fetch", _)) => {
fetch();
}
Some(("pull", _)) => {
pull();
}
Some(("todo", _)) => {
show_todos();
}
Some(("log", _)) => {
Exec::cmd("serie").popen().unwrap().wait().unwrap();
}
Some(("status", _)) => {
Exec::cmd("git")
.arg("status")
.popen()
.unwrap()
.wait()
.unwrap();
}
Some(("add", args)) => {
let file: &String = args.get_one("FILE").unwrap();
git_add(file);
}
_ => {}
}
}

22
src/precommit.rs Normal file
View file

@ -0,0 +1,22 @@
use crate::{git::git_add, read_stdout};
use subprocess::Exec;
pub fn rust_pre_commit() {
println!("Running cargo fmt");
let e = Exec::cmd("cargo").arg("fmt").arg("--verbose");
let out = read_stdout(e);
for line in out.lines() {
println!("line {line}");
if line.starts_with("rustfmt") {
let mut split: Vec<_> = line.split_whitespace().collect();
split.reverse();
split.pop();
split.pop();
split.pop();
split.reverse();
let file = split.join(" ");
git_add(&file);
}
}
}