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
5 changes: 3 additions & 2 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 @@ -6,6 +6,7 @@ edition = "2024"
[dependencies]
clap = { version = "4.5.57", features = ["derive"] }
env_logger = "0.11.9"
libc = "0.2.183"
log = "0.4.29"
serde = { version = "1.0.228", features = ["derive"] }
serde_yaml = "0.9.34"
Expand Down
39 changes: 28 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,25 @@ for repo configs), where `<ext>` is `yml`, `yaml`, `json`, `jsonc`, or `toml`.

### `lhm install`

- Creates symlinks for all standard git hooks in `~/.lhm/hooks/`, each pointing to the `lhm` binary
- Sets `git config --global core.hooksPath ~/.lhm/hooks`
- Writes a default `~/.lefthook.yaml` if no global config exists
- Creates symlinks for all standard git hooks in `~/.local/libexec/lhm/hooks/`, each pointing to the `lhm` binary
- Sets `git config --global core.hooksPath ~/.local/libexec/lhm/hooks`
- Writes a default `~/.local/etc/lefthook.yaml` if no user config exists

### `lhm install --system`

Same as `lhm install` but targets a system-wide location (requires root):

- Creates symlinks in `/usr/local/libexec/lhm/hooks/`
- Sets `git config --system core.hooksPath /usr/local/libexec/lhm/hooks`
- Writes a default `/usr/local/etc/lefthook.yaml` if no system config exists

### `lhm disable`

Unsets `git config --global core.hooksPath`, disabling lhm. The hook symlinks in `~/.lhm/hooks/` are left in place so `lhm install` can re-enable quickly.
Unsets `git config --global core.hooksPath`, disabling lhm. The hook symlinks in `~/.local/libexec/lhm/hooks/` are left in place so `lhm install` can re-enable quickly.

### `lhm disable --system`

Unsets `git config --system core.hooksPath` (requires root). The hook symlinks in `/usr/local/libexec/lhm/hooks/` are left in place.

### `lhm dry-run`

Expand Down Expand Up @@ -61,18 +73,23 @@ LHM_LOCAL_CONFIG=./other.yml git commit

### Hook execution

When git triggers a hook, it invokes the symlink in `~/.lhm/hooks/`. `lhm` detects the hook name from `argv[0]` and:
When git triggers a hook, it invokes the symlink in the hooks directory. `lhm` detects the hook name from `argv[0]` and:

0. **lefthook not in PATH**: falls back to executing `.git/hooks/<hook>` directly (if it exists), bypassing all config merging
1. **No config at all** (no global, no repo, no adapter): hook is skipped silently
2. **Both configs exist** (`~/.lefthook.yaml` + `$REPO/lefthook.yaml`): merges global and repo configs, runs `lefthook run <hook>` with `LEFTHOOK_CONFIG` pointing to the merged temp file
3. **Global only** (no repo config or adapter): runs `lefthook run <hook>` with the global config
4. **Repo/adapter only** (no global config): runs `lefthook run <hook>` with the repo or adapter config
5. **No repo config, but adapter detected**: generates a dynamic lefthook config from the adapter, merges it with the global config (if present), and runs `lefthook run <hook>`
1. **No config at all** (no system, no global, no repo, no adapter): hook is skipped silently
2. **Configs exist**: merges all available layers in order (system, global, repo/adapter), runs `lefthook run <hook>` with `LEFTHOOK_CONFIG` pointing to the merged temp file

Config is resolved as a three-layer merge, where later layers override earlier ones:

1. **System** (`/usr/local/etc/lefthook.yaml`) — organizational baseline
2. **User global** (`~/.local/etc/lefthook.yaml`) — per-user overrides
3. **Repo** (`$REPO/lefthook.yaml` or adapter) — per-repo overrides

Any layer may be absent. When a repo has no lefthook config, the adapter system is used in its place (see below).

### Adapters

When a repo has no `lefthook.yaml`, lhm checks for other git hook managers and transparently adapts them. The generated adapter config is merged with `~/.lefthook.yaml` using the standard merging system, so global hooks still apply.
When a repo has no `lefthook.yaml`, lhm checks for other git hook managers and transparently adapts them. The generated adapter config is merged with `~/.local/etc/lefthook.yaml` using the standard merging system, so global hooks still apply.

Adapters are tried in this order (first match wins):

Expand Down
48 changes: 37 additions & 11 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ use std::io::Write;
use std::path::{Path, PathBuf};
use tempfile::NamedTempFile;

/// System-wide config directory.
pub const SYSTEM_CONFIG_DIR: &str = "/usr/local/etc";

/// Overrides for the global and local (repo) config paths.
/// CLI flags take precedence; env vars (`LHM_GLOBAL_CONFIG`, `LHM_LOCAL_CONFIG`)
/// are used as fallback so that overrides work during hook invocations too.
Expand Down Expand Up @@ -81,6 +84,7 @@ pub fn find_config(dir: &Path, check_dot_config: bool) -> Option<PathBuf> {
None
}

/// Find the user-level lefthook config in the given directory (e.g. `~/.local/etc`).
pub fn global_config(home: &Path, overrides: &ConfigOverrides) -> Option<PathBuf> {
if let Some(ref p) = overrides.global_config {
debug!("using global config override: {}", p.display());
Expand All @@ -89,6 +93,11 @@ pub fn global_config(home: &Path, overrides: &ConfigOverrides) -> Option<PathBuf
find_config(home, false)
}

/// Find the system-wide lefthook config in `/usr/local/etc`.
pub fn system_config() -> Option<PathBuf> {
find_config(Path::new(SYSTEM_CONFIG_DIR), false)
}

pub fn repo_config(root: &Path, overrides: &ConfigOverrides) -> Option<PathBuf> {
if let Some(ref p) = overrides.local_config {
debug!("using local config override: {}", p.display());
Expand All @@ -97,24 +106,41 @@ pub fn repo_config(root: &Path, overrides: &ConfigOverrides) -> Option<PathBuf>
find_config(root, true)
}

/// Write the default global config to `~/.lefthook.yaml` if no global config exists.
pub fn install_default_global_config(home: &Path) -> Result<(), String> {
if find_config(home, false).is_some() {
/// Write a default `lefthook.yaml` in `dir` if no lefthook config exists there.
pub fn install_default_global_config(dir: &Path) -> Result<(), String> {
if find_config(dir, false).is_some() {
debug!("global config already exists, skipping default");
return Ok(());
}
let path = home.join(".lefthook.yaml");
let path = dir.join("lefthook.yaml");
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| format!("failed to create {}: {e}", parent.display()))?;
}
fs::write(&path, DEFAULT_GLOBAL_CONFIG).map_err(|e| format!("failed to write {}: {e}", path.display()))?;
info!("created default global config at {}", path.display());
Ok(())
}

/// Load the global config from `~/.lefthook.yaml` (or override) if it exists.
pub fn load_global_config(home: &Path, overrides: &ConfigOverrides) -> Result<Option<Value>, String> {
match global_config(home, overrides) {
/// Load the user-level config from `~/.local/etc/lefthook.yaml` (or override) if it exists.
pub fn load_global_config(dir: &Path, overrides: &ConfigOverrides) -> Result<Option<Value>, String> {
match global_config(dir, overrides) {
Some(path) => read_yaml(&path).map(Some),
None => {
debug!("no global config file found");
debug!("no user config file found");
Ok(None)
}
}
}

/// Load the system-wide config from `/usr/local/etc/lefthook.yaml` if it exists.
pub fn load_system_config() -> Result<Option<Value>, String> {
match system_config() {
Some(path) => {
debug!("system config: {}", path.display());
read_yaml(&path).map(Some)
}
None => {
debug!("no system config file found");
Ok(None)
}
}
Expand Down Expand Up @@ -212,7 +238,7 @@ mod tests {
let dir = tempfile::tempdir().unwrap();
install_default_global_config(dir.path()).unwrap();

let created = dir.path().join(".lefthook.yaml");
let created = dir.path().join("lefthook.yaml");
assert!(created.is_file());
let content = fs::read_to_string(&created).unwrap();
assert!(content.contains("pre-push:"));
Expand All @@ -229,8 +255,8 @@ mod tests {

// Original file untouched
assert_eq!(fs::read_to_string(&existing).unwrap(), "custom: true\n");
// No .lefthook.yaml created
assert!(!dir.path().join(".lefthook.yaml").exists());
// No lefthook.yaml created
assert!(!dir.path().join("lefthook.yaml").exists());
}

#[test]
Expand Down
Loading
Loading