修复 PR316 review:pre-commit Rust 格式检查按暂存文件筛 workspace
- scripts/lint-staged-rustfmt.mjs:12 [maintainability medium] 改为读取 lint-staged 追加的暂存 .rs 路径,只对真正命中暂存文件的 workspace 跑 cargo fmt --all -- --check:此前永远全量跑两个 workspace,只暂存 server-rs 干净文件的提交会被别人未暂存的 src-tauri 改动挡下来;没收到暂存列表(手工执行)时回退全量检查并在 stderr 说明,避免假装检查过。 - scripts/lint-staged-rustfmt.mjs:7 [maintainability low] apps/desktop-shell/src-tauri 显式记为「有意排除」而不是静默通过:实测该 workspace 当前整体不过 cargo fmt --check(host_bridge/mod.rs 等),直接纳入会让任何 Rust 提交都失败;命中该前缀的暂存文件会打印跳过提示,纳入步骤写在模块注释里(同时要补 package.json 的 check:rustfmt / format:rust,package.json 不在本批次改动范围)。 - 新增 scripts/lint-staged-rustfmt-workspaces.mjs:把「暂存路径 → 待检查 workspace」的纯映射逻辑独立出来,附 Windows 反斜杠/绝对路径归一化。 - 新增 scripts/lint-staged-rustfmt.test.ts(vitest,随 npm test 运行):覆盖只暂存单侧 workspace 的回归判据、手工执行回退全量、排除项与未登记路径必须被点名、路径归一化,以及与 package.json check:rustfmt 的 workspace 表同集交叉校验。
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* pre-commit 的 Rust 格式检查只按 Cargo workspace 粒度执行:`cargo fmt` 不接受「只格式化
|
||||
* 某个文件」(按文件跑 rustfmt 又会丢掉 manifest 的 edition 口径),所以 hook 收到的是
|
||||
* lint-staged 追加的暂存 `.rs` 路径,而真正执行的是 `cargo fmt --all --manifest-path ...`。
|
||||
*
|
||||
* 这段映射逻辑单独成模块,是为了能脱离 cargo 单测:`--all` 会连**未暂存**的在改文件一起
|
||||
* 检查,如果无脑跑完所有 workspace,一个只暂存了 server-rs 干净文件的提交会被「别人正在改的
|
||||
* src-tauri 文件没格式化」挡下来。按暂存路径筛选 workspace 才是「只查暂存的」的正确粒度。
|
||||
*/
|
||||
|
||||
/** 受检查的 workspace:`prefix` 是仓库相对路径前缀,`manifestPath` 是它的 Cargo manifest。 */
|
||||
export const RUSTFMT_WORKSPACES = [
|
||||
{ prefix: 'server-rs/', manifestPath: 'server-rs/Cargo.toml' },
|
||||
{
|
||||
prefix: 'apps/ai-game-creator-shell/src-tauri/',
|
||||
manifestPath: 'apps/ai-game-creator-shell/src-tauri/Cargo.toml',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 有意排除的第三个 workspace:`apps/desktop-shell/src-tauri`。
|
||||
*
|
||||
* `package.json` 的 `*.rs` glob 会命中它的文件,但仓库当前**整体**不过
|
||||
* `cargo fmt --check`(2026-09 实测 `apps/desktop-shell/src-tauri/src/host_bridge/mod.rs`
|
||||
* 等仍待格式化)。直接纳入会让任何一次只改了别处的 Rust 提交因为历史未格式化文件而失败,
|
||||
* 所以这里显式记录「不查」,而不是让它静默通过:CLI 会对命中该前缀的暂存文件打印提示。
|
||||
* 纳入前先单独跑一次
|
||||
* `cargo fmt --all --manifest-path apps/desktop-shell/src-tauri/Cargo.toml` 并把它加进
|
||||
* `RUSTFMT_WORKSPACES`(同时补 `package.json` 的 `check:rustfmt` / `format:rust`)。
|
||||
*/
|
||||
export const RUSTFMT_EXCLUDED_WORKSPACES = [
|
||||
{ prefix: 'apps/desktop-shell/src-tauri/' },
|
||||
];
|
||||
|
||||
/** 把 lint-staged 传来的路径统一成「仓库相对 + 正斜杠」形式,便于前缀比较。 */
|
||||
function normalizeStagedPath(filePath, repoRoot) {
|
||||
if (typeof filePath !== 'string') return null;
|
||||
const normalized = filePath.replaceAll('\\', '/').replace(/^\.\//u, '');
|
||||
if (normalized.length === 0) return null;
|
||||
|
||||
const normalizedRoot = repoRoot?.replaceAll('\\', '/').replace(/\/+$/u, '');
|
||||
if (!normalizedRoot) return normalized;
|
||||
|
||||
const isAbsolute =
|
||||
/^[A-Za-z]:\//u.test(normalized) || normalized.startsWith('/');
|
||||
if (!isAbsolute) return normalized;
|
||||
|
||||
const windowsRoot = normalizedRoot.startsWith('/')
|
||||
? normalizedRoot
|
||||
: `/${normalizedRoot}`;
|
||||
const absolutePrefix = `${normalizedRoot}/`;
|
||||
const absolutePrefixWindows = `${windowsRoot}/`;
|
||||
const lowerPath = normalized.toLowerCase();
|
||||
for (const prefix of [absolutePrefix, absolutePrefixWindows]) {
|
||||
if (lowerPath.startsWith(prefix.toLowerCase())) {
|
||||
return normalized.slice(prefix.length);
|
||||
}
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* 由暂存 `.rs` 路径筛出要跑 `cargo fmt --check` 的 workspace。
|
||||
*
|
||||
* - 没有暂存文件列表(手工执行)时保持旧的「全量检查」语义;
|
||||
* - 只检查真的被暂存文件命中的 workspace;
|
||||
* - 命中排除前缀 / 任何已知 workspace 之外的暂存文件单独回报,由调用方提示,
|
||||
* 避免「glob 命中了但谁都没查」这种静默通过。
|
||||
*/
|
||||
export function selectRustfmtWorkspaces({ stagedFiles, repoRoot }) {
|
||||
const normalized = (stagedFiles ?? [])
|
||||
.map((filePath) => normalizeStagedPath(filePath, repoRoot))
|
||||
.filter((filePath) => filePath !== null);
|
||||
|
||||
if (normalized.length === 0) {
|
||||
return {
|
||||
workspaces: RUSTFMT_WORKSPACES,
|
||||
excluded: [],
|
||||
unmanaged: [],
|
||||
usedStagedList: false,
|
||||
};
|
||||
}
|
||||
|
||||
const hitsPrefix = (filePath, prefix) => filePath.startsWith(prefix);
|
||||
const hitsAnyPrefix = (filePath, entries) =>
|
||||
entries.some(({ prefix }) => hitsPrefix(filePath, prefix));
|
||||
|
||||
return {
|
||||
workspaces: RUSTFMT_WORKSPACES.filter((workspace) =>
|
||||
normalized.some((filePath) => hitsPrefix(filePath, workspace.prefix)),
|
||||
),
|
||||
excluded: normalized.filter((filePath) =>
|
||||
hitsAnyPrefix(filePath, RUSTFMT_EXCLUDED_WORKSPACES),
|
||||
),
|
||||
unmanaged: normalized.filter(
|
||||
(filePath) =>
|
||||
!hitsAnyPrefix(filePath, RUSTFMT_WORKSPACES) &&
|
||||
!hitsAnyPrefix(filePath, RUSTFMT_EXCLUDED_WORKSPACES),
|
||||
),
|
||||
usedStagedList: true,
|
||||
};
|
||||
}
|
||||
@@ -1,15 +1,43 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import process from 'node:process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { selectRustfmtWorkspaces } from './lint-staged-rustfmt-workspaces.mjs';
|
||||
|
||||
// lint-staged 会把命中的暂存文件路径追加到命令末尾,而 `cargo fmt` 只按 workspace 粒度格式化、
|
||||
// 不接受文件参数(也做不到「只格式化某个文件」),所以这里忽略 argv,直接对两个 workspace 跑
|
||||
// `--check`。只查不改:pre-commit 不应该自动改写别人正在改的 Rust 文件。
|
||||
const workspaces = [
|
||||
'server-rs/Cargo.toml',
|
||||
'apps/ai-game-creator-shell/src-tauri/Cargo.toml',
|
||||
];
|
||||
// 不接受文件参数(也做不到「只格式化某个文件」),所以这里用暂存路径筛出**需要检查的
|
||||
// workspace**,再对它们跑 `--check`。只查不改:pre-commit 不应该自动改写别人正在改的 Rust 文件。
|
||||
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const { workspaces, excluded, unmanaged, usedStagedList } =
|
||||
selectRustfmtWorkspaces({
|
||||
stagedFiles: process.argv.slice(2),
|
||||
repoRoot,
|
||||
});
|
||||
|
||||
for (const manifestPath of workspaces) {
|
||||
// 静默通过是这里最贵的失败模式:glob 命中了文件、但没有任何 workspace 检查到它。
|
||||
if (excluded.length > 0) {
|
||||
process.stderr.write(
|
||||
`Rust 格式检查跳过(有意排除的 workspace):${excluded.join(', ')}\n` +
|
||||
`该 workspace 当前整体未过 \`cargo fmt --check\`,详见 scripts/lint-staged-rustfmt-workspaces.mjs。\n`,
|
||||
);
|
||||
}
|
||||
if (unmanaged.length > 0) {
|
||||
process.stderr.write(
|
||||
`Rust 格式检查未覆盖以下暂存文件(不在任何已登记 workspace 内):${unmanaged.join(', ')}\n` +
|
||||
`如果是新 workspace,请登记到 scripts/lint-staged-rustfmt-workspaces.mjs。\n`,
|
||||
);
|
||||
}
|
||||
|
||||
// 一条都没筛出来又不曾拿到暂存列表,说明调用方式变了(例如 lint-staged 不再追加参数):
|
||||
// 宁可回退到全量检查,也不要假装检查过了。
|
||||
if (!usedStagedList) {
|
||||
process.stderr.write(
|
||||
'Rust 格式检查未收到 lint-staged 的暂存文件列表,回退为全量检查所有 workspace。\n',
|
||||
);
|
||||
}
|
||||
|
||||
for (const { manifestPath } of workspaces) {
|
||||
const result = spawnSync(
|
||||
'cargo',
|
||||
['fmt', '--all', '--manifest-path', manifestPath, '--', '--check'],
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import {
|
||||
RUSTFMT_EXCLUDED_WORKSPACES,
|
||||
RUSTFMT_WORKSPACES,
|
||||
selectRustfmtWorkspaces,
|
||||
} from './lint-staged-rustfmt-workspaces.mjs';
|
||||
|
||||
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
function selectedManifests(stagedFiles: string[], root = repoRoot) {
|
||||
return selectRustfmtWorkspaces({
|
||||
stagedFiles,
|
||||
repoRoot: root,
|
||||
}).workspaces.map((workspace) => workspace.manifestPath);
|
||||
}
|
||||
|
||||
describe('lint-staged Rust 格式检查的 workspace 选择', () => {
|
||||
test('只暂存 server-rs 时不再连带检查 src-tauri', () => {
|
||||
// 回归判据:`cargo fmt --all` 会连未暂存的在改文件一起查,全量跑两个 workspace
|
||||
// 会让「只暂存了 server-rs 干净文件」的提交被别人的 src-tauri 改动挡下来。
|
||||
expect(
|
||||
selectedManifests([
|
||||
'server-rs/crates/api-server/src/editor_project.rs',
|
||||
'server-rs/crates/shared-contracts/src/game_creation_app.rs',
|
||||
]),
|
||||
).toEqual(['server-rs/Cargo.toml']);
|
||||
});
|
||||
|
||||
test('只暂存 src-tauri 时不再连带检查 server-rs', () => {
|
||||
expect(
|
||||
selectedManifests(['apps/ai-game-creator-shell/src-tauri/src/assets.rs']),
|
||||
).toEqual(['apps/ai-game-creator-shell/src-tauri/Cargo.toml']);
|
||||
});
|
||||
|
||||
test('两个 workspace 都有暂存文件时两个都查', () => {
|
||||
expect(
|
||||
selectedManifests([
|
||||
'server-rs/crates/api-server/src/editor_project.rs',
|
||||
'apps/ai-game-creator-shell/src-tauri/src/assets.rs',
|
||||
]),
|
||||
).toEqual([
|
||||
'server-rs/Cargo.toml',
|
||||
'apps/ai-game-creator-shell/src-tauri/Cargo.toml',
|
||||
]);
|
||||
});
|
||||
|
||||
test('手工执行(没有暂存列表)时保持全量检查语义', () => {
|
||||
const selection = selectRustfmtWorkspaces({ stagedFiles: [], repoRoot });
|
||||
expect(selection.usedStagedList).toBe(false);
|
||||
expect(selection.workspaces).toEqual(RUSTFMT_WORKSPACES);
|
||||
});
|
||||
|
||||
test('排除的 workspace 与未登记路径都会被显式报出,不静默通过', () => {
|
||||
const selection = selectRustfmtWorkspaces({
|
||||
stagedFiles: [
|
||||
'apps/desktop-shell/src-tauri/src/host_bridge/mod.rs',
|
||||
'somewhere-new/src/lib.rs',
|
||||
],
|
||||
repoRoot,
|
||||
});
|
||||
// desktop-shell 当前整体未过 `cargo fmt --check`,所以它不参与检查……
|
||||
expect(selection.workspaces).toEqual([]);
|
||||
// ……但必须被点名,否则「glob 命中了、谁都没查」就是静默通过。
|
||||
expect(selection.excluded).toEqual([
|
||||
'apps/desktop-shell/src-tauri/src/host_bridge/mod.rs',
|
||||
]);
|
||||
expect(selection.unmanaged).toEqual(['somewhere-new/src/lib.rs']);
|
||||
});
|
||||
|
||||
test('Windows 反斜杠与绝对路径都归一化成仓库相对路径', () => {
|
||||
const root = 'C:\\repo\\genarrative';
|
||||
expect(
|
||||
selectedManifests(
|
||||
[
|
||||
'server-rs\\crates\\api-server\\src\\editor_project.rs',
|
||||
`${root}\\apps\\ai-game-creator-shell\\src-tauri\\src\\assets.rs`,
|
||||
],
|
||||
root,
|
||||
),
|
||||
).toEqual([
|
||||
'server-rs/Cargo.toml',
|
||||
'apps/ai-game-creator-shell/src-tauri/Cargo.toml',
|
||||
]);
|
||||
});
|
||||
|
||||
test('检查清单与 package.json 的 check:rustfmt 保持同一份 workspace 表', () => {
|
||||
// 两处各写一份 workspace 列表就会漂移:hook 查的与 `npm run check:rustfmt` 查的必须同集。
|
||||
const packageJson = JSON.parse(
|
||||
readFileSync(resolve(repoRoot, 'package.json'), 'utf8'),
|
||||
) as { scripts: Record<string, string> };
|
||||
const manifests = Array.from(
|
||||
packageJson.scripts['check:rustfmt']!.matchAll(
|
||||
/--manifest-path\s+(\S+)\s+--\s+--check/gu,
|
||||
),
|
||||
).map((match) => match[1]!);
|
||||
expect(manifests).toEqual(
|
||||
RUSTFMT_WORKSPACES.map((workspace) => workspace.manifestPath),
|
||||
);
|
||||
// 排除项必须是「检查清单之外」的,否则这段注释与实际行为不符。
|
||||
for (const excluded of RUSTFMT_EXCLUDED_WORKSPACES) {
|
||||
expect(
|
||||
RUSTFMT_WORKSPACES.some((workspace) =>
|
||||
workspace.prefix.startsWith(excluded.prefix),
|
||||
),
|
||||
).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user