## 起因(实测,同一天两次)
`redeploy-gateway.sh` 用 mtime 比对源码与 dist:
newer=$(find src -type f -newer dist/index.html)
[ -n "$newer" ] && exit 1
mtime 只说「这个文件被碰过」,不说「它变了什么」。于是共享工作树里**任何人
改一行注释就会拦下部署** —— 那行注释不进 bundle,重建产物与现有 dist
逐字节相同。
2026-10-02 实测被拦两次,都是 `client/electron/src/api/client.ts` 的线程树
说明注释(另一会话的工作树在制品,未提交),每次多花一轮 `npm run build`。
## 为什么不是拆掉那道闸
2026-09-14 踩过它的来历:改了 `src/lib/appearance.ts` 的请求路径却没跑
vite build,部署照样「同步成功」,出去的还是旧 bundle —— 表现为接口 404
(`/api/v1/api/v1/…` 双前缀),而**所有单测都是绿的**。那种失败是沉默的,
所以闸不能拆。
本改动是在闸**前面**加一层精化:先问「差异是否只是注释」,是则放行并说明,
否则维持拦截。拿不准时一律偏向拦截:
误拦的代价 = 多构建一次(几十秒)
误放的代价 = 把旧界面打进二进制(接口 404,且没人立刻归因)
## 判定口径(deploy/web-comment-only.mjs)
对每个「比 dist 新」的文件取它相对 **HEAD** 的 diff,去掉 `---`/`+++` 头、
diff 元信息与整行注释后若还剩内容 ⇒ 真改动 ⇒ 拦截。
- **只看未提交差异**(`git diff HEAD --`)。已提交改动早于本次部署决策。
- **未跟踪的新文件**按真改动处理 —— 判不出就别放行。
- 滤的是「整行都是注释」的行;行尾注释(`code(); // 注释`)算真改动(保守)。
- 块注释中间行(` * …`)与结尾也算注释。
## 判据(7 格)
`client/electron/test/web-comment-only.test.mjs`。判据本身必须能区分
「仅注释」与「真代码」,所以每格都给**两侧**对照。其中「只有注释差异」
那格用的 diff **逐字取自当天实测的 git diff**。
**变异验证**:
去掉注释分支(所有行算真改动) → 红 7(部署会被那一行注释继续拦)
isCodeChange 恒 false → 红 7(把 2026-09-14 的静默失败放回来)
第二条是关键:它证明这套判据不会为了「少拦一次」而牺牲那道沉默失败的闸。
## 真实场景验证(不是只跑单测)
把 dist/index.html 时间戳改早 → 5 个源文件「比 dist 新」
⇒ commentOnly=true,理由写明「差异仅为注释」
临时往 sse.ts 追加一行真代码
⇒ commentOnly=false,理由点名那行 `+const __probe = 1;`
bash deploy/redeploy-gateway.sh --dry-run
⇒ [WARN] 前端源码被更新,但差异**仅为注释** ⇒ 不重建
## 顺带
`find … | head -20`(原 head -3):文件多时不至于只看到前 3 个就下结论。
134 lines
5.2 KiB
JavaScript
134 lines
5.2 KiB
JavaScript
#!/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));
|
||
} |