From 558c3e412890b32c2b6ce5a0fcc9e6269f06d244 Mon Sep 17 00:00:00 2001 From: Jonathan Marette <16948342+jmarette@users.noreply.github.com> Date: Mon, 15 Jun 2026 10:04:51 +0200 Subject: [PATCH 1/2] feat: add a man page, installed automatically by git id init MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `git-id man` renders the page from the clap definition (clap_mangen) — the same source of truth as --help and the shell completions. `git id init` installs it next to the binary (/share/man/man1, or ~/.local/share/man as a fallback), so `man git-id` and `git id --help` (which git rewrites to `man git-id`) work with no extra step. `uninstall` removes it, `doctor` reports it, and it is a no-op on Windows, which has no man pages. The install target is resolved by a pure cfg!(windows)-gated `man_dir`, unit-tested on every host like the other cross-platform helpers; `Env::data_base()` is factored out and shared with `completions`. --- CHANGELOG.md | 7 ++ Cargo.lock | 17 ++++ Cargo.toml | 1 + src/cli.rs | 3 + src/commands/completions.rs | 10 +- src/commands/doctor.rs | 17 +++- src/commands/init.rs | 13 +++ src/commands/man.rs | 181 ++++++++++++++++++++++++++++++++++++ src/commands/mod.rs | 2 + src/commands/uninstall.rs | 7 +- src/env.rs | 11 +++ src/main.rs | 8 ++ tests/cli.rs | 35 +++++++ 13 files changed, 301 insertions(+), 11 deletions(-) create mode 100644 src/commands/man.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 1dc1e46..423539a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `doctor` flags an identity whose `gpg.format` is not one of openpgp/ssh/x509, and warns when `gpg.format = ssh` but the installed git is older than 2.34 (which cannot sign with SSH). +- A man page. `git-id man` prints it (rendered from the CLI definition, like the + shell completions), and `git-id init` installs it beside the binary — in + `/share/man/man1` for a cargo/installer/Homebrew layout, or + `~/.local/share/man` as a fallback — so `man git-id` and `git id --help` + (which git rewrites to `man git-id`) work with no extra step. `git-id + uninstall` removes it; `doctor` reports whether it is installed. No-op on + Windows, which has no man pages. ### Fixed diff --git a/Cargo.lock b/Cargo.lock index 9569868..8ac1758 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -171,6 +171,16 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "clap_mangen" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d82842b45bf9f6a3be090dd860095ac30728042c08e0d6261ca7259b5d850f07" +dependencies = [ + "clap", + "roff", +] + [[package]] name = "colorchoice" version = "1.0.5" @@ -242,6 +252,7 @@ dependencies = [ "clap", "clap_complete", "clap_complete_nushell", + "clap_mangen", "predicates", "serde", "serde_json", @@ -449,6 +460,12 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "roff" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "323c417e1d9665a65b263ec744ba09030cfb277e9daa0b018a4ab62e57bc8189" + [[package]] name = "rustix" version = "1.1.4" diff --git a/Cargo.toml b/Cargo.toml index 22d3d08..592414d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,7 @@ anyhow = "1.0.102" clap = { version = "4.6.1", features = ["derive", "wrap_help"] } clap_complete = "4.6.5" clap_complete_nushell = "4.6.0" +clap_mangen = "0.3.0" serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.150" tempfile = "3.27.0" diff --git a/src/cli.rs b/src/cli.rs index c0a3355..18744bd 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -56,6 +56,9 @@ pub enum Cmd { /// Print shell completions, or install them with `completions install` #[command(args_conflicts_with_subcommands = true)] Completions(CompletionsArgs), + /// Print the man page (roff). `git id init` installs it for you; a packager + /// can pipe it into a man directory. + Man, } #[derive(Args)] diff --git a/src/commands/completions.rs b/src/commands/completions.rs index b7a06aa..af7f699 100644 --- a/src/commands/completions.rs +++ b/src/commands/completions.rs @@ -117,14 +117,6 @@ fn completion_target( } } -/// `$XDG_DATA_HOME` when absolute, else `~/.local/share`. -fn data_base(env: &Env) -> PathBuf { - std::env::var_os("XDG_DATA_HOME") - .map(PathBuf::from) - .filter(|p| p.is_absolute()) - .unwrap_or_else(|| env.home.join(".local/share")) -} - /// Print the completion script to stdout. Needs no filesystem or HOME, so it /// stays usable in minimal build/packaging environments. pub fn print(shell: Option) -> Result { @@ -145,7 +137,7 @@ pub fn print(shell: Option) -> Result { /// Write the completion script to the right place for the shell. pub fn install(env: &Env, shell: Option) -> Result { let shell = resolve_shell(shell)?; - let target = completion_target(shell, &env.home, &env.config_base, &data_base(env)); + let target = completion_target(shell, &env.home, &env.config_base, &env.data_base()); store::atomic_write(&target.path, &render(shell))?; let pretty = display_pretty(&target.path.to_string_lossy(), &env.home); diff --git a/src/commands/doctor.rs b/src/commands/doctor.rs index 36ee54e..c8bd601 100644 --- a/src/commands/doctor.rs +++ b/src/commands/doctor.rs @@ -8,7 +8,7 @@ use crate::env::Env; use crate::paths::{self, display_pretty}; use crate::{gitcfg, routes, store}; -use super::init; +use super::{init, man}; #[derive(Default)] struct Doctor { @@ -186,6 +186,21 @@ pub fn run(env: &Env) -> Result { ); } + // On Windows there is no man system; `installed_man_path` returns None and + // we say nothing. Elsewhere, report whether the page `init` installs is in + // place (discoverability by `man` still depends on the manpath). + match man::installed_man_path(env) { + Some(path) if path.exists() => d.ok(&format!( + "man page installed: {}", + display_pretty(&path.to_string_lossy(), &env.home) + )), + Some(path) => d.info(&format!( + "no man page at {} — run `git-id init` to install it (or `git-id man` to print it)", + display_pretty(&path.to_string_lossy(), &env.home) + )), + None => {} + } + println!(); if d.errors == 0 && d.warnings == 0 { println!("doctor: everything looks good"); diff --git a/src/commands/init.rs b/src/commands/init.rs index fa53207..4036240 100644 --- a/src/commands/init.rs +++ b/src/commands/init.rs @@ -10,6 +10,8 @@ use crate::env::Env; use crate::paths::display_pretty; use crate::{gitcfg, paths, prompt, routes}; +use super::man; + /// The global-level config files git actually reads and that git-id may have /// written to, in increasing precedence order, existing files only. Mirrors /// git: an explicit `GIT_CONFIG_GLOBAL` is the *only* global file; otherwise @@ -175,6 +177,17 @@ pub fn run(env: &Env, args: &InitArgs) -> Result { if let Some(bak) = &backup.path { println!("Backed up the previous global config to: {}", pretty(bak)); } + // Install the man page so `man git-id` and `git id --help` (which git + // rewrites to `man git-id`) work without a separate command. Best-effort: + // a failure here must never fail setup. + match man::install_man_page(env) { + Ok(Some(path)) => println!( + "Installed the man page: {} (so `man git-id` and `git id --help` work)", + pretty(&path) + ), + Ok(None) => {} + Err(err) => eprintln!("warning: could not install the man page: {err:#}"), + } match uco { Uco::Enabled => { println!( diff --git a/src/commands/man.rs b/src/commands/man.rs new file mode 100644 index 0000000..24ba02a --- /dev/null +++ b/src/commands/man.rs @@ -0,0 +1,181 @@ +use std::fs; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; +use std::process::ExitCode; + +use anyhow::{Context, Result}; +use clap::CommandFactory; +use clap_mangen::Man; + +use crate::cli::Cli; +use crate::env::Env; +use crate::store; + +/// File name of the installed man page (section 1). +const MAN_FILENAME: &str = "git-id.1"; + +/// Render the roff man page for the root command, from the same clap definition +/// that powers `--help` and the shell completions — so it can never drift. +fn render() -> Result { + let mut buf = Vec::new(); + Man::new(Cli::command()) + .render(&mut buf) + .context("rendering the man page")?; + String::from_utf8(buf).context("clap_mangen produced non-UTF-8 output") +} + +/// Print the man page (roff) to stdout. Needs no environment (no HOME), so it +/// stays usable in minimal build/packaging setups. This is the idiomatic +/// primitive a packager installs — e.g. a Homebrew formula doing +/// `(man1/"git-id.1").write \`git-id man\``, as ripgrep's does. +pub fn print() -> Result { + io::stdout().write_all(render()?.as_bytes())?; + Ok(ExitCode::SUCCESS) +} + +/// Where the man page should live so `man` — and thus `git id --help`, which +/// git rewrites to `man git-id` — finds it without the user touching MANPATH. +/// +/// Both macOS `man` and Linux man-db derive part of their search path from +/// `$PATH`, mapping a `…/bin` entry to its sibling `…/share/man`; a page placed +/// next to the binary's prefix is therefore found automatically. Returns `None` +/// on Windows, which has no man system. Only the platform *selection* is gated; +/// the path logic is pure and unit-tested on every host. +fn man_dir(exe: &Path, data_base: &Path) -> Option { + if cfg!(windows) { + return None; + } + // Homebrew runs from a Cellar and symlinks `/share/man`; the + // Cellar's own share/man is not on the manpath, so map back to . + if let Some(prefix) = homebrew_prefix(exe) { + return Some(prefix.join("share").join("man").join("man1")); + } + // A binary in `/bin` — cargo's `~/.cargo/bin`, a manual + // `/usr/local/bin`, or the Homebrew symlink — maps to `/share/man`. + if let Some(bin) = exe.parent() { + if bin.file_name().is_some_and(|n| n == "bin") { + if let Some(prefix) = bin.parent() { + return Some(prefix.join("share").join("man").join("man1")); + } + } + } + // Last resort: the XDG data dir. man-db finds `~/.local/share/man`; on + // macOS it may not be on the default manpath (doctor flags discoverability). + Some(data_base.join("man").join("man1")) +} + +/// If `exe` lives under a Homebrew Cellar +/// (`/Cellar///bin/`), return ``. +fn homebrew_prefix(exe: &Path) -> Option { + let mut prefix = PathBuf::new(); + for comp in exe.components() { + if comp.as_os_str() == "Cellar" { + return Some(prefix); + } + prefix.push(comp); + } + None +} + +/// The path the man page is (or would be) installed at for this binary, or +/// `None` where man pages do not apply (Windows) or the executable is unknown. +pub fn installed_man_path(env: &Env) -> Option { + let exe = std::env::current_exe().ok()?; + Some(man_dir(&exe, &env.data_base())?.join(MAN_FILENAME)) +} + +/// Best-effort install of the man page. Returns the path written, or `None` +/// when there is no suitable target (Windows / unknown executable). Callers +/// must not fail on an `Err` here: the page is a convenience, not core setup. +pub fn install_man_page(env: &Env) -> Result> { + let Some(path) = installed_man_path(env) else { + return Ok(None); + }; + store::atomic_write(&path, &render()?)?; + Ok(Some(path)) +} + +/// Remove a man page previously installed by `install_man_page`, if present. +pub fn remove_man_page(env: &Env) -> Result<()> { + if let Some(path) = installed_man_path(env) { + if path.exists() { + fs::remove_file(&path).with_context(|| format!("cannot remove {}", path.display()))?; + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn render_emits_a_man_page_naming_the_binary_and_a_subcommand() { + let page = render().unwrap(); + assert!( + page.contains(".TH git-id"), + "should be a roff page titled for the binary" + ); + assert!( + page.contains("SUBCOMMANDS"), + "should carry a subcommands section" + ); + assert!(page.contains("create"), "should list the create subcommand"); + } + + // The path logic is pure on every host; `man_dir` only gates the Windows + // selection, so these expectations are written for the non-Windows branch. + #[cfg(not(windows))] + mod target { + use super::*; + + #[test] + fn cargo_bin_maps_to_sibling_share_man() { + let dir = man_dir( + Path::new("/home/jane/.cargo/bin/git-id"), + Path::new("/home/jane/.local/share"), + ); + assert_eq!(dir, Some(PathBuf::from("/home/jane/.cargo/share/man/man1"))); + } + + #[test] + fn homebrew_cellar_maps_to_prefix_share_man() { + let dir = man_dir( + Path::new("/opt/homebrew/Cellar/git-id/0.3.0/bin/git-id"), + Path::new("/home/jane/.local/share"), + ); + assert_eq!(dir, Some(PathBuf::from("/opt/homebrew/share/man/man1"))); + } + + #[test] + fn homebrew_symlink_in_bin_also_maps_to_prefix() { + let dir = man_dir( + Path::new("/opt/homebrew/bin/git-id"), + Path::new("/home/jane/.local/share"), + ); + assert_eq!(dir, Some(PathBuf::from("/opt/homebrew/share/man/man1"))); + } + + #[test] + fn unknown_layout_falls_back_to_xdg_data_dir() { + // e.g. the test binary at `target/debug/git-id` (parent not `bin`). + let dir = man_dir( + Path::new("/work/git-id/target/debug/git-id"), + Path::new("/home/jane/.local/share"), + ); + assert_eq!(dir, Some(PathBuf::from("/home/jane/.local/share/man/man1"))); + } + } + + #[test] + #[cfg(windows)] + fn windows_has_no_man_target() { + assert_eq!( + man_dir( + Path::new("C:\\Users\\jane\\.cargo\\bin\\git-id.exe"), + Path::new("C:\\Users\\jane\\AppData") + ), + None + ); + } +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 6af98da..cf13ba6 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -5,6 +5,7 @@ pub mod doctor; pub mod edit; pub mod init; pub mod list; +pub mod man; pub mod show; pub mod uninstall; pub mod unset; @@ -32,5 +33,6 @@ pub fn dispatch(env: &Env, cmd: &Cmd) -> Result { Cmd::Doctor => doctor::run(env), Cmd::Uninstall(args) => uninstall::run(env, args), Cmd::Completions(args) => completions::run(env, args), + Cmd::Man => man::print(), } } diff --git a/src/commands/uninstall.rs b/src/commands/uninstall.rs index e2e0e6d..2de75f9 100644 --- a/src/commands/uninstall.rs +++ b/src/commands/uninstall.rs @@ -8,7 +8,7 @@ use crate::env::Env; use crate::paths::display_pretty; use crate::{gitcfg, prompt}; -use super::init; +use super::{init, man}; /// Matches the git-id include entry by the tail of its path, so it is found /// whatever the absolute prefix or path style (forward slashes, `~`, a custom @@ -62,6 +62,11 @@ pub fn run(env: &Env, args: &UninstallArgs) -> Result { fs::remove_dir_all(&env.config_dir) .with_context(|| format!("cannot remove {}", env.config_dir.display()))?; } + // Remove the man page `init` installed (best-effort — it lives outside the + // config dir, next to the binary or in the XDG data dir). + if let Err(err) = man::remove_man_page(env) { + eprintln!("warning: could not remove the man page: {err:#}"); + } println!("Removed git-id's configuration."); print_binary_hint(); diff --git a/src/env.rs b/src/env.rs index 9c9debe..9882ee4 100644 --- a/src/env.rs +++ b/src/env.rs @@ -94,6 +94,17 @@ impl Env { (gitconfig, false) } } + + /// Base data directory: `$XDG_DATA_HOME` (when absolute) or + /// `~/.local/share`. Read from the process, like git's XDG handling, so + /// callers that write user data (shell completions, the man page) agree on + /// where it goes. + pub fn data_base(&self) -> PathBuf { + std::env::var_os("XDG_DATA_HOME") + .map(PathBuf::from) + .filter(|p| p.is_absolute()) + .unwrap_or_else(|| self.home.join(".local/share")) + } } /// Resolve the home directory on Unix: `HOME`, which must be set and non-empty. diff --git a/src/main.rs b/src/main.rs index 3ef662c..56c59bd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -25,6 +25,14 @@ fn main() -> ExitCode { }; } } + // Rendering the man page is likewise HOME-free (a packager may run it in a + // minimal sandbox); installing it happens through `init`, which has an Env. + if let cli::Cmd::Man = &cli.command { + return match commands::man::print() { + Ok(code) => code, + Err(err) => fail(&err), + }; + } let env = match env::Env::from_process() { Ok(env) => env, Err(err) => return fail(&err), diff --git a/tests/cli.rs b/tests/cli.rs index b19799c..c22fc65 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -918,6 +918,41 @@ fn completions_install_detects_shell_from_env() { ); } +#[test] +fn man_prints_a_roff_page() { + let t = TestEnv::new(); + let out = t.ok(&["man"]); + assert!( + out.contains(".TH git-id"), + "`man` should print roff:\n{out}" + ); + assert!(out.contains("create"), "should list subcommands"); +} + +#[test] +#[cfg(unix)] +fn init_installs_the_man_page_and_uninstall_removes_it() { + let t = TestEnv::new(); + // The test binary lives at `target/debug/git-id` (parent not `bin`), so the + // page lands in the XDG data dir the sandbox controls. + let page = t.home.join(".local/share/man/man1/git-id.1"); + + let out = t.ok(&["init"]); + assert!( + page.exists(), + "init should install the man page at {}", + page.display() + ); + assert!( + out.contains("man page"), + "init should report installing it:\n{out}" + ); + assert!(t.read(&page).contains(".TH git-id")); + + t.ok(&["uninstall", "--yes"]); + assert!(!page.exists(), "uninstall should remove the man page"); +} + // --------------------------------------------------------------------------- // input validation / injection From 69ac902e1b7cc0445f63825b7d7cd9cd33dc9281 Mon Sep 17 00:00:00 2001 From: Jonathan Marette <16948342+jmarette@users.noreply.github.com> Date: Mon, 15 Jun 2026 10:05:03 +0200 Subject: [PATCH 2/2] docs: document the man page and refresh the git id --help caveat Add a `git id man` row to the commands table and rewrite the caveat: the page is installed by `git id init`, so `git id --help` works (Windows has no man pages). --- README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 700116c..a12cf28 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,7 @@ guessing one from your hostname. | `git id doctor` | Sanity-check the whole setup (include line, fragments, stale routes, …). | | `git id uninstall` | Undo git-id's setup (include line, `useConfigOnly`, config dir) before removing the binary. | | `git id completions [shell]` | Print shell completions; `--install` writes them into place (bash, zsh, fish, nushell, elvish, powershell). | +| `git id man` | Print the man page (roff). `git id init` already installs it, so `man git-id` and `git id --help` just work. | Every command has a detailed `--help`. @@ -192,8 +193,9 @@ $ git-id completions nushell | save -f ~/.config/nushell/completions/git-id.nu routed directory itself, as well as every repository below it. - **Symlinked paths** are stored resolved (e.g. `/tmp/...` becomes `/private/tmp/...` on macOS) because that is what Git matches against. -- `git id --help` tries to open a `git-id` man page and fails; use - `git-id --help` or `git id --help` instead. +- `git id --help` opens the `git-id` man page; `git id init` installs it (and + `git-id man` prints it). Windows has no man pages — use `git-id --help` or + `git id --help` there. - Completions cover `git-id …` invocations; completing through `git id …` would need a git-specific completion script (future work).