Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
`<prefix>/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

Expand Down
17 changes: 17 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down Expand Up @@ -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 <command> --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 <command> --help` there.
- Completions cover `git-id …` invocations; completing through `git id …`
would need a git-specific completion script (future work).

Expand Down
3 changes: 3 additions & 0 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down
10 changes: 1 addition & 9 deletions src/commands/completions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<CompletionShell>) -> Result<ExitCode> {
Expand All @@ -145,7 +137,7 @@ pub fn print(shell: Option<CompletionShell>) -> Result<ExitCode> {
/// Write the completion script to the right place for the shell.
pub fn install(env: &Env, shell: Option<CompletionShell>) -> Result<ExitCode> {
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);
Expand Down
17 changes: 16 additions & 1 deletion src/commands/doctor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -186,6 +186,21 @@ pub fn run(env: &Env) -> Result<ExitCode> {
);
}

// 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");
Expand Down
13 changes: 13 additions & 0 deletions src/commands/init.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -175,6 +177,17 @@ pub fn run(env: &Env, args: &InitArgs) -> Result<ExitCode> {
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!(
Expand Down
181 changes: 181 additions & 0 deletions src/commands/man.rs
Original file line number Diff line number Diff line change
@@ -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<String> {
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<ExitCode> {
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<PathBuf> {
if cfg!(windows) {
return None;
}
// Homebrew runs from a Cellar and symlinks `<prefix>/share/man`; the
// Cellar's own share/man is not on the manpath, so map back to <prefix>.
if let Some(prefix) = homebrew_prefix(exe) {
return Some(prefix.join("share").join("man").join("man1"));
}
// A binary in `<prefix>/bin` — cargo's `~/.cargo/bin`, a manual
// `/usr/local/bin`, or the Homebrew symlink — maps to `<prefix>/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
/// (`<prefix>/Cellar/<pkg>/<ver>/bin/<exe>`), return `<prefix>`.
fn homebrew_prefix(exe: &Path) -> Option<PathBuf> {
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<PathBuf> {
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<Option<PathBuf>> {
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
);
}
}
2 changes: 2 additions & 0 deletions src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -32,5 +33,6 @@ pub fn dispatch(env: &Env, cmd: &Cmd) -> Result<ExitCode> {
Cmd::Doctor => doctor::run(env),
Cmd::Uninstall(args) => uninstall::run(env, args),
Cmd::Completions(args) => completions::run(env, args),
Cmd::Man => man::print(),
}
}
7 changes: 6 additions & 1 deletion src/commands/uninstall.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -62,6 +62,11 @@ pub fn run(env: &Env, args: &UninstallArgs) -> Result<ExitCode> {
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();
Expand Down
Loading
Loading