#!/usr/bin/env node /** * 判定「前端源码更新是否只是注释」。 * * # 为什么需要它 * * redeploy-gateway.sh 用 mtime 比对源码与 dist: * * newer=$(find src -type f -newer dist/index.html) * [ -n "$newer" ] && exit 1 * * 而 mtime 只说「这个文件被碰过」,不说「它变了什么」。于是**任何人在共享 * 工作树里改一行注释就会卡住部署** —— 那行注释不进 bundle,重建产物与 * 旧产物逐字节相同。 * * 实测两次(2026-10-02 同一天):`client/electron/src/api/client.ts` 的 * 线程树说明注释(另一会话的在制品,未提交)把部署拦了两次,每次多花一轮 * `npm run build`。 * * # 为什么不是「无条件跳过」 * * mtime 那道闸是有来历的:2026-09-14 改了 `src/lib/appearance.ts` 的请求 * 路径却没跑 vite build,部署照样「同步成功」,出去的还是旧 bundle —— * 表现为接口 404(`/api/v1/api/v1/…` 双前缀),而**所有单测都是绿的**。 * 那种失败是沉默的,所以闸不能拆。 * * 本模块只在「差异确为纯注释」时给出放行信号,其余一律维持拦截。 * 拿不准时偏向拦截:误拦的代价是多构建一次(几十秒),误放的代价是 * 把旧界面打进二进制(接口 404,且没人立刻归因)。 * * # 判定口径 * * 对每个「比 dist 新」的源码文件,取它相对 **HEAD** 的 diff,去掉 * `---`/`+++` 头与整行注释后若还剩内容 ⇒ 是真改动 ⇒ 维持拦截。 * * 几个刻意的取舍: * * - 只看**未提交**的差异(`git diff HEAD --`)。已提交的改动早于本次 * 部署决策,不该在这里判;否则每次部署都要把整个提交历史重扫一遍。 * - **未跟踪的新文件**按真改动处理。判不出来就拦。 * - 滤掉的是「整行都是注释」的行。行尾注释(`code(); // 注释`)算真改动 —— * 保守方向,且那种改动本来也该重建。 * - 块注释的中间行(` * …`)与结尾(星号 + 斜杠)也算注释。 */ /** * @param {string[]} files 比 dist 新的源码文件(绝对或相对仓库根) * @param {string} cwd 仓库根 * @param {(cmd: string[]) => {stdout: string, code: number}} run * @returns {{commentOnly: boolean, changed: string[], untracked: string[], reason: string}} */ export function classifySourceChanges(files, cwd, run) { const rel = files .map((f) => f.startsWith(cwd) ? f.slice(cwd.length + 1) : f) .filter(Boolean); if (rel.length === 0) { return { commentOnly: true, changed: [], untracked: [], reason: '没有文件更新' }; } // 未跟踪的新文件:判不出来 ⇒ 当真改动。 const untrackedOut = run(['git', 'ls-files', '--others', '--exclude-standard', '--', ...rel]); const untracked = splitLines(untrackedOut.stdout); if (untracked.length > 0) { return { commentOnly: false, changed: untracked, untracked, reason: `新增未跟踪文件 ${untracked.join(' ')} —— 判不出是否只是注释,按真改动处理` }; } // 取相对 HEAD 的差异(只看工作树里还没提交的)。 const diffOut = run(['git', 'diff', '-U0', 'HEAD', '--', ...rel]); const significant = splitLines(diffOut.stdout).filter(isCodeChange); if (significant.length === 0) { return { commentOnly: true, changed: [], untracked: [], reason: `差异仅为注释(${rel.join(' ')})—— 不进 bundle,重建产物与现有 dist 相同` }; } return { commentOnly: false, changed: rel, untracked: [], reason: `有非注释改动(${significant.length} 行,如:${significant[0]})` }; } /** 判断一行 diff 文本是否算「真代码改动」。 */ export function isCodeChange(line) { // ---/+++ 文件头 if (line.startsWith('---') || line.startsWith('+++')) return false; // diff 元信息 if (line.startsWith('diff ') || line.startsWith('index ') || line.startsWith('@@')) return false; // 必须是增删行之一 if (!line.startsWith('+') && !line.startsWith('-')) return false; const body = line.slice(1); // 空行 if (body.trim() === '') return false; // 整行注释:// …、* …(块注释中间行)、* / …(块注释结尾) if (/^\s*(\/\/|\*|\/\*)/.test(body)) return false; return true; } function splitLines(s) { return String(s || '').split('\n').map((x) => x.trim()).filter(Boolean); } // 直接执行时打印判定结果(给 shell 侧一行输出用)。 if (process.argv[1] && process.argv[1].endsWith('web-comment-only.mjs')) { const { execFileSync } = await import('node:child_process'); const files = process.argv.slice(2); if (files.length === 0) { console.log(JSON.stringify({ commentOnly: false, reason: '没有传入文件' })); process.exit(0); } const cwd = process.env.REPO || process.cwd(); const run = (cmd) => { try { const stdout = execFileSync(cmd[0], cmd.slice(1), { cwd, encoding: 'utf8' }); return { stdout, code: 0 }; } catch (e) { return { stdout: (e.stdout || '') + (e.stderr || ''), code: 1 }; } }; const res = classifySourceChanges(files, cwd, run); console.log(JSON.stringify(res)); }