11 Commits

Author SHA1 Message Date
eric
71c7fe09cd chore(release): v3.4.0 2026-03-15 17:15:23 +01:00
eric
45f3830794 ci: limit lefthook logging 2026-03-15 17:14:25 +01:00
eric
b8d0a69d4d fix: lefthook logging 2026-03-15 17:10:26 +01:00
eric
c5f8ee6005 chore(release): v3.3.0 2026-03-15 16:55:02 +01:00
eric
9983f0b8e9 feat: expose more options for lefthook 2026-03-15 16:51:49 +01:00
eric
0d339e2de0 chore(release): v3.2.0 2026-03-15 16:31:43 +01:00
eric
7dcb0d1b3a feat: replace githooks with lefthook 2026-03-15 16:31:32 +01:00
eric
f8658265ae chore(release): v3.1.0 2026-03-15 15:50:04 +01:00
eric
c42899c89e feat: add icons and command tools (like nix) 2026-03-15 15:49:54 +01:00
eric
00fb6ef297 feat: add icons and command tools (like nix) 2026-03-15 15:48:51 +01:00
eric
dc475afcd1 chore(release): v3.0.0 2026-03-12 18:55:20 +01:00
15 changed files with 1186 additions and 234 deletions

1
.envrc
View File

@@ -1 +1,2 @@
watch_file flake.nix
use flake use flake

1
.gitignore vendored
View File

@@ -1,4 +1,5 @@
.pre-commit-config.yaml .pre-commit-config.yaml
lefthook.yml
.direnv .direnv
result result
template/flake.lock template/flake.lock

View File

@@ -15,7 +15,7 @@
## Use the template ## Use the template
```bash ```bash
nix flake new myapp -t 'git+https://git.dgren.dev/eric/nix-flake-lib?ref=v3.0.0#default' --refresh nix flake new myapp -t 'git+https://git.dgren.dev/eric/nix-flake-lib?ref=refs/tags/v3.4.0#default' --refresh
``` ```
## Use the library ## Use the library
@@ -23,7 +23,7 @@ nix flake new myapp -t 'git+https://git.dgren.dev/eric/nix-flake-lib?ref=v3.0.0#
Add this flake input: Add this flake input:
```nix ```nix
inputs.repo-lib.url = "git+https://git.dgren.dev/eric/nix-flake-lib?ref=v3.0.0"; inputs.repo-lib.url = "git+https://git.dgren.dev/eric/nix-flake-lib?ref=refs/tags/v3.4.0";
inputs.repo-lib.inputs.nixpkgs.follows = "nixpkgs"; inputs.repo-lib.inputs.nixpkgs.follows = "nixpkgs";
``` ```
@@ -49,10 +49,10 @@ outputs = { self, nixpkgs, repo-lib, ... }:
perSystem = { pkgs, system, ... }: { perSystem = { pkgs, system, ... }: {
tools = [ tools = [
(repo-lib.lib.tools.fromPackage { (repo-lib.lib.tools.fromCommand {
name = "Nix"; name = "Nix";
package = pkgs.nix;
version.args = [ "--version" ]; version.args = [ "--version" ];
command = "nix";
}) })
]; ];
@@ -66,14 +66,24 @@ outputs = { self, nixpkgs, repo-lib, ... }:
`mkRepo` generates: `mkRepo` generates:
- `devShells.${system}.default` - `devShells.${system}.default`
- `checks.${system}.pre-commit-check` - `checks.${system}.hook-check`
- `checks.${system}.lefthook-check`
- `formatter.${system}` - `formatter.${system}`
- `packages.${system}.release` when `config.release != null` - `packages.${system}.release` when `config.release != null`
- merged `packages` and `apps` from `perSystem` - merged `packages` and `apps` from `perSystem`
Checks are installed through `lefthook`, with `pre-commit` and `pre-push` commands configured to run in parallel.
repo-lib also sets Lefthook `output = [ "failure" "summary" ]` by default.
For advanced Lefthook features, use raw `config.lefthook` or `perSystem.lefthook`. Those attrsets are merged after generated checks, so you can augment a generated command with fields that the simple `checks` abstraction does not carry, such as `stage_fixed`:
```nix
config.lefthook.pre-push.commands.tests.stage_fixed = true;
```
## Tool banners ## Tool banners
Tools are declared once, from packages. They are added to the shell automatically and rendered in the startup banner. Tools are declared once. Package-backed tools are added to the shell automatically, and both package-backed and command-backed tools are rendered in the startup banner.
```nix ```nix
(repo-lib.lib.tools.fromPackage { (repo-lib.lib.tools.fromPackage {
@@ -86,6 +96,16 @@ Tools are declared once, from packages. They are added to the shell automaticall
Required tools fail shell startup if their version probe fails. This keeps banner output honest instead of silently hiding misconfiguration. Required tools fail shell startup if their version probe fails. This keeps banner output honest instead of silently hiding misconfiguration.
When a tool should come from the host environment instead of `nixpkgs`, use `fromCommand`:
```nix
(repo-lib.lib.tools.fromCommand {
name = "Nix";
command = "nix";
version.args = [ "--version" ];
})
```
## Purity model ## Purity model
The default path is pure: declare tools and packages in Nix, then let `mkRepo` assemble the shell. The default path is pure: declare tools and packages in Nix, then let `mkRepo` assemble the shell.

View File

@@ -1,3 +1,4 @@
3.0.0 3.4.0
stable stable
0 0

77
flake.lock generated
View File

@@ -1,79 +1,26 @@
{ {
"nodes": { "nodes": {
"flake-compat": { "lefthook-nix": {
"flake": false,
"locked": {
"lastModified": 1767039857,
"narHash": "sha256-vNpUSpF5Nuw8xvDLj2KCwwksIbjua2LZCqhV1LNRDns=",
"owner": "NixOS",
"repo": "flake-compat",
"rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab",
"type": "github"
},
"original": {
"owner": "NixOS",
"repo": "flake-compat",
"type": "github"
}
},
"git-hooks": {
"inputs": {
"flake-compat": "flake-compat",
"gitignore": "gitignore",
"nixpkgs": "nixpkgs"
},
"locked": {
"lastModified": 1772024342,
"narHash": "sha256-+eXlIc4/7dE6EcPs9a2DaSY3fTA9AE526hGqkNID3Wg=",
"owner": "cachix",
"repo": "git-hooks.nix",
"rev": "6e34e97ed9788b17796ee43ccdbaf871a5c2b476",
"type": "github"
},
"original": {
"owner": "cachix",
"repo": "git-hooks.nix",
"type": "github"
}
},
"gitignore": {
"inputs": { "inputs": {
"nixpkgs": [ "nixpkgs": [
"git-hooks",
"nixpkgs" "nixpkgs"
] ]
}, },
"locked": { "locked": {
"lastModified": 1709087332, "lastModified": 1770377107,
"narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=", "narHash": "sha256-/QEXSDeAo5RK81PtM0yDhmt9k3v1/pse/jsrT1yXNhU=",
"owner": "hercules-ci", "owner": "sudosubin",
"repo": "gitignore.nix", "repo": "lefthook.nix",
"rev": "637db329424fd7e46cf4185293b9cc8c88c95394", "rev": "9cdaf7ce95ae77cbabc5b556bdd35d3cf0b849f5",
"type": "github" "type": "github"
}, },
"original": { "original": {
"owner": "hercules-ci", "owner": "sudosubin",
"repo": "gitignore.nix", "repo": "lefthook.nix",
"type": "github" "type": "github"
} }
}, },
"nixpkgs": { "nixpkgs": {
"locked": {
"lastModified": 1770073757,
"narHash": "sha256-Vy+G+F+3E/Tl+GMNgiHl9Pah2DgShmIUBJXmbiQPHbI=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "47472570b1e607482890801aeaf29bfb749884f6",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_2": {
"locked": { "locked": {
"lastModified": 1772542754, "lastModified": 1772542754,
"narHash": "sha256-WGV2hy+VIeQsYXpsLjdr4GvHv5eECMISX1zKLTedhdg=", "narHash": "sha256-WGV2hy+VIeQsYXpsLjdr4GvHv5eECMISX1zKLTedhdg=",
@@ -89,7 +36,7 @@
"type": "github" "type": "github"
} }
}, },
"nixpkgs_3": { "nixpkgs_2": {
"locked": { "locked": {
"lastModified": 1770107345, "lastModified": 1770107345,
"narHash": "sha256-tbS0Ebx2PiA1FRW8mt8oejR0qMXmziJmPaU1d4kYY9g=", "narHash": "sha256-tbS0Ebx2PiA1FRW8mt8oejR0qMXmziJmPaU1d4kYY9g=",
@@ -107,14 +54,14 @@
}, },
"root": { "root": {
"inputs": { "inputs": {
"git-hooks": "git-hooks", "lefthook-nix": "lefthook-nix",
"nixpkgs": "nixpkgs_2", "nixpkgs": "nixpkgs",
"treefmt-nix": "treefmt-nix" "treefmt-nix": "treefmt-nix"
} }
}, },
"treefmt-nix": { "treefmt-nix": {
"inputs": { "inputs": {
"nixpkgs": "nixpkgs_3" "nixpkgs": "nixpkgs_2"
}, },
"locked": { "locked": {
"lastModified": 1770228511, "lastModified": 1770228511,

View File

@@ -4,7 +4,8 @@
inputs = { inputs = {
nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable"; nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable";
git-hooks.url = "github:cachix/git-hooks.nix"; lefthook-nix.url = "github:sudosubin/lefthook.nix";
lefthook-nix.inputs.nixpkgs.follows = "nixpkgs";
treefmt-nix.url = "github:numtide/treefmt-nix"; treefmt-nix.url = "github:numtide/treefmt-nix";
}; };
@@ -13,13 +14,14 @@
self, self,
nixpkgs, nixpkgs,
treefmt-nix, treefmt-nix,
git-hooks, lefthook-nix,
... ...
}: }:
let let
lib = nixpkgs.lib; lib = nixpkgs.lib;
repoLib = import ./packages/repo-lib/lib.nix { repoLib = import ./packages/repo-lib/lib.nix {
inherit nixpkgs treefmt-nix git-hooks; inherit nixpkgs treefmt-nix;
lefthookNix = lefthook-nix;
releaseScriptPath = ./packages/release/release.sh; releaseScriptPath = ./packages/release/release.sh;
shellHookTemplatePath = ./packages/repo-lib/shell-hook.sh; shellHookTemplatePath = ./packages/repo-lib/shell-hook.sh;
}; };
@@ -36,21 +38,21 @@
replace = { replace = {
path = "template/flake.nix"; path = "template/flake.nix";
regex = ''^([[:space:]]*repo-lib\.url = ")git\+https://git\.dgren\.dev/eric/nix-flake-lib[^"]*(";)''; regex = ''^([[:space:]]*repo-lib\.url = ")git\+https://git\.dgren\.dev/eric/nix-flake-lib[^"]*(";)'';
replacement = ''\1git+https://git.dgren.dev/eric/nix-flake-lib?ref=$FULL_TAG\2''; replacement = ''\1git+https://git.dgren.dev/eric/nix-flake-lib?ref=refs/tags/$FULL_TAG\2'';
}; };
} }
{ {
replace = { replace = {
path = "README.md"; path = "README.md";
regex = ''(nix flake new myapp -t ')git\+https://git\.dgren\.dev/eric/nix-flake-lib[^']*(#default' --refresh)''; regex = ''(nix flake new myapp -t ')git\+https://git\.dgren\.dev/eric/nix-flake-lib[^']*(#default' --refresh)'';
replacement = ''\1git+https://git.dgren.dev/eric/nix-flake-lib?ref=$FULL_TAG\2''; replacement = ''\1git+https://git.dgren.dev/eric/nix-flake-lib?ref=refs/tags/$FULL_TAG\2'';
}; };
} }
{ {
replace = { replace = {
path = "README.md"; path = "README.md";
regex = ''^([[:space:]]*inputs\.repo-lib\.url = ")git\+https://git\.dgren\.dev/eric/nix-flake-lib[^"]*(";)''; regex = ''^([[:space:]]*inputs\.repo-lib\.url = ")git\+https://git\.dgren\.dev/eric/nix-flake-lib[^"]*(";)'';
replacement = ''\1git+https://git.dgren.dev/eric/nix-flake-lib?ref=$FULL_TAG\2''; replacement = ''\1git+https://git.dgren.dev/eric/nix-flake-lib?ref=refs/tags/$FULL_TAG\2'';
}; };
} }
]; ];
@@ -64,10 +66,17 @@
}: }:
{ {
tools = [ tools = [
(repoLib.tools.fromPackage { (repoLib.tools.fromCommand {
name = "Nix"; name = "Nix";
package = pkgs.nix; command = "nix";
version.args = [ "--version" ]; version = {
args = [ "--version" ];
group = 1;
};
banner = {
color = "BLUE";
icon = "";
};
}) })
]; ];
@@ -87,10 +96,10 @@
nativeBuildInputs = with pkgs; [ nativeBuildInputs = with pkgs; [
bash bash
git git
nix
gnused gnused
coreutils coreutils
gnugrep gnugrep
nix
perl perl
]; ];
} }
@@ -98,7 +107,6 @@
export REPO_LIB_ROOT=${./.} export REPO_LIB_ROOT=${./.}
export NIXPKGS_FLAKE_PATH=${nixpkgs} export NIXPKGS_FLAKE_PATH=${nixpkgs}
export HOME="$TMPDIR" export HOME="$TMPDIR"
export NIX_CONFIG="experimental-features = nix-command flakes"
${pkgs.bash}/bin/bash ${./tests/release.sh} ${pkgs.bash}/bin/bash ${./tests/release.sh}
touch "$out" touch "$out"
''; '';

View File

@@ -1,7 +1,7 @@
{ {
nixpkgs, nixpkgs,
treefmt-nix, treefmt-nix,
git-hooks, lefthookNix,
releaseScriptPath ? ./release.sh, releaseScriptPath ? ./release.sh,
shellHookTemplatePath ? ../repo-lib/shell-hook.sh, shellHookTemplatePath ? ../repo-lib/shell-hook.sh,
}: }:
@@ -9,7 +9,7 @@ import ../repo-lib/lib.nix {
inherit inherit
nixpkgs nixpkgs
treefmt-nix treefmt-nix
git-hooks lefthookNix
releaseScriptPath releaseScriptPath
shellHookTemplatePath shellHookTemplatePath
; ;

View File

@@ -6,6 +6,8 @@ ROOT_DIR="$(git rev-parse --show-toplevel)"
GITLINT_FILE="$ROOT_DIR/.gitlint" GITLINT_FILE="$ROOT_DIR/.gitlint"
START_HEAD="" START_HEAD=""
CREATED_TAG="" CREATED_TAG=""
VERSION_META_LINES=()
VERSION_META_EXPORT_NAMES=()
# ── logging ──────────────────────────────────────────────────────────────── # ── logging ────────────────────────────────────────────────────────────────
@@ -168,6 +170,119 @@ compute_full_version() {
export BASE_VERSION CHANNEL PRERELEASE_NUM FULL_VERSION FULL_TAG export BASE_VERSION CHANNEL PRERELEASE_NUM FULL_VERSION FULL_TAG
} }
meta_env_name() {
local key="$1"
key="${key//[^[:alnum:]]/_}"
key="$(printf '%s' "$key" | tr '[:lower:]' '[:upper:]')"
printf 'VERSION_META_%s\n' "$key"
}
clear_version_meta_exports() {
local export_name
for export_name in "${VERSION_META_EXPORT_NAMES[@]:-}"; do
unset "$export_name"
done
VERSION_META_EXPORT_NAMES=()
}
load_version_metadata() {
VERSION_META_LINES=()
[[ ! -f "$ROOT_DIR/VERSION" ]] && return 0
while IFS= read -r line || [[ -n $line ]]; do
VERSION_META_LINES+=("$line")
done < <(tail -n +4 "$ROOT_DIR/VERSION" 2>/dev/null || true)
}
export_version_metadata() {
clear_version_meta_exports
local line key value export_name
for line in "${VERSION_META_LINES[@]:-}"; do
[[ $line != *=* ]] && continue
key="${line%%=*}"
value="${line#*=}"
[[ -z $key ]] && continue
export_name="$(meta_env_name "$key")"
printf -v "$export_name" '%s' "$value"
export "${export_name?}=$value"
VERSION_META_EXPORT_NAMES+=("$export_name")
done
}
write_version_file() {
local channel_to_write="$1"
local n_to_write="$2"
{
printf '%s\n%s\n%s\n' "$BASE_VERSION" "$channel_to_write" "$n_to_write"
local line
for line in "${VERSION_META_LINES[@]:-}"; do
printf '%s\n' "$line"
done
} >"$ROOT_DIR/VERSION"
}
version_meta_get() {
local key="${1-}"
local line
for line in "${VERSION_META_LINES[@]:-}"; do
if [[ $line == "$key="* ]]; then
printf '%s\n' "${line#*=}"
return 0
fi
done
return 1
}
version_meta_set() {
local key="${1-}"
local value="${2-}"
[[ -z $key ]] && echo "Error: version_meta_set requires a key" >&2 && exit 1
local updated=0
local index
for index in "${!VERSION_META_LINES[@]}"; do
if [[ ${VERSION_META_LINES[index]} == "$key="* ]]; then
VERSION_META_LINES[index]="$key=$value"
updated=1
break
fi
done
if [[ $updated -eq 0 ]]; then
VERSION_META_LINES+=("$key=$value")
fi
export_version_metadata
version_meta_write
}
version_meta_unset() {
local key="${1-}"
[[ -z $key ]] && echo "Error: version_meta_unset requires a key" >&2 && exit 1
local filtered=()
local line
for line in "${VERSION_META_LINES[@]:-}"; do
[[ $line == "$key="* ]] && continue
filtered+=("$line")
done
VERSION_META_LINES=("${filtered[@]}")
export_version_metadata
version_meta_write
}
version_meta_write() {
local channel_to_write="$CHANNEL"
local n_to_write="${PRERELEASE_NUM:-1}"
if [[ $channel_to_write == "stable" || -z $channel_to_write ]]; then
channel_to_write="stable"
n_to_write="0"
fi
write_version_file "$channel_to_write" "$n_to_write"
}
# ── gitlint ──────────────────────────────────────────────────────────────── # ── gitlint ────────────────────────────────────────────────────────────────
get_gitlint_title_regex() { get_gitlint_title_regex() {
@@ -205,6 +320,8 @@ run_release_steps() {
# and never contaminates the stdout of do_read_version. # and never contaminates the stdout of do_read_version.
init_version_file() { init_version_file() {
if [[ -f "$ROOT_DIR/VERSION" ]]; then if [[ -f "$ROOT_DIR/VERSION" ]]; then
load_version_metadata
export_version_metadata
return 0 return 0
fi fi
@@ -233,11 +350,16 @@ init_version_file() {
n_to_write="0" n_to_write="0"
fi fi
printf '%s\n%s\n%s\n' "$BASE_VERSION" "$channel_to_write" "$n_to_write" >"$ROOT_DIR/VERSION" VERSION_META_LINES=()
write_version_file "$channel_to_write" "$n_to_write"
export_version_metadata
log "Initialized $ROOT_DIR/VERSION from highest tag: v$highest_tag" log "Initialized $ROOT_DIR/VERSION from highest tag: v$highest_tag"
} }
do_read_version() { do_read_version() {
load_version_metadata
export_version_metadata
local base_line channel_line n_line local base_line channel_line n_line
base_line="$(sed -n '1p' "$ROOT_DIR/VERSION" | tr -d '\r')" base_line="$(sed -n '1p' "$ROOT_DIR/VERSION" | tr -d '\r')"
channel_line="$(sed -n '2p' "$ROOT_DIR/VERSION" | tr -d '\r')" channel_line="$(sed -n '2p' "$ROOT_DIR/VERSION" | tr -d '\r')"
@@ -257,7 +379,8 @@ do_write_version() {
channel_to_write="stable" channel_to_write="stable"
n_to_write="0" n_to_write="0"
fi fi
printf '%s\n%s\n%s\n' "$BASE_VERSION" "$channel_to_write" "$n_to_write" >"$ROOT_DIR/VERSION" write_version_file "$channel_to_write" "$n_to_write"
export_version_metadata
} }
# ── user-provided hook ───────────────────────────────────────────────────── # ── user-provided hook ─────────────────────────────────────────────────────

View File

@@ -1,7 +1,7 @@
{ {
nixpkgs, nixpkgs,
treefmt-nix, treefmt-nix,
git-hooks, lefthookNix,
releaseScriptPath, releaseScriptPath,
shellHookTemplatePath, shellHookTemplatePath,
}: }:
@@ -44,11 +44,37 @@ let
sanitizeName = name: lib.strings.sanitizeDerivationName name; sanitizeName = name: lib.strings.sanitizeDerivationName name;
defaultShellBanner = {
style = "simple";
icon = "🚀";
title = "Dev shell ready";
titleColor = "GREEN";
subtitle = "";
subtitleColor = "GRAY";
borderColor = "BLUE";
};
normalizeShellBanner =
rawBanner:
let
banner = defaultShellBanner // rawBanner;
in
if
!(builtins.elem banner.style [
"simple"
"pretty"
])
then
throw "repo-lib: config.shell.banner.style must be one of simple or pretty"
else
banner;
normalizeStrictTool = normalizeStrictTool =
pkgs: tool: pkgs: tool:
let let
version = { version = {
args = [ "--version" ]; args = [ "--version" ];
match = null;
regex = null; regex = null;
group = 0; group = 0;
line = 1; line = 1;
@@ -56,22 +82,26 @@ let
// (tool.version or { }); // (tool.version or { });
banner = { banner = {
color = "YELLOW"; color = "YELLOW";
icon = null;
iconColor = null;
} }
// (tool.banner or { }); // (tool.banner or { });
executable = executable =
if tool ? exe && tool.exe != null then if tool ? command && tool.command != null then
tool.command
else if tool ? exe && tool.exe != null then
"${lib.getExe' tool.package tool.exe}" "${lib.getExe' tool.package tool.exe}"
else else
"${lib.getExe tool.package}"; "${lib.getExe tool.package}";
in in
if !(tool ? package) then if !(tool ? command && tool.command != null) && !(tool ? package) then
throw "repo-lib: tool '${tool.name or "<unnamed>"}' is missing 'package'" throw "repo-lib: tool '${tool.name or "<unnamed>"}' is missing 'package' or 'command'"
else else
{ {
kind = "strict"; kind = "strict";
inherit executable version banner; inherit executable version banner;
name = tool.name; name = tool.name;
package = tool.package; package = tool.package or null;
required = tool.required or true; required = tool.required or true;
}; };
@@ -87,11 +117,13 @@ let
versionCommand = tool.versionCmd or "--version"; versionCommand = tool.versionCmd or "--version";
banner = { banner = {
color = tool.color or "YELLOW"; color = tool.color or "YELLOW";
icon = tool.icon or null;
iconColor = tool.iconColor or null;
}; };
required = tool.required or false; required = tool.required or false;
}; };
normalizeCheck = checkToLefthookConfig =
pkgs: name: rawCheck: pkgs: name: rawCheck:
let let
check = { check = {
@@ -120,13 +152,85 @@ let
then then
throw "repo-lib: check '${name}' has unsupported stage '${check.stage}'" throw "repo-lib: check '${name}' has unsupported stage '${check.stage}'"
else else
{ lib.setAttrByPath [ check.stage "commands" name ] {
enable = true; run = "${wrapper}/bin/${wrapperName}${hookStageFileArgs check.stage check.passFilenames}";
entry = "${wrapper}/bin/${wrapperName}";
pass_filenames = check.passFilenames;
stages = [ check.stage ];
}; };
normalizeLefthookConfig =
label: raw: if builtins.isAttrs raw then raw else throw "repo-lib: ${label} must be an attrset";
normalizeHookStage =
hookName: stage:
if
builtins.elem stage [
"pre-commit"
"pre-push"
"commit-msg"
]
then
stage
else
throw "repo-lib: hook '${hookName}' has unsupported stage '${stage}' for lefthook";
hookStageFileArgs =
stage: passFilenames:
if !passFilenames then
""
else if stage == "pre-commit" then
" {staged_files}"
else if stage == "pre-push" then
" {push_files}"
else if stage == "commit-msg" then
" {1}"
else
throw "repo-lib: unsupported lefthook stage '${stage}'";
hookToLefthookConfig =
name: hook:
let
supportedFields = [
"description"
"enable"
"entry"
"name"
"package"
"pass_filenames"
"stages"
];
unsupportedFields = builtins.filter (field: !(builtins.elem field supportedFields)) (
builtins.attrNames hook
);
stages = builtins.map (stage: normalizeHookStage name stage) (hook.stages or [ "pre-commit" ]);
passFilenames = hook.pass_filenames or false;
in
if unsupportedFields != [ ] then
throw ''
repo-lib: hook '${name}' uses unsupported fields for lefthook: ${lib.concatStringsSep ", " unsupportedFields}
''
else if !(hook ? entry) then
throw "repo-lib: hook '${name}' is missing 'entry'"
else
lib.foldl' lib.recursiveUpdate { } (
builtins.map (
stage:
lib.setAttrByPath [ stage "commands" name ] {
run = "${hook.entry}${hookStageFileArgs stage passFilenames}";
}
) stages
);
parallelHookStageConfig =
stage:
if
builtins.elem stage [
"pre-commit"
"pre-push"
]
then
lib.setAttrByPath [ stage "parallel" ] true
else
{ };
normalizeReleaseStep = normalizeReleaseStep =
step: step:
if step ? writeFile then if step ? writeFile then
@@ -245,10 +349,10 @@ let
buildShellHook = buildShellHook =
{ {
preCommitShellHook, hooksShellHook,
shellEnvScript, shellEnvScript,
bootstrap, bootstrap,
toolBannerScript, shellBannerScript,
extraShellText, extraShellText,
toolLabelWidth, toolLabelWidth,
}: }:
@@ -257,19 +361,19 @@ let
in in
builtins.replaceStrings builtins.replaceStrings
[ [
"\${pre-commit-check.shellHook}" "@HOOKS_SHELL_HOOK@"
"@TOOL_LABEL_WIDTH@" "@TOOL_LABEL_WIDTH@"
"@SHELL_ENV_SCRIPT@" "@SHELL_ENV_SCRIPT@"
"@BOOTSTRAP@" "@BOOTSTRAP@"
"@TOOL_BANNER_SCRIPT@" "@SHELL_BANNER_SCRIPT@"
"@EXTRA_SHELL_TEXT@" "@EXTRA_SHELL_TEXT@"
] ]
[ [
preCommitShellHook hooksShellHook
(toString toolLabelWidth) (toString toolLabelWidth)
shellEnvScript shellEnvScript
bootstrap bootstrap
toolBannerScript shellBannerScript
extraShellText extraShellText
] ]
template; template;
@@ -286,9 +390,11 @@ let
env = { }; env = { };
extraShellText = ""; extraShellText = "";
bootstrap = ""; bootstrap = "";
banner = defaultShellBanner;
}, },
checkSpecs ? { }, checkSpecs ? { },
rawHookEntries ? { }, rawHookEntries ? { },
lefthookConfig ? { },
extraPackages ? [ ], extraPackages ? [ ],
}: }:
let let
@@ -309,26 +415,44 @@ let
// formatting.programs; // formatting.programs;
settings.formatter = { } // formatting.settings; settings.formatter = { } // formatting.settings;
}; };
treefmtWrapper = treefmtEval.config.build.wrapper;
lefthookBinWrapper = pkgs.writeShellScript "lefthook-dumb-term" ''
exec env TERM=dumb ${lib.getExe pkgs.lefthook} "$@"
'';
normalizedChecks = lib.mapAttrs (name: check: normalizeCheck pkgs name check) checkSpecs; normalizedLefthookConfig = normalizeLefthookConfig "lefthook config" lefthookConfig;
hooks = mergeUniqueAttrs "hook" rawHookEntries normalizedChecks; lefthookCheck = lefthookNix.lib.${system}.run {
pre-commit-check = git-hooks.lib.${system}.run {
inherit src; inherit src;
hooks = { config = lib.foldl' lib.recursiveUpdate { } (
treefmt = { [
enable = true; {
entry = "${treefmtEval.config.build.wrapper}/bin/treefmt --ci"; output = [
pass_filenames = true; "failure"
}; "summary"
gitlint.enable = true; ];
gitleaks = { }
enable = true; (parallelHookStageConfig "pre-commit")
entry = "${pkgs.gitleaks}/bin/gitleaks protect --staged"; (parallelHookStageConfig "pre-push")
pass_filenames = false; (lib.setAttrByPath [ "pre-commit" "commands" "treefmt" ] {
}; run = "${treefmtWrapper}/bin/treefmt --no-cache {staged_files}";
} stage_fixed = true;
// hooks; })
(lib.setAttrByPath [ "pre-commit" "commands" "gitleaks" ] {
run = "${pkgs.gitleaks}/bin/gitleaks protect --staged";
})
(lib.setAttrByPath [ "commit-msg" "commands" "gitlint" ] {
run = "${pkgs.gitlint}/bin/gitlint --staged --msg-filename {1}";
})
]
++ lib.mapAttrsToList (name: check: checkToLefthookConfig pkgs name check) checkSpecs
++ lib.mapAttrsToList hookToLefthookConfig rawHookEntries
++ [ normalizedLefthookConfig ]
);
};
selectedCheckOutputs = {
formatting-check = treefmtEval.config.build.check src;
hook-check = lefthookCheck;
lefthook-check = lefthookCheck;
}; };
toolNames = builtins.map (tool: tool.name) tools; toolNames = builtins.map (tool: tool.name) tools;
@@ -345,45 +469,119 @@ let
) shellConfig.env ) shellConfig.env
); );
toolBannerScript = lib.concatMapStrings ( banner = normalizeShellBanner (shellConfig.banner or { });
tool:
if tool.kind == "strict" then shellBannerScript =
if banner.style == "pretty" then
'' ''
repo_lib_probe_tool \ repo_lib_print_pretty_header \
${lib.escapeShellArg tool.name} \ ${lib.escapeShellArg banner.borderColor} \
${lib.escapeShellArg tool.banner.color} \ ${lib.escapeShellArg banner.titleColor} \
${lib.escapeShellArg (if tool.required then "1" else "0")} \ ${lib.escapeShellArg banner.icon} \
${lib.escapeShellArg (toString tool.version.line)} \ ${lib.escapeShellArg banner.title} \
${lib.escapeShellArg (toString tool.version.group)} \ ${lib.escapeShellArg banner.subtitleColor} \
${lib.escapeShellArg (tool.version.regex or "")} \ ${lib.escapeShellArg banner.subtitle}
${lib.escapeShellArg tool.executable} \ ''
${lib.escapeShellArgs tool.version.args} + lib.concatMapStrings (
tool:
if tool.kind == "strict" then
''
repo_lib_print_pretty_tool \
${lib.escapeShellArg banner.borderColor} \
${lib.escapeShellArg tool.name} \
${lib.escapeShellArg tool.banner.color} \
${lib.escapeShellArg (if tool.banner.icon == null then "" else tool.banner.icon)} \
${lib.escapeShellArg (if tool.banner.iconColor == null then "" else tool.banner.iconColor)} \
${lib.escapeShellArg (if tool.required then "1" else "0")} \
${lib.escapeShellArg (toString tool.version.line)} \
${lib.escapeShellArg (toString tool.version.group)} \
${lib.escapeShellArg (if tool.version.regex == null then "" else tool.version.regex)} \
${lib.escapeShellArg (if tool.version.match == null then "" else tool.version.match)} \
${lib.escapeShellArg tool.executable} \
${lib.escapeShellArgs tool.version.args}
''
else
''
repo_lib_print_pretty_legacy_tool \
${lib.escapeShellArg banner.borderColor} \
${lib.escapeShellArg tool.name} \
${lib.escapeShellArg tool.banner.color} \
${lib.escapeShellArg (if tool.banner.icon == null then "" else tool.banner.icon)} \
${lib.escapeShellArg (if tool.banner.iconColor == null then "" else tool.banner.iconColor)} \
${lib.escapeShellArg (if tool.required then "1" else "0")} \
${lib.escapeShellArg tool.command} \
${lib.escapeShellArg tool.versionCommand}
''
) tools
+ ''
repo_lib_print_pretty_footer \
${lib.escapeShellArg banner.borderColor}
'' ''
else else
'' ''
repo_lib_probe_legacy_tool \ repo_lib_print_simple_header \
${lib.escapeShellArg tool.name} \ ${lib.escapeShellArg banner.titleColor} \
${lib.escapeShellArg tool.banner.color} \ ${lib.escapeShellArg banner.icon} \
${lib.escapeShellArg (if tool.required then "1" else "0")} \ ${lib.escapeShellArg banner.title} \
${lib.escapeShellArg tool.command} \ ${lib.escapeShellArg banner.subtitleColor} \
${lib.escapeShellArg tool.versionCommand} ${lib.escapeShellArg banner.subtitle}
'' ''
) tools; + lib.concatMapStrings (
tool:
if tool.kind == "strict" then
''
repo_lib_print_simple_tool \
${lib.escapeShellArg tool.name} \
${lib.escapeShellArg tool.banner.color} \
${lib.escapeShellArg (if tool.banner.icon == null then "" else tool.banner.icon)} \
${lib.escapeShellArg (if tool.banner.iconColor == null then "" else tool.banner.iconColor)} \
${lib.escapeShellArg (if tool.required then "1" else "0")} \
${lib.escapeShellArg (toString tool.version.line)} \
${lib.escapeShellArg (toString tool.version.group)} \
${lib.escapeShellArg (if tool.version.regex == null then "" else tool.version.regex)} \
${lib.escapeShellArg (if tool.version.match == null then "" else tool.version.match)} \
${lib.escapeShellArg tool.executable} \
${lib.escapeShellArgs tool.version.args}
''
else
''
repo_lib_print_simple_legacy_tool \
${lib.escapeShellArg tool.name} \
${lib.escapeShellArg tool.banner.color} \
${lib.escapeShellArg (if tool.banner.icon == null then "" else tool.banner.icon)} \
${lib.escapeShellArg (if tool.banner.iconColor == null then "" else tool.banner.iconColor)} \
${lib.escapeShellArg (if tool.required then "1" else "0")} \
${lib.escapeShellArg tool.command} \
${lib.escapeShellArg tool.versionCommand}
''
) tools
+ ''
printf "\n"
'';
in in
{ {
inherit pre-commit-check; checks = selectedCheckOutputs;
formatter = treefmtEval.config.build.wrapper; formatter = treefmtWrapper;
shell = pkgs.mkShell { shell = pkgs.mkShell {
packages = lib.unique (selectedStandardPackages ++ extraPackages ++ toolPackages); LEFTHOOK_BIN = builtins.toString lefthookBinWrapper;
buildInputs = pre-commit-check.enabledPackages; packages = lib.unique (
selectedStandardPackages
++ extraPackages
++ toolPackages
++ [
pkgs.lefthook
treefmtWrapper
]
);
shellHook = buildShellHook { shellHook = buildShellHook {
preCommitShellHook = pre-commit-check.shellHook; hooksShellHook = lefthookCheck.shellHook;
inherit toolLabelWidth shellEnvScript toolBannerScript; inherit toolLabelWidth shellEnvScript shellBannerScript;
bootstrap = shellConfig.bootstrap; bootstrap = shellConfig.bootstrap;
extraShellText = shellConfig.extraShellText; extraShellText = shellConfig.extraShellText;
}; };
}; };
}; }
// selectedCheckOutputs;
in in
rec { rec {
systems = { systems = {
@@ -411,6 +609,24 @@ rec {
; ;
}; };
fromCommand =
{
name,
command,
version ? { },
banner ? { },
required ? true,
}:
{
inherit
name
command
version
banner
required
;
};
simple = simple =
name: package: args: name: package: args:
fromPackage { fromPackage {
@@ -429,12 +645,14 @@ rec {
extraShellText = ""; extraShellText = "";
allowImpureBootstrap = false; allowImpureBootstrap = false;
bootstrap = ""; bootstrap = "";
banner = { };
}; };
formatting = { formatting = {
programs = { }; programs = { };
settings = { }; settings = { };
}; };
checks = { }; checks = { };
lefthook = { };
release = null; release = null;
} rawConfig; } rawConfig;
release = release =
@@ -452,7 +670,13 @@ rec {
if merged.shell.bootstrap != "" && !merged.shell.allowImpureBootstrap then if merged.shell.bootstrap != "" && !merged.shell.allowImpureBootstrap then
throw "repo-lib: config.shell.bootstrap requires config.shell.allowImpureBootstrap = true" throw "repo-lib: config.shell.bootstrap requires config.shell.allowImpureBootstrap = true"
else else
merged // { inherit release; }; merged
// {
inherit release;
shell = merged.shell // {
banner = normalizeShellBanner merged.shell.banner;
};
};
mkDevShell = mkDevShell =
{ {
@@ -463,6 +687,7 @@ rec {
preToolHook ? "", preToolHook ? "",
extraShellHook ? "", extraShellHook ? "",
additionalHooks ? { }, additionalHooks ? { },
lefthook ? { },
tools ? [ ], tools ? [ ],
includeStandardPackages ? true, includeStandardPackages ? true,
formatters ? { }, formatters ? { },
@@ -487,6 +712,7 @@ rec {
extraShellText = extraShellHook; extraShellText = extraShellHook;
allowImpureBootstrap = true; allowImpureBootstrap = true;
bootstrap = preToolHook; bootstrap = preToolHook;
banner = defaultShellBanner;
}; };
in in
if duplicateToolNames != [ ] then if duplicateToolNames != [ ] then
@@ -501,6 +727,7 @@ rec {
; ;
formatting = normalizedFormatting; formatting = normalizedFormatting;
rawHookEntries = additionalHooks; rawHookEntries = additionalHooks;
lefthookConfig = lefthook;
shellConfig = shellConfig; shellConfig = shellConfig;
tools = legacyTools; tools = legacyTools;
extraPackages = extraPackages =
@@ -580,6 +807,7 @@ rec {
tools = [ ]; tools = [ ];
shell = { }; shell = { };
checks = { }; checks = { };
lefthook = { };
packages = { }; packages = { };
apps = { }; apps = { };
} }
@@ -592,6 +820,9 @@ rec {
strictTools = builtins.map (tool: normalizeStrictTool pkgs tool) perSystemResult.tools; strictTools = builtins.map (tool: normalizeStrictTool pkgs tool) perSystemResult.tools;
duplicateToolNames = duplicateStrings (builtins.map (tool: tool.name) strictTools); duplicateToolNames = duplicateStrings (builtins.map (tool: tool.name) strictTools);
mergedChecks = mergeUniqueAttrs "check" normalizedConfig.checks perSystemResult.checks; mergedChecks = mergeUniqueAttrs "check" normalizedConfig.checks perSystemResult.checks;
mergedLefthookConfig =
lib.recursiveUpdate (normalizeLefthookConfig "config.lefthook" normalizedConfig.lefthook)
(normalizeLefthookConfig "perSystem.lefthook" (perSystemResult.lefthook or { }));
shellConfig = lib.recursiveUpdate normalizedConfig.shell (perSystemResult.shell or { }); shellConfig = lib.recursiveUpdate normalizedConfig.shell (perSystemResult.shell or { });
env = env =
if duplicateToolNames != [ ] then if duplicateToolNames != [ ] then
@@ -607,6 +838,7 @@ rec {
formatting = normalizedConfig.formatting; formatting = normalizedConfig.formatting;
tools = strictTools; tools = strictTools;
checkSpecs = mergedChecks; checkSpecs = mergedChecks;
lefthookConfig = mergedLefthookConfig;
shellConfig = shellConfig; shellConfig = shellConfig;
extraPackages = perSystemResult.shell.packages or [ ]; extraPackages = perSystemResult.shell.packages or [ ];
}; };
@@ -638,9 +870,7 @@ rec {
default = systemResults.${system}.env.shell; default = systemResults.${system}.env.shell;
}); });
checks = lib.genAttrs systems (system: { checks = lib.genAttrs systems (system: systemResults.${system}.env.checks);
inherit (systemResults.${system}.env) pre-commit-check;
});
formatter = lib.genAttrs systems (system: systemResults.${system}.env.formatter); formatter = lib.genAttrs systems (system: systemResults.${system}.env.formatter);
packages = lib.genAttrs systems (system: systemResults.${system}.packages); packages = lib.genAttrs systems (system: systemResults.${system}.packages);

View File

@@ -1,4 +1,4 @@
${pre-commit-check.shellHook} @HOOKS_SHELL_HOOK@
if [ -t 1 ]; then if [ -t 1 ]; then
command -v tput >/dev/null 2>&1 && tput clear || printf '\033c' command -v tput >/dev/null 2>&1 && tput clear || printf '\033c'
@@ -16,107 +16,326 @@ BOLD=$'\033[1m'
UNDERLINE=$'\033[4m' UNDERLINE=$'\033[4m'
RESET=$'\033[0m' RESET=$'\033[0m'
repo_lib_probe_tool() { REPO_LIB_TOOL_VERSION=""
local name="$1" REPO_LIB_TOOL_ERROR=""
local color_name="$2"
local required="$3" repo_lib_capture_tool() {
local line_no="$4" local required="$1"
local group_no="$5" local line_no="$2"
local regex="$6" local group_no="$3"
local executable="$7" local regex="$4"
shift 7 local match_regex="$5"
local executable="$6"
shift 6
local color="${!color_name:-$YELLOW}"
local output="" local output=""
local selected="" local selected=""
local version="" local version=""
REPO_LIB_TOOL_VERSION=""
REPO_LIB_TOOL_ERROR=""
if ! output="$("$executable" "$@" 2>&1)"; then if ! output="$("$executable" "$@" 2>&1)"; then
printf " $CYAN %-@TOOL_LABEL_WIDTH@s$RESET $RED%s$RESET\n" "${name}:" "probe failed" REPO_LIB_TOOL_ERROR="probe failed"
printf "%s\n" "$output" >&2 printf "%s\n" "$output" >&2
if [ "$required" = "1" ]; then return 1
exit 1
fi
return 0
fi fi
selected="$(printf '%s\n' "$output" | sed -n "${line_no}p")" if [ -n "$match_regex" ]; then
selected="$(printf '%s\n' "$output" | grep -E -m 1 "$match_regex" || true)"
else
selected="$(printf '%s\n' "$output" | sed -n "${line_no}p")"
fi
selected="$(printf '%s' "$selected" | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//')" selected="$(printf '%s' "$selected" | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//')"
if [ -n "$regex" ]; then if [ -n "$regex" ]; then
if [[ "$selected" =~ $regex ]]; then if [[ "$selected" =~ $regex ]]; then
version="${BASH_REMATCH[$group_no]}" version="${BASH_REMATCH[$group_no]}"
else else
printf " $CYAN %-@TOOL_LABEL_WIDTH@s$RESET $RED%s$RESET\n" "${name}:" "version parse failed" REPO_LIB_TOOL_ERROR="version parse failed"
printf "%s\n" "$output" >&2 printf "%s\n" "$output" >&2
if [ "$required" = "1" ]; then return 1
exit 1
fi
return 0
fi fi
else else
version="$selected" version="$selected"
fi fi
if [ -z "$version" ]; then if [ -z "$version" ]; then
printf " $CYAN %-@TOOL_LABEL_WIDTH@s$RESET $RED%s$RESET\n" "${name}:" "empty version" REPO_LIB_TOOL_ERROR="empty version"
printf "%s\n" "$output" >&2 printf "%s\n" "$output" >&2
if [ "$required" = "1" ]; then return 1
exit 1
fi
return 0
fi fi
printf " $CYAN %-@TOOL_LABEL_WIDTH@s$RESET %s%s$RESET\n" "${name}:" "$color" "$version" REPO_LIB_TOOL_VERSION="$version"
return 0
} }
repo_lib_probe_legacy_tool() { repo_lib_capture_legacy_tool() {
local name="$1" local required="$1"
local color_name="$2" local command_name="$2"
local required="$3" local version_command="$3"
local command_name="$4"
local version_command="$5"
local color="${!color_name:-$YELLOW}"
local output="" local output=""
local version="" local version=""
REPO_LIB_TOOL_VERSION=""
REPO_LIB_TOOL_ERROR=""
if ! command -v "$command_name" >/dev/null 2>&1; then if ! command -v "$command_name" >/dev/null 2>&1; then
if [ "$required" = "1" ]; then REPO_LIB_TOOL_ERROR="missing command"
printf " $CYAN %-@TOOL_LABEL_WIDTH@s$RESET $RED%s$RESET\n" "${name}:" "missing command" return 1
exit 1
fi
return 0
fi fi
if ! output="$(sh -c "$command_name $version_command" 2>&1)"; then if ! output="$(sh -c "$command_name $version_command" 2>&1)"; then
printf " $CYAN %-@TOOL_LABEL_WIDTH@s$RESET $RED%s$RESET\n" "${name}:" "probe failed" REPO_LIB_TOOL_ERROR="probe failed"
printf "%s\n" "$output" >&2 printf "%s\n" "$output" >&2
if [ "$required" = "1" ]; then return 1
exit 1
fi
return 0
fi fi
version="$(printf '%s\n' "$output" | head -n 1 | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//')" version="$(printf '%s\n' "$output" | head -n 1 | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//')"
if [ -z "$version" ]; then if [ -z "$version" ]; then
printf " $CYAN %-@TOOL_LABEL_WIDTH@s$RESET $RED%s$RESET\n" "${name}:" "empty version" REPO_LIB_TOOL_ERROR="empty version"
printf "%s\n" "$output" >&2 printf "%s\n" "$output" >&2
return 1
fi
REPO_LIB_TOOL_VERSION="$version"
return 0
}
repo_lib_print_simple_header() {
local title_color_name="$1"
local icon="$2"
local title="$3"
local subtitle_color_name="$4"
local subtitle="$5"
local title_color="${!title_color_name:-$GREEN}"
local subtitle_color="${!subtitle_color_name:-$GRAY}"
printf "\n%s" "$title_color"
if [ -n "$icon" ]; then
printf "%s " "$icon"
fi
printf "%s%s" "$title" "$RESET"
if [ -n "$subtitle" ]; then
printf " %s%s%s" "$subtitle_color" "$subtitle" "$RESET"
fi
printf "\n\n"
}
repo_lib_print_simple_tool() {
local name="$1"
local color_name="$2"
local icon="$3"
local icon_color_name="$4"
local required="$5"
local line_no="$6"
local group_no="$7"
local regex="$8"
local match_regex="$9"
local executable="${10}"
shift 10
local color="${!color_name:-$YELLOW}"
local effective_icon_color_name="$icon_color_name"
local icon_color=""
if [ -z "$effective_icon_color_name" ]; then
effective_icon_color_name="$color_name"
fi
if repo_lib_capture_tool "$required" "$line_no" "$group_no" "$regex" "$match_regex" "$executable" "$@"; then
icon_color="${!effective_icon_color_name:-$color}"
printf " "
if [ -n "$icon" ]; then
printf "%s%s%s " "$icon_color" "$icon" "$RESET"
fi
printf "$CYAN %-@TOOL_LABEL_WIDTH@s$RESET %s%s$RESET\n" "${name}:" "$color" "$REPO_LIB_TOOL_VERSION"
else
printf " "
if [ -n "$icon" ]; then
printf "%s%s%s " "$RED" "$icon" "$RESET"
fi
printf "$CYAN %-@TOOL_LABEL_WIDTH@s$RESET $RED%s$RESET\n" "${name}:" "$REPO_LIB_TOOL_ERROR"
if [ "$required" = "1" ]; then if [ "$required" = "1" ]; then
exit 1 exit 1
fi fi
return 0 fi
}
repo_lib_print_simple_legacy_tool() {
local name="$1"
local color_name="$2"
local icon="$3"
local icon_color_name="$4"
local required="$5"
local command_name="$6"
local version_command="$7"
local color="${!color_name:-$YELLOW}"
local effective_icon_color_name="$icon_color_name"
local icon_color=""
if [ -z "$effective_icon_color_name" ]; then
effective_icon_color_name="$color_name"
fi fi
printf " $CYAN %-@TOOL_LABEL_WIDTH@s$RESET %s%s$RESET\n" "${name}:" "$color" "$version" if repo_lib_capture_legacy_tool "$required" "$command_name" "$version_command"; then
icon_color="${!effective_icon_color_name:-$color}"
printf " "
if [ -n "$icon" ]; then
printf "%s%s%s " "$icon_color" "$icon" "$RESET"
fi
printf "$CYAN %-@TOOL_LABEL_WIDTH@s$RESET %s%s$RESET\n" "${name}:" "$color" "$REPO_LIB_TOOL_VERSION"
else
printf " "
if [ -n "$icon" ]; then
printf "%s%s%s " "$RED" "$icon" "$RESET"
fi
printf "$CYAN %-@TOOL_LABEL_WIDTH@s$RESET $RED%s$RESET\n" "${name}:" "$REPO_LIB_TOOL_ERROR"
if [ "$required" = "1" ]; then
exit 1
fi
fi
}
repo_lib_print_pretty_header() {
local border_color_name="$1"
local title_color_name="$2"
local icon="$3"
local title="$4"
local subtitle_color_name="$5"
local subtitle="$6"
local border_color="${!border_color_name:-$BLUE}"
local title_color="${!title_color_name:-$GREEN}"
local subtitle_color="${!subtitle_color_name:-$GRAY}"
printf "\n%s╭─%s %s" "$border_color" "$RESET" "$title_color"
if [ -n "$icon" ]; then
printf "%s " "$icon"
fi
printf "%s%s" "$title" "$RESET"
if [ -n "$subtitle" ]; then
printf " %s%s%s" "$subtitle_color" "$subtitle" "$RESET"
fi
printf "\n"
}
repo_lib_print_pretty_row() {
local border_color_name="$1"
local icon="$2"
local icon_color_name="$3"
local label="$4"
local value="$5"
local value_color_name="$6"
local border_color="${!border_color_name:-$BLUE}"
local icon_color="${!icon_color_name:-$WHITE}"
local value_color="${!value_color_name:-$YELLOW}"
if [ -z "$icon" ]; then
icon="•"
fi
printf "%s│%s %s%s%s ${WHITE}%-@TOOL_LABEL_WIDTH@s${RESET} %s%s${RESET}\n" \
"$border_color" "$RESET" "$icon_color" "$icon" "$RESET" "$label" "$value_color" "$value"
}
repo_lib_print_pretty_tool() {
local border_color_name="$1"
local name="$2"
local color_name="$3"
local icon="$4"
local icon_color_name="$5"
local required="$6"
local line_no="$7"
local group_no="$8"
local regex="$9"
local match_regex="${10}"
local executable="${11}"
shift 11
local effective_icon_color_name="$icon_color_name"
local value_color_name="$color_name"
local value=""
if [ -z "$effective_icon_color_name" ]; then
effective_icon_color_name="$color_name"
fi
if repo_lib_capture_tool "$required" "$line_no" "$group_no" "$regex" "$match_regex" "$executable" "$@"; then
value="$REPO_LIB_TOOL_VERSION"
else
value="$REPO_LIB_TOOL_ERROR"
effective_icon_color_name="RED"
value_color_name="RED"
fi
repo_lib_print_pretty_row \
"$border_color_name" \
"$icon" \
"$effective_icon_color_name" \
"$name" \
"$value" \
"$value_color_name"
if [ "$value_color_name" = "RED" ] && [ "$required" = "1" ]; then
exit 1
fi
}
repo_lib_print_pretty_legacy_tool() {
local border_color_name="$1"
local name="$2"
local color_name="$3"
local icon="$4"
local icon_color_name="$5"
local required="$6"
local command_name="$7"
local version_command="$8"
local effective_icon_color_name="$icon_color_name"
local value_color_name="$color_name"
local value=""
if [ -z "$effective_icon_color_name" ]; then
effective_icon_color_name="$color_name"
fi
if repo_lib_capture_legacy_tool "$required" "$command_name" "$version_command"; then
value="$REPO_LIB_TOOL_VERSION"
else
value="$REPO_LIB_TOOL_ERROR"
effective_icon_color_name="RED"
value_color_name="RED"
fi
repo_lib_print_pretty_row \
"$border_color_name" \
"$icon" \
"$effective_icon_color_name" \
"$name" \
"$value" \
"$value_color_name"
if [ "$value_color_name" = "RED" ] && [ "$required" = "1" ]; then
exit 1
fi
}
repo_lib_print_pretty_footer() {
local border_color_name="$1"
local border_color="${!border_color_name:-$BLUE}"
printf "%s╰─%s\n\n" "$border_color" "$RESET"
} }
@SHELL_ENV_SCRIPT@ @SHELL_ENV_SCRIPT@
@BOOTSTRAP@ @BOOTSTRAP@
printf "\n$GREEN 🚀 Dev shell ready$RESET\n\n" @SHELL_BANNER_SCRIPT@
@TOOL_BANNER_SCRIPT@
printf "\n"
@EXTRA_SHELL_TEXT@ @EXTRA_SHELL_TEXT@

View File

@@ -35,6 +35,7 @@ repo-lib.lib.mkRepo {
}; };
checks = { }; checks = { };
lefthook = { };
release = null; # or attrset below release = null; # or attrset below
}; };
@@ -43,6 +44,7 @@ repo-lib.lib.mkRepo {
tools = [ ]; tools = [ ];
shell.packages = [ ]; shell.packages = [ ];
checks = { }; checks = { };
lefthook = { };
packages = { }; packages = { };
apps = { }; apps = { };
}; };
@@ -52,7 +54,8 @@ repo-lib.lib.mkRepo {
Generated outputs: Generated outputs:
- `devShells.${system}.default` - `devShells.${system}.default`
- `checks.${system}.pre-commit-check` - `checks.${system}.hook-check`
- `checks.${system}.lefthook-check`
- `formatter.${system}` - `formatter.${system}`
- `packages.${system}.release` when `config.release != null` - `packages.${system}.release` when `config.release != null`
- merged `packages` and `apps` from `perSystem` - merged `packages` and `apps` from `perSystem`
@@ -112,7 +115,36 @@ Defaults:
Rules: Rules:
- Only `pre-commit` and `pre-push` are supported. - Only `pre-commit` and `pre-push` are supported.
- The command is wrapped as a script and connected into `git-hooks.nix`. - The command is wrapped as a script and connected into `lefthook.nix`.
- `pre-commit` and `pre-push` commands are configured to run in parallel.
## Raw Lefthook config
Use `config.lefthook` or `perSystem.lefthook` for advanced Lefthook features that the built-in `checks` abstraction does not carry.
Example:
```nix
{
checks.tests = {
command = "go test ./...";
stage = "pre-push";
};
lefthook.pre-push.commands.tests.stage_fixed = true;
lefthook.commit-msg.commands.commitlint = {
run = "pnpm commitlint --edit {1}";
stage_fixed = true;
};
}
```
Rules:
- These attrsets are passed through to `lefthook.nix`.
- They are merged after generated checks, so they can extend generated commands.
- Prefer `checks` for the simple common case and `lefthook` for advanced fields such as `stage_fixed`, `files`, `glob`, `exclude`, `jobs`, or `scripts`.
## Tools ## Tools
@@ -136,15 +168,26 @@ Preferred shape in `perSystem.tools`:
}) })
``` ```
For a tool that should come from the host `PATH` instead of `nixpkgs`:
```nix
(repo-lib.lib.tools.fromCommand {
name = "Nix";
command = "nix";
version.args = [ "--version" ];
})
```
Helper: Helper:
```nix ```nix
repo-lib.lib.tools.simple "Nix" pkgs.nix [ "--version" ] repo-lib.lib.tools.simple "Go" pkgs.go [ "version" ]
``` ```
Tool behavior: Tool behavior:
- Tool packages are added to the shell automatically. - Tool packages are added to the shell automatically.
- Command-backed tools are probed from the existing `PATH` and are not added to the shell automatically.
- Banner probing uses absolute executable paths. - Banner probing uses absolute executable paths.
- `required = true` by default. - `required = true` by default.
- Required tool probe failure aborts shell startup. - Required tool probe failure aborts shell startup.
@@ -257,6 +300,7 @@ If the user asks for a webhook after the tag exists remotely:
- `extraPackages` - `extraPackages`
- `preToolHook` - `preToolHook`
- `extraShellHook` - `extraShellHook`
- `lefthook`
- `additionalHooks` - `additionalHooks`
- old `tools = [ { name; bin; versionCmd; color; } ]` - old `tools = [ { name; bin; versionCmd; color; } ]`
- `features.oxfmt` - `features.oxfmt`

View File

@@ -1 +1,2 @@
watch_file flake.nix
use flake use flake

1
template/.gitignore vendored
View File

@@ -1,5 +1,6 @@
.direnv/ .direnv/
.pre-commit-config.yaml .pre-commit-config.yaml
lefthook.yml
bazel-* bazel-*
build/ build/

View File

@@ -4,7 +4,7 @@
inputs = { inputs = {
nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable"; nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable";
repo-lib.url = "git+https://git.dgren.dev/eric/nix-flake-lib?ref=v3.0.0"; repo-lib.url = "git+https://git.dgren.dev/eric/nix-flake-lib?ref=refs/tags/v3.4.0";
repo-lib.inputs.nixpkgs.follows = "nixpkgs"; repo-lib.inputs.nixpkgs.follows = "nixpkgs";
}; };
@@ -40,6 +40,7 @@
}; };
formatting = { formatting = {
# nixfmt is enabled by default and wired into lefthook.
programs = { programs = {
# shfmt.enable = true; # shfmt.enable = true;
# gofmt.enable = true; # gofmt.enable = true;
@@ -50,14 +51,40 @@
}; };
}; };
checks.tests = { # These checks become lefthook commands in the generated `lefthook.yml`.
command = "echo 'No tests defined yet.'"; # repo-lib runs `pre-commit` and `pre-push` hook commands in parallel.
stage = "pre-push"; # It also sets `output = [ "failure" "summary" ]` by default.
passFilenames = false; checks = {
tests = {
command = "echo 'No tests defined yet.'";
stage = "pre-push";
passFilenames = false;
};
# fmt = {
# command = "nix fmt";
# stage = "pre-commit";
# passFilenames = false;
# };
}; };
# For advanced Lefthook fields like `stage_fixed`, use raw passthrough.
# repo-lib merges this after generated checks.
# lefthook.pre-push.commands.tests.stage_fixed = true;
# lefthook.commit-msg.commands.commitlint = {
# run = "pnpm commitlint --edit {1}";
# stage_fixed = true;
# };
# repo-lib also installs built-in hooks for:
# - treefmt / nixfmt on `pre-commit`
# - gitleaks on `pre-commit`
# - gitlint on `commit-msg`
# release = null;
release = { release = {
steps = [ steps = [
# Write a generated version file during release.
# { # {
# writeFile = { # writeFile = {
# path = "src/version.ts"; # path = "src/version.ts";
@@ -66,6 +93,8 @@
# ''; # '';
# }; # };
# } # }
# Replace a version string while preserving surrounding captures.
# { # {
# replace = { # replace = {
# path = "README.md"; # path = "README.md";
@@ -73,6 +102,16 @@
# replacement = ''\1$FULL_VERSION\2''; # replacement = ''\1$FULL_VERSION\2'';
# }; # };
# } # }
# Run any extra release step with declared runtime inputs.
# {
# run = {
# runtimeInputs = [ pkgs.git ];
# script = ''
# git status --short
# '';
# };
# }
]; ];
}; };
}; };
@@ -85,10 +124,17 @@
}: }:
{ {
tools = [ tools = [
(repo-lib.lib.tools.fromPackage { (repo-lib.lib.tools.fromCommand {
name = "Nix"; name = "Nix";
package = pkgs.nix; command = "nix";
version.args = [ "--version" ]; version = {
args = [ "--version" ];
group = 1;
};
banner = {
color = "BLUE";
icon = "";
};
}) })
# (repo-lib.lib.tools.fromPackage { # (repo-lib.lib.tools.fromPackage {
@@ -106,9 +152,16 @@
]; ];
# checks.lint = { # checks.lint = {
# command = "go test ./..."; # command = "bun test";
# stage = "pre-push"; # stage = "pre-push";
# runtimeInputs = [ pkgs.go ]; # passFilenames = false;
# runtimeInputs = [ pkgs.bun ];
# };
# checks.generated = {
# command = "git diff --exit-code";
# stage = "pre-commit";
# passFilenames = false;
# }; # };
# packages.my-tool = pkgs.writeShellApplication { # packages.my-tool = pkgs.writeShellApplication {

View File

@@ -6,6 +6,11 @@ ROOT_DIR="${REPO_LIB_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
RELEASE_TEMPLATE="$ROOT_DIR/packages/release/release.sh" RELEASE_TEMPLATE="$ROOT_DIR/packages/release/release.sh"
NIXPKGS_FLAKE_PATH="${NIXPKGS_FLAKE_PATH:-}" NIXPKGS_FLAKE_PATH="${NIXPKGS_FLAKE_PATH:-}"
CURRENT_LOG="" CURRENT_LOG=""
QC_SEEN_TAGS=()
if [[ -z "$NIXPKGS_FLAKE_PATH" ]]; then
NIXPKGS_FLAKE_PATH="$(nix eval --raw --impure --expr "(builtins.getFlake (toString ${ROOT_DIR})).inputs.nixpkgs.outPath")"
fi
fail() { fail() {
echo "[test] FAIL: $*" >&2 echo "[test] FAIL: $*" >&2
@@ -30,7 +35,7 @@ assert_contains() {
local needle="$1" local needle="$1"
local haystack_file="$2" local haystack_file="$2"
local message="$3" local message="$3"
if ! grep -Fq "$needle" "$haystack_file"; then if ! grep -Fq -- "$needle" "$haystack_file"; then
fail "$message (missing '$needle')" fail "$message (missing '$needle')"
fi fi
} }
@@ -70,6 +75,8 @@ setup_repo() {
run_capture_ok "setup_repo: git init failed" git -C "$repo_dir" init run_capture_ok "setup_repo: git init failed" git -C "$repo_dir" init
run_capture_ok "setup_repo: git config user.name failed" git -C "$repo_dir" config user.name "Release Test" run_capture_ok "setup_repo: git config user.name failed" git -C "$repo_dir" config user.name "Release Test"
run_capture_ok "setup_repo: git config user.email failed" git -C "$repo_dir" config user.email "release-test@example.com" run_capture_ok "setup_repo: git config user.email failed" git -C "$repo_dir" config user.email "release-test@example.com"
run_capture_ok "setup_repo: git config commit.gpgsign failed" git -C "$repo_dir" config commit.gpgsign false
run_capture_ok "setup_repo: git config tag.gpgsign failed" git -C "$repo_dir" config tag.gpgsign false
cat >"$repo_dir/flake.nix" <<'EOF' cat >"$repo_dir/flake.nix" <<'EOF'
{ {
@@ -213,6 +220,92 @@ write_mk_repo_flake() {
EOF EOF
} }
write_mk_repo_command_tool_flake() {
local repo_dir="$1"
cat >"$repo_dir/flake.nix" <<EOF
{
description = "mkRepo command-backed tool";
inputs = {
nixpkgs.url = "path:${NIXPKGS_FLAKE_PATH}";
repo-lib.url = "path:${ROOT_DIR}";
repo-lib.inputs.nixpkgs.follows = "nixpkgs";
};
outputs = { self, nixpkgs, repo-lib, ... }:
repo-lib.lib.mkRepo {
inherit self nixpkgs;
src = ./.;
config.release = {
steps = [ ];
};
perSystem = { system, ... }: {
tools = [
(repo-lib.lib.tools.fromCommand {
name = "Nix";
command = "nix";
version.args = [ "--version" ];
banner = {
color = "BLUE";
icon = "";
};
})
];
shell.packages = [
self.packages.\${system}.release
];
};
};
}
EOF
}
write_mk_repo_lefthook_flake() {
local repo_dir="$1"
cat >"$repo_dir/flake.nix" <<EOF
{
description = "mkRepo raw lefthook config";
inputs = {
nixpkgs.url = "path:${NIXPKGS_FLAKE_PATH}";
repo-lib.url = "path:${ROOT_DIR}";
repo-lib.inputs.nixpkgs.follows = "nixpkgs";
};
outputs = { self, nixpkgs, repo-lib, ... }:
repo-lib.lib.mkRepo {
inherit self nixpkgs;
src = ./.;
config = {
checks.tests = {
command = "echo test";
stage = "pre-push";
passFilenames = false;
};
lefthook.pre-push.commands.tests.stage_fixed = true;
release.steps = [ ];
};
};
}
EOF
}
init_git_repo() {
local repo_dir="$1"
run_capture_ok "init_git_repo: git init failed" git -C "$repo_dir" init
run_capture_ok "init_git_repo: git config user.name failed" git -C "$repo_dir" config user.name "Repo Lib Test"
run_capture_ok "init_git_repo: git config user.email failed" git -C "$repo_dir" config user.email "repo-lib-test@example.com"
run_capture_ok "init_git_repo: git add failed" git -C "$repo_dir" add flake.nix
run_capture_ok "init_git_repo: git commit failed" git -C "$repo_dir" commit -m "init"
}
write_tool_failure_flake() { write_tool_failure_flake() {
local repo_dir="$1" local repo_dir="$1"
cat >"$repo_dir/flake.nix" <<EOF cat >"$repo_dir/flake.nix" <<EOF
@@ -297,7 +390,7 @@ write_release_replace_backref_flake() {
replace = { replace = {
path = "template/flake.nix"; path = "template/flake.nix";
regex = ''^([[:space:]]*repo-lib\.url = ")[^"]*(";)$''; regex = ''^([[:space:]]*repo-lib\.url = ")[^"]*(";)$'';
replacement = ''\1git+https://example.invalid/repo-lib?ref=$FULL_TAG\2''; replacement = ''\1git+https://example.invalid/repo-lib?ref=refs/tags/\$FULL_TAG\2'';
}; };
} }
]; ];
@@ -365,7 +458,7 @@ write_legacy_flake() {
}; };
in in
{ {
inherit (env) pre-commit-check; inherit (env) lefthook-check;
} }
); );
@@ -392,7 +485,7 @@ EOF
write_template_fixture() { write_template_fixture() {
local repo_dir="$1" local repo_dir="$1"
sed \ sed \
-e "s|git+https://git.dgren.dev/eric/nix-flake-lib?ref=v[0-9.]*|path:${ROOT_DIR}|" \ -e "s|git+https://git.dgren.dev/eric/nix-flake-lib?ref=refs/tags/v[0-9.]*|path:${ROOT_DIR}|" \
-e "s|github:nixos/nixpkgs?ref=nixos-unstable|path:${NIXPKGS_FLAKE_PATH}|" \ -e "s|github:nixos/nixpkgs?ref=nixos-unstable|path:${NIXPKGS_FLAKE_PATH}|" \
"$ROOT_DIR/template/flake.nix" >"$repo_dir/flake.nix" "$ROOT_DIR/template/flake.nix" >"$repo_dir/flake.nix"
} }
@@ -496,6 +589,18 @@ qc_oracle_init() {
QC_STATE_BASE="1.0.0" QC_STATE_BASE="1.0.0"
QC_STATE_CHANNEL="stable" QC_STATE_CHANNEL="stable"
QC_STATE_PRE="" QC_STATE_PRE=""
QC_SEEN_TAGS=()
}
qc_seen_tag() {
local tag="$1"
local existing
for existing in "${QC_SEEN_TAGS[@]:-}"; do
if [[ "$existing" == "$tag" ]]; then
return 0
fi
done
return 1
} }
qc_oracle_current_full() { qc_oracle_current_full() {
@@ -576,12 +681,16 @@ qc_oracle_apply() {
if [[ $cmp_status -eq 0 || $cmp_status -eq 2 ]]; then if [[ $cmp_status -eq 0 || $cmp_status -eq 2 ]]; then
return 0 return 0
fi fi
if qc_seen_tag "v$QC_FULL_VERSION"; then
return 0
fi
QC_STATE_BASE="$QC_BASE_VERSION" QC_STATE_BASE="$QC_BASE_VERSION"
QC_STATE_CHANNEL="$QC_CHANNEL" QC_STATE_CHANNEL="$QC_CHANNEL"
QC_STATE_PRE="$QC_PRERELEASE_NUM" QC_STATE_PRE="$QC_PRERELEASE_NUM"
QC_EXPECT_SUCCESS=1 QC_EXPECT_SUCCESS=1
QC_EXPECT_VERSION="$QC_FULL_VERSION" QC_EXPECT_VERSION="$QC_FULL_VERSION"
QC_SEEN_TAGS+=("v$QC_FULL_VERSION")
return 0 return 0
fi fi
@@ -649,12 +758,16 @@ qc_oracle_apply() {
if [[ $QC_FULL_VERSION == "$current_full" ]]; then if [[ $QC_FULL_VERSION == "$current_full" ]]; then
return 0 return 0
fi fi
if qc_seen_tag "v$QC_FULL_VERSION"; then
return 0
fi
QC_STATE_BASE="$QC_BASE_VERSION" QC_STATE_BASE="$QC_BASE_VERSION"
QC_STATE_CHANNEL="$QC_CHANNEL" QC_STATE_CHANNEL="$QC_CHANNEL"
QC_STATE_PRE="$QC_PRERELEASE_NUM" QC_STATE_PRE="$QC_PRERELEASE_NUM"
QC_EXPECT_SUCCESS=1 QC_EXPECT_SUCCESS=1
QC_EXPECT_VERSION="$QC_FULL_VERSION" QC_EXPECT_VERSION="$QC_FULL_VERSION"
QC_SEEN_TAGS+=("v$QC_FULL_VERSION")
} }
run_randomized_quickcheck_cases() { run_randomized_quickcheck_cases() {
@@ -798,6 +911,38 @@ run_set_prerelease_then_full_case() {
echo "[test] PASS: $case_name" >&2 echo "[test] PASS: $case_name" >&2
} }
run_stable_then_beta_cannot_reuse_same_base_case() {
local case_name="stable release cannot go back to same-base beta"
local workdir
workdir="$(mktemp -d)"
local repo_dir="$workdir/repo"
local remote_dir="$workdir/remote.git"
CURRENT_LOG="$workdir/case.log"
prepare_case_repo "$repo_dir" "$remote_dir"
run_capture_ok "$case_name: initial beta release failed" run_release "$repo_dir" beta
run_capture_ok "$case_name: stable promotion failed" run_release "$repo_dir" full
run_capture_ok "$case_name: second beta release failed" run_release "$repo_dir" beta
local got_version
got_version="$(version_from_file "$repo_dir")"
assert_eq "1.0.2-beta.1" "$got_version" "$case_name: VERSION mismatch"
if ! git -C "$repo_dir" tag --list | grep -qx "v1.0.1"; then
fail "$case_name: expected stable tag v1.0.1 was not created"
fi
if ! git -C "$repo_dir" tag --list | grep -qx "v1.0.2-beta.1"; then
fail "$case_name: expected tag v1.0.2-beta.1 was not created"
fi
rm -rf "$workdir"
CURRENT_LOG=""
echo "[test] PASS: $case_name" >&2
}
run_set_stable_then_full_noop_case() { run_set_stable_then_full_noop_case() {
local case_name="set stable then full fails with no-op" local case_name="set stable then full fails with no-op"
@@ -985,6 +1130,65 @@ EOF
echo "[test] PASS: $case_name" >&2 echo "[test] PASS: $case_name" >&2
} }
run_version_metadata_case() {
local case_name="release metadata is preserved and exported"
local release_steps
read -r -d '' release_steps <<'EOF' || true
if [[ "$(version_meta_get desktop_backend_change_scope)" != "bindings" ]]; then
echo "metadata getter mismatch" >&2
exit 1
fi
if [[ "${VERSION_META_DESKTOP_BACKEND_CHANGE_SCOPE:-}" != "bindings" ]]; then
echo "metadata export mismatch" >&2
exit 1
fi
if [[ "${VERSION_META_DESKTOP_RELEASE_MODE:-}" != "binary" ]]; then
echo "metadata export mismatch" >&2
exit 1
fi
version_meta_set desktop_release_mode codepush
version_meta_set desktop_binary_version_min 1.0.0
version_meta_set desktop_binary_version_max "$FULL_VERSION"
version_meta_set desktop_backend_compat_id compat-123
version_meta_unset desktop_unused
EOF
local workdir
workdir="$(mktemp -d)"
local repo_dir="$workdir/repo"
local remote_dir="$workdir/remote.git"
CURRENT_LOG="$workdir/case.log"
prepare_case_repo_with_release_script "$repo_dir" "$remote_dir" "$release_steps" ":"
cat >"$repo_dir/VERSION" <<'EOF'
1.0.0
stable
0
desktop_backend_change_scope=bindings
desktop_release_mode=binary
desktop_unused=temporary
EOF
run_capture_ok "$case_name: setup commit failed" git -C "$repo_dir" add VERSION
run_capture_ok "$case_name: setup commit failed" git -C "$repo_dir" commit -m "chore: seed metadata"
run_capture_ok "$case_name: release command failed" run_release "$repo_dir" patch
assert_eq "1.0.1" "$(version_from_file "$repo_dir")" "$case_name: VERSION mismatch"
assert_contains "desktop_backend_change_scope=bindings" "$repo_dir/VERSION" "$case_name: missing preserved scope"
assert_contains "desktop_release_mode=codepush" "$repo_dir/VERSION" "$case_name: missing updated mode"
assert_contains "desktop_binary_version_min=1.0.0" "$repo_dir/VERSION" "$case_name: missing min version"
assert_contains "desktop_binary_version_max=1.0.1" "$repo_dir/VERSION" "$case_name: missing max version"
assert_contains "desktop_backend_compat_id=compat-123" "$repo_dir/VERSION" "$case_name: missing compat id"
if grep -Fq "desktop_unused=temporary" "$repo_dir/VERSION"; then
fail "$case_name: unset metadata key was preserved"
fi
rm -rf "$workdir"
CURRENT_LOG=""
echo "[test] PASS: $case_name" >&2
}
run_mk_repo_case() { run_mk_repo_case() {
local case_name="mkRepo exposes outputs and auto-installs tools" local case_name="mkRepo exposes outputs and auto-installs tools"
local workdir local workdir
@@ -994,13 +1198,105 @@ run_mk_repo_case() {
write_mk_repo_flake "$repo_dir" write_mk_repo_flake "$repo_dir"
CURRENT_LOG="$workdir/mk-repo.log" CURRENT_LOG="$workdir/mk-repo.log"
run_capture_ok "$case_name: flake show failed" nix flake show --json "$repo_dir" run_capture_ok "$case_name: flake show failed" nix flake show --json --no-write-lock-file "$repo_dir"
assert_contains '"pre-commit-check"' "$CURRENT_LOG" "$case_name: missing pre-commit-check" assert_contains '"lefthook-check"' "$CURRENT_LOG" "$case_name: missing lefthook-check"
assert_contains '"release"' "$CURRENT_LOG" "$case_name: missing release package" assert_contains '"release"' "$CURRENT_LOG" "$case_name: missing release package"
assert_contains '"example"' "$CURRENT_LOG" "$case_name: missing merged package" assert_contains '"example"' "$CURRENT_LOG" "$case_name: missing merged package"
run_capture_ok "$case_name: tool package should be available in shell" nix develop "$repo_dir" -c hello --version run_capture_ok "$case_name: tool package should be available in shell" bash -c 'cd "$1" && nix develop --no-write-lock-file . -c hello --version' _ "$repo_dir"
run_capture_ok "$case_name: release package should be available in shell" nix develop "$repo_dir" -c sh -c 'command -v release >/dev/null' run_capture_ok "$case_name: release package should be available in shell" bash -c 'cd "$1" && nix develop --no-write-lock-file . -c sh -c "command -v release >/dev/null"' _ "$repo_dir"
rm -rf "$workdir"
CURRENT_LOG=""
echo "[test] PASS: $case_name" >&2
}
run_mk_repo_command_tool_case() {
local case_name="mkRepo supports command-backed tools from PATH"
local workdir
workdir="$(mktemp -d)"
local repo_dir="$workdir/mk-repo-command-tool"
mkdir -p "$repo_dir"
write_mk_repo_command_tool_flake "$repo_dir"
CURRENT_LOG="$workdir/mk-repo-command-tool.log"
run_capture_ok "$case_name: flake show failed" nix flake show --json --no-write-lock-file "$repo_dir"
assert_contains '"lefthook-check"' "$CURRENT_LOG" "$case_name: missing lefthook-check"
assert_contains '"release"' "$CURRENT_LOG" "$case_name: missing release package"
run_capture_ok "$case_name: system nix should be available in shell" bash -c 'cd "$1" && nix develop --no-write-lock-file . -c nix --version' _ "$repo_dir"
assert_contains "" "$CURRENT_LOG" "$case_name: missing tool icon in banner"
run_capture_ok "$case_name: release package should be available in shell" bash -c 'cd "$1" && nix develop --no-write-lock-file . -c sh -c "command -v release >/dev/null"' _ "$repo_dir"
rm -rf "$workdir"
CURRENT_LOG=""
echo "[test] PASS: $case_name" >&2
}
run_mk_repo_lefthook_case() {
local case_name="mkRepo exposes raw lefthook config for advanced hook fields"
local workdir
workdir="$(mktemp -d)"
local repo_dir="$workdir/mk-repo-lefthook"
local system
local derivation_json="$workdir/lefthook-run.drv.json"
local lefthook_yml_drv
local lefthook_yml_json="$workdir/lefthook-yml.drv.json"
mkdir -p "$repo_dir"
write_mk_repo_lefthook_flake "$repo_dir"
CURRENT_LOG="$workdir/mk-repo-lefthook.log"
system="$(nix eval --raw --impure --expr 'builtins.currentSystem')"
run_capture_ok "$case_name: flake show failed" nix flake show --json --no-write-lock-file "$repo_dir"
run_capture_ok "$case_name: lefthook derivation show failed" bash -c 'nix derivation show "$1" >"$2"' _ "$repo_dir#checks.${system}.lefthook-check" "$derivation_json"
lefthook_yml_drv="$(perl -0ne 'print "/nix/store/$1\n" if /"([a-z0-9]{32}-lefthook\.yml\.drv)"/' "$derivation_json")"
if [[ -z "$lefthook_yml_drv" ]]; then
fail "$case_name: could not locate lefthook.yml derivation"
fi
run_capture_ok "$case_name: lefthook.yml derivation show failed" bash -c 'nix derivation show "$1" >"$2"' _ "$lefthook_yml_drv" "$lefthook_yml_json"
assert_contains '\"pre-push\":{\"commands\":{\"tests\":{' "$lefthook_yml_json" "$case_name: generated check missing from pre-push"
assert_contains 'repo-lib-check-tests' "$lefthook_yml_json" "$case_name: generated check command missing from lefthook config"
assert_contains '\"output\":[\"failure\",\"summary\"]' "$lefthook_yml_json" "$case_name: lefthook output config missing"
assert_contains '\"stage_fixed\":true' "$lefthook_yml_json" "$case_name: stage_fixed missing from lefthook config"
rm -rf "$workdir"
CURRENT_LOG=""
echo "[test] PASS: $case_name" >&2
}
run_mk_repo_treefmt_hook_case() {
local case_name="mkRepo configures treefmt and lefthook for dev shell hooks"
local workdir
workdir="$(mktemp -d)"
local repo_dir="$workdir/mk-repo-treefmt"
local system
local derivation_json="$workdir/treefmt-hook.drv.json"
local lefthook_yml_drv
local lefthook_yml_json="$workdir/treefmt-hook-yml.drv.json"
mkdir -p "$repo_dir"
write_mk_repo_flake "$repo_dir"
CURRENT_LOG="$workdir/mk-repo-treefmt.log"
init_git_repo "$repo_dir"
run_capture_ok "$case_name: treefmt should be available in shell" bash -c 'cd "$1" && nix develop --no-write-lock-file . -c sh -c '"'"'printf "%s\n" "$LEFTHOOK_BIN" && command -v treefmt'"'"'' _ "$repo_dir"
assert_contains 'lefthook-dumb-term' "$CURRENT_LOG" "$case_name: LEFTHOOK_BIN wrapper missing"
assert_contains '/bin/treefmt' "$CURRENT_LOG" "$case_name: treefmt missing from shell"
system="$(nix eval --raw --impure --expr 'builtins.currentSystem')"
run_capture_ok "$case_name: formatting check derivation show failed" bash -c 'nix derivation show "$1" >"$2"' _ "$repo_dir#checks.${system}.formatting-check" "$workdir/formatting-check.drv.json"
run_capture_ok "$case_name: lefthook derivation show failed" bash -c 'nix derivation show "$1" >"$2"' _ "$repo_dir#checks.${system}.lefthook-check" "$derivation_json"
lefthook_yml_drv="$(perl -0ne 'print "/nix/store/$1\n" if /"([a-z0-9]{32}-lefthook\.yml\.drv)"/' "$derivation_json")"
if [[ -z "$lefthook_yml_drv" ]]; then
fail "$case_name: could not locate lefthook.yml derivation"
fi
run_capture_ok "$case_name: lefthook.yml derivation show failed" bash -c 'nix derivation show "$1" >"$2"' _ "$lefthook_yml_drv" "$lefthook_yml_json"
assert_contains '--no-cache {staged_files}' "$lefthook_yml_json" "$case_name: treefmt hook missing staged-file format command"
assert_contains '\"stage_fixed\":true' "$lefthook_yml_json" "$case_name: treefmt hook should re-stage formatted files"
rm -rf "$workdir" rm -rf "$workdir"
CURRENT_LOG="" CURRENT_LOG=""
@@ -1016,7 +1312,7 @@ run_mk_repo_tool_failure_case() {
write_tool_failure_flake "$repo_dir" write_tool_failure_flake "$repo_dir"
CURRENT_LOG="$workdir/tool-failure.log" CURRENT_LOG="$workdir/tool-failure.log"
run_expect_failure "$case_name: shell startup should fail" nix develop "$repo_dir" -c true run_expect_failure "$case_name: shell startup should fail" bash -c 'cd "$1" && nix develop . -c true' _ "$repo_dir"
assert_contains "probe failed" "$CURRENT_LOG" "$case_name: failure reason missing" assert_contains "probe failed" "$CURRENT_LOG" "$case_name: failure reason missing"
rm -rf "$workdir" rm -rf "$workdir"
@@ -1051,7 +1347,7 @@ run_legacy_api_eval_case() {
CURRENT_LOG="$workdir/legacy.log" CURRENT_LOG="$workdir/legacy.log"
run_capture_ok "$case_name: flake show failed" nix flake show --json "$repo_dir" run_capture_ok "$case_name: flake show failed" nix flake show --json "$repo_dir"
assert_contains '"pre-commit-check"' "$CURRENT_LOG" "$case_name: missing pre-commit-check" assert_contains '"lefthook-check"' "$CURRENT_LOG" "$case_name: missing lefthook-check"
assert_contains '"release"' "$CURRENT_LOG" "$case_name: missing release package" assert_contains '"release"' "$CURRENT_LOG" "$case_name: missing release package"
rm -rf "$workdir" rm -rf "$workdir"
@@ -1069,7 +1365,7 @@ run_template_eval_case() {
CURRENT_LOG="$workdir/template.log" CURRENT_LOG="$workdir/template.log"
run_capture_ok "$case_name: flake show failed" nix flake show --json "$repo_dir" run_capture_ok "$case_name: flake show failed" nix flake show --json "$repo_dir"
assert_contains '"pre-commit-check"' "$CURRENT_LOG" "$case_name: missing pre-commit-check" assert_contains '"lefthook-check"' "$CURRENT_LOG" "$case_name: missing lefthook-check"
assert_contains '"release"' "$CURRENT_LOG" "$case_name: missing release package" assert_contains '"release"' "$CURRENT_LOG" "$case_name: missing release package"
rm -rf "$workdir" rm -rf "$workdir"
@@ -1090,7 +1386,7 @@ run_release_replace_backref_case() {
cat >"$repo_dir/template/flake.nix" <<'EOF' cat >"$repo_dir/template/flake.nix" <<'EOF'
{ {
inputs = { inputs = {
repo-lib.url = "git+https://git.dgren.dev/eric/nix-flake-lib?ref=v0.0.0"; repo-lib.url = "git+https://git.dgren.dev/eric/nix-flake-lib?ref=refs/tags/v0.0.0";
}; };
} }
EOF EOF
@@ -1098,10 +1394,10 @@ EOF
run_capture_ok "$case_name: setup commit failed" git -C "$repo_dir" add flake.nix template/flake.nix run_capture_ok "$case_name: setup commit failed" git -C "$repo_dir" add flake.nix template/flake.nix
run_capture_ok "$case_name: setup commit failed" git -C "$repo_dir" commit -m "chore: add replace fixture" run_capture_ok "$case_name: setup commit failed" git -C "$repo_dir" commit -m "chore: add replace fixture"
run_capture_ok "$case_name: nix run release failed" bash -c 'cd "$1" && nix run .#release -- patch' _ "$repo_dir" run_capture_ok "$case_name: nix run release failed" bash -c 'cd "$1" && nix run --no-write-lock-file .#release -- patch' _ "$repo_dir"
assert_contains 'repo-lib.url = "git+https://example.invalid/repo-lib?ref=v1.0.1";' "$repo_dir/template/flake.nix" "$case_name: replacement did not preserve captures" assert_contains 'repo-lib.url = "git+https://example.invalid/repo-lib?ref=refs/tags/v1.0.1";' "$repo_dir/template/flake.nix" "$case_name: replacement did not preserve captures"
if grep -Fq '\1git+https://example.invalid/repo-lib?ref=v1.0.1\2' "$repo_dir/template/flake.nix"; then if grep -Fq '\1git+https://example.invalid/repo-lib?ref=refs/tags/v1.0.1\2' "$repo_dir/template/flake.nix"; then
fail "$case_name: replacement left literal backreferences in output" fail "$case_name: replacement left literal backreferences in output"
fi fi
@@ -1113,16 +1409,23 @@ EOF
run_case "channel-only from stable bumps patch" "beta" "1.0.1-beta.1" run_case "channel-only from stable bumps patch" "beta" "1.0.1-beta.1"
run_case "explicit minor bump keeps requested bump" "minor beta" "1.1.0-beta.1" run_case "explicit minor bump keeps requested bump" "minor beta" "1.1.0-beta.1"
run_set_prerelease_then_full_case run_set_prerelease_then_full_case
run_stable_then_beta_cannot_reuse_same_base_case
run_set_stable_then_full_noop_case run_set_stable_then_full_noop_case
run_set_stable_from_prerelease_requires_full_case run_set_stable_from_prerelease_requires_full_case
run_patch_stable_from_prerelease_requires_full_case run_patch_stable_from_prerelease_requires_full_case
run_structured_release_steps_case run_structured_release_steps_case
run_version_metadata_case
run_mk_repo_case run_mk_repo_case
run_mk_repo_command_tool_case
run_mk_repo_lefthook_case
run_mk_repo_treefmt_hook_case
run_mk_repo_tool_failure_case run_mk_repo_tool_failure_case
run_impure_bootstrap_validation_case run_impure_bootstrap_validation_case
run_legacy_api_eval_case run_legacy_api_eval_case
run_template_eval_case run_template_eval_case
run_release_replace_backref_case run_release_replace_backref_case
run_randomized_quickcheck_cases if [[ "${QUICKCHECK:-0}" == "1" ]]; then
run_randomized_quickcheck_cases
fi
echo "[test] All release tests passed" >&2 echo "[test] All release tests passed" >&2