ci: enforce product naming on new commit ranges
This commit is contained in:
1 parent
0133873c46
commit
02e13c1f97
10 files changed
+670
No files matched your search
@@ -36,6 +36,27 @@ jobs:
|
|||||||
scripts.tests.test_architecture_budgets
|
scripts.tests.test_architecture_budgets
|
||||||
scripts.tests.test_architecture_dependencies
|
scripts.tests.test_architecture_dependencies
|
||||||
scripts.tests.test_architecture_governance
|
scripts.tests.test_architecture_governance
|
||||||
|
scripts.tests.test_prohibited_names
|
||||||
|
- name: Check prohibited product names in the new range
|
||||||
|
env:
|
||||||
|
PROHIBITED_NAMES_BASE: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha || github.event.before }}
|
||||||
|
PROHIBITED_NAMES_HEAD: ${{ github.event.pull_request.head.sha || github.event.merge_group.head_sha || github.sha }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
base="$PROHIBITED_NAMES_BASE"
|
||||||
|
head="$PROHIBITED_NAMES_HEAD"
|
||||||
|
# The first push has no before SHA. Derive its known parent when one
|
||||||
|
# exists; an unresolved/zero base must still fail closed in the checker.
|
||||||
|
if [[ -z "$base" || "$base" =~ ^0+$ ]]; then
|
||||||
|
base="$(git rev-parse "${head}^" 2>/dev/null || true)"
|
||||||
|
fi
|
||||||
|
if [[ -z "$base" || "$base" =~ ^0+$ || -z "$head" || "$head" =~ ^0+$ ]]; then
|
||||||
|
echo "::error::Cannot determine a non-zero base/head for prohibited-name range" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
python3 scripts/check_prohibited_names.py range \
|
||||||
|
--base "$base" \
|
||||||
|
--head "$head"
|
||||||
- name: Check source size and dependency direction
|
- name: Check source size and dependency direction
|
||||||
env:
|
env:
|
||||||
ARCHITECTURE_BASE: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha || github.event.before }}
|
ARCHITECTURE_BASE: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha || github.event.before }}
|
||||||
|
|||||||
@@ -159,6 +159,8 @@ scripts/drawer_repro.ps1
|
|||||||
scripts/ghost_repro/
|
scripts/ghost_repro/
|
||||||
scripts/icon-build/
|
scripts/icon-build/
|
||||||
scripts/render_shell_icon.html
|
scripts/render_shell_icon.html
|
||||||
|
# The competitor-name policy is a maintained CI and hook contract, not a local probe.
|
||||||
|
!/scripts/check_prohibited_names.py
|
||||||
# Only the maintained Markdown stress runner is public; its scratch variants stay local.
|
# Only the maintained Markdown stress runner is public; its scratch variants stay local.
|
||||||
/scripts/markdown-memory-*
|
/scripts/markdown-memory-*
|
||||||
!/scripts/markdown-memory-stress.ps1
|
!/scripts/markdown-memory-stress.ps1
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# Prohibited-name range checks
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Implemented for review. The existing local hook modes remain unchanged; the public CI entry point is `range --base BASE --head HEAD`.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The repository already used an ignored `scripts/check_prohibited_names.py` in staged, commit-message, and pre-push hooks. The policy must also run in the existing `architecture-contracts` check for pull requests, merge groups, and pushes to `main`, without rescanning old release history or making legitimate attribution and protocol compatibility impossible.
|
||||||
|
|
||||||
|
## Evidence
|
||||||
|
|
||||||
|
- `.github/workflows/architecture.yml` already runs on `pull_request`, `merge_group`, and `push` to `main`, with a full checkout.
|
||||||
|
- The local checker already owns the prohibited-name list and hook behavior; duplicating the list in workflow YAML would create a second policy source.
|
||||||
|
- Inline copyright/third-party attribution can contain a project name, while Cargo git/source references and protocol/external-format identifiers include key-reporting constants, multiplexer escape sequences, and theme-format variants.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
- Publish the existing checker as the single policy implementation and add a fail-closed range mode.
|
||||||
|
- Resolve both revisions, compute their `merge-base`, scan ordinary commits against their first parent and merge commits with a combined diff, and scan every commit message in the resulting range. Missing, zero, unreadable, or unrelated revisions return failure.
|
||||||
|
- Keep only the checker, its contract tests and the intentional negative comparison fixture as exact path exceptions. Strip only reviewed inline attribution, Cargo git/source references, and protocol compatibility identifiers before applying the existing patterns.
|
||||||
|
- Run the checker as a step inside `architecture-contracts` and run its unit tests with the other guardrail tests. No new required check or server-side ruleset change is introduced.
|
||||||
|
|
||||||
|
## Rejected alternatives
|
||||||
|
|
||||||
|
- Scanning the whole repository would fail on historical, already-reviewed attribution and compatibility names.
|
||||||
|
- Using only `git diff base..head` would miss a prohibited name added and removed by separate commits; range mode reuses the hook's per-commit added-line scan. Comparing every merge to its first parent would replay second-parent main changes, so merge commits use combined diff instead.
|
||||||
|
- Treating an unknown base as an empty range would make an unavailable GitHub event input look green; the workflow derives only a known first-push parent and otherwise fails. A normal PR that has diverged from main uses its merge-base.
|
||||||
|
- Adding a broad `docs/`, `src/`, or product-directory exemption would hide ordinary competitor comparisons, so exemptions remain exact and line-scoped.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
New PR/merge-group/main-push text and commit subjects are checked by the existing required job. A legitimate inline attribution, real dependency source, or supported protocol identifier remains usable. Adding another legal/protocol/dependency spelling requires a narrow fixture and policy update rather than silently bypassing the scan.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
`scripts/tests/test_prohibited_names.py` covers ordinary comparison failure, inline attribution positive/negative paths, protocol identifiers, Cargo dependency references, GitHub CLI text, range text, range commit messages, diverged related bases, unrelated/missing bases, old-history exclusion, added-then-deleted text, and merge-resolution additions. The checker also passes `py_compile` before CI execution.
|
||||||
|
|
||||||
|
## Supersedes
|
||||||
|
|
||||||
|
None.
|
||||||
|
|
||||||
|
## Revisit when
|
||||||
|
|
||||||
|
GitHub changes the event payload fields used for base/head selection, the repository adopts a different merge queue event, or a new legal/protocol/dependency contract requires a narrowly documented exception.
|
||||||
@@ -0,0 +1,324 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""拒绝把竞品名称写入新增内容、提交说明或待推送提交。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Iterable
|
||||||
|
|
||||||
|
|
||||||
|
PROHIBITED_PATTERNS = (
|
||||||
|
re.compile(r"(?<![A-Za-z0-9_])tty[-_ ]?7(?![A-Za-z0-9_])", re.IGNORECASE),
|
||||||
|
re.compile(r"(?<![A-Za-z0-9_])otty(?![A-Za-z0-9_])", re.IGNORECASE),
|
||||||
|
re.compile(r"(?<![A-Za-z0-9_])netcaxx(?![A-Za-z0-9_])", re.IGNORECASE),
|
||||||
|
re.compile(r"(?<![A-Za-z0-9_])netcat[-_ ]?ty(?![A-Za-z0-9_])", re.IGNORECASE),
|
||||||
|
re.compile(r"(?<![A-Za-z0-9_])arxxx(?![A-Za-z0-9_])", re.IGNORECASE),
|
||||||
|
re.compile(r"(?<![A-Za-z0-9_])cmux(?![A-Za-z0-9_])", re.IGNORECASE),
|
||||||
|
re.compile(r"(?<![A-Za-z0-9_])ghostty(?![A-Za-z0-9_])", re.IGNORECASE),
|
||||||
|
re.compile(r"(?<![A-Za-z0-9_])herdr(?![A-Za-z0-9_])", re.IGNORECASE),
|
||||||
|
re.compile(r"(?<![A-Za-z0-9_])kaku(?![A-Za-z0-9_])", re.IGNORECASE),
|
||||||
|
re.compile(r"(?<![A-Za-z0-9_])orca(?![A-Za-z0-9_])", re.IGNORECASE),
|
||||||
|
re.compile(r"(?<![A-Za-z0-9_])tmux(?![A-Za-z0-9_])", re.IGNORECASE),
|
||||||
|
re.compile(r"(?<![A-Za-z0-9_])wezterm(?![A-Za-z0-9_])", re.IGNORECASE),
|
||||||
|
re.compile(r"(?<![A-Za-z0-9_])windows[-_ ]?terminal(?![A-Za-z0-9_])", re.IGNORECASE),
|
||||||
|
re.compile(r"(?<![A-Za-z0-9_])alacritty(?![A-Za-z0-9_])", re.IGNORECASE),
|
||||||
|
re.compile(r"(?<![A-Za-z0-9_])kitty(?![A-Za-z0-9_])", re.IGNORECASE),
|
||||||
|
re.compile(r"(?<![A-Za-z0-9_])tabby(?![A-Za-z0-9_])", re.IGNORECASE),
|
||||||
|
re.compile(r"(?<![A-Za-z0-9_])warp(?:[-_ ]?terminal)?(?![A-Za-z0-9_])", re.IGNORECASE),
|
||||||
|
re.compile(r"(?<![A-Za-z0-9_])wave[-_ ]?terminal(?![A-Za-z0-9_])", re.IGNORECASE),
|
||||||
|
re.compile(r"(?<![A-Za-z0-9_])electerm(?![A-Za-z0-9_])", re.IGNORECASE),
|
||||||
|
re.compile(r"(?<![A-Za-z0-9_])termius(?![A-Za-z0-9_])", re.IGNORECASE),
|
||||||
|
re.compile(r"(?<![A-Za-z0-9_])xshell(?![A-Za-z0-9_])", re.IGNORECASE),
|
||||||
|
re.compile(r"(?<![A-Za-z0-9_])windterm(?![A-Za-z0-9_])", re.IGNORECASE),
|
||||||
|
re.compile(r"(?<![A-Za-z0-9_])mobaxterm(?![A-Za-z0-9_])", re.IGNORECASE),
|
||||||
|
re.compile(r"(?<![A-Za-z0-9_])iterm2?(?![A-Za-z0-9_])", re.IGNORECASE),
|
||||||
|
re.compile(r"(?<![A-Za-z0-9_])hyper[-_ ]?terminal(?![A-Za-z0-9_])", re.IGNORECASE),
|
||||||
|
re.compile(r"(?<![A-Za-z0-9_])rio[-_ ]?terminal(?![A-Za-z0-9_])", re.IGNORECASE),
|
||||||
|
re.compile(r"(?<![A-Za-z0-9_])contour(?![A-Za-z0-9_])", re.IGNORECASE),
|
||||||
|
re.compile(r"(?<![A-Za-z0-9_])extraterm(?![A-Za-z0-9_])", re.IGNORECASE),
|
||||||
|
re.compile(r"(?<![A-Za-z0-9_])finalshell(?![A-Za-z0-9_])", re.IGNORECASE),
|
||||||
|
re.compile(r"(?<![A-Za-z0-9_])royal[-_ ]?ts(?![A-Za-z0-9_])", re.IGNORECASE),
|
||||||
|
re.compile(r"(?<![A-Za-z0-9_])securecrt(?![A-Za-z0-9_])", re.IGNORECASE),
|
||||||
|
)
|
||||||
|
PROHIBITED_EXAMPLES = (
|
||||||
|
"tty7",
|
||||||
|
"otty",
|
||||||
|
"netcaxx",
|
||||||
|
"netcatty",
|
||||||
|
"arxxx",
|
||||||
|
"cmux",
|
||||||
|
"ghostty",
|
||||||
|
"herdr",
|
||||||
|
"kaku",
|
||||||
|
"orca",
|
||||||
|
"tmux",
|
||||||
|
"wezterm",
|
||||||
|
"Windows Terminal",
|
||||||
|
"Alacritty",
|
||||||
|
"Kitty",
|
||||||
|
"Tabby",
|
||||||
|
"Warp",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 门禁必须能维护自己的关键词表;除此之外没有源码或文档例外。
|
||||||
|
EXEMPT_PATHS = {
|
||||||
|
"scripts/check_prohibited_names.py",
|
||||||
|
# These fixtures intentionally contain both accepted and rejected names so
|
||||||
|
# the policy can test its own false-positive boundaries.
|
||||||
|
"scripts/tests/test_prohibited_names.py",
|
||||||
|
"scripts/tests/fixtures/prohibited_names/ordinary-comparison.md",
|
||||||
|
}
|
||||||
|
LEGAL_ATTRIBUTION_PATTERNS = (
|
||||||
|
re.compile(
|
||||||
|
r"^\s*(?:[#/;*=-]+\s*)?copyright\b[^;\n]*\b(?:project|contributors?|authors?)\b",
|
||||||
|
re.IGNORECASE,
|
||||||
|
),
|
||||||
|
re.compile(
|
||||||
|
r"^\s*(?:[#/;*=-]+\s*)?(?:third[- ]party|upstream)\s+(?:notice|attribution|project)\b[^;\n]*",
|
||||||
|
re.IGNORECASE,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
DEPENDENCY_REFERENCE_PATTERNS = (
|
||||||
|
re.compile(r"\bgit\s*=\s*[\"']https?://[^\"']+[\"']", re.IGNORECASE),
|
||||||
|
re.compile(r"\bsource\s*=\s*[\"']git\+https?://[^\"']+[\"']", re.IGNORECASE),
|
||||||
|
)
|
||||||
|
PROTOCOL_COMPATIBILITY_PATTERNS = (
|
||||||
|
re.compile(r"\bKITTY_[A-Z0-9_]+\b"),
|
||||||
|
re.compile(r"\bkitty_(?:keyboard|seq|event|release|escape|input)[A-Za-z0-9_]*\b", re.IGNORECASE),
|
||||||
|
re.compile(r"\b(?:kitty|tmux)/(?:legacy|style|protocol)\b", re.IGNORECASE),
|
||||||
|
re.compile(r"\b(?:kitty|tmux)\s+(?:keyboard|protocol|escape|sequence)\b", re.IGNORECASE),
|
||||||
|
re.compile(r"\bThemeFormat::(?:WindowsTerminal|Kitty|Ghostty|WezTerm|Alacritty)\b"),
|
||||||
|
re.compile(
|
||||||
|
r"unsupported theme format; use Pebrel JSON, Windows Terminal JSON, Kitty, Ghostty, WezTerm or Alacritty",
|
||||||
|
re.IGNORECASE,
|
||||||
|
),
|
||||||
|
re.compile(r"\\x1bPtmux;"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def git(*args: str) -> bytes:
|
||||||
|
return subprocess.check_output(["git", *args], stderr=subprocess.DEVNULL)
|
||||||
|
|
||||||
|
|
||||||
|
def prohibited(text: str) -> bool:
|
||||||
|
return any(pattern.search(text) for pattern in PROHIBITED_PATTERNS)
|
||||||
|
|
||||||
|
|
||||||
|
def source_without_allowed_occurrences(path: str, text: str) -> str:
|
||||||
|
for pattern in LEGAL_ATTRIBUTION_PATTERNS:
|
||||||
|
text = pattern.sub("", text)
|
||||||
|
if Path(path).name in {"Cargo.toml", "Cargo.lock"} and any(
|
||||||
|
pattern.search(text) for pattern in DEPENDENCY_REFERENCE_PATTERNS
|
||||||
|
):
|
||||||
|
for pattern in DEPENDENCY_REFERENCE_PATTERNS:
|
||||||
|
text = pattern.sub("", text)
|
||||||
|
for pattern in PROTOCOL_COMPATIBILITY_PATTERNS:
|
||||||
|
text = pattern.sub("", text)
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def prohibited_source_line(path: str, text: str) -> bool:
|
||||||
|
return prohibited(source_without_allowed_occurrences(path, text))
|
||||||
|
|
||||||
|
|
||||||
|
def changed_paths(*revision_args: str) -> list[str]:
|
||||||
|
raw = git("diff", *revision_args, "--name-only", "-z", "--diff-filter=ACMR", "--", ".")
|
||||||
|
return [path.decode("utf-8", "replace") for path in raw.split(b"\0") if path]
|
||||||
|
|
||||||
|
|
||||||
|
def added_lines(path: str, *revision_args: str) -> Iterable[tuple[int, str]]:
|
||||||
|
patch = git("diff", *revision_args, "--no-ext-diff", "--unified=0", "--", path)
|
||||||
|
for line_no, raw_line in enumerate(patch.decode("utf-8", "replace").splitlines(), 1):
|
||||||
|
if raw_line.startswith("+") and not raw_line.startswith("+++"):
|
||||||
|
yield line_no, raw_line[1:]
|
||||||
|
|
||||||
|
|
||||||
|
def scan_added_lines(*revision_args: str) -> list[str]:
|
||||||
|
hits: list[str] = []
|
||||||
|
for path in changed_paths(*revision_args):
|
||||||
|
normalized = path.replace("\\", "/")
|
||||||
|
if normalized in EXEMPT_PATHS:
|
||||||
|
continue
|
||||||
|
for line_no, line in added_lines(path, *revision_args):
|
||||||
|
if prohibited_source_line(normalized, line):
|
||||||
|
hits.append(f"{normalized}:{line_no}:{line}")
|
||||||
|
return hits
|
||||||
|
|
||||||
|
|
||||||
|
def added_patch_lines(patch: bytes, merge_parent_count: int | None) -> Iterable[tuple[int, str]]:
|
||||||
|
lines = patch.decode("utf-8", "replace").splitlines()
|
||||||
|
if merge_parent_count is None:
|
||||||
|
for line_no, raw_line in enumerate(lines, 1):
|
||||||
|
if raw_line.startswith("+") and not raw_line.startswith("+++"):
|
||||||
|
yield line_no, raw_line[1:]
|
||||||
|
return
|
||||||
|
|
||||||
|
in_hunk = False
|
||||||
|
prefix = "+" * merge_parent_count
|
||||||
|
for line_no, raw_line in enumerate(lines, 1):
|
||||||
|
if raw_line.startswith("@@@"):
|
||||||
|
in_hunk = True
|
||||||
|
continue
|
||||||
|
# Combined diff headers (including `+++ `) occur before the hunk.
|
||||||
|
if in_hunk and raw_line.startswith(prefix):
|
||||||
|
yield line_no, raw_line[merge_parent_count:]
|
||||||
|
|
||||||
|
|
||||||
|
def scan_pending_commits(revision_range: str) -> list[str]:
|
||||||
|
hits: list[str] = []
|
||||||
|
commits = git("rev-list", "--reverse", revision_range).decode("ascii", "replace").splitlines()
|
||||||
|
for commit in commits:
|
||||||
|
parents = git("rev-list", "--parents", "-n", "1", commit).decode("ascii").split()
|
||||||
|
parent = parents[1] if len(parents) > 1 else None
|
||||||
|
merge_parent_count = len(parents) - 1
|
||||||
|
merge_commit = merge_parent_count > 1
|
||||||
|
if merge_commit:
|
||||||
|
# Combined diff reports only content resolved against all parents;
|
||||||
|
# comparing to one parent would replay the other branch's changes.
|
||||||
|
paths = git("diff-tree", "--cc", "--no-commit-id", "--name-only", "-z", "-r", commit)
|
||||||
|
elif parent:
|
||||||
|
# Comparing against the first parent includes a merge resolution's
|
||||||
|
# actual additions for ordinary commits.
|
||||||
|
paths = git("diff", "--name-only", "-z", "--diff-filter=ACMR", parent, commit, "--", ".")
|
||||||
|
else:
|
||||||
|
paths = git("diff-tree", "--root", "--no-commit-id", "--name-only", "-z", "-r", commit)
|
||||||
|
for raw_path in paths.split(b"\0"):
|
||||||
|
if not raw_path:
|
||||||
|
continue
|
||||||
|
path = raw_path.decode("utf-8", "replace")
|
||||||
|
normalized = path.replace("\\", "/")
|
||||||
|
if normalized in EXEMPT_PATHS:
|
||||||
|
continue
|
||||||
|
if merge_commit:
|
||||||
|
patch = git("show", "--format=", "--cc", "--no-ext-diff", "--unified=0", commit, "--", path)
|
||||||
|
elif parent:
|
||||||
|
patch = git("diff", "--no-ext-diff", "--unified=0", parent, commit, "--", path)
|
||||||
|
else:
|
||||||
|
patch = git("show", "--format=", "--no-ext-diff", "--unified=0", commit, "--", path)
|
||||||
|
for line_no, line in added_patch_lines(
|
||||||
|
patch, merge_parent_count if merge_commit else None
|
||||||
|
):
|
||||||
|
if prohibited_source_line(normalized, line):
|
||||||
|
hits.append(f"{commit}:{normalized}:{line_no}:{line}")
|
||||||
|
return hits
|
||||||
|
|
||||||
|
|
||||||
|
def scan_message(path: Path) -> list[str]:
|
||||||
|
return [
|
||||||
|
f"{line_no}:{line}"
|
||||||
|
for line_no, line in enumerate(path.read_text(encoding="utf-8", errors="replace").splitlines(), 1)
|
||||||
|
if prohibited(line)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def resolved_commit(revision: str) -> str:
|
||||||
|
if not revision or re.fullmatch(r"0+", revision):
|
||||||
|
raise ValueError(f"missing or zero revision: {revision!r}")
|
||||||
|
try:
|
||||||
|
return git("rev-parse", "--verify", f"{revision}^{{commit}}").decode("ascii").strip()
|
||||||
|
except (subprocess.CalledProcessError, UnicodeDecodeError) as exc:
|
||||||
|
raise ValueError(f"cannot resolve revision {revision!r}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def checked_range(base: str, head: str) -> tuple[str, str]:
|
||||||
|
base_commit = resolved_commit(base)
|
||||||
|
head_commit = resolved_commit(head)
|
||||||
|
try:
|
||||||
|
common = git("merge-base", base_commit, head_commit).decode("ascii").strip()
|
||||||
|
except (subprocess.CalledProcessError, UnicodeDecodeError) as exc:
|
||||||
|
raise ValueError(f"base {base_commit} and head {head_commit} are unrelated") from exc
|
||||||
|
if not common:
|
||||||
|
raise ValueError(f"base {base_commit} and head {head_commit} have no common ancestor")
|
||||||
|
return common, head_commit
|
||||||
|
|
||||||
|
|
||||||
|
def scan_commit_messages(revision_range: str) -> list[str]:
|
||||||
|
hits: list[str] = []
|
||||||
|
commits = git("rev-list", "--reverse", revision_range).decode("ascii").splitlines()
|
||||||
|
for commit in commits:
|
||||||
|
message = git("show", "-s", "--format=%B", commit).decode("utf-8", "replace")
|
||||||
|
for line_no, line in enumerate(message.splitlines(), 1):
|
||||||
|
if prohibited(line):
|
||||||
|
hits.append(f"commit-message:{commit}:{line_no}:{line}")
|
||||||
|
return hits
|
||||||
|
|
||||||
|
|
||||||
|
def scan_range(base: str, head: str) -> list[str]:
|
||||||
|
base_commit, head_commit = checked_range(base, head)
|
||||||
|
revision_range = f"{base_commit}..{head_commit}"
|
||||||
|
hits = scan_commit_messages(revision_range)
|
||||||
|
# Scan each commit's added lines so a name added and later deleted in the
|
||||||
|
# same range cannot disappear from the final two-commit diff.
|
||||||
|
hits.extend(scan_pending_commits(revision_range))
|
||||||
|
return hits
|
||||||
|
|
||||||
|
|
||||||
|
def pending_push_range() -> str | None:
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"],
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
upstream = result.stdout.decode("utf-8", "replace").strip()
|
||||||
|
return f"{upstream}..HEAD" if result.returncode == 0 and upstream else None
|
||||||
|
|
||||||
|
|
||||||
|
def report(title: str, hits: list[str]) -> int:
|
||||||
|
if not hits:
|
||||||
|
return 0
|
||||||
|
print(f"ERROR: {title}\n" + "\n".join(hits), file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str]) -> int:
|
||||||
|
if len(argv) < 2:
|
||||||
|
print(
|
||||||
|
"usage: check_prohibited_names.py staged|message|push [message-file] | "
|
||||||
|
"range --base BASE --head HEAD",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
mode = argv[1]
|
||||||
|
if mode == "staged":
|
||||||
|
return report("staged additions contain prohibited product names", scan_added_lines("--cached"))
|
||||||
|
if mode == "message":
|
||||||
|
if len(argv) != 3:
|
||||||
|
print("message mode requires the commit message path", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
return report("commit message contains prohibited product names", scan_message(Path(argv[2])))
|
||||||
|
if mode == "push":
|
||||||
|
revision_range = pending_push_range()
|
||||||
|
if revision_range is None:
|
||||||
|
print("No upstream branch; skipping pending-push name check")
|
||||||
|
return 0
|
||||||
|
message_text = git("log", "--format=%H:%s%n%b", revision_range).decode("utf-8", "replace")
|
||||||
|
hits = [
|
||||||
|
f"commit-message:{line_no}:{line}"
|
||||||
|
for line_no, line in enumerate(message_text.splitlines(), 1)
|
||||||
|
if prohibited(line)
|
||||||
|
]
|
||||||
|
# 逐提交看新增行,避免“先加入、后删除”在最终 range diff 中被抵消。
|
||||||
|
hits.extend(scan_pending_commits(revision_range))
|
||||||
|
return report("commits pending push contain prohibited product names", hits)
|
||||||
|
if mode == "range":
|
||||||
|
if len(argv) != 6 or argv[2] != "--base" or argv[4] != "--head":
|
||||||
|
print("range mode requires --base BASE --head HEAD", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
try:
|
||||||
|
hits = scan_range(argv[3], argv[5])
|
||||||
|
except (OSError, ValueError, subprocess.CalledProcessError) as exc:
|
||||||
|
print(f"ERROR: cannot scan prohibited-name range: {exc}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
return report("range contains prohibited product names", hits)
|
||||||
|
|
||||||
|
print(f"unknown mode: {mode}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main(sys.argv))
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
printf '%s\n' 'gh api repos/example/project'
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
gh api repos/Kuddev/pebrel
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Copyright 2020 The Alacritty Project
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Pebrel is faster than Ghostty.
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
const KITTY_KEYBOARD_PROTOCOL: bool = true;
|
||||||
|
const TMUX_ESCAPE: &str = "\\x1bPtmux;";
|
||||||
|
const FORMAT = ThemeFormat::Kitty;
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from scripts import check_prohibited_names
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
CHECKER = ROOT / "scripts" / "check_prohibited_names.py"
|
||||||
|
FIXTURES = ROOT / "scripts" / "tests" / "fixtures" / "prohibited_names"
|
||||||
|
|
||||||
|
|
||||||
|
def run_git(repository: Path, *args: str) -> None:
|
||||||
|
subprocess.run(["git", *args], cwd=repository, check=True, stdout=subprocess.DEVNULL)
|
||||||
|
|
||||||
|
|
||||||
|
class ProhibitedNamesTests(unittest.TestCase):
|
||||||
|
def test_names_do_not_match_inside_unrelated_words(self) -> None:
|
||||||
|
self.assertFalse(check_prohibited_names.prohibited("spotty network; tty72; netcatty_adapter"))
|
||||||
|
self.assertTrue(check_prohibited_names.prohibited("Use Otty or TTY-7"))
|
||||||
|
|
||||||
|
def test_ordinary_competitor_comparison_is_rejected(self) -> None:
|
||||||
|
text = (FIXTURES / "ordinary-comparison.md").read_text(encoding="utf-8")
|
||||||
|
self.assertTrue(check_prohibited_names.prohibited_source_line("comparison.md", text))
|
||||||
|
|
||||||
|
def test_inline_legal_attribution_is_allowed_but_comparison_is_not(self) -> None:
|
||||||
|
text = (FIXTURES / "legal-notice.txt").read_text(encoding="utf-8")
|
||||||
|
self.assertFalse(check_prohibited_names.prohibited_source_line("source.rs", text))
|
||||||
|
self.assertTrue(
|
||||||
|
check_prohibited_names.prohibited_source_line("README.md", "Compare with Alacritty.")
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
check_prohibited_names.prohibited_source_line(
|
||||||
|
"source.rs", "Copyright (c) Alacritty contributors; compare with Ghostty."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_protocol_identifiers_are_allowed(self) -> None:
|
||||||
|
text = (FIXTURES / "protocol.rs").read_text(encoding="utf-8")
|
||||||
|
self.assertFalse(check_prohibited_names.prohibited_source_line("src/protocol.rs", text))
|
||||||
|
|
||||||
|
def test_github_cli_text_is_not_a_competitor_name(self) -> None:
|
||||||
|
command = (FIXTURES / "github-cli.sh").read_text(encoding="utf-8")
|
||||||
|
documentation = (FIXTURES / "github-cli-docs.sh").read_text(encoding="utf-8")
|
||||||
|
self.assertFalse(check_prohibited_names.prohibited_source_line("check.sh", command))
|
||||||
|
self.assertFalse(check_prohibited_names.prohibited_source_line("check.sh", documentation))
|
||||||
|
|
||||||
|
def test_real_cargo_git_dependency_is_allowed_but_readme_reference_is_not(self) -> None:
|
||||||
|
dependency = 'gpui = { git = "https://github.com/ghostty/ghostty", rev = "deadbeef" }'
|
||||||
|
self.assertFalse(check_prohibited_names.prohibited_source_line("Cargo.toml", dependency))
|
||||||
|
self.assertTrue(check_prohibited_names.prohibited_source_line("README.md", dependency))
|
||||||
|
|
||||||
|
def test_staged_hook_configuration_is_not_exempt(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
repository = Path(directory)
|
||||||
|
run_git(repository, "init", "-q")
|
||||||
|
(repository / ".pre-commit-config.yaml").write_text(
|
||||||
|
"# Compare with Ghostty.\nrepos: []\n", encoding="utf-8"
|
||||||
|
)
|
||||||
|
run_git(repository, "add", ".pre-commit-config.yaml")
|
||||||
|
result = subprocess.run(
|
||||||
|
[sys.executable, str(CHECKER), "staged"], cwd=repository,
|
||||||
|
text=True, capture_output=True,
|
||||||
|
)
|
||||||
|
self.assertEqual(result.returncode, 1, result.stderr)
|
||||||
|
self.assertIn(".pre-commit-config.yaml", result.stderr)
|
||||||
|
|
||||||
|
def test_theme_format_message_does_not_exempt_an_appended_comparison(self) -> None:
|
||||||
|
message = (
|
||||||
|
"unsupported theme format; use Pebrel JSON, Windows Terminal JSON, "
|
||||||
|
"Kitty, Ghostty, WezTerm or Alacritty"
|
||||||
|
)
|
||||||
|
self.assertFalse(check_prohibited_names.prohibited_source_line("src/theme.rs", message))
|
||||||
|
self.assertTrue(check_prohibited_names.prohibited_source_line(
|
||||||
|
"src/theme.rs", message + "; copied from Ghostty"
|
||||||
|
))
|
||||||
|
|
||||||
|
def test_range_scans_added_text_and_new_commit_messages(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
repository = Path(directory)
|
||||||
|
run_git(repository, "init", "-q")
|
||||||
|
run_git(repository, "config", "user.name", "CI test")
|
||||||
|
run_git(repository, "config", "user.email", "ci@example.invalid")
|
||||||
|
(repository / "README.md").write_text("safe\n", encoding="utf-8")
|
||||||
|
run_git(repository, "add", "README.md")
|
||||||
|
run_git(repository, "commit", "-qm", "baseline")
|
||||||
|
base = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=repository, text=True).strip()
|
||||||
|
|
||||||
|
(repository / "comparison.md").write_text("Compare with Ghostty.\n", encoding="utf-8")
|
||||||
|
run_git(repository, "add", "comparison.md")
|
||||||
|
run_git(repository, "commit", "-qm", "ordinary comparison")
|
||||||
|
head = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=repository, text=True).strip()
|
||||||
|
result = subprocess.run(
|
||||||
|
[sys.executable, str(CHECKER), "range", "--base", base, "--head", head],
|
||||||
|
cwd=repository,
|
||||||
|
text=True,
|
||||||
|
capture_output=True,
|
||||||
|
env={**os.environ, "PYTHONUTF8": "1"},
|
||||||
|
)
|
||||||
|
self.assertNotEqual(result.returncode, 0)
|
||||||
|
self.assertIn("Ghostty", result.stderr)
|
||||||
|
|
||||||
|
def test_range_scans_a_commit_message_even_when_text_is_legal(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
repository = Path(directory)
|
||||||
|
run_git(repository, "init", "-q")
|
||||||
|
run_git(repository, "config", "user.name", "CI test")
|
||||||
|
run_git(repository, "config", "user.email", "ci@example.invalid")
|
||||||
|
(repository / "README.md").write_text("safe\n", encoding="utf-8")
|
||||||
|
run_git(repository, "add", "README.md")
|
||||||
|
run_git(repository, "commit", "-qm", "baseline")
|
||||||
|
base = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=repository, text=True).strip()
|
||||||
|
|
||||||
|
(repository / "README.md").write_text("still safe\n", encoding="utf-8")
|
||||||
|
run_git(repository, "add", "README.md")
|
||||||
|
run_git(repository, "commit", "-qm", "Compare with Ghostty")
|
||||||
|
head = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=repository, text=True).strip()
|
||||||
|
result = subprocess.run(
|
||||||
|
[sys.executable, str(CHECKER), "range", "--base", base, "--head", head],
|
||||||
|
cwd=repository,
|
||||||
|
text=True,
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
|
self.assertNotEqual(result.returncode, 0)
|
||||||
|
self.assertIn("commit-message", result.stderr)
|
||||||
|
|
||||||
|
def test_range_uses_merge_base_and_keeps_added_then_deleted_text(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
repository = Path(directory)
|
||||||
|
run_git(repository, "init", "-q")
|
||||||
|
run_git(repository, "config", "user.name", "CI test")
|
||||||
|
run_git(repository, "config", "user.email", "ci@example.invalid")
|
||||||
|
(repository / "README.md").write_text("Historical Ghostty attribution\n", encoding="utf-8")
|
||||||
|
run_git(repository, "add", "README.md")
|
||||||
|
run_git(repository, "commit", "-qm", "baseline")
|
||||||
|
base = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=repository, text=True).strip()
|
||||||
|
|
||||||
|
run_git(repository, "branch", "main")
|
||||||
|
run_git(repository, "switch", "main")
|
||||||
|
(repository / "README.md").write_text("safe on main\n", encoding="utf-8")
|
||||||
|
run_git(repository, "add", "README.md")
|
||||||
|
run_git(repository, "commit", "-qm", "advance main")
|
||||||
|
|
||||||
|
run_git(repository, "switch", "-c", "feature", base)
|
||||||
|
(repository / "README.md").write_text("safe on feature\n", encoding="utf-8")
|
||||||
|
run_git(repository, "add", "README.md")
|
||||||
|
run_git(repository, "commit", "-qm", "advance feature")
|
||||||
|
clean_head = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=repository, text=True).strip()
|
||||||
|
clean = subprocess.run(
|
||||||
|
[sys.executable, str(CHECKER), "range", "--base", "main", "--head", clean_head],
|
||||||
|
cwd=repository,
|
||||||
|
text=True,
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
|
self.assertEqual(clean.returncode, 0, clean.stderr)
|
||||||
|
|
||||||
|
(repository / "temporary.md").write_text("Ghostty\n", encoding="utf-8")
|
||||||
|
run_git(repository, "add", "temporary.md")
|
||||||
|
run_git(repository, "commit", "-qm", "temporary comparison")
|
||||||
|
(repository / "temporary.md").unlink()
|
||||||
|
run_git(repository, "add", "-u")
|
||||||
|
run_git(repository, "commit", "-qm", "remove temporary comparison")
|
||||||
|
head = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=repository, text=True).strip()
|
||||||
|
result = subprocess.run(
|
||||||
|
[sys.executable, str(CHECKER), "range", "--base", base, "--head", head],
|
||||||
|
cwd=repository,
|
||||||
|
text=True,
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
|
self.assertNotEqual(result.returncode, 0)
|
||||||
|
self.assertIn("temporary.md", result.stderr)
|
||||||
|
|
||||||
|
unrelated_tree = subprocess.check_output(["git", "write-tree"], cwd=repository, text=True).strip()
|
||||||
|
unrelated = subprocess.check_output(
|
||||||
|
["git", "commit-tree", unrelated_tree, "-m", "unrelated"],
|
||||||
|
cwd=repository,
|
||||||
|
text=True,
|
||||||
|
).strip()
|
||||||
|
unrelated_result = subprocess.run(
|
||||||
|
[sys.executable, str(CHECKER), "range", "--base", unrelated, "--head", clean_head],
|
||||||
|
cwd=repository,
|
||||||
|
text=True,
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
|
self.assertNotEqual(unrelated_result.returncode, 0)
|
||||||
|
self.assertIn("cannot scan prohibited-name range", unrelated_result.stderr)
|
||||||
|
|
||||||
|
def test_range_scans_merge_resolution_without_replaying_second_parent(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
repository = Path(directory)
|
||||||
|
run_git(repository, "init", "-q")
|
||||||
|
run_git(repository, "config", "user.name", "CI test")
|
||||||
|
run_git(repository, "config", "user.email", "ci@example.invalid")
|
||||||
|
(repository / "shared.md").write_text("base\n", encoding="utf-8")
|
||||||
|
run_git(repository, "add", "shared.md")
|
||||||
|
run_git(repository, "commit", "-qm", "baseline")
|
||||||
|
run_git(repository, "branch", "main")
|
||||||
|
|
||||||
|
run_git(repository, "switch", "-c", "feature")
|
||||||
|
(repository / "shared.md").write_text("feature\n", encoding="utf-8")
|
||||||
|
run_git(repository, "add", "shared.md")
|
||||||
|
run_git(repository, "commit", "-qm", "feature change")
|
||||||
|
feature_parent = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=repository, text=True).strip()
|
||||||
|
run_git(repository, "switch", "main")
|
||||||
|
(repository / "shared.md").write_text("main Ghostty\n", encoding="utf-8")
|
||||||
|
run_git(repository, "add", "shared.md")
|
||||||
|
run_git(repository, "commit", "-qm", "main change")
|
||||||
|
main_head = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=repository, text=True).strip()
|
||||||
|
run_git(repository, "switch", "feature")
|
||||||
|
merge = subprocess.run(
|
||||||
|
["git", "merge", "--no-ff", "main", "-m", "merge main"],
|
||||||
|
cwd=repository,
|
||||||
|
text=True,
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
|
self.assertNotEqual(merge.returncode, 0, merge.stdout + merge.stderr)
|
||||||
|
(repository / "shared.md").write_text("resolved safe\n", encoding="utf-8")
|
||||||
|
run_git(repository, "add", "shared.md")
|
||||||
|
run_git(repository, "commit", "-qm", "resolve safe merge")
|
||||||
|
safe_head = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=repository, text=True).strip()
|
||||||
|
|
||||||
|
safe_result = subprocess.run(
|
||||||
|
[sys.executable, str(CHECKER), "range", "--base", main_head, "--head", safe_head],
|
||||||
|
cwd=repository,
|
||||||
|
text=True,
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
|
self.assertEqual(safe_result.returncode, 0, safe_result.stderr)
|
||||||
|
|
||||||
|
run_git(repository, "switch", "-c", "feature-bad", feature_parent)
|
||||||
|
merge_bad = subprocess.run(
|
||||||
|
["git", "merge", "--no-ff", "main", "-m", "merge main with bad resolution"],
|
||||||
|
cwd=repository,
|
||||||
|
text=True,
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
|
self.assertNotEqual(merge_bad.returncode, 0, merge_bad.stdout + merge_bad.stderr)
|
||||||
|
(repository / "shared.md").write_text("resolved Ghostty\n", encoding="utf-8")
|
||||||
|
run_git(repository, "add", "shared.md")
|
||||||
|
run_git(repository, "commit", "-qm", "resolve bad merge")
|
||||||
|
bad_head = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=repository, text=True).strip()
|
||||||
|
bad_result = subprocess.run(
|
||||||
|
[sys.executable, str(CHECKER), "range", "--base", main_head, "--head", bad_head],
|
||||||
|
cwd=repository,
|
||||||
|
text=True,
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
|
self.assertNotEqual(bad_result.returncode, 0)
|
||||||
|
self.assertIn("shared.md", bad_result.stderr)
|
||||||
|
|
||||||
|
def test_range_fails_closed_for_an_unknown_base(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
repository = Path(directory)
|
||||||
|
run_git(repository, "init", "-q")
|
||||||
|
result = subprocess.run(
|
||||||
|
[sys.executable, str(CHECKER), "range", "--base", "missing", "--head", "HEAD"],
|
||||||
|
cwd=repository,
|
||||||
|
text=True,
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
|
self.assertNotEqual(result.returncode, 0)
|
||||||
|
self.assertIn("cannot scan prohibited-name range", result.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in new issue
Block a user