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();
+ }
+}