diff --git a/.husky/pre-push b/.husky/pre-push new file mode 100644 index 0000000..970dc77 --- /dev/null +++ b/.husky/pre-push @@ -0,0 +1,26 @@ +#!/usr/bin/env sh + +set -e + +zero_object_id=0000000000000000000000000000000000000000 + +while IFS=' ' read -r local_ref local_object_id remote_ref remote_object_id +do + if [ "$remote_ref" != "refs/heads/main" ]; then + continue + fi + + if [ "$local_object_id" = "$zero_object_id" ]; then + echo "拒绝推送:禁止删除远端 main。" >&2 + exit 1 + fi + + if [ "$remote_object_id" = "$zero_object_id" ]; then + continue + fi + + if ! git merge-base --is-ancestor "$remote_object_id" "$local_object_id"; then + echo "拒绝推送:远端 main 只允许快进更新,禁止强制推送。" >&2 + exit 1 + fi +done diff --git a/package.json b/package.json index 092f953..94cc359 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "dev:harness": "pnpm --filter @trade-message-center/mind-test-harness run dev", "build": "pnpm run clean:dist && pnpm run version:ensure && node scripts/with-build-hash.mjs --filter @trade-message-center/onetalk-contract --filter @trade-message-center/server --filter @trade-message-center/chrome-extension --if-present run build", "typecheck": "pnpm run clean:dist && pnpm run version:ensure && pnpm --filter @trade-message-center/onetalk-contract --filter @trade-message-center/server --filter @trade-message-center/chrome-extension --if-present run typecheck", - "test": "pnpm run clean:dist && pnpm run version:ensure && node --test scripts/package-version.test.mjs scripts/release.test.mjs scripts/dev-startup.test.mjs && pnpm --filter @trade-message-center/onetalk-contract --filter @trade-message-center/server --filter @trade-message-center/chrome-extension --if-present run test", + "test": "pnpm run clean:dist && pnpm run version:ensure && node --test scripts/package-version.test.mjs scripts/release.test.mjs scripts/dev-startup.test.mjs scripts/pre-push-main-protection.test.mjs && pnpm --filter @trade-message-center/onetalk-contract --filter @trade-message-center/server --filter @trade-message-center/chrome-extension --if-present run test", "format": "oxfmt", "format:check": "pnpm run version:ensure && oxfmt --check", "version:sync": "node scripts/sync-package-versions.mjs", diff --git a/scripts/pre-push-main-protection.test.mjs b/scripts/pre-push-main-protection.test.mjs new file mode 100644 index 0000000..40cb300 --- /dev/null +++ b/scripts/pre-push-main-protection.test.mjs @@ -0,0 +1,96 @@ +// 验证 pre-push 对远端 main 的非快进更新进行保护。 + +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawnSync } from "node:child_process"; +import test from "node:test"; + +const repositoryRoot = fileURLToPath(new URL("../", import.meta.url)); +const hookPath = join(repositoryRoot, ".husky", "pre-push"); +const zeroObjectId = "0".repeat(40); + +const runGit = (cwd, arguments_) => { + const result = spawnSync("git", arguments_, { + cwd, + encoding: "utf8", + }); + assert.equal(result.status, 0, result.stderr); + return result.stdout.trim(); +}; + +const createRepository = () => { + const repository = mkdtempSync(join(tmpdir(), "tmc-pre-push-")); + runGit(repository, ["init", "--quiet"]); + runGit(repository, ["config", "user.email", "test@example.com"]); + runGit(repository, ["config", "user.name", "Pre-push test"]); + + const commit = (fileName, content, message) => { + writeFileSync(join(repository, fileName), content); + runGit(repository, ["add", fileName]); + runGit(repository, ["commit", "--quiet", "-m", message]); + return runGit(repository, ["rev-parse", "HEAD"]); + }; + + const base = commit("base.txt", "base\n", "base"); + const main = commit("main.txt", "main\n", "main"); + runGit(repository, ["switch", "--detach", base]); + const divergent = commit("divergent.txt", "divergent\n", "divergent"); + + return { base, divergent, main, repository }; +}; + +const runHook = (repository, localObjectId, remoteRef, remoteObjectId) => + spawnSync("sh", [hookPath], { + cwd: repository, + encoding: "utf8", + input: `refs/heads/local ${localObjectId} ${remoteRef} ${remoteObjectId}\n`, + }); + +test("allows a fast-forward update to remote main", () => { + const { base, main, repository } = createRepository(); + try { + const result = runHook(repository, main, "refs/heads/main", base); + + assert.equal(result.status, 0, result.stderr); + } finally { + rmSync(repository, { force: true, recursive: true }); + } +}); + +test("rejects a non-fast-forward update to remote main", () => { + const { divergent, main, repository } = createRepository(); + try { + const result = runHook(repository, divergent, "refs/heads/main", main); + + assert.notEqual(result.status, 0); + assert.match(result.stderr, /只允许快进更新/u); + } finally { + rmSync(repository, { force: true, recursive: true }); + } +}); + +test("does not restrict non-fast-forward updates to other remote branches", () => { + const { divergent, main, repository } = createRepository(); + try { + const result = runHook(repository, divergent, "refs/heads/release", main); + + assert.equal(result.status, 0, result.stderr); + } finally { + rmSync(repository, { force: true, recursive: true }); + } +}); + +test("rejects deleting remote main", () => { + const { main, repository } = createRepository(); + try { + const result = runHook(repository, zeroObjectId, "refs/heads/main", main); + + assert.notEqual(result.status, 0); + assert.match(result.stderr, /禁止删除远端 main/u); + } finally { + rmSync(repository, { force: true, recursive: true }); + } +});