From c04a13bf8a3af3a2c77ba88eccb46ce37c2cbbbb Mon Sep 17 00:00:00 2001 From: lanyuanxiaoyao Date: Wed, 6 May 2026 13:44:28 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E9=87=8D=E5=86=99=20Git=20hooks=20?= =?UTF-8?q?=E4=BD=93=E7=B3=BB=EF=BC=8C=E5=A7=94=E6=89=98=E5=B7=B2=E6=9C=89?= =?UTF-8?q?=E6=A3=80=E6=9F=A5=E3=80=81=E6=96=B0=E5=A2=9E=E6=A8=A1=E6=9D=BF?= =?UTF-8?q?=E4=B8=8E=20LFS=20=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pre-commit 代码检查改为委托 _backend-lint / _versionctl-lint / _frontend-check,新增 LFS 指针校验;commit-msg 新增多行空行格式校验和模板注释忽略,移除 CJK/Python 字符集检测;新增 prepare-commit-msg 提交信息模板;hooks-install 增加 source 文件存在性校验;前端 check 补入 tsc -b 类型检查并修复暴露的类型错误 --- Makefile | 83 +++++---- README.md | 5 +- frontend/README.md | 3 +- frontend/package.json | 5 +- frontend/src/pages/Providers/ModelTable.tsx | 4 +- .../src/pages/Providers/ProviderTable.tsx | 8 +- openspec/specs/frontend-lint-rules/spec.md | 18 +- openspec/specs/git-hooks/spec.md | 107 +++++++----- .../specs/prepare-commit-msg-hook/spec.md | 57 ++++++ openspec/specs/prettier-formatting/spec.md | 13 +- scripts/git-hooks/commit-msg | 39 ++++- scripts/git-hooks/prepare-commit-msg | 49 ++++++ scripts/git-hooks/test-hooks.sh | 163 ++++++++++++++++-- 13 files changed, 443 insertions(+), 111 deletions(-) create mode 100644 openspec/specs/prepare-commit-msg-hook/spec.md create mode 100755 scripts/git-hooks/prepare-commit-msg diff --git a/Makefile b/Makefile index 7db8493..c779bd1 100644 --- a/Makefile +++ b/Makefile @@ -70,15 +70,21 @@ clean: _backend-clean _frontend-clean _desktop-clean hooks-install: @hooks_dir=$$(git rev-parse --git-path hooks); \ mkdir -p "$$hooks_dir"; \ - cp scripts/git-hooks/pre-commit "$$hooks_dir/pre-commit"; \ - cp scripts/git-hooks/commit-msg "$$hooks_dir/commit-msg"; \ - chmod +x "$$hooks_dir/pre-commit" "$$hooks_dir/commit-msg"; \ + for hook in pre-commit commit-msg prepare-commit-msg; do \ + src="scripts/git-hooks/$$hook"; \ + if [ ! -f "$$src" ]; then \ + printf 'ERROR: source hook not found: %s\n' "$$src" >&2; \ + exit 1; \ + fi; \ + cp "$$src" "$$hooks_dir/$$hook"; \ + chmod +x "$$hooks_dir/$$hook"; \ + done; \ printf 'Installed Git hooks to %s\n' "$$hooks_dir" hooks-check: @hooks_dir=$$(git rev-parse --git-path hooks); \ status=0; \ - for hook in pre-commit commit-msg; do \ + for hook in pre-commit commit-msg prepare-commit-msg; do \ if [ -x "$$hooks_dir/$$hook" ]; then \ printf 'OK: %s\n' "$$hook"; \ else \ @@ -92,17 +98,18 @@ hooks-test: @scripts/git-hooks/test-hooks.sh _hooks-pre-commit: - @set -e; \ + @set -ef; \ staged_files=$$(git diff --cached --name-only --diff-filter=ACM); \ if [ -z "$$staged_files" ]; then \ printf 'No staged files to check\n'; \ exit 0; \ fi; \ - backend_pkgs=''; \ - versionctl_pkgs=''; \ + run_backend_lint=; \ + run_versionctl_lint=; \ + run_frontend_check=; \ + lfs_patterns=$$(grep 'filter=lfs' .gitattributes 2>/dev/null | awk '{print $$1}' || true); \ for file in $$staged_files; do \ [ -n "$$file" ] || continue; \ - case "$$file" in scripts/git-hooks/*) continue ;; esac; \ if git show ":$$file" 2>/dev/null | grep -Eq '^(<<<<<<<|=======|>>>>>>>)'; then \ printf 'Found conflict markers in staged file: %s\n' "$$file" >&2; \ printf 'Resolve conflict markers before committing.\n' >&2; \ @@ -114,37 +121,41 @@ _hooks-pre-commit: printf 'Warning: large staged text file (%s bytes): %s\n' "$$size" "$$file" >&2; \ fi; \ fi; \ + if [ -n "$$lfs_patterns" ]; then \ + for lfs_pat in $$lfs_patterns; do \ + case "$$file" in $$lfs_pat) \ + content=$$(git show ":$$file" 2>/dev/null | head -1); \ + case "$$content" in \ + "version https://git-lfs.github.com/spec/v1"*) ;; \ + *) \ + printf 'LFS-tracked file not using LFS pointer: %s\n' "$$file" >&2; \ + printf 'Run "git lfs install" and re-add this file.\n' >&2; \ + exit 1; \ + ;; \ + esac; \ + break; \ + ;; \ + esac; \ + done; \ + fi; \ case "$$file" in \ - backend/*.go) \ - dir=$$(dirname "$${file#backend/}"); \ - case " $$backend_pkgs " in *" $$dir "*) ;; *) backend_pkgs="$$backend_pkgs $$dir" ;; esac; \ - ;; \ - versionctl/*.go) \ - dir=$$(dirname "$${file#versionctl/}"); \ - case " $$versionctl_pkgs " in *" $$dir "*) ;; *) versionctl_pkgs="$$versionctl_pkgs $$dir" ;; esac; \ - ;; \ - frontend/*.ts|frontend/*.tsx) \ - rel=$${file#frontend/}; \ - printf 'Frontend lint: frontend/%s\n' "$$rel"; \ - (cd frontend && bunx eslint "$$rel"); \ - printf 'Frontend format: frontend/%s\n' "$$rel"; \ - (cd frontend && bunx prettier --check "$$rel"); \ - ;; \ - frontend/*.scss) \ - rel=$${file#frontend/}; \ - printf 'Frontend format: frontend/%s\n' "$$rel"; \ - (cd frontend && bunx prettier --check "$$rel"); \ - ;; \ + backend/*.go) run_backend_lint=1 ;; \ + versionctl/*.go) run_versionctl_lint=1 ;; \ + frontend/*.ts|frontend/*.tsx|frontend/*.scss) run_frontend_check=1 ;; \ esac; \ done; \ - for dir in $$backend_pkgs; do \ - printf 'Go lint: backend/%s\n' "$$dir"; \ - (cd backend && go tool golangci-lint run "$$dir/"); \ - done; \ - for dir in $$versionctl_pkgs; do \ - printf 'Go lint: versionctl/%s\n' "$$dir"; \ - (cd versionctl && go tool golangci-lint run "$$dir/"); \ - done; \ + if [ -n "$$run_backend_lint" ]; then \ + printf 'Running backend lint...\n'; \ + $(MAKE) _backend-lint; \ + fi; \ + if [ -n "$$run_versionctl_lint" ]; then \ + printf 'Running versionctl lint...\n'; \ + $(MAKE) _versionctl-lint; \ + fi; \ + if [ -n "$$run_frontend_check" ]; then \ + printf 'Running frontend check...\n'; \ + $(MAKE) _frontend-check; \ + fi; \ printf 'Pre-commit checks passed\n' # ============================================ diff --git a/README.md b/README.md index dec88c8..6ca7f46 100644 --- a/README.md +++ b/README.md @@ -369,8 +369,9 @@ make desktop-clean # 清理 desktop 产物 Git hooks 使用仓库内 `scripts/git-hooks/` 的原生脚本,不依赖额外 hook 框架。当前 hooks 包含: -- pre-commit:检查 staged files 的冲突标记、Go lint、前端 lint/格式和大文件告警 -- commit-msg:校验提交信息格式为 `类型: 简短描述`,描述需使用中文 +- pre-commit:检查 staged files 的冲突标记、大文件告警和 LFS 指针,并按文件类型委托后端、versionctl、前端检查 +- prepare-commit-msg:在编辑器打开时提供提交信息模板,辅助填写 `类型: 简短描述` 和多行说明 +- commit-msg:校验提交信息格式为 `类型: 简短描述`,多行说明需在首行后保留空行;提交描述按项目规范使用中文,hook 不做字符集检测 ## 版本与发布 diff --git a/frontend/README.md b/frontend/README.md index e7e576d..2c88d2a 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -149,7 +149,8 @@ bun run build ```bash bun run lint # ESLint 检查 bun run format:check # Prettier 格式检查 -bun run check # 同时检查 lint 和格式 +bun run typecheck # TypeScript 类型检查 +bun run check # 同时检查类型、lint 和格式 ``` ### 代码格式化 diff --git a/frontend/package.json b/frontend/package.json index 452a552..0be2ebd 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,12 +6,13 @@ "type": "module", "scripts": { "dev": "vite", - "build": "tsc -b && bun run check && vite build", + "build": "bun run check && vite build", "lint": "eslint .", "lint:fix": "eslint . --fix", "format": "prettier --write .", "format:check": "prettier --check .", - "check": "bun run lint && bun run format:check", + "typecheck": "tsc -b", + "check": "bun run typecheck && bun run lint && bun run format:check", "fix": "bun run lint:fix && bun run format", "preview": "vite preview", "test": "vitest run", diff --git a/frontend/src/pages/Providers/ModelTable.tsx b/frontend/src/pages/Providers/ModelTable.tsx index f2fc495..7c7de46 100644 --- a/frontend/src/pages/Providers/ModelTable.tsx +++ b/frontend/src/pages/Providers/ModelTable.tsx @@ -38,7 +38,9 @@ export function ModelTable({ providerId, onAdd, onEdit }: ModelTableProps) { text: id, onCopy: () => MessagePlugin.success('已复制统一模型 ID'), }} - /> + > + {''} + ) : null }, diff --git a/frontend/src/pages/Providers/ProviderTable.tsx b/frontend/src/pages/Providers/ProviderTable.tsx index c7bd996..7463464 100644 --- a/frontend/src/pages/Providers/ProviderTable.tsx +++ b/frontend/src/pages/Providers/ProviderTable.tsx @@ -51,7 +51,9 @@ export function ProviderTable({ text: row.baseUrl, onCopy: () => MessagePlugin.success('已复制 Base URL'), }} - /> + > + {''} + ) : null, }, @@ -87,7 +89,9 @@ export function ProviderTable({ text: row.apiKey, onCopy: () => MessagePlugin.success('已复制 API Key'), }} - /> + > + {''} + ) : null, }, diff --git a/openspec/specs/frontend-lint-rules/spec.md b/openspec/specs/frontend-lint-rules/spec.md index b225e9d..0c365f6 100644 --- a/openspec/specs/frontend-lint-rules/spec.md +++ b/openspec/specs/frontend-lint-rules/spec.md @@ -49,13 +49,14 @@ TBD - 定义前端 ESLint 规则配置、构建集成 lint 检查、以及自定 ### Requirement: 构建集成 lint 检查 -前端 SHALL 在 `build` 命令中集成 ESLint 检查和 Prettier 格式检查。 +前端 SHALL 在 `build` 命令中集成 TypeScript 类型检查、ESLint 检查和 Prettier 格式检查。 -#### Scenario: 构建时执行 lint 和格式检查 +#### Scenario: 构建时执行类型检查、lint 和格式检查 - **WHEN** 执行 `bun run build` -- **THEN** 构建 SHALL 依次执行 `tsc -b`、`bun run check`、`vite build` -- **THEN** `bun run check` SHALL 执行 `bun run lint && bun run format:check` +- **THEN** 构建 SHALL 依次执行 `bun run check`、`vite build` +- **THEN** `bun run check` SHALL 依次执行 `bun run typecheck`、`bun run lint`、`bun run format:check` +- **THEN** 若 `tsc -b` 报告类型错误,构建 SHALL 中断 - **THEN** 若 `eslint .` 报告任何错误,构建 SHALL 中断 - **THEN** 若 `prettier --check .` 报告任何格式问题,构建 SHALL 中断 @@ -77,8 +78,13 @@ TBD - 定义前端 ESLint 规则配置、构建集成 lint 检查、以及自定 #### Scenario: 统一检查命令 - **WHEN** 执行 `bun run check` -- **THEN** SHALL 运行 `bun run lint && bun run format:check` -- **THEN** lint 错误和格式问题 SHALL 都被检查 +- **THEN** SHALL 依次运行 `bun run typecheck`、`bun run lint`、`bun run format:check` +- **THEN** 类型错误、lint 错误和格式问题 SHALL 都被检查 + +#### Scenario: 单独执行类型检查 + +- **WHEN** 执行 `bun run typecheck` +- **THEN** SHALL 运行 `tsc -b` #### Scenario: 统一修复命令 diff --git a/openspec/specs/git-hooks/spec.md b/openspec/specs/git-hooks/spec.md index e10f34d..5551876 100644 --- a/openspec/specs/git-hooks/spec.md +++ b/openspec/specs/git-hooks/spec.md @@ -8,12 +8,33 @@ ### Requirement: pre-commit hook 快速检查 -pre-commit hook SHALL 在 `git commit` 执行前对 staged files 进行快速检查,仅检查本次提交涉及的文件。 +pre-commit hook SHALL 在 `git commit` 执行前对 staged files 进行快速检查。非代码检查(冲突标记、大文件告警、LFS 指针)SHALL 在 `_hooks-pre-commit` 中直接实现;代码检查(Go 后端、Go versionctl、前端)SHALL 根据 staged 文件类型有条件地委托给已有 Makefile target(`_backend-lint`、`_versionctl-lint`、`_frontend-check`),不再内联独立的 lint 命令。 -#### Scenario: 无 Go 和前端文件变更时跳过 +#### Scenario: 无 Go 和前端文件变更时跳过代码检查 - **WHEN** staged files 中既无 `.go` 文件也无 `.ts`/`.tsx`/`.scss` 文件 -- **THEN** pre-commit hook SHALL 直接通过,不运行任何 linter +- **THEN** pre-commit hook SHALL 跳过代码检查委托,仅执行非代码检查 + +#### Scenario: Go 文件变更时委托后端 lint + +- **WHEN** staged files 中包含 `backend/*.go` 文件 +- **THEN** pre-commit hook SHALL 委托 `_backend-lint` target 进行 Go 代码检查 +- **THEN** `_backend-lint` SHALL 复用 `backend/.golangci.yml` 配置 +- **THEN** 若 lint 报告任何错误,commit SHALL 被阻止 + +#### Scenario: versionctl Go 文件变更时委托 versionctl lint + +- **WHEN** staged files 中包含 `versionctl/*.go` 文件 +- **THEN** pre-commit hook SHALL 委托 `_versionctl-lint` target 进行 Go 代码检查 +- **THEN** `_versionctl-lint` SHALL 复用 `versionctl/.golangci.yml` 配置 +- **THEN** 若 lint 报告任何错误,commit SHALL 被阻止 + +#### Scenario: 前端文件变更时委托前端检查 + +- **WHEN** staged files 中包含 `.ts`、`.tsx` 或 `.scss` 文件 +- **THEN** pre-commit hook SHALL 委托 `_frontend-check` target 进行前端代码检查 +- **THEN** `_frontend-check` SHALL 运行 `bun run check`(包含 `tsc -b` TypeScript 类型检查、ESLint 和 Prettier 格式检查) +- **THEN** 若检查报告任何错误,commit SHALL 被阻止 #### Scenario: 冲突标记检测 @@ -21,37 +42,26 @@ pre-commit hook SHALL 在 `git commit` 执行前对 staged files 进行快速检 - **THEN** pre-commit hook SHALL 报告错误并列出包含冲突的文件名 - **THEN** commit SHALL 被阻止 -#### Scenario: Go 文件 lint 检查 - -- **WHEN** staged files 中包含 `.go` 文件 -- **THEN** pre-commit hook SHALL 对 staged `.go` 文件运行 `golangci-lint run`(复用 `backend/.golangci.yml` 配置) -- **THEN** 若 lint 报告任何错误,commit SHALL 被阻止 - -#### Scenario: 前端文件 lint 检查 - -- **WHEN** staged files 中包含 `.ts` 或 `.tsx` 文件 -- **THEN** pre-commit hook SHALL 对 staged 前端文件运行 ESLint(复用 `frontend/eslint.config.js` 配置) -- **THEN** 若 ESLint 报告任何错误,commit SHALL 被阻止 - -#### Scenario: 前端文件格式检查 - -- **WHEN** staged files 中包含 `.ts`、`.tsx` 或 `.scss` 文件 -- **THEN** pre-commit hook SHALL 对 staged 前端文件运行 Prettier 格式检查(复用 `frontend/.prettierrc` 配置) -- **THEN** 若存在格式不符合规范的文件,commit SHALL 被阻止 - #### Scenario: 大文件告警 - **WHEN** staged files 中存在超过 500KB 的文本文件 - **THEN** pre-commit hook SHALL 输出警告信息(不阻止提交),提示检查是否误提交 +#### Scenario: LFS 指针校验 + +- **WHEN** staged files 匹配 `.gitattributes` 中 `filter=lfs` 的路径模式 +- **THEN** pre-commit hook SHALL 检查 staged 内容是否为 LFS 指针格式(`version https://git-lfs.github.com/spec/v1`) +- **THEN** 若内容不是 LFS 指针格式,commit SHALL 被阻止,并提示安装 git-lfs +- **THEN** 若 staged files 不匹配任何 `filter=lfs` 路径模式,SHALL 跳过此检查 + #### Scenario: commit 被阻止时显示修复提示 - **WHEN** pre-commit hook 检查失败 -- **THEN** hook SHALL 输出明确的修复提示(如 `bun run fix`、手动解决冲突标记等) +- **THEN** hook SHALL 输出明确的修复提示(如 `make lint` 修复代码问题、手动解决冲突标记等) ### Requirement: commit-msg hook 校验提交信息格式 -commit-msg hook SHALL 在 `git commit` 输入提交信息后校验格式,确保符合项目规范。提交描述 SHALL 使用中文;版本号、英文专有名词可与中文描述混用。 +commit-msg hook SHALL 在 `git commit` 输入提交信息后校验格式,确保首行符合项目规范。提交描述按项目规范应使用中文,但 hook SHALL NOT 通过 Python/CJK 字符集检测强制判断描述语言,以避免引入新的运行时依赖。 #### Scenario: 合法格式通过 @@ -63,12 +73,6 @@ commit-msg hook SHALL 在 `git commit` 输入提交信息后校验格式,确 - **WHEN** 提交信息首行使用的类型不在允许列表中(如 `update: xxx`) - **THEN** commit-msg hook SHALL 报告错误,显示允许的类型列表,commit SHALL 被阻止 -#### Scenario: 英文描述被拒绝 - -- **WHEN** 提交信息首行为 `feat: add auth` -- **THEN** commit-msg hook SHALL 报告错误,提示提交描述需使用中文 -- **THEN** commit SHALL 被阻止 - #### Scenario: 缺少冒号空格被拒绝 - **WHEN** 提交信息首行为 `feat:xxx`(冒号后无空格)或 `feat xxx` @@ -89,16 +93,35 @@ commit-msg hook SHALL 在 `git commit` 输入提交信息后校验格式,确 - **WHEN** commit-msg hook 检查失败 - **THEN** hook SHALL 输出包含正确格式示例的错误信息(如 `feat: 添加供应商批量管理功能`) +#### Scenario: 不执行字符集检测 + +- **WHEN** 提交信息首行格式合法且类型合法,但描述部分不包含 CJK 字符(如 `feat: add hook tests`) +- **THEN** commit-msg hook SHALL 通过 +- **THEN** hook SHALL NOT 调用 `python3` 或其他额外运行时做 Unicode/CJK 检测 + +#### Scenario: 多行格式校验 + +- **WHEN** 提交信息忽略 `#` 注释行后,第三行及之后存在任一非空详细说明行 +- **THEN** commit-msg hook SHALL 检查第二行是否为空行 +- **THEN** 若第二行非空行,commit SHALL 被阻止,提示首行后应空行再写详细描述 + +#### Scenario: 模板注释不参与校验 + +- **WHEN** 提交信息文件中包含 prepare-commit-msg 写入的 `#` 注释模板 +- **THEN** commit-msg hook SHALL 忽略这些注释行 +- **THEN** 注释行 SHALL NOT 导致首行格式、多行空行分隔校验失败 + ### Requirement: hooks-install 安装命令 `make hooks-install` SHALL 将 `scripts/git-hooks/` 下的 hook 脚本安装到 `.git/hooks/`,不覆盖 Git LFS 管理的 hook。 -#### Scenario: 安装 pre-commit 和 commit-msg +#### Scenario: 安装所有 hook 脚本 - **WHEN** 执行 `make hooks-install` - **THEN** `scripts/git-hooks/pre-commit` SHALL 被复制到 `.git/hooks/pre-commit` - **THEN** `scripts/git-hooks/commit-msg` SHALL 被复制到 `.git/hooks/commit-msg` -- **THEN** 两个文件 SHALL 被设置为可执行(`chmod +x`) +- **THEN** `scripts/git-hooks/prepare-commit-msg` SHALL 被复制到 `.git/hooks/prepare-commit-msg` +- **THEN** 所有复制文件 SHALL 被设置为可执行(`chmod +x`) #### Scenario: 不覆盖 LFS 管理的 hook @@ -113,9 +136,15 @@ commit-msg hook SHALL 在 `git commit` 输入提交信息后校验格式,确 #### Scenario: hooks-check 验证安装状态 - **WHEN** 执行 `make hooks-check` -- **THEN** 命令 SHALL 检查 `.git/hooks/pre-commit` 和 `.git/hooks/commit-msg` 是否存在且可执行 +- **THEN** 命令 SHALL 检查 `.git/hooks/pre-commit`、`.git/hooks/commit-msg`、`.git/hooks/prepare-commit-msg` 是否存在且可执行 - **THEN** SHALL 输出每个 hook 的安装状态 +#### Scenario: 安装前验证 source 文件存在 + +- **WHEN** 执行 `make hooks-install` +- **THEN** 命令 SHALL 在复制前验证每个 source 文件(`scripts/git-hooks/`)是否存在 +- **THEN** 若 source 文件不存在,命令 SHALL 报告错误并返回非零退出码 + ### Requirement: hooks-test 回归测试命令 `make hooks-test` SHALL 运行仓库内 hook 回归测试,覆盖 commit-msg 格式校验和 pre-commit staged-file 检查,不污染真实 git index。 @@ -146,19 +175,19 @@ pre-commit 和 commit-msg hook 脚本 SHALL 可在 macOS 和 Windows(Git Bash ### Requirement: pre-commit 核心逻辑在 Makefile 中复用 -pre-commit hook 的检查逻辑 SHALL 通过 Makefile target 调用项目已有工具链,不重复实现 hook 框架逻辑。commit-msg hook SHALL 在脚本内直接完成格式校验。 +pre-commit hook 的检查逻辑 SHALL 通过 Makefile target 调用项目已有工具链,不重复实现。非代码检查(冲突标记、大文件、LFS 指针)SHALL 在 `_hooks-pre-commit` 中直接实现;代码检查 SHALL 委托 `_backend-lint`、`_versionctl-lint`、`_frontend-check` target。 -#### Scenario: Go lint 复用后端配置 +#### Scenario: Go lint 委托后端 lint target - **WHEN** pre-commit 需要检查 Go 文件 -- **THEN** SHALL 调用 Makefile 逻辑,在 `backend/` 目录对 staged `.go` 文件运行 `go tool golangci-lint run` -- **THEN** SHALL 复用 `backend/.golangci.yml` 中的 lint 配置 +- **THEN** SHALL 委托 `_backend-lint` 或 `_versionctl-lint` target(根据文件路径 `backend/` vs `versionctl/`) +- **THEN** SHALL NOT 在 `_hooks-pre-commit` 中内联 `golangci-lint` 命令 -#### Scenario: 前端 lint 使用 staged 文件参数 +#### Scenario: 前端检查委托前端 check target - **WHEN** pre-commit 需要检查前端文件 -- **THEN** SHALL 调用 Makefile 逻辑,在 `frontend/` 目录对 staged 前端文件运行 ESLint 和 Prettier 的文件参数模式 -- **THEN** SHALL NOT 在 pre-commit 阶段运行全量 `bun run check` +- **THEN** SHALL 委托 `_frontend-check` target +- **THEN** SHALL NOT 在 `_hooks-pre-commit` 中内联 `eslint` 或 `prettier` 命令 #### Scenario: 终端直接调试 diff --git a/openspec/specs/prepare-commit-msg-hook/spec.md b/openspec/specs/prepare-commit-msg-hook/spec.md new file mode 100644 index 0000000..999722e --- /dev/null +++ b/openspec/specs/prepare-commit-msg-hook/spec.md @@ -0,0 +1,57 @@ +# prepare-commit-msg-hook + +## Purpose + +定义 prepare-commit-msg Git hook,在 `git commit` 编辑器打开时为开发者提供提交信息模板。 + +## Requirements + +### Requirement: prepare-commit-msg hook 提供提交信息模板 + +prepare-commit-msg hook SHALL 在 `git commit` 打开编辑器时,将规范格式的提交信息模板预填充到提交信息文件中,辅助开发者编写符合项目规范的多行提交信息。 + +#### Scenario: 模板预填充到提交信息文件 + +- **WHEN** `git commit` 被执行且编辑器打开提交信息文件 +- **THEN** prepare-commit-msg hook SHALL 在提交信息文件中写入模板内容 +- **THEN** 模板 SHALL 包含注释行(以 `#` 开头)引导开发者填写规范格式 + +#### Scenario: 模板包含格式引导 + +- **WHEN** 模板被写入提交信息文件 +- **THEN** 模板 SHALL 包含首行格式提示:`# <类型>: <简短中文描述>` +- **THEN** 模板 SHALL 包含空行占位符 +- **THEN** 模板 SHALL 包含详细描述区:`# <详细说明>` +- **THEN** 模板 SHALL 列出可用类型:`feat / fix / refactor / docs / style / test / chore` +- **THEN** 模板 SHALL 包含示例:`feat: 添加供应商批量管理功能` + +#### Scenario: 注释行不被提交 + +- **WHEN** 用户在编辑器中基于模板填写提交信息并保存 +- **THEN** 以 `#` 开头的模板注释行 SHALL 被 Git 作为注释过滤,不会成为提交信息的一部分 + +#### Scenario: 已有提交信息时跳过 + +- **WHEN** 提交信息文件已包含非注释内容(如 `-m` 参数指定、`git commit --amend`、merge commit、cherry-pick) +- **THEN** prepare-commit-msg hook SHALL NOT 覆盖已有内容,直接退出 + +#### Scenario: Git 默认注释不阻止模板写入 + +- **WHEN** 提交信息文件只包含空行或 Git 默认生成的 `#` 注释行 +- **THEN** prepare-commit-msg hook SHALL 将其视为没有已有提交信息 +- **THEN** hook SHALL 在文件顶部写入模板,并保留 Git 原有注释内容 + +### Requirement: 通过 hooks-install 安装 + +prepare-commit-msg hook SHALL 随 `make hooks-install` 一起安装到 `.git/hooks/`。 + +#### Scenario: 安装 prepare-commit-msg + +- **WHEN** 执行 `make hooks-install` +- **THEN** `scripts/git-hooks/prepare-commit-msg` SHALL 被复制到 `.git/hooks/prepare-commit-msg` +- **THEN** 该文件 SHALL 被设置为可执行(`chmod +x`) + +#### Scenario: hooks-check 验证安装状态 + +- **WHEN** 执行 `make hooks-check` +- **THEN** 命令 SHALL 检查 `.git/hooks/prepare-commit-msg` 是否存在且可执行 diff --git a/openspec/specs/prettier-formatting/spec.md b/openspec/specs/prettier-formatting/spec.md index 8b88f91..54b7fcd 100644 --- a/openspec/specs/prettier-formatting/spec.md +++ b/openspec/specs/prettier-formatting/spec.md @@ -189,7 +189,8 @@ - `format = "prettier --write ."` — 格式化所有文件 - `format:check = "prettier --check ."` — 检查文件格式 -- `check = "bun run lint && bun run format:check"` — 检查 lint 和格式 +- `typecheck = "tsc -b"` — TypeScript 类型检查 +- `check = "bun run typecheck && bun run lint && bun run format:check"` — 检查类型、lint 和格式 - `fix = "bun run lint:fix && bun run format"` — 修复 lint 问题并格式化 #### Scenario: 运行格式化命令 @@ -207,8 +208,14 @@ #### Scenario: 运行统一检查命令 - **WHEN** 执行 `bun run check` -- **THEN** SHALL 运行 `bun run lint && bun run format:check` -- **THEN** lint 错误和格式问题 SHALL 都被检查 +- **THEN** SHALL 依次运行 `bun run typecheck`、`bun run lint`、`bun run format:check` +- **THEN** TypeScript 类型错误、lint 错误和格式问题 SHALL 都被检查 + +#### Scenario: 运行类型检查命令 + +- **WHEN** 执行 `bun run typecheck` +- **THEN** SHALL 运行 `tsc -b` +- **THEN** TypeScript 类型错误 SHALL 报告错误 #### Scenario: 运行统一修复命令 diff --git a/scripts/git-hooks/commit-msg b/scripts/git-hooks/commit-msg index 99682ce..d4709e8 100755 --- a/scripts/git-hooks/commit-msg +++ b/scripts/git-hooks/commit-msg @@ -8,7 +8,33 @@ if [ ! -f "$MSG_FILE" ]; then exit 1 fi -IFS= read -r FIRST_LINE < "$MSG_FILE" || FIRST_LINE= +FIRST_LINE= +SECOND_LINE= +HAS_BODY= +LINE_NO=0 + +while IFS= read -r LINE || [ -n "$LINE" ]; do + case "$LINE" in + \#*) continue ;; + esac + + if [ -z "$FIRST_LINE" ]; then + [ -n "$LINE" ] || continue + FIRST_LINE=$LINE + LINE_NO=1 + continue + fi + + LINE_NO=$((LINE_NO + 1)) + case "$LINE_NO" in + 2) SECOND_LINE=$LINE ;; + *) + if [ -n "$LINE" ]; then + HAS_BODY=1 + fi + ;; + esac +done < "$MSG_FILE" case "$FIRST_LINE" in Merge*) @@ -31,12 +57,11 @@ EOF exit 1 fi -DESCRIPTION=${FIRST_LINE#*: } -if printf '%s\n' "$DESCRIPTION" | LC_ALL=C grep -Eq '^[ -~]+$'; then - printf '%s\n' '提交描述需使用中文。' >&2 - exit 1 -fi - if [ ${#FIRST_LINE} -gt 72 ]; then printf '%s\n' '警告: 提交信息首行超过 72 个字符,建议保持简短。' >&2 fi + +if [ -n "$HAS_BODY" ] && [ -n "$SECOND_LINE" ]; then + printf '%s\n' '提交信息首行后应为空行,再写详细描述。' >&2 + exit 1 +fi diff --git a/scripts/git-hooks/prepare-commit-msg b/scripts/git-hooks/prepare-commit-msg new file mode 100755 index 0000000..3a2d60d --- /dev/null +++ b/scripts/git-hooks/prepare-commit-msg @@ -0,0 +1,49 @@ +#!/bin/sh +set -e + +MSG_FILE=$1 +MSG_SOURCE=$2 + +case "$MSG_SOURCE" in + "") ;; + *) exit 0 ;; +esac + +if [ ! -f "$MSG_FILE" ]; then + exit 0 +fi + +has_content=0 +while IFS= read -r line || [ -n "$line" ]; do + case "$line" in + ''|\#*) ;; + *) + has_content=1 + break + ;; + esac +done < "$MSG_FILE" + +if [ "$has_content" -eq 1 ]; then + exit 0 +fi + +tmp_file=${MSG_FILE}.nex-template.$$ +{ + cat <<'EOF' +# <类型>: <简短中文描述> +# +# <详细说明> +# +# 类型: feat / fix / refactor / docs / style / test / chore +# 示例: feat: 添加供应商批量管理功能 +EOF + if [ -s "$MSG_FILE" ]; then + printf '\n' + while IFS= read -r line || [ -n "$line" ]; do + printf '%s\n' "$line" + done < "$MSG_FILE" + fi +} > "$tmp_file" + +mv "$tmp_file" "$MSG_FILE" diff --git a/scripts/git-hooks/test-hooks.sh b/scripts/git-hooks/test-hooks.sh index 0521e9f..82e9c09 100755 --- a/scripts/git-hooks/test-hooks.sh +++ b/scripts/git-hooks/test-hooks.sh @@ -14,7 +14,9 @@ cleanup() { frontend/src/hook_format_fixture.ts \ docs/hook-doc-fixture.md \ docs/hook-conflict-fixture.md \ - docs/hook-large-fixture.txt + docs/hook-large-fixture.txt \ + "$TMP_DIR/lfs-pointer-fixture" \ + "$TMP_DIR/lfs-bad-fixture" rm -rf "$TMP_DIR" } @@ -35,6 +37,14 @@ write_msg() { printf '%s\n' "$*" > "$file" } +write_conflict() { + file=$1 + less7=$(printf '<%.0s' $(seq 7)) + eq7=$(printf '=%.0s' $(seq 7)) + gt7=$(printf '>%.0s' $(seq 7)) + printf '%s\n' "${less7} HEAD" '' "${eq7}" '' "${gt7} branch" > "$file" +} + expect_success() { name=$1 shift @@ -66,12 +76,34 @@ run_precommit_for() { GIT_INDEX_FILE=$index make _hooks-pre-commit } +run_hooks_install_missing_source() { + install_repo=$TMP_DIR/hooks-install-missing + rm -rf "$install_repo" + mkdir -p "$install_repo/scripts/git-hooks" + cp Makefile "$install_repo/Makefile" + cp scripts/git-hooks/pre-commit "$install_repo/scripts/git-hooks/pre-commit" + cp scripts/git-hooks/commit-msg "$install_repo/scripts/git-hooks/commit-msg" + git -C "$install_repo" init >/dev/null 2>&1 + (cd "$install_repo" && make hooks-install) +} + MSG_FILE=$TMP_DIR/commit-msg.txt + +# ============================================ +# commit-msg 测试 +# ============================================ + write_msg "$MSG_FILE" 'feat: 添加 hook 测试' expect_success 'commit-msg accepts Chinese description' scripts/git-hooks/commit-msg "$MSG_FILE" write_msg "$MSG_FILE" 'feat: add hook tests' -expect_failure 'commit-msg rejects English-only description' scripts/git-hooks/commit-msg "$MSG_FILE" +expect_success 'commit-msg accepts English-only description without CJK enforcement' scripts/git-hooks/commit-msg "$MSG_FILE" + +write_msg "$MSG_FILE" 'fix: 修复 auth 模块 bug' +expect_success 'commit-msg accepts Chinese with English technical terms' scripts/git-hooks/commit-msg "$MSG_FILE" + +write_msg "$MSG_FILE" 'docs: ajouter une fonctionnalité' +expect_success 'commit-msg accepts non-CJK unicode description without CJK enforcement' scripts/git-hooks/commit-msg "$MSG_FILE" write_msg "$MSG_FILE" 'update: 添加 hook 测试' expect_failure 'commit-msg rejects invalid type' scripts/git-hooks/commit-msg "$MSG_FILE" @@ -79,6 +111,81 @@ expect_failure 'commit-msg rejects invalid type' scripts/git-hooks/commit-msg "$ write_msg "$MSG_FILE" 'Merge branch feature' expect_success 'commit-msg accepts merge commits' scripts/git-hooks/commit-msg "$MSG_FILE" +write_msg "$MSG_FILE" 'feat: 添加新功能 +' +expect_success 'commit-msg accepts single line with trailing newline' scripts/git-hooks/commit-msg "$MSG_FILE" + +printf 'feat: 添加新功能\n\n详细描述内容\n' > "$MSG_FILE" +expect_success 'commit-msg accepts multi-line with blank line separator' scripts/git-hooks/commit-msg "$MSG_FILE" + +printf 'feat: 添加新功能\n缺少空行\n详细描述\n' > "$MSG_FILE" +expect_failure 'commit-msg rejects multi-line without blank line separator' scripts/git-hooks/commit-msg "$MSG_FILE" + +printf 'feat: 添加新功能\n\n' > "$MSG_FILE" +expect_success 'commit-msg accepts two lines with blank line 2' scripts/git-hooks/commit-msg "$MSG_FILE" + +printf 'feat: 添加新功能\n非空行\n' > "$MSG_FILE" +expect_success 'commit-msg accepts two lines without body (no line 3)' scripts/git-hooks/commit-msg "$MSG_FILE" + +printf 'feat: 添加模板测试\n# <类型>: <简短中文描述>\n#\n# <详细说明>\n' > "$MSG_FILE" +expect_success 'commit-msg ignores template comments after subject' scripts/git-hooks/commit-msg "$MSG_FILE" + +printf '# <类型>: <简短中文描述>\n#\nfeat: 添加模板测试\n' > "$MSG_FILE" +expect_success 'commit-msg ignores leading template comments' scripts/git-hooks/commit-msg "$MSG_FILE" + +printf 'feat: 添加新功能\n缺少空行\n# 模板注释\n详细描述\n' > "$MSG_FILE" +expect_failure 'commit-msg rejects non-blank separator with intervening comments' scripts/git-hooks/commit-msg "$MSG_FILE" + +# ============================================ +# prepare-commit-msg 测试 +# ============================================ + +prepare_msg_file="$TMP_DIR/prepare-msg.txt" +rm -f "$prepare_msg_file" +touch "$prepare_msg_file" +expect_success 'prepare-commit-msg writes template for empty commit' scripts/git-hooks/prepare-commit-msg "$prepare_msg_file" "" + +if grep -q '<类型>' "$prepare_msg_file" && grep -q 'feat / fix / refactor' "$prepare_msg_file"; then + pass 'prepare-commit-msg template contains format guidance' +else + fail 'prepare-commit-msg template contains format guidance' +fi + +printf '\n# Please enter the commit message for your changes.\n# On branch main\n' > "$prepare_msg_file" +expect_success 'prepare-commit-msg writes template before git comments' scripts/git-hooks/prepare-commit-msg "$prepare_msg_file" "" +if grep -q '<类型>' "$prepare_msg_file" && grep -q 'Please enter the commit message' "$prepare_msg_file"; then + pass 'prepare-commit-msg preserves git comments after template' +else + fail 'prepare-commit-msg preserves git comments after template' +fi + +write_msg "$prepare_msg_file" 'existing content' +expect_success 'prepare-commit-msg skips when file has content' scripts/git-hooks/prepare-commit-msg "$prepare_msg_file" "" +if printf '%s\n' "$(cat "$prepare_msg_file")" | grep -q '^existing content$'; then + pass 'prepare-commit-msg does not overwrite existing content' +else + fail 'prepare-commit-msg does not overwrite existing content' +fi + +rm -f "$prepare_msg_file" +touch "$prepare_msg_file" +expect_success 'prepare-commit-msg skips for merge' scripts/git-hooks/prepare-commit-msg "$prepare_msg_file" "merge" +if [ ! -s "$prepare_msg_file" ]; then + pass 'prepare-commit-msg skips template for merge' +else + fail 'prepare-commit-msg skips template for merge' +fi + +# ============================================ +# hooks-install 测试 +# ============================================ + +expect_failure 'hooks-install rejects missing source hook' run_hooks_install_missing_source + +# ============================================ +# pre-commit 测试 +# ============================================ + cat > backend/pkg/buildinfo/hook_bad_test_fixture.go <<'EOF' package buildinfo @@ -88,20 +195,20 @@ func hookBadTestFixture() { fmt.Println("bad") } EOF -expect_failure 'pre-commit rejects Go lint errors' run_precommit_for backend/pkg/buildinfo/hook_bad_test_fixture.go +expect_failure 'pre-commit rejects Go lint errors (delegated to _backend-lint)' run_precommit_for backend/pkg/buildinfo/hook_bad_test_fixture.go rm -f backend/pkg/buildinfo/hook_bad_test_fixture.go cat > frontend/src/hook_bad_fixture.ts <<'EOF' console.log('bad') EOF -expect_failure 'pre-commit rejects frontend lint errors' run_precommit_for frontend/src/hook_bad_fixture.ts +expect_failure 'pre-commit rejects frontend lint errors (delegated to _frontend-check)' run_precommit_for frontend/src/hook_bad_fixture.ts rm -f frontend/src/hook_bad_fixture.ts cat > frontend/src/hook_format_fixture.ts <<'EOF' const hookFormatFixture={foo:"bar"} export { hookFormatFixture } EOF -expect_failure 'pre-commit rejects frontend format errors' run_precommit_for frontend/src/hook_format_fixture.ts +expect_failure 'pre-commit rejects frontend format errors (delegated to _frontend-check)' run_precommit_for frontend/src/hook_format_fixture.ts rm -f frontend/src/hook_format_fixture.ts cat > docs/hook-doc-fixture.md <<'EOF' @@ -110,16 +217,19 @@ EOF expect_success 'pre-commit skips non-code staged files' run_precommit_for docs/hook-doc-fixture.md rm -f docs/hook-doc-fixture.md -cat > docs/hook-conflict-fixture.md <<'EOF' -<<<<<<< HEAD -conflict -======= -other ->>>>>>> branch -EOF +write_conflict docs/hook-conflict-fixture.md expect_failure 'pre-commit rejects conflict markers' run_precommit_for docs/hook-conflict-fixture.md rm -f docs/hook-conflict-fixture.md +index=$TMP_DIR/index +rm -f "$index" +GIT_INDEX_FILE=$index git read-tree HEAD +write_conflict "$TMP_DIR/hook-conflict-fixture.sh" +hash=$(git hash-object -w "$TMP_DIR/hook-conflict-fixture.sh") +rm -f "$TMP_DIR/hook-conflict-fixture.sh" +GIT_INDEX_FILE=$index git update-index --add --cacheinfo 100644 "$hash" "scripts/git-hooks/hook-conflict-fixture.sh" +expect_failure 'pre-commit rejects conflict markers in hook scripts' env GIT_INDEX_FILE=$index make _hooks-pre-commit + i=0 while [ "$i" -lt 40000 ]; do printf 'large hook fixture line\n' @@ -132,3 +242,32 @@ else fail 'pre-commit warns for large text files' fi rm -f docs/hook-large-fixture.txt + +# LFS pointer 校验 +lfs_pointer='version https://git-lfs.github.com/spec/v1 +oid sha256:abc123 +size 100 +' +printf '%s\n' "$lfs_pointer" > "$TMP_DIR/lfs-pointer-fixture" +hash=$(git hash-object -w "$TMP_DIR/lfs-pointer-fixture") +index=$TMP_DIR/index +rm -f "$index" +GIT_INDEX_FILE=$index git read-tree HEAD +GIT_INDEX_FILE=$index git update-index --add --cacheinfo 100644 "$hash" "assets/test-lfs-fixture.png" +if GIT_INDEX_FILE=$index make _hooks-pre-commit > "$TMP_DIR/out" 2>&1; then + pass 'pre-commit allows LFS pointer files' +else + cat "$TMP_DIR/out" >&2 + fail 'pre-commit allows LFS pointer files' +fi + +printf 'fake binary content\n' > "$TMP_DIR/lfs-bad-fixture" +hash=$(git hash-object -w "$TMP_DIR/lfs-bad-fixture") +rm -f "$index" +GIT_INDEX_FILE=$index git read-tree HEAD +GIT_INDEX_FILE=$index git update-index --add --cacheinfo 100644 "$hash" "assets/test-lfs-bad-fixture.png" +if GIT_INDEX_FILE=$index make _hooks-pre-commit > "$TMP_DIR/out" 2>&1; then + cat "$TMP_DIR/out" >&2 + fail 'pre-commit rejects non-pointer LFS files' +fi +pass 'pre-commit rejects non-pointer LFS files'