From c79cd6fabb3c313c16a48f96ecd2a21e74195b3e Mon Sep 17 00:00:00 2001 From: "Chris (ChrisJr404)" <11917633+ChrisJr404@users.noreply.github.com> Date: Tue, 18 Aug 2026 14:56:44 -0400 Subject: [PATCH] feat(cli): add completion subcommand for shell completion scripts Add a `gitu completion ` subcommand that prints a completion script to stdout for bash, elvish, fish, powershell or zsh, generated with clap_complete. It is handled before terminal setup and git repo discovery, so it works from anywhere. Closes #544 --- Cargo.lock | 12 +++++++++++- Cargo.toml | 1 + README.md | 2 ++ docs/installing.md | 20 ++++++++++++++++++++ src/app.rs | 4 ++++ src/cli.rs | 18 +++++++++++++++++- src/main.rs | 9 ++++++++- src/tests/completion.rs | 42 +++++++++++++++++++++++++++++++++++++++++ src/tests/mod.rs | 1 + 9 files changed, 106 insertions(+), 3 deletions(-) create mode 100644 src/tests/completion.rs diff --git a/Cargo.lock b/Cargo.lock index cc3e0a10b4..7712a40704 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -279,6 +279,15 @@ dependencies = [ "strsim", ] +[[package]] +name = "clap_complete" +version = "4.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3be2ad0423bdbbb0e25bc89add796f3559706d4a95e1bc98e4d9662a957b6a19" +dependencies = [ + "clap", +] + [[package]] name = "clap_derive" version = "4.5.49" @@ -666,6 +675,7 @@ dependencies = [ "cached", "chrono", "clap", + "clap_complete", "criterion", "crossterm", "etcetera", @@ -1232,7 +1242,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 4e3c827291..8f928f5bf8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,6 +34,7 @@ strip = true arboard = { version = "3.6.1", default-features = false, features = ["wayland-data-control", "windows-sys"] } chrono = "0.4.42" clap = { version = "4.5.54", features = ["derive"] } +clap_complete = "4.5.58" crossterm = "0.28.1" etcetera = "0.11.0" figment = { version = "0.10.19", features = ["toml"] } diff --git a/README.md b/README.md index e3aee5847e..cfc7537e2e 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,8 @@ Or install from your package manager: [![Packaging status](https://repology.org/badge/vertical-allrepos/gitu.svg)](https://repology.org/project/gitu/versions) +Shell completions can be generated with `gitu completion `, see [Shell completions](docs/installing.md#shell-completions). + ### Contributing PRs are welcome! This may help to get you started: [Development & Tooling](docs/dev-tooling.md) diff --git a/docs/installing.md b/docs/installing.md index 2be962ba4b..7d5056ef41 100644 --- a/docs/installing.md +++ b/docs/installing.md @@ -62,4 +62,24 @@ nix.settings = { } ``` +## Shell completions +Gitu can print a completion script for your shell to stdout via the +`completion` subcommand. Supported shells are `bash`, `elvish`, `fish`, +`powershell` and `zsh`. + +Bash: +```shell +gitu completion bash > ~/.local/share/bash-completion/completions/gitu +``` + +Zsh (the target directory must be on your `$fpath`): +```shell +gitu completion zsh > ~/.zfunc/_gitu +``` + +Fish: +```shell +gitu completion fish > ~/.config/fish/completions/gitu.fish +``` + diff --git a/src/app.rs b/src/app.rs index c05a0cbb18..55f056f333 100644 --- a/src/app.rs +++ b/src/app.rs @@ -91,6 +91,10 @@ impl App { None, )?] } + // `completion` prints its script and exits in `main`, before the app is ever built. + Some(cli::Commands::Completion { .. }) => { + unreachable!("completion is handled before the app starts") + } None => vec![screen::status::create( Arc::clone(&config), Rc::clone(&repo), diff --git a/src/cli.rs b/src/cli.rs index 0ca77e58e3..bdadbfb99a 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,6 +1,8 @@ +use std::io; use std::path::PathBuf; -use clap::{Parser, Subcommand}; +use clap::{CommandFactory, Parser, Subcommand}; +use clap_complete::Shell; #[derive(Default, Debug, Parser)] #[command(name = "gitu")] @@ -44,4 +46,18 @@ pub enum Commands { #[clap(short, long)] rev: Option, }, + /// Print a shell completion script to stdout. + /// + /// Example (bash): `gitu completion bash > ~/.local/share/bash-completion/completions/gitu` + Completion { + /// The shell to generate a completion script for + shell: Shell, + }, +} + +/// Write a shell completion script for `gitu` to the given writer. +pub fn completions(shell: Shell, out: &mut impl io::Write) { + let mut cmd = Args::command(); + let bin_name = cmd.get_name().to_string(); + clap_complete::generate(shell, &mut cmd, bin_name, out); } diff --git a/src/main.rs b/src/main.rs index f45677df16..8eaa747c1c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,7 @@ use clap::Parser; use gitu::{ Res, - cli::Args, + cli::{self, Args, Commands}, config::{self, Config}, error::Error, term::{self, Term}, @@ -18,6 +18,13 @@ pub fn main() -> Res<()> { return Ok(()); } + // Generating completions doesn't need a git repository or the terminal, + // so handle it before any of that setup happens. + if let Some(Commands::Completion { shell }) = args.command { + cli::completions(shell, &mut std::io::stdout()); + return Ok(()); + } + if args.log { simple_logging::log_to_file(gitu::LOG_FILE_NAME, LevelFilter::Debug) .map_err(Error::OpenLogFile)?; diff --git a/src/tests/completion.rs b/src/tests/completion.rs new file mode 100644 index 0000000000..7b918db0c0 --- /dev/null +++ b/src/tests/completion.rs @@ -0,0 +1,42 @@ +use clap::Parser; +use clap_complete::Shell; + +use crate::cli::{Args, Commands, completions}; + +#[test] +fn completion_subcommand_parses() { + let args = Args::try_parse_from(["gitu", "completion", "fish"]).unwrap(); + assert!(matches!( + args.command, + Some(Commands::Completion { shell: Shell::Fish }) + )); +} + +#[test] +fn completion_rejects_unknown_shell() { + assert!(Args::try_parse_from(["gitu", "completion", "not-a-shell"]).is_err()); +} + +#[test] +fn generates_non_empty_script_for_every_shell() { + for shell in [ + Shell::Bash, + Shell::Elvish, + Shell::Fish, + Shell::PowerShell, + Shell::Zsh, + ] { + let mut out = Vec::new(); + completions(shell, &mut out); + let script = String::from_utf8(out).expect("completion script should be valid UTF-8"); + + assert!( + !script.trim().is_empty(), + "{shell} completion script should not be empty" + ); + assert!( + script.contains("gitu"), + "{shell} completion script should reference the binary name" + ); + } +} diff --git a/src/tests/mod.rs b/src/tests/mod.rs index 92f333f1ba..24289d4f3e 100644 --- a/src/tests/mod.rs +++ b/src/tests/mod.rs @@ -18,6 +18,7 @@ mod blame; mod branch; mod cherry_pick; mod commit; +mod completion; mod discard; mod editor; mod fetch;