8 Commits

Author SHA1 Message Date
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
13 changed files with 1112 additions and 232 deletions

1
.gitignore vendored
View File

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

View File

@@ -15,7 +15,7 @@
## Use the template
```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.3.0#default' --refresh
```
## 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:
```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.3.0";
inputs.repo-lib.inputs.nixpkgs.follows = "nixpkgs";
```
@@ -49,10 +49,10 @@ outputs = { self, nixpkgs, repo-lib, ... }:
perSystem = { pkgs, system, ... }: {
tools = [
(repo-lib.lib.tools.fromPackage {
(repo-lib.lib.tools.fromCommand {
name = "Nix";
package = pkgs.nix;
version.args = [ "--version" ];
command = "nix";
})
];
@@ -66,14 +66,23 @@ outputs = { self, nixpkgs, repo-lib, ... }:
`mkRepo` generates:
- `devShells.${system}.default`
- `checks.${system}.pre-commit-check`
- `checks.${system}.hook-check`
- `checks.${system}.lefthook-check`
- `formatter.${system}`
- `packages.${system}.release` when `config.release != null`
- merged `packages` and `apps` from `perSystem`
Checks are installed through `lefthook`, with `pre-commit` and `pre-push` commands configured to run in parallel.
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
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
(repo-lib.lib.tools.fromPackage {
@@ -86,6 +95,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.
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
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.3.0
stable
0

77
flake.lock generated
View File

@@ -1,79 +1,26 @@
{
"nodes": {
"flake-compat": {
"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": {
"lefthook-nix": {
"inputs": {
"nixpkgs": [
"git-hooks",
"nixpkgs"
]
},
"locked": {
"lastModified": 1709087332,
"narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=",
"owner": "hercules-ci",
"repo": "gitignore.nix",
"rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
"lastModified": 1770377107,
"narHash": "sha256-/QEXSDeAo5RK81PtM0yDhmt9k3v1/pse/jsrT1yXNhU=",
"owner": "sudosubin",
"repo": "lefthook.nix",
"rev": "9cdaf7ce95ae77cbabc5b556bdd35d3cf0b849f5",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "gitignore.nix",
"owner": "sudosubin",
"repo": "lefthook.nix",
"type": "github"
}
},
"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": {
"lastModified": 1772542754,
"narHash": "sha256-WGV2hy+VIeQsYXpsLjdr4GvHv5eECMISX1zKLTedhdg=",
@@ -89,7 +36,7 @@
"type": "github"
}
},
"nixpkgs_3": {
"nixpkgs_2": {
"locked": {
"lastModified": 1770107345,
"narHash": "sha256-tbS0Ebx2PiA1FRW8mt8oejR0qMXmziJmPaU1d4kYY9g=",
@@ -107,14 +54,14 @@
},
"root": {
"inputs": {
"git-hooks": "git-hooks",
"nixpkgs": "nixpkgs_2",
"lefthook-nix": "lefthook-nix",
"nixpkgs": "nixpkgs",
"treefmt-nix": "treefmt-nix"
}
},
"treefmt-nix": {
"inputs": {
"nixpkgs": "nixpkgs_3"
"nixpkgs": "nixpkgs_2"
},
"locked": {
"lastModified": 1770228511,

View File

@@ -4,7 +4,8 @@
inputs = {
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";
};
@@ -13,13 +14,14 @@
self,
nixpkgs,
treefmt-nix,
git-hooks,
lefthook-nix,
...
}:
let
lib = nixpkgs.lib;
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;
shellHookTemplatePath = ./packages/repo-lib/shell-hook.sh;
};
@@ -36,21 +38,21 @@
replace = {
path = "template/flake.nix";
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 = {
path = "README.md";
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 = {
path = "README.md";
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 = [
(repoLib.tools.fromPackage {
(repoLib.tools.fromCommand {
name = "Nix";
package = pkgs.nix;
version.args = [ "--version" ];
command = "nix";
version = {
args = [ "--version" ];
group = 1;
};
banner = {
color = "BLUE";
icon = "";
};
})
];
@@ -87,10 +96,10 @@
nativeBuildInputs = with pkgs; [
bash
git
nix
gnused
coreutils
gnugrep
nix
perl
];
}
@@ -98,7 +107,6 @@
export REPO_LIB_ROOT=${./.}
export NIXPKGS_FLAKE_PATH=${nixpkgs}
export HOME="$TMPDIR"
export NIX_CONFIG="experimental-features = nix-command flakes"
${pkgs.bash}/bin/bash ${./tests/release.sh}
touch "$out"
'';

View File

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

View File

@@ -6,6 +6,8 @@ ROOT_DIR="$(git rev-parse --show-toplevel)"
GITLINT_FILE="$ROOT_DIR/.gitlint"
START_HEAD=""
CREATED_TAG=""
VERSION_META_LINES=()
VERSION_META_EXPORT_NAMES=()
# ── logging ────────────────────────────────────────────────────────────────
@@ -168,6 +170,119 @@ compute_full_version() {
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 ────────────────────────────────────────────────────────────────
get_gitlint_title_regex() {
@@ -205,6 +320,8 @@ run_release_steps() {
# and never contaminates the stdout of do_read_version.
init_version_file() {
if [[ -f "$ROOT_DIR/VERSION" ]]; then
load_version_metadata
export_version_metadata
return 0
fi
@@ -233,11 +350,16 @@ init_version_file() {
n_to_write="0"
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"
}
do_read_version() {
load_version_metadata
export_version_metadata
local base_line channel_line n_line
base_line="$(sed -n '1p' "$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"
n_to_write="0"
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 ─────────────────────────────────────────────────────

View File

@@ -1,7 +1,7 @@
{
nixpkgs,
treefmt-nix,
git-hooks,
lefthookNix,
releaseScriptPath,
shellHookTemplatePath,
}:
@@ -44,11 +44,37 @@ let
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 =
pkgs: tool:
let
version = {
args = [ "--version" ];
match = null;
regex = null;
group = 0;
line = 1;
@@ -56,22 +82,26 @@ let
// (tool.version or { });
banner = {
color = "YELLOW";
icon = null;
iconColor = null;
}
// (tool.banner or { });
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}"
else
"${lib.getExe tool.package}";
in
if !(tool ? package) then
throw "repo-lib: tool '${tool.name or "<unnamed>"}' is missing 'package'"
if !(tool ? command && tool.command != null) && !(tool ? package) then
throw "repo-lib: tool '${tool.name or "<unnamed>"}' is missing 'package' or 'command'"
else
{
kind = "strict";
inherit executable version banner;
name = tool.name;
package = tool.package;
package = tool.package or null;
required = tool.required or true;
};
@@ -87,11 +117,13 @@ let
versionCommand = tool.versionCmd or "--version";
banner = {
color = tool.color or "YELLOW";
icon = tool.icon or null;
iconColor = tool.iconColor or null;
};
required = tool.required or false;
};
normalizeCheck =
checkToLefthookConfig =
pkgs: name: rawCheck:
let
check = {
@@ -120,13 +152,85 @@ let
then
throw "repo-lib: check '${name}' has unsupported stage '${check.stage}'"
else
{
enable = true;
entry = "${wrapper}/bin/${wrapperName}";
pass_filenames = check.passFilenames;
stages = [ check.stage ];
lib.setAttrByPath [ check.stage "commands" name ] {
run = "${wrapper}/bin/${wrapperName}${hookStageFileArgs check.stage check.passFilenames}";
};
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 =
step:
if step ? writeFile then
@@ -245,10 +349,10 @@ let
buildShellHook =
{
preCommitShellHook,
hooksShellHook,
shellEnvScript,
bootstrap,
toolBannerScript,
shellBannerScript,
extraShellText,
toolLabelWidth,
}:
@@ -257,19 +361,19 @@ let
in
builtins.replaceStrings
[
"\${pre-commit-check.shellHook}"
"@HOOKS_SHELL_HOOK@"
"@TOOL_LABEL_WIDTH@"
"@SHELL_ENV_SCRIPT@"
"@BOOTSTRAP@"
"@TOOL_BANNER_SCRIPT@"
"@SHELL_BANNER_SCRIPT@"
"@EXTRA_SHELL_TEXT@"
]
[
preCommitShellHook
hooksShellHook
(toString toolLabelWidth)
shellEnvScript
bootstrap
toolBannerScript
shellBannerScript
extraShellText
]
template;
@@ -286,9 +390,11 @@ let
env = { };
extraShellText = "";
bootstrap = "";
banner = defaultShellBanner;
},
checkSpecs ? { },
rawHookEntries ? { },
lefthookConfig ? { },
extraPackages ? [ ],
}:
let
@@ -310,25 +416,31 @@ let
settings.formatter = { } // formatting.settings;
};
normalizedChecks = lib.mapAttrs (name: check: normalizeCheck pkgs name check) checkSpecs;
hooks = mergeUniqueAttrs "hook" rawHookEntries normalizedChecks;
pre-commit-check = git-hooks.lib.${system}.run {
normalizedLefthookConfig = normalizeLefthookConfig "lefthook config" lefthookConfig;
lefthookCheck = lefthookNix.lib.${system}.run {
inherit src;
hooks = {
treefmt = {
enable = true;
entry = "${treefmtEval.config.build.wrapper}/bin/treefmt --ci";
pass_filenames = true;
config = lib.foldl' lib.recursiveUpdate { } (
[
(parallelHookStageConfig "pre-commit")
(parallelHookStageConfig "pre-push")
(lib.setAttrByPath [ "pre-commit" "commands" "treefmt" ] {
run = "${treefmtEval.config.build.wrapper}/bin/treefmt --ci {staged_files}";
})
(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 ]
);
};
gitlint.enable = true;
gitleaks = {
enable = true;
entry = "${pkgs.gitleaks}/bin/gitleaks protect --staged";
pass_filenames = false;
};
}
// hooks;
selectedCheckOutputs = {
hook-check = lefthookCheck;
lefthook-check = lefthookCheck;
};
toolNames = builtins.map (tool: tool.name) tools;
@@ -345,45 +457,112 @@ let
) shellConfig.env
);
toolBannerScript = lib.concatMapStrings (
banner = normalizeShellBanner (shellConfig.banner or { });
shellBannerScript =
if banner.style == "pretty" then
''
repo_lib_print_pretty_header \
${lib.escapeShellArg banner.borderColor} \
${lib.escapeShellArg banner.titleColor} \
${lib.escapeShellArg banner.icon} \
${lib.escapeShellArg banner.title} \
${lib.escapeShellArg banner.subtitleColor} \
${lib.escapeShellArg banner.subtitle}
''
+ lib.concatMapStrings (
tool:
if tool.kind == "strict" then
''
repo_lib_probe_tool \
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 (tool.version.regex or "")} \
${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_probe_legacy_tool \
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;
) tools
+ ''
repo_lib_print_pretty_footer \
${lib.escapeShellArg banner.borderColor}
''
else
''
repo_lib_print_simple_header \
${lib.escapeShellArg banner.titleColor} \
${lib.escapeShellArg banner.icon} \
${lib.escapeShellArg banner.title} \
${lib.escapeShellArg banner.subtitleColor} \
${lib.escapeShellArg banner.subtitle}
''
+ 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
{
inherit pre-commit-check;
checks = selectedCheckOutputs;
formatter = treefmtEval.config.build.wrapper;
shell = pkgs.mkShell {
packages = lib.unique (selectedStandardPackages ++ extraPackages ++ toolPackages);
buildInputs = pre-commit-check.enabledPackages;
packages = lib.unique (
selectedStandardPackages ++ extraPackages ++ toolPackages ++ [ pkgs.lefthook ]
);
shellHook = buildShellHook {
preCommitShellHook = pre-commit-check.shellHook;
inherit toolLabelWidth shellEnvScript toolBannerScript;
hooksShellHook = lefthookCheck.shellHook;
inherit toolLabelWidth shellEnvScript shellBannerScript;
bootstrap = shellConfig.bootstrap;
extraShellText = shellConfig.extraShellText;
};
};
};
}
// selectedCheckOutputs;
in
rec {
systems = {
@@ -411,6 +590,24 @@ rec {
;
};
fromCommand =
{
name,
command,
version ? { },
banner ? { },
required ? true,
}:
{
inherit
name
command
version
banner
required
;
};
simple =
name: package: args:
fromPackage {
@@ -429,12 +626,14 @@ rec {
extraShellText = "";
allowImpureBootstrap = false;
bootstrap = "";
banner = { };
};
formatting = {
programs = { };
settings = { };
};
checks = { };
lefthook = { };
release = null;
} rawConfig;
release =
@@ -452,7 +651,13 @@ rec {
if merged.shell.bootstrap != "" && !merged.shell.allowImpureBootstrap then
throw "repo-lib: config.shell.bootstrap requires config.shell.allowImpureBootstrap = true"
else
merged // { inherit release; };
merged
// {
inherit release;
shell = merged.shell // {
banner = normalizeShellBanner merged.shell.banner;
};
};
mkDevShell =
{
@@ -463,6 +668,7 @@ rec {
preToolHook ? "",
extraShellHook ? "",
additionalHooks ? { },
lefthook ? { },
tools ? [ ],
includeStandardPackages ? true,
formatters ? { },
@@ -487,6 +693,7 @@ rec {
extraShellText = extraShellHook;
allowImpureBootstrap = true;
bootstrap = preToolHook;
banner = defaultShellBanner;
};
in
if duplicateToolNames != [ ] then
@@ -501,6 +708,7 @@ rec {
;
formatting = normalizedFormatting;
rawHookEntries = additionalHooks;
lefthookConfig = lefthook;
shellConfig = shellConfig;
tools = legacyTools;
extraPackages =
@@ -580,6 +788,7 @@ rec {
tools = [ ];
shell = { };
checks = { };
lefthook = { };
packages = { };
apps = { };
}
@@ -592,6 +801,9 @@ rec {
strictTools = builtins.map (tool: normalizeStrictTool pkgs tool) perSystemResult.tools;
duplicateToolNames = duplicateStrings (builtins.map (tool: tool.name) strictTools);
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 { });
env =
if duplicateToolNames != [ ] then
@@ -607,6 +819,7 @@ rec {
formatting = normalizedConfig.formatting;
tools = strictTools;
checkSpecs = mergedChecks;
lefthookConfig = mergedLefthookConfig;
shellConfig = shellConfig;
extraPackages = perSystemResult.shell.packages or [ ];
};
@@ -638,9 +851,7 @@ rec {
default = systemResults.${system}.env.shell;
});
checks = lib.genAttrs systems (system: {
inherit (systemResults.${system}.env) pre-commit-check;
});
checks = lib.genAttrs systems (system: systemResults.${system}.env.checks);
formatter = lib.genAttrs systems (system: systemResults.${system}.env.formatter);
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
command -v tput >/dev/null 2>&1 && tput clear || printf '\033c'
@@ -16,107 +16,326 @@ BOLD=$'\033[1m'
UNDERLINE=$'\033[4m'
RESET=$'\033[0m'
repo_lib_probe_tool() {
local name="$1"
local color_name="$2"
local required="$3"
local line_no="$4"
local group_no="$5"
local regex="$6"
local executable="$7"
shift 7
REPO_LIB_TOOL_VERSION=""
REPO_LIB_TOOL_ERROR=""
repo_lib_capture_tool() {
local required="$1"
local line_no="$2"
local group_no="$3"
local regex="$4"
local match_regex="$5"
local executable="$6"
shift 6
local color="${!color_name:-$YELLOW}"
local output=""
local selected=""
local version=""
REPO_LIB_TOOL_VERSION=""
REPO_LIB_TOOL_ERROR=""
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
if [ "$required" = "1" ]; then
exit 1
fi
return 0
return 1
fi
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:]]+$//')"
if [ -n "$regex" ]; then
if [[ "$selected" =~ $regex ]]; then
version="${BASH_REMATCH[$group_no]}"
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
if [ "$required" = "1" ]; then
exit 1
fi
return 0
return 1
fi
else
version="$selected"
fi
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
if [ "$required" = "1" ]; then
exit 1
fi
return 0
return 1
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() {
local name="$1"
local color_name="$2"
local required="$3"
local command_name="$4"
local version_command="$5"
repo_lib_capture_legacy_tool() {
local required="$1"
local command_name="$2"
local version_command="$3"
local color="${!color_name:-$YELLOW}"
local output=""
local version=""
REPO_LIB_TOOL_VERSION=""
REPO_LIB_TOOL_ERROR=""
if ! command -v "$command_name" >/dev/null 2>&1; then
if [ "$required" = "1" ]; then
printf " $CYAN %-@TOOL_LABEL_WIDTH@s$RESET $RED%s$RESET\n" "${name}:" "missing command"
exit 1
fi
return 0
REPO_LIB_TOOL_ERROR="missing command"
return 1
fi
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
if [ "$required" = "1" ]; then
exit 1
fi
return 0
return 1
fi
version="$(printf '%s\n' "$output" | head -n 1 | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//')"
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
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
exit 1
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
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@
@BOOTSTRAP@
printf "\n$GREEN 🚀 Dev shell ready$RESET\n\n"
@TOOL_BANNER_SCRIPT@
printf "\n"
@SHELL_BANNER_SCRIPT@
@EXTRA_SHELL_TEXT@

View File

@@ -35,6 +35,7 @@ repo-lib.lib.mkRepo {
};
checks = { };
lefthook = { };
release = null; # or attrset below
};
@@ -43,6 +44,7 @@ repo-lib.lib.mkRepo {
tools = [ ];
shell.packages = [ ];
checks = { };
lefthook = { };
packages = { };
apps = { };
};
@@ -52,7 +54,8 @@ repo-lib.lib.mkRepo {
Generated outputs:
- `devShells.${system}.default`
- `checks.${system}.pre-commit-check`
- `checks.${system}.hook-check`
- `checks.${system}.lefthook-check`
- `formatter.${system}`
- `packages.${system}.release` when `config.release != null`
- merged `packages` and `apps` from `perSystem`
@@ -112,7 +115,36 @@ Defaults:
Rules:
- 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
@@ -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:
```nix
repo-lib.lib.tools.simple "Nix" pkgs.nix [ "--version" ]
repo-lib.lib.tools.simple "Go" pkgs.go [ "version" ]
```
Tool behavior:
- 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.
- `required = true` by default.
- Required tool probe failure aborts shell startup.
@@ -257,6 +300,7 @@ If the user asks for a webhook after the tag exists remotely:
- `extraPackages`
- `preToolHook`
- `extraShellHook`
- `lefthook`
- `additionalHooks`
- old `tools = [ { name; bin; versionCmd; color; } ]`
- `features.oxfmt`

1
template/.gitignore vendored
View File

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

View File

@@ -4,7 +4,7 @@
inputs = {
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.3.0";
repo-lib.inputs.nixpkgs.follows = "nixpkgs";
};
@@ -40,6 +40,7 @@
};
formatting = {
# nixfmt is enabled by default and wired into lefthook.
programs = {
# shfmt.enable = true;
# gofmt.enable = true;
@@ -50,14 +51,39 @@
};
};
checks.tests = {
# These checks become lefthook commands in the generated `lefthook.yml`.
# repo-lib runs `pre-commit` and `pre-push` hook commands in parallel.
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 = {
steps = [
# Write a generated version file during release.
# {
# writeFile = {
# path = "src/version.ts";
@@ -66,6 +92,8 @@
# '';
# };
# }
# Replace a version string while preserving surrounding captures.
# {
# replace = {
# path = "README.md";
@@ -73,6 +101,16 @@
# replacement = ''\1$FULL_VERSION\2'';
# };
# }
# Run any extra release step with declared runtime inputs.
# {
# run = {
# runtimeInputs = [ pkgs.git ];
# script = ''
# git status --short
# '';
# };
# }
];
};
};
@@ -85,10 +123,17 @@
}:
{
tools = [
(repo-lib.lib.tools.fromPackage {
(repo-lib.lib.tools.fromCommand {
name = "Nix";
package = pkgs.nix;
version.args = [ "--version" ];
command = "nix";
version = {
args = [ "--version" ];
group = 1;
};
banner = {
color = "BLUE";
icon = "";
};
})
# (repo-lib.lib.tools.fromPackage {
@@ -106,9 +151,16 @@
];
# checks.lint = {
# command = "go test ./...";
# command = "bun test";
# 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 {

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"
NIXPKGS_FLAKE_PATH="${NIXPKGS_FLAKE_PATH:-}"
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() {
echo "[test] FAIL: $*" >&2
@@ -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 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 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'
{
@@ -213,6 +220,82 @@ write_mk_repo_flake() {
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
}
write_tool_failure_flake() {
local repo_dir="$1"
cat >"$repo_dir/flake.nix" <<EOF
@@ -297,7 +380,7 @@ write_release_replace_backref_flake() {
replace = {
path = "template/flake.nix";
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 +448,7 @@ write_legacy_flake() {
};
in
{
inherit (env) pre-commit-check;
inherit (env) lefthook-check;
}
);
@@ -392,7 +475,7 @@ EOF
write_template_fixture() {
local repo_dir="$1"
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}|" \
"$ROOT_DIR/template/flake.nix" >"$repo_dir/flake.nix"
}
@@ -496,6 +579,18 @@ qc_oracle_init() {
QC_STATE_BASE="1.0.0"
QC_STATE_CHANNEL="stable"
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() {
@@ -576,12 +671,16 @@ qc_oracle_apply() {
if [[ $cmp_status -eq 0 || $cmp_status -eq 2 ]]; then
return 0
fi
if qc_seen_tag "v$QC_FULL_VERSION"; then
return 0
fi
QC_STATE_BASE="$QC_BASE_VERSION"
QC_STATE_CHANNEL="$QC_CHANNEL"
QC_STATE_PRE="$QC_PRERELEASE_NUM"
QC_EXPECT_SUCCESS=1
QC_EXPECT_VERSION="$QC_FULL_VERSION"
QC_SEEN_TAGS+=("v$QC_FULL_VERSION")
return 0
fi
@@ -649,12 +748,16 @@ qc_oracle_apply() {
if [[ $QC_FULL_VERSION == "$current_full" ]]; then
return 0
fi
if qc_seen_tag "v$QC_FULL_VERSION"; then
return 0
fi
QC_STATE_BASE="$QC_BASE_VERSION"
QC_STATE_CHANNEL="$QC_CHANNEL"
QC_STATE_PRE="$QC_PRERELEASE_NUM"
QC_EXPECT_SUCCESS=1
QC_EXPECT_VERSION="$QC_FULL_VERSION"
QC_SEEN_TAGS+=("v$QC_FULL_VERSION")
}
run_randomized_quickcheck_cases() {
@@ -798,6 +901,38 @@ run_set_prerelease_then_full_case() {
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() {
local case_name="set stable then full fails with no-op"
@@ -985,6 +1120,65 @@ EOF
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() {
local case_name="mkRepo exposes outputs and auto-installs tools"
local workdir
@@ -994,13 +1188,67 @@ run_mk_repo_case() {
write_mk_repo_flake "$repo_dir"
CURRENT_LOG="$workdir/mk-repo.log"
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"
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"
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: release package should be available in shell" nix develop "$repo_dir" -c sh -c 'command -v release >/dev/null'
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" 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 '\"stage_fixed\":true' "$lefthook_yml_json" "$case_name: stage_fixed missing from lefthook config"
rm -rf "$workdir"
CURRENT_LOG=""
@@ -1016,7 +1264,7 @@ run_mk_repo_tool_failure_case() {
write_tool_failure_flake "$repo_dir"
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"
rm -rf "$workdir"
@@ -1051,7 +1299,7 @@ run_legacy_api_eval_case() {
CURRENT_LOG="$workdir/legacy.log"
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"
rm -rf "$workdir"
@@ -1069,7 +1317,7 @@ run_template_eval_case() {
CURRENT_LOG="$workdir/template.log"
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"
rm -rf "$workdir"
@@ -1090,7 +1338,7 @@ run_release_replace_backref_case() {
cat >"$repo_dir/template/flake.nix" <<'EOF'
{
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
@@ -1098,10 +1346,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" 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"
if grep -Fq '\1git+https://example.invalid/repo-lib?ref=v1.0.1\2' "$repo_dir/template/flake.nix"; then
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=refs/tags/v1.0.1\2' "$repo_dir/template/flake.nix"; then
fail "$case_name: replacement left literal backreferences in output"
fi
@@ -1113,16 +1361,22 @@ EOF
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_set_prerelease_then_full_case
run_stable_then_beta_cannot_reuse_same_base_case
run_set_stable_then_full_noop_case
run_set_stable_from_prerelease_requires_full_case
run_patch_stable_from_prerelease_requires_full_case
run_structured_release_steps_case
run_version_metadata_case
run_mk_repo_case
run_mk_repo_command_tool_case
run_mk_repo_lefthook_case
run_mk_repo_tool_failure_case
run_impure_bootstrap_validation_case
run_legacy_api_eval_case
run_template_eval_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