背景
之前的文章 一个简单使用的git提交工具,基于fish自定义函数 介绍了通过 Fish 函数 gcp 交互式选择 Conventional Commits 前缀并提交推送。
后来又给 Lazygit 添加了类似功能的 custom command,也在菜单中列出了同样的 11 种提交类型。再后来 AI agent(opencode、Claude Code、Codex 等)的 git-auto-commit skill 也需要同样的 commit 格式约定。
问题在于:三个功能的提交类型列表是完全独立的硬编码。Fish 函数里有一份 switch 分支,Lazygit 配置里又有一份 YAML 列表,skill 里还有第三份 Markdown 列表。新增一个类型要改三处,改描述也要改三处,迟早会不一致。
所以需要把"提交类型定义"这个数据抽出来,做成单一数据源,三个功能都从它生成。
最终目录结构
1
2
3
4
5
6
| modules/dev/git/
├── commit_prefix_list.nix # 共享数据:11 种提交类型定义
├── git_fiash_fun.nix # → 生成 Fish gcp 函数
├── tool_lazygit.nix # → 生成 Lazygit custom command 选项
├── git-auto-commit-skill.nix # → 生成 AI agent SKILL.md
└── git_github_gitea.nix # 其他 Git 相关配置
|
核心:commit_prefix_list.nix
这是一个纯数据文件,不包含模块逻辑,导出一个列表(list of attrsets):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| # modules/dev/git/commit_prefix_list.nix
[
{ emoji = "✨"; prefix = "feat"; description = "新功能,包含实质性逻辑的新文件"; example = "add support for CSV import"; }
{ emoji = "🐛"; prefix = "fix"; description = "修复 bug"; example = "handle token refresh race condition"; }
{ emoji = "📝"; prefix = "docs"; description = "仅文档变更(README、注释、文档字符串)"; example = "update installation instructions"; }
{ emoji = "💄"; prefix = "style"; description = "格式调整、空白、缺少分号"; example = "format with prettier"; }
{ emoji = "♻️"; prefix = "refactor"; description = "代码重构,非新增功能或修复 bug"; example = "extract connection pool into separate module"; }
{ emoji = "⚡"; prefix = "perf"; description = "性能改进"; example = "reduce TTL to improve memory usage"; }
{ emoji = "✅"; prefix = "test"; description = "添加或修改测试"; example = "add integration tests for /users endpoint"; }
{ emoji = "👷"; prefix = "build"; description = "构建系统、依赖、CI 配置"; example = "pin Node version in GitHub Actions"; }
{ emoji = "🔧"; prefix = "chore"; description = "维护、工具、配置文件、gitignore"; example = "bump lodash to 4.17.21"; }
{ emoji = "⏪"; prefix = "revert"; description = "回退之前的提交"; example = "✨feat(parser): add support for CSV import"; }
{ emoji = "🔄"; prefix = "update"; description = "依赖更新、工具链版本更新、库更新等"; example = "upgrade axios to 1.7.0"; }
]
|
因为是一个纯 Nix 表达式而非模块(没有函数参数),其他文件直接 import ./commit_prefix_list.nix 就能拿到这份数据。Nix 会自动缓存 import 结果,多处引用不会重复求值。
每个条目四个字段:
| 字段 | 用途 |
|---|
emoji | 视觉标识,在菜单和提交信息中显示 |
prefix | 提交前缀 |
description | 中文说明,帮助用户选择 |
example | 示例提交描述(AI agent skill 的 Message format reference 使用) |
revert 的 example 比较特殊,它记录的是被回退的那条提交信息(✨feat(parser): add support for CSV import),这样在 skill 的 Message format reference 中呈现为 ⏪revert: ✨feat(parser): add support for CSV import。
消费方一:Fish gcp 函数
原来的 gcp 函数把类型列表和 switch 分支都硬编码在 Fish 脚本里。现在改为在 Nix 层生成这些代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
| { pkgs, username, lib, ... }:
let
commitTypes = import ./commit_prefix_list.nix;
# 生成 Fish 数组行:"✨feat — 新功能..."
fishTypeLines = lib.concatStringsSep " \\\n " (map (t:
''"${t.emoji}${t.prefix} — ${t.description}"''
) commitTypes);
# 生成 switch 分支
switchCases = lib.concatStringsSep "\n" (lib.imap1 (i: t:
" case ${toString i}; set prefix \"${t.emoji}${t.prefix}\""
) commitTypes);
typesCount = builtins.toString (builtins.length commitTypes);
in
{
home-manager = {
users.${username} = { ... }: {
programs.fish.functions = {
gcp = {
description = "交互式 Git 提交与推送(Conventional Commits)";
body = ''
set -l types \
${fishTypeLines}
echo ""
echo "========== 变更概览 =========="
git status --short
echo "=============================="
echo ""
echo "请选择提交类型 (Conventional Commits):"
echo ""
for i in (seq (count $types))
if test $i -lt 10
echo " $i) "$types[$i]
else
echo " $i) "$types[$i]
end
end
echo ""
read -l -P "输入序号 (1-${typesCount}) [默认 1]: " type_idx
if test -z "$type_idx"
set type_idx 1
end
set -l prefix
switch $type_idx
${switchCases}
case '*'
echo "无效输入,已取消。"
return 1
end
read -l -P "输入提交描述: " commit_msg
if test -z "$commit_msg"
echo "描述不能为空,已取消。"
return 1
end
echo ""
echo "即将执行: git add . && git commit -m \"$prefix: $commit_msg\" && git push"
echo ""
git add .
git commit -m "$prefix: $commit_msg"
and git push
or echo "提交或推送失败,请检查错误信息。"
'';
};
};
};
};
}
|
关键点:
lib.imap1 生成带序号(从 1 开始)的 switch case,索引与菜单序号一一对应builtins.length 确保提示文本中的范围(“1-N”)始终与列表实际长度一致- 整个 Fish 函数体依然是 Nix
''...'' 字符串,但内部的类型列表和 switch 分支由 Nix 动态生成
消费方二:Lazygit Custom Command
Lazygit 通过 YAML 配置 customCommands 实现交互菜单。同样,从共享列表生成 YAML 选项:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
| { pkgs, username, lib, ... }:
let
commitTypes = import ./commit_prefix_list.nix;
yamlOptions = lib.concatStringsSep "\n" (lib.concatMap (t: [
" - name: \"${t.emoji} ${t.prefix}\""
" description: \"${t.description}\""
" value: \"${t.emoji}${t.prefix}\""
]) commitTypes);
in
{
home-manager = {
users.${username} = {
home.packages = with pkgs; [
lazygit
];
xdg.configFile."lazygit/config.yml".text = ''
gui:
editor: nvim
nerdFontsVersion: "3"
parseEmoji: true
os:
editCommand: nvim
editCommandTemplate: nvim {{filename}}
customCommands:
- key: "C"
context: "files"
description: "交互式提交(选择 Conventional Commits 前缀)"
prompts:
- type: "menu"
title: "选择提交类型 (Conventional Commits)"
key: "CommitType"
options:
${yamlOptions}
- type: "input"
title: "输入提交描述"
key: "Message"
command: "git commit -m \"{{.Form.CommitType}}: {{.Form.Message}}\""
loadingText: "提交中..."
output: terminal
'';
};
};
}
|
这里有一个容易踩的坑:YAML 缩进层级。options: 位于 16 空格深度,其子项必须缩进 18 空格(- name:)和 20 空格(description: / value:)。如果用 Nix ''...'' 字符串生成这些行,会因为 Nix 自动剥离公共空白前缀而丢失缩进。
解决方案:用普通 "..." 字符串(不含嵌套缩进剥离)配合 lib.concatMap 逐行构建,缩进完全由开发者显式控制。
消费方三:AI Agent Skill
第三个消费方是 AI 编码 agent(opencode、Claude Code、Codex、Gemini CLI 等)的 git-auto-commit skill。这个 skill 以 Markdown 文件(SKILL.md)定义 AI agent 的自动化提交流程,其中包含:
- 步骤 3 中的提交类型列表(与 gcp / lazygit 一致)
- 底部的 Message format reference 示例
这两个部分原来也是硬编码的。现在改为从 commit_prefix_list.nix 生成,并且新增了一个 example 字段来提供示例描述。
生成方式稍有不同:Fish gcp 和 Lazygit 使用 home-manager 的声明式 programs.fish.functions 和 xdg.configFile 来放置配置,而 skill 文件使用 home.activation 脚本在 rebuild 时写入到仓库目录:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
| { pkgs, username, global_settings, lib, ... }:
let
commitTypes = import ./commit_prefix_list.nix;
# 生成 Message format reference
refLines = lib.concatStringsSep "\n" (
lib.concatMap (t:
if t.prefix == "revert" then
[ " ${t.emoji}${t.prefix}: ${t.example}" ]
else
[ " ${t.emoji}${t.prefix}(${t.prefix}): ${t.example}" ]
) commitTypes
);
# ... skillContent = ''...''
# 包含由 refLines 插值的完整 SKILL.md
in
{
home-manager = {
users.${username} = { config, lib, ... }: {
home.activation.generateGitAutoCommitSkill = lib.hm.dag.entryAfter [ "writeBoundary" ] ''
mkdir -p "${global_settings.repoDir}/modules/ai/skill/git-auto-commit"
cat > "${global_settings.repoDir}/modules/ai/skill/git-auto-commit/SKILL.md" << 'SKILL_EOF'
${skillContent}
SKILL_EOF
'';
};
};
}
|
这里为什么用 activation 而非 home.file?因为项目中有一个统一的 ai_common.nix 模块,它通过 home.activation 将 modules/ai/skill/ 目录作为整体符号链接到各 AI 工具的 skills 路径下(opencode、Claude Code、Codex、Gemini CLI 等)。把生成的 SKILL.md 放回 modules/ai/skill/git-auto-commit/ 目录,就能通过这个现成的目录级 symlink 自动暴露给所有 AI agent,无需为每个工具单独配置。
生成后的解析链:
1
2
3
4
| AI agent 读取 SKILL.md
→ ~/.config/opencode/skills/git-auto-commit/SKILL.md
→ $repo/modules/ai/skill/git-auto-commit/SKILL.md (ai_common.nix 的目录 symlink)
→ 由 home.activation 写入的实际文件内容
|
这样 commit_prefix_list.nix 中新增的 example 字段就会自动呈现在 AI agent 看到的提交类型参考中。
数据流
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| commit_prefix_list.nix(单一数据源)
│
├── git_fiash_fun.nix
│ └── lib.imap1 + lib.concatStringsSep
│ └── Fish types[] + switch cases
│ └── ~/.config/fish/functions/gcp.fish
│
├── tool_lazygit.nix
│ └── lib.concatMap + 显式缩进字符串
│ └── YAML options
│ └── ~/.config/lazygit/config.yml
│
└── git-auto-commit-skill.nix
└── lib.concatMap
└── Markdown type list + examples
└── $repo/modules/ai/skill/git-auto-commit/SKILL.md
└── (通过 ai_common.nix 的目录 symlink 暴露给各 AI agent)
|
与 NixOS 模块系统契合
这些 Git 相关的 .nix 文件统一放在 modules/dev/git/ 下,在 app_and_modules_list.nix 中按条件加载:
1
2
3
4
| ++ importIf IsAll ./modules/dev/git/git_fiash_fun.nix
++ importIf IsAll ./modules/dev/git/git_github_gitea.nix
++ importIf IsAll ./modules/dev/git/git-auto-commit-skill.nix
++ importIf isWorkMachine ./modules/dev/git/tool_lazygit.nix
|
三个消费方共用同一个 import ./commit_prefix_list.nix,提高一致性和可维护性
总结
相关阅读:NixOS 入门指南 | gcp Fish 函数详解 | NixOS 模块管理机制 | mkOutOfStoreSymlink 的一个坑
这个案例展示了 Nix 作为配置语言的一个独特优势:Nix 不仅仅是包管理和系统声明,它还是一个图灵完备的纯函数式语言。你可以用它定义数据(commit_prefix_list.nix),然后基于这些数据生成不同格式的输出(Fish 脚本、YAML 配置、Markdown skill),整个过程在 build 时完成,零运行时开销。
这种"定义一次数据,生成多处消费"的模式,在其他配置管理工具(Ansible、Puppet 等)中通常需要外部模板引擎和额外的编排。而 Nix 内置的 import、map、concatStringsSep 等函数直接支持这种模式,不需要引入任何额外工具。
如果你也在 NixOS 中管理多份有重叠数据的配置,不妨考虑抽取出共享数据文件,让 Nix 来替你解决同步问题。