ci: enforce product naming on new commit ranges

This commit is contained in:
kuddev committed 2026-09-23 22:34:07 +08:00
1 parent 0133873c46
commit 02e13c1f97
10 files changed
+670

No files matched your search

+21
View File
@@ -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 }}
+2
View File
@@ -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.
+324
View File
@@ -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'
+1
View File
@@ -0,0 +1 @@
gh api repos/Kuddev/pebrel
@@ -0,0 +1 @@
Copyright 2020 The Alacritty Project
@@ -0,0 +1 @@
Pebrel is faster than Ghostty.
+3
View File
@@ -0,0 +1,3 @@
const KITTY_KEYBOARD_PROTOCOL: bool = true;
const TMUX_ESCAPE: &str = "\\x1bPtmux;";
const FORMAT = ThemeFormat::Kitty;
+271
View File
@@ -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()