小类随手记

NixOS 中统一管理 fish lazygit 以及SKILL的git Commits 前缀定义

Fish 的 gcp 函数和 Lazygit custom command 各自维护一份 Conventional Commits 前缀列表,改一处容易忘另一处。用 Nix 抽成共享数据文件,两处自动同步。 同样的思路还可以扩展到 AI agent skill 的 commit 格式定义。

背景

之前的文章 一个简单使用的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 使用)

revertexample 比较特殊,它记录的是被回退的那条提交信息(✨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.functionsxdg.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.activationmodules/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 内置的 importmapconcatStringsSep 等函数直接支持这种模式,不需要引入任何额外工具。

如果你也在 NixOS 中管理多份有重叠数据的配置,不妨考虑抽取出共享数据文件,让 Nix 来替你解决同步问题。

Licensed under CC BY-NC-SA 4.0
comments powered by Disqus
Theme Stack