diff --git a/CHANGELOG.md b/CHANGELOG.md index e0a76e8..e093b23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- shell completion for bash, zsh, fish, elvish and PowerShell, including the + branches, tags and remotes of the current repository (#28) + ## [0.4.0] - 2026-10-16 ### Changed diff --git a/Cargo.lock b/Cargo.lock index 7161add..fa1af04 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -145,6 +145,18 @@ dependencies = [ "strsim", ] +[[package]] +name = "clap_complete" +version = "4.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037e2a1a92236d0aff7e845093f64661d6df4c02c9fcc61a60e9e1d736fa392f" +dependencies = [ + "clap", + "clap_lex", + "is_executable", + "shlex", +] + [[package]] name = "clap_derive" version = "4.6.7" @@ -186,6 +198,22 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + [[package]] name = "find-msvc-tools" version = "0.1.13" @@ -291,11 +319,13 @@ version = "0.4.0" dependencies = [ "anyhow", "clap", + "clap_complete", "flexi_logger", "git-url-parse", "git2", "log", "open", + "tempfile", "thiserror", ] @@ -452,6 +482,15 @@ dependencies = [ "once_cell", ] +[[package]] +name = "is_executable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82cb6a9f675da968c63b6208c641b9dca58fc0133ae53375736b1767b0cab8bd" +dependencies = [ + "windows-sys", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -510,6 +549,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.3" @@ -679,6 +724,19 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + [[package]] name = "rustversion" version = "1.0.23" @@ -777,6 +835,19 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + [[package]] name = "thiserror" version = "2.0.20" diff --git a/Cargo.toml b/Cargo.toml index e4d66e5..342c5c9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,10 +14,14 @@ version = "0.4.0" [dependencies] anyhow = "1.0" -clap = { version = "4.5", features = ["derive", "env"] } +clap = { version = "4.6", features = ["derive", "env"] } +clap_complete = { version = "=4.6.11", features = ["unstable-dynamic"] } flexi_logger = "0.31" git-url-parse = "0.6" git2 = { version = "0.21", features = ["vendored-openssl"] } log = "0.4" open = "5.4" thiserror = "2.0" + +[dev-dependencies] +tempfile = "3" diff --git a/README.md b/README.md index eb0ffdd..612e719 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,32 @@ sudo port selfupdate sudo port install gitweb ``` +## Shell completion + +`gitweb` completes its options on TAB, as well as the branches, tags +and remotes of the current repository (`gitweb --branch `). Add the line +for your shell to its configuration. The completion is generated at startup, so +it stays in sync when you upgrade `gitweb`. + +```sh +# Bash +echo 'source <(COMPLETE=bash gitweb)' >> ~/.bashrc + +# Zsh +echo 'source <(COMPLETE=zsh gitweb)' >> ~/.zshrc + +# Fish +echo 'COMPLETE=fish gitweb | source' >> ~/.config/fish/completions/gitweb.fish + +# Elvish +echo 'eval (E:COMPLETE=elvish gitweb | slurp)' >> ~/.elvish/rc.elv +``` + +```powershell +# PowerShell +echo '$env:COMPLETE = "powershell"; gitweb | Out-String | Invoke-Expression; Remove-Item Env:\COMPLETE' >> $PROFILE +``` + ## Usage `gitweb` will by default open the remote in the browser of the current diff --git a/src/git.rs b/src/git.rs index d91d338..2bc73f9 100644 --- a/src/git.rs +++ b/src/git.rs @@ -1,6 +1,7 @@ use crate::Issue; use anyhow::Result; -use git2::{ErrorCode, Repository}; +use git2::{BranchType, ErrorCode, Repository}; +use std::collections::BTreeSet; /// Get the current repository. // Will check that the user is in a git repository. @@ -34,3 +35,146 @@ pub fn get_branch(repo: &Repository) -> String { String::from(head.unwrap_or("master")) } + +// Get the local and remote-tracking branch names, without the remote prefix. +pub fn get_branches(repo: &Repository) -> Vec { + let Ok(branches) = repo.branches(None) else { + return Vec::new(); + }; + + let remotes = get_remotes(repo); + + // A set removes the duplicates between local and remote branches and sorts + // the names. + let names: BTreeSet = branches + .flatten() + .filter_map(|(branch, branch_type)| { + let name = branch.name().ok().flatten()?; + + match branch_type { + BranchType::Local => Some(name.to_string()), + BranchType::Remote => remotes + .iter() + .find_map(|remote| name.strip_prefix(&format!("{}/", remote))) + .map(String::from), + } + }) + .filter(|name| name != "HEAD") + .collect(); + + names.into_iter().collect() +} + +// Get the tag names. +pub fn get_tags(repo: &Repository) -> Vec { + repo.tag_names(None) + .map(|tags| tags.iter().flatten().flatten().map(String::from).collect()) + .unwrap_or_default() +} + +// Get the remote names. +pub fn get_remotes(repo: &Repository) -> Vec { + repo.remotes() + .map(|remotes| { + remotes + .iter() + .flatten() + .flatten() + .map(String::from) + .collect() + }) + .unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::*; + use git2::{Commit, RepositoryInitOptions, Signature}; + use tempfile::TempDir; + + // Create a repository with a single commit on the main branch. + fn init_repo() -> (TempDir, Repository) { + let dir = tempfile::tempdir().unwrap(); + let repo = Repository::init_opts( + dir.path(), + RepositoryInitOptions::new().initial_head("main"), + ) + .unwrap(); + + { + let signature = Signature::now("gitweb", "gitweb@example.com").unwrap(); + let tree_id = repo.index().unwrap().write_tree().unwrap(); + let tree = repo.find_tree(tree_id).unwrap(); + repo.commit(Some("HEAD"), &signature, &signature, "init", &tree, &[]) + .unwrap(); + } + + (dir, repo) + } + + fn head_commit(repo: &Repository) -> Commit<'_> { + repo.head().unwrap().peel_to_commit().unwrap() + } + + #[test] + fn test_get_branches_merges_local_and_remote_branches() { + let (_dir, repo) = init_repo(); + let commit = head_commit(&repo); + + repo.branch("local-only", &commit, false).unwrap(); + repo.remote("origin", "git@github.com:yoannfleurydev/gitweb.git") + .unwrap(); + repo.reference("refs/remotes/origin/main", commit.id(), false, "") + .unwrap(); + repo.reference("refs/remotes/origin/remote-only", commit.id(), false, "") + .unwrap(); + repo.reference_symbolic( + "refs/remotes/origin/HEAD", + "refs/remotes/origin/main", + false, + "", + ) + .unwrap(); + + assert_eq!( + get_branches(&repo), + vec!["local-only", "main", "remote-only"] + ); + } + + #[test] + fn test_get_branches_without_commit() { + let dir = tempfile::tempdir().unwrap(); + let repo = Repository::init(dir.path()).unwrap(); + + assert!(get_branches(&repo).is_empty()); + } + + #[test] + fn test_get_tags() { + let (_dir, repo) = init_repo(); + let commit = head_commit(&repo); + + repo.tag_lightweight("v0.1.0", commit.as_object(), false) + .unwrap(); + repo.tag_lightweight("v0.2.0", commit.as_object(), false) + .unwrap(); + + let mut tags = get_tags(&repo); + tags.sort(); + + assert_eq!(tags, vec!["v0.1.0", "v0.2.0"]); + } + + #[test] + fn test_get_remotes() { + let (_dir, repo) = init_repo(); + + repo.remote("upstream", "git@github.com:yoannfleurydev/gitweb.git") + .unwrap(); + repo.remote("origin", "git@github.com:someone/gitweb.git") + .unwrap(); + + assert_eq!(get_remotes(&repo), vec!["origin", "upstream"]); + } +} diff --git a/src/main.rs b/src/main.rs index 3adfd4f..529e941 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,5 @@ -use clap::Parser; +use clap::{CommandFactory, Parser}; +use clap_complete::CompleteEnv; use flexi_logger::Logger; use gitweb::options::Opt; use gitweb::run; @@ -8,6 +9,10 @@ use std::process::exit; extern crate log; fn main() { + // Answer the shell completion requests (COMPLETE=) before anything + // else, as nothing must be written to the output before. + CompleteEnv::with_factory(Opt::command).complete(); + // Get the command line options. let opt = Opt::parse(); diff --git a/src/options.rs b/src/options.rs index 5144d8e..71b08c1 100644 --- a/src/options.rs +++ b/src/options.rs @@ -1,4 +1,8 @@ use clap::Parser; +use clap_complete::{ArgValueCandidates, CompletionCandidate}; +use git2::Repository; + +use crate::git; #[derive(Debug, Parser)] #[command(version, about)] @@ -10,7 +14,7 @@ pub struct Opt { /// By setting the branch, you can override the default behavior that will /// set the branch to the current one in the repository. If something went /// wrong with the current one, it will set the value to master. - #[arg(short, long)] + #[arg(short, long, add = ArgValueCandidates::new(complete_branches))] pub branch: Option, /// Set the tag (alias for --branch). @@ -18,7 +22,7 @@ pub struct Opt { /// By setting the tag, you can override the default behavior that will set /// the branch to the current one in the repository and will instead take /// the reference tag (or branch) given as parameter. - #[arg(short, long)] + #[arg(short, long, add = ArgValueCandidates::new(complete_tags))] pub tag: Option, /// Set the merge request flag. @@ -51,7 +55,7 @@ pub struct Opt { /// /// By default, the selected remote will be origin by convention. You can /// override this setting by using this option. - #[arg(short, long)] + #[arg(short, long, add = ArgValueCandidates::new(complete_remotes))] pub remote: Option, /// Set the verbosity of the command @@ -60,3 +64,39 @@ pub struct Opt { #[arg(short, long)] pub verbose: bool, } + +fn complete_branches() -> Vec { + complete_with(git::get_branches) +} + +fn complete_tags() -> Vec { + complete_with(git::get_tags) +} + +fn complete_remotes() -> Vec { + complete_with(git::get_remotes) +} + +// Complete with the values from the current repository. Nothing is suggested +// outside of a repository, as anything printed would end up in the shell. +fn complete_with(get_values: fn(&Repository) -> Vec) -> Vec { + git::get_repo() + .map(|repo| { + get_values(&repo) + .into_iter() + .map(CompletionCandidate::new) + .collect() + }) + .unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::*; + use clap::CommandFactory; + + #[test] + fn test_options_are_valid() { + Opt::command().debug_assert(); + } +}