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
72 changes: 28 additions & 44 deletions src/lib/cfg/lefthook.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,51 +22,35 @@ is just the template/scaffold, the real config data gets merged in by consumers
(e.g. in `src/local/configs.nix` or wherever this is used). Nixago deep-merges
`data` from the caller.

`hook.extra` is a Nixago hook hat runs whenever the generated `lefthook.yml` is
materialized. It receives the final merged `config` (the full lefthook YAML
content as a Nix attrset) and produces extra shell commands. Here's the pipeline
step by step:
`hook.extra` is a Nixago hook that runs whenever the generated `lefthook.yml`
is materialized. It receives the final merged `config` (the full lefthook YAML
content as a Nix attrset) and emits a shell snippet that symlinks a Nix-built
wrapper script into the repo's hooks directory for each configured stage. The
wrapper runs `lefthook run "<stage>" "$@"` unless `$LEFTHOOK == "0"` (escape
hatch).

```nix
hook.extra = config:
let
commands = lib.pipe config [
# 1. Strip non-stage keys (colors, extends, etc.)
# e.g. { pre-commit = {...}; commit-msg = {...}; colors = true; }
# becomes { pre-commit = {...}; commit-msg = {...}; }
toStagesConfig

# 2. Extract just the attribute names → ["pre-commit" "commit-msg"]
lib.attrNames

# 3. For each stage, create a symlink command:
# ln -sf "/nix/store/...-lefthook-pre-commit" ".git/hooks/pre-commit"
# The target is a Nix-built script (mkScript) that runs:
# lefthook run "pre-commit" "$@"
# (unless $LEFTHOOK == "0", which disables it)
(lib.map (stage: ''ln -sf "${mkScript stage}" ".git/hooks/${stage}"''))

# 4. Prepend "mkdir -p .git/hooks" IF there are any stages
# ["mkdir -p .git/hooks" "ln -sf ..." "ln -sf ..."]
(stages:
lib.optional (stages != []) "mkdir -p .git/hooks"
++ stages)

# 5. Join into a single newline-separated shell script string
(lib.concatStringsSep "\n")
];
in ''
# Only install hooks in the main repo, not in worktrees.
# In worktrees, .git is a file pointing to the main repo's .git dir,
# so mkdir -p .git/hooks would fail.
if test "$(git rev-parse --git-dir)" = "$(git rev-parse --git-common-dir)"; then
${commands}
fi
'';
```
#### Worktree behavior

The hooks directory is resolved via `git rev-parse --git-path hooks` rather
than the literal `.git/hooks`. Two reasons:

1. In a linked worktree, `.git` is a *file* (not a directory) pointing at
`<main>/.git/worktrees/<name>`, so `mkdir -p .git/hooks` would fail.
2. Per [`gitrepository-layout(5)`][layout]: *"This directory is ignored if
`$GIT_COMMON_DIR` is set and `$GIT_COMMON_DIR/hooks` will be used
instead."* In other words, git always looks up hooks in the **common**
gitdir, regardless of which worktree you commit from.

`--git-path hooks` returns `<common-dir>/hooks` from any worktree, so a single
install (from main checkout or any worktree) wires up hooks for every
worktree.

[layout]: https://git-scm.com/docs/gitrepository-layout

#### Lifecycle

When you run the Nixago hook (e.g. via `direnv allow` or `std` commands), it:

1. Writes `lefthook.yml` to your project root
2. Runs this `hook.extra` script, which symlinks Nix-built wrapper scripts into
`.git/hooks/` (only in the main repo, not in worktrees)
1. Writes `lefthook.yml` to your project root.
2. Runs `hook.extra`, which symlinks Nix-built wrapper scripts into the
resolved hooks directory.
33 changes: 17 additions & 16 deletions src/lib/cfg/lefthook.nix
Original file line number Diff line number Diff line change
Expand Up @@ -21,21 +21,22 @@ in {
format = "yaml";
output = "lefthook.yml";
packages = [nixpkgs.lefthook];
# Add an extra hook for adding required stages whenever the file changes.
# Skip hook installation in git worktrees where .git is a file, not a directory.
# Install symlinks into the resolved hooks dir, so this works from both the
# main checkout and linked worktrees. `git rev-parse --git-path hooks`
# returns `<common-dir>/hooks` (per gitrepository-layout(5): hooks live in
# the common gitdir, not the per-worktree private gitdir), so a single
# install from any worktree wires up hooks for every worktree.
hook.extra = config: let
commands = lib.pipe config [
toStagesConfig
lib.attrNames
(lib.map (stage: ''ln -sf "${mkScript stage}" ".git/hooks/${stage}"''))
(stages:
lib.optional (stages != []) "mkdir -p .git/hooks"
++ stages)
(lib.concatStringsSep "\n")
];
in ''
if test "$(${lib.getExe nixpkgs.git} rev-parse --git-dir 2>/dev/null)" = "$(${lib.getExe nixpkgs.git} rev-parse --git-common-dir 2>/dev/null)"; then
${commands}
fi
'';
stages = lib.pipe config [toStagesConfig lib.attrNames];
links =
lib.concatMapStringsSep "\n"
(stage: ''ln -sf "${mkScript stage}" "$_std_hooks_dir/${stage}"'')
stages;
in
lib.optionalString (stages != []) ''
_std_hooks_dir="$(${lib.getExe nixpkgs.git} rev-parse --git-path hooks)"
mkdir -p "$_std_hooks_dir"
${links}
unset _std_hooks_dir
'';
}
Loading