diff --git a/Cargo.lock b/Cargo.lock index bb8cf84..b729e1e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -198,7 +198,7 @@ checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" [[package]] name = "lgit" -version = "0.8.1" +version = "0.9.0" dependencies = [ "clap", "dialoguer", diff --git a/Cargo.toml b/Cargo.toml index ae12e01..21e375c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lgit" -version = "0.8.1" +version = "0.9.0" edition = "2021" authors = ["Peter Luladjiev"] description = "CLI tool for managing git repositories" diff --git a/README.md b/README.md index 5982c70..6a85615 100644 --- a/README.md +++ b/README.md @@ -1,50 +1,149 @@ # lgit-rs +[![Rust](https://github.com/Luladjiev/lgit-rs/actions/workflows/rust.yml/badge.svg)](https://github.com/Luladjiev/lgit-rs/actions/workflows/rust.yml) +[![Crates.io](https://img.shields.io/crates/v/lgit.svg)](https://crates.io/crates/lgit) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) +[![Downloads](https://img.shields.io/crates/d/lgit.svg)](https://crates.io/crates/lgit) + `lgit-rs` is a powerful, opinionated command-line interface (CLI) tool, designed to simplify the management of git repositories. It provides a set of commands that streamline common git operations, making your workflow more efficient. +## Table of Contents + +- [Features](#features) +- [Requirements](#requirements) +- [Installation](#installation) +- [Quick Start](#quick-start) +- [Usage](#usage) +- [Configuration](#configuration) +- [Why lgit?](#why-lgit) +- [Development](#development) +- [Contributing](#contributing) +- [FAQ & Troubleshooting](#faq--troubleshooting) +- [License](#license) + ## Features -- **Autosquash**: Automatically squash all fixup commits in the current branch. -- **Branch**: Quickly create a new branch from a freshly pulled BASE branch. -- **Checkout**: Checkout a branch by name, or by selecting from a list of all branches. -- **CherryPick**: Interactively select and cherry-pick commits from another branch. -- **DeleteBranches**: Safely delete all branches for which remotes are gone. Use with caution! -- **Fixup**: Commit as a fixup, simplifying your commit history. -- **Rebase**: Rebase the current branch on top of freshly pulled BASE branch with a single command. +- **Autosquash** (`as`): Automatically squash all fixup commits in the current branch, cleaning up your commit history with interactive rebase. Perfect for consolidating work-in-progress commits. + +- **Branch** (`b`): Quickly create a new branch from a freshly pulled BASE branch (defaults to main/master). Ensures you're always branching from the latest code. + +- **Checkout** (`co`): Checkout a branch by name with fuzzy matching, or interactively select from a list of all local/remote branches. Supports `--remote` and `--all` flags for filtering. + +- **CherryPick** (`cp`): Interactively select and cherry-pick commits from another branch using a fuzzy finder. Makes it easy to apply specific commits across branches. + +- **DeleteBranches**: Safely delete all local branches whose remote tracking branches no longer exist. Helps keep your local repository clean. + +- **Fixup** (`f`): Commit changes as a fixup commit that can later be automatically squashed with autosquash. Streamlines the process of fixing up previous commits. + +- **Rebase** (`r`): Rebase the current branch on top of a freshly pulled BASE branch with a single command. Keeps your feature branches up to date. + +- **Git Command Fallback**: For any git command not directly supported by lgit, the tool will automatically pass the command through to git, making lgit a drop-in replacement. ## Requirements -### Git +- **Git**: Version 2.0 or higher +- **Rust**: Version 1.70 or higher (if building from source) -`lgit-rs` requires git to be installed on your system. You can check if git is installed by running the following -command: +You can check your versions: ```bash git --version +rustc --version # if building from source ``` ## Installation -[Archives of precompiled binaries for lgit are available for Windows, macOS and Linux.](https://github.com/Luladjiev/lgit-rs/releases) +### Precompiled Binaries (Recommended) + +[Download precompiled binaries for Windows, macOS and Linux](https://github.com/Luladjiev/lgit-rs/releases) from the releases page. ### Using Cargo -Installing `lgit-rs` through Cargo is the easiest way to get started. You can install it by running the following -command: +Installing through Cargo is the easiest way if you have Rust installed: + +```bash +cargo install lgit +``` +To update to the latest version: ```bash cargo install lgit ``` -### Building from source +### Building from Source -You can also build `lgit-rs` from source by running the following command: +Clone and build the project locally: ```bash +git clone https://github.com/Luladjiev/lgit-rs.git +cd lgit-rs cargo install --path . ``` +### Troubleshooting Installation + +**Cargo not found**: Install Rust and Cargo from [rustup.rs](https://rustup.rs/) + +**Permission denied**: On macOS/Linux, you might need to add `~/.cargo/bin` to your PATH: +```bash +echo 'export PATH="$HOME/.cargo/bin:$PATH"' >> ~/.bashrc +source ~/.bashrc +``` + +**Old version**: Make sure you're getting the latest version: +```bash +cargo install lgit --force +``` + +## Quick Start + +Here are some common workflows to get you started with lgit: + +### Creating and Working on a Feature Branch + +```bash +# Create a new feature branch from latest main +lgit branch feature/awesome-feature + +# Make your changes, then commit as fixup for easy cleanup later +lgit fixup "Add awesome feature" + +# Make more changes, create another fixup +lgit fixup "Fix typo in awesome feature" + +# Squash all fixup commits when you're ready +lgit autosquash + +# Keep your branch up to date with main +lgit rebase +``` + +### Branch Management + +```bash +# Checkout a branch interactively +lgit checkout + +# Clean up old branches whose remotes are gone +lgit delete-branches + +# Cherry-pick commits from another branch interactively +lgit cherry-pick other-branch +``` + +### Using Git Commands + +```bash +# Any git command works through lgit +lgit status +lgit log --oneline +lgit push origin main + +# Explicitly pass commands to git with -- +lgit -- branch -d old-feature +``` + ## Usage To get a comprehensive list of all available commands and options, you can use the `--help` flag: @@ -59,6 +158,131 @@ Each command has a dedicated help page that can be accessed by running `lgit ), } diff --git a/src/commands.rs b/src/commands.rs index 50d2779..e687cdc 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -8,6 +8,7 @@ pub mod checkout; pub mod cherry_pick; pub mod delete_branches; pub mod fixup; +pub mod git_fallback; pub mod rebase; pub trait Exec { diff --git a/src/commands/autosquash.rs b/src/commands/autosquash.rs index 1bb6e26..b4dd42d 100644 --- a/src/commands/autosquash.rs +++ b/src/commands/autosquash.rs @@ -5,7 +5,7 @@ pub fn run( base: &str, number: Option, verbose: bool, -) -> Result<(), &'static str> { +) -> Result<(), String> { let mut args = vec![ "-c", "sequence.editor=:", // used in order to prevent --interactive blocking the autosquash @@ -25,7 +25,7 @@ pub fn run( cmd.exec(&args, verbose) .map(|_| ()) - .map_err(|()| "Failed to auto squash commits") + .map_err(|()| "Failed to auto squash commits".to_string()) } #[cfg(test)] diff --git a/src/commands/branch.rs b/src/commands/branch.rs index f21634c..e7d810e 100644 --- a/src/commands/branch.rs +++ b/src/commands/branch.rs @@ -6,14 +6,14 @@ pub fn run( name: &str, base: &str, verbose: bool, -) -> Result<(), &'static str> { +) -> Result<(), String> { let unsaved_changes = stash(command, verbose)?; - refresh_base(command, base, verbose).map_err(|()| "Failed to refresh base branch")?; + refresh_base(command, base, verbose).map_err(|()| "Failed to refresh base branch".to_string())?; command .exec(&["checkout", "-b", name], verbose) - .map_err(|()| "Failed to create branch")?; + .map_err(|()| "Failed to create branch".to_string())?; if unsaved_changes { unstash(command, verbose)?; diff --git a/src/commands/checkout.rs b/src/commands/checkout.rs index 6f6cd09..3156418 100644 --- a/src/commands/checkout.rs +++ b/src/commands/checkout.rs @@ -9,7 +9,7 @@ pub fn run( remote: bool, all: bool, verbose: bool, -) -> Result<(), &str> { +) -> Result<(), String> { if let Some(name) = name { return do_checkout(cmd, &name, verbose); } @@ -19,17 +19,17 @@ pub fn run( do_checkout(cmd, &branch, verbose) } -fn do_checkout(cmd: &T, branch: &str, verbose: bool) -> Result<(), &'static str> { +fn do_checkout(cmd: &T, branch: &str, verbose: bool) -> Result<(), String> { cmd.exec(&["checkout", branch], verbose) - .map_err(|()| "Failed to checkout branch")?; + .map_err(|()| "Failed to checkout branch".to_string())?; Ok(()) } -fn get_branches(cmd: &T, remote: bool, all: bool, verbose: bool) -> Result { +fn get_branches(cmd: &T, remote: bool, all: bool, verbose: bool) -> Result { let remotes: Vec = cmd .exec(&["remote"], verbose) - .map_err(|()| "Failed to get remotes")? + .map_err(|()| "Failed to get remotes".to_string())? .lines() .map(String::from) .collect(); @@ -45,7 +45,7 @@ fn get_branches(cmd: &T, remote: bool, all: bool, verbose: bool) -> Res let mut branches: Vec = cmd .exec(&branch_args, verbose) - .map_err(|()| "Failed to list branches")? + .map_err(|()| "Failed to list branches".to_string())? .lines() .map(|line| { let mut line = String::from(line); @@ -65,7 +65,7 @@ fn get_branches(cmd: &T, remote: bool, all: bool, verbose: bool) -> Res branches.dedup(); if branches.is_empty() { - return Err("No branches found"); + return Err("No branches found".to_string()); } let option = FuzzySelect::with_theme(&ColorfulTheme::default()) @@ -78,13 +78,13 @@ fn get_branches(cmd: &T, remote: bool, all: bool, verbose: bool) -> Res println!("{err}"); } - "There was an error determining the branch" + "There was an error determining the branch".to_string() })?; let branch = branches.get(option); if branch.is_none() { - return Err("There was an error getting the branch"); + return Err("There was an error getting the branch".to_string()); } let branch = branch.unwrap(); diff --git a/src/commands/cherry_pick.rs b/src/commands/cherry_pick.rs index 4767805..d906b2c 100644 --- a/src/commands/cherry_pick.rs +++ b/src/commands/cherry_pick.rs @@ -2,17 +2,17 @@ use dialoguer::MultiSelect; use crate::commands::Exec; -pub fn run(cmd: &dyn Exec, branch: &str, number: u32, verbose: bool) -> Result<(), &'static str> { +pub fn run(cmd: &dyn Exec, branch: &str, number: u32, verbose: bool) -> Result<(), String> { let commits = get_commits(cmd, branch, number, verbose)?; let selections = MultiSelect::new() .with_prompt("Select commits to cherry-pick (use space to select, enter to confirm)") .items(&commits) .interact() - .map_err(|_| "Failed to get user input")?; + .map_err(|_| "Failed to get user input".to_string())?; if selections.is_empty() { - return Err("No commits selected"); + return Err("No commits selected".to_string()); } let selected_commits: Result, String> = selections @@ -30,7 +30,7 @@ pub fn run(cmd: &dyn Exec, branch: &str, number: u32, verbose: bool) -> Result<( println!("{err}"); } - return Err("Failed to parse commits format"); + return Err("Failed to parse commits format".to_string()); } }; @@ -38,7 +38,7 @@ pub fn run(cmd: &dyn Exec, branch: &str, number: u32, verbose: bool) -> Result<( for commit in selected_commits { if cmd.exec(&["cherry-pick", commit], verbose).is_err() { - return Err("Failed to cherry-pick commit"); + return Err("Failed to cherry-pick commit".to_string()); } } @@ -50,7 +50,7 @@ fn get_commits( branch: &str, number: u32, verbose: bool, -) -> Result, &'static str> { +) -> Result, String> { let output = cmd .exec( &[ @@ -61,7 +61,7 @@ fn get_commits( ], verbose, ) - .map_err(|()| "Failed to get commit history")?; + .map_err(|()| "Failed to get commit history".to_string())?; Ok(output.lines().map(String::from).collect()) } diff --git a/src/commands/delete_branches.rs b/src/commands/delete_branches.rs index 7746757..7e5c02f 100644 --- a/src/commands/delete_branches.rs +++ b/src/commands/delete_branches.rs @@ -1,6 +1,6 @@ use crate::commands::Exec; -pub fn run(command: &T, dry_run: bool, verbose: bool) -> Result<(), &str> { +pub fn run(command: &T, dry_run: bool, verbose: bool) -> Result<(), String> { match delete_branches(command, dry_run, verbose) { Ok(output) => { println!("{output}"); @@ -14,14 +14,14 @@ fn delete_branches( command: &T, dry_run: bool, verbose: bool, -) -> Result { +) -> Result { command .exec(&["fetch", "--prune"], verbose) - .map_err(|()| "Failed to fetch")?; + .map_err(|()| "Failed to fetch".to_string())?; let branches = command .exec(&["branch", "-vv"], verbose) - .map_err(|()| "Failed to get branches")?; + .map_err(|()| "Failed to get branches".to_string())?; let mut result = Vec::new(); @@ -33,12 +33,12 @@ fn delete_branches( let branch_name = line .split_whitespace() .next() - .ok_or("Failed to parse branch name")?; + .ok_or("Failed to parse branch name".to_string())?; if !dry_run { command .exec(&["branch", "-D", branch_name], verbose) - .map_err(|()| "Failed to delete branch")?; + .map_err(|()| "Failed to delete branch".to_string())?; } result.push(format!("Deleted branch {branch_name}")); diff --git a/src/commands/fixup.rs b/src/commands/fixup.rs index 14ee66d..e48991c 100644 --- a/src/commands/fixup.rs +++ b/src/commands/fixup.rs @@ -3,17 +3,17 @@ use dialoguer::FuzzySelect; use crate::commands::Exec; -pub fn run(command: &T, number: u32, verbose: bool) -> Result<(), &'static str> { +pub fn run(command: &T, number: u32, verbose: bool) -> Result<(), String> { let commit = get_sha(command, number, verbose)?; let result = command.exec(&["commit", "--fixup", commit.as_str()], verbose); match result { Ok(_) => Ok(()), - Err(()) => Err("Failed to fixup commit"), + Err(()) => Err("Failed to fixup commit".to_string()), } } -fn get_sha(command: &T, number: u32, verbose: bool) -> Result { +fn get_sha(command: &T, number: u32, verbose: bool) -> Result { let options = get_log(command, number, verbose); let options = options?; @@ -27,13 +27,13 @@ fn get_sha(command: &T, number: u32, verbose: bool) -> Result(command: &T, number: u32, verbose: bool) -> Result(command: &T, number: u32, verbose: bool) -> Result, &'static str> { +fn get_log(command: &T, number: u32, verbose: bool) -> Result, String> { let log = command .exec( &["log", "--format=%h %s", "-n", &number.to_string()], verbose, ) - .map_err(|()| "Failed to fetch git log")?; + .map_err(|()| "Failed to fetch git log".to_string())?; let log = log.lines().map(String::from); let log = log.collect(); diff --git a/src/commands/git_fallback.rs b/src/commands/git_fallback.rs new file mode 100644 index 0000000..128f306 --- /dev/null +++ b/src/commands/git_fallback.rs @@ -0,0 +1,19 @@ +use crate::commands::Exec; + +pub fn run(cmd: &dyn Exec, args: &[String], verbose: bool) -> Result<(), String> { + let str_args: Vec<&str> = args.iter().map(|s| s.as_str()).collect(); + + match cmd.exec(&str_args, verbose) { + Ok(output) => { + if !output.trim().is_empty() { + println!("{}", output.trim()); + } + Ok(()) + } + Err(_) => { + let command = format!("git {}", args.join(" ")); + let error_msg = format!("Git command '{}' failed", command); + Err(error_msg) + } + } +} diff --git a/src/commands/rebase.rs b/src/commands/rebase.rs index c859156..61555c0 100644 --- a/src/commands/rebase.rs +++ b/src/commands/rebase.rs @@ -1,18 +1,18 @@ use crate::commands::Exec; use crate::utils::{refresh_base, stash, unstash}; -pub fn run(command: &T, base: &str, verbose: bool) -> Result<(), &'static str> { +pub fn run(command: &T, base: &str, verbose: bool) -> Result<(), String> { let unsaved_changes = stash(command, verbose)?; - refresh_base(command, base, verbose).map_err(|()| "Failed to refresh base branch")?; + refresh_base(command, base, verbose).map_err(|()| "Failed to refresh base branch".to_string())?; command .exec(&["checkout", "-"], verbose) - .map_err(|()| "Failed to checkout back to initial branch")?; + .map_err(|()| "Failed to checkout back to initial branch".to_string())?; command .exec(&["rebase", base], verbose) - .map_err(|()| "Failed to rebase")?; + .map_err(|()| "Failed to rebase".to_string())?; if unsaved_changes { unstash(command, verbose)?; diff --git a/src/main.rs b/src/main.rs index 2a0f073..c71f41e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,9 @@ use clap::Parser; use crate::cli::{Args, Commands}; -use crate::commands::{autosquash, branch, checkout, cherry_pick, delete_branches, rebase, Cmd}; +use crate::commands::{ + autosquash, branch, checkout, cherry_pick, delete_branches, git_fallback, rebase, Cmd, +}; use crate::utils::get_base; mod cli; @@ -39,7 +41,8 @@ fn main() { Some(Commands::CherryPick { branch, number }) => { cherry_pick::run(&command, &branch, number, cli.verbose) } - None => Err("No command specified, please run with --help for more info"), + Some(Commands::External(args)) => git_fallback::run(&command, &args, cli.verbose), + None => Err("No command specified, please run with --help for more info".to_string()), }; if let Err(err) = result { diff --git a/src/utils.rs b/src/utils.rs index 472e3d8..84c7ee2 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -3,14 +3,14 @@ use crate::commands::Exec; pub fn get_default_branch( command: &T, verbose: bool, -) -> Result<&'static str, &'static str> { +) -> Result<&'static str, String> { for branch in ["main", "master"] { if search_branch(command, branch, verbose).is_ok() { return Ok(branch); } } - Err("Failed to determine default branch") + Err("Failed to determine default branch".to_string()) } pub fn get_base(command: &T, base: Option, verbose: bool) -> String { @@ -25,22 +25,22 @@ pub fn refresh_base<'a, T: Exec>(command: &T, base: &'a str, verbose: bool) -> R command.exec(&["pull"], verbose).map(|_| base) } -fn search_branch(command: &T, branch: &str, verbose: bool) -> Result<(), &'static str> { +fn search_branch(command: &T, branch: &str, verbose: bool) -> Result<(), String> { let result = command .exec(&["branch", "-l", branch], verbose) - .map_err(|()| "Failed to list branch")?; + .map_err(|()| "Failed to list branch".to_string())?; if result.is_empty() { - Err("Branch not found") + Err("Branch not found".to_string()) } else { Ok(()) } } -pub fn stash(command: &T, verbose: bool) -> Result { +pub fn stash(command: &T, verbose: bool) -> Result { let result = command .exec(&["status", "--porcelain"], verbose) - .map_err(|()| "Failed to retrieve branch status")?; + .map_err(|()| "Failed to retrieve branch status".to_string())?; if result.is_empty() { return Ok(false); @@ -48,15 +48,15 @@ pub fn stash(command: &T, verbose: bool) -> Result command .exec(&["stash", "-u"], verbose) - .map_err(|()| "Failed to stash changes")?; + .map_err(|()| "Failed to stash changes".to_string())?; Ok(true) } -pub fn unstash(command: &T, verbose: bool) -> Result<(), &'static str> { +pub fn unstash(command: &T, verbose: bool) -> Result<(), String> { command .exec(&["stash", "pop"], verbose) - .map_err(|()| "Failed to unstash changes")?; + .map_err(|()| "Failed to unstash changes".to_string())?; Ok(()) }