From 02e13c1f97e31a68b481eddfe025266cf0047f47 Mon Sep 17 00:00:00 2001 From: kuddev Date: Wed, 23 Sep 2026 20:19:58 +0800 Subject: [PATCH] ci: enforce product naming on new commit ranges --- .github/workflows/architecture.yml | 21 ++ .gitignore | 2 + .../2026-09-23-prohibited-name-range-check.md | 45 +++ scripts/check_prohibited_names.py | 324 ++++++++++++++++++ .../prohibited_names/github-cli-docs.sh | 1 + .../fixtures/prohibited_names/github-cli.sh | 1 + .../prohibited_names/legal-notice.txt | 1 + .../prohibited_names/ordinary-comparison.md | 1 + .../fixtures/prohibited_names/protocol.rs | 3 + scripts/tests/test_prohibited_names.py | 271 +++++++++++++++ 10 files changed, 670 insertions(+) create mode 100644 architecture/notes/scripts/ci/2026-09-23-prohibited-name-range-check.md create mode 100644 scripts/check_prohibited_names.py create mode 100644 scripts/tests/fixtures/prohibited_names/github-cli-docs.sh create mode 100644 scripts/tests/fixtures/prohibited_names/github-cli.sh create mode 100644 scripts/tests/fixtures/prohibited_names/legal-notice.txt create mode 100644 scripts/tests/fixtures/prohibited_names/ordinary-comparison.md create mode 100644 scripts/tests/fixtures/prohibited_names/protocol.rs create mode 100644 scripts/tests/test_prohibited_names.py diff --git a/.github/workflows/architecture.yml b/.github/workflows/architecture.yml index b062120..5613d3c 100644 --- a/.github/workflows/architecture.yml +++ b/.github/workflows/architecture.yml @@ -36,6 +36,27 @@ jobs: scripts.tests.test_architecture_budgets scripts.tests.test_architecture_dependencies 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 env: ARCHITECTURE_BASE: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha || github.event.before }} diff --git a/.gitignore b/.gitignore index 5b93fd4..f5160ec 100644 --- a/.gitignore +++ b/.gitignore @@ -159,6 +159,8 @@ scripts/drawer_repro.ps1 scripts/ghost_repro/ scripts/icon-build/ 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. /scripts/markdown-memory-* !/scripts/markdown-memory-stress.ps1 diff --git a/architecture/notes/scripts/ci/2026-09-23-prohibited-name-range-check.md b/architecture/notes/scripts/ci/2026-09-23-prohibited-name-range-check.md new file mode 100644 index 0000000..0f1a507 --- /dev/null +++ b/architecture/notes/scripts/ci/2026-09-23-prohibited-name-range-check.md @@ -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. diff --git a/scripts/check_prohibited_names.py b/scripts/check_prohibited_names.py new file mode 100644 index 0000000..5382954 --- /dev/null +++ b/scripts/check_prohibited_names.py @@ -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"(? 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)) diff --git a/scripts/tests/fixtures/prohibited_names/github-cli-docs.sh b/scripts/tests/fixtures/prohibited_names/github-cli-docs.sh new file mode 100644 index 0000000..9649d8c --- /dev/null +++ b/scripts/tests/fixtures/prohibited_names/github-cli-docs.sh @@ -0,0 +1 @@ +printf '%s\n' 'gh api repos/example/project' diff --git a/scripts/tests/fixtures/prohibited_names/github-cli.sh b/scripts/tests/fixtures/prohibited_names/github-cli.sh new file mode 100644 index 0000000..44044a5 --- /dev/null +++ b/scripts/tests/fixtures/prohibited_names/github-cli.sh @@ -0,0 +1 @@ +gh api repos/Kuddev/pebrel diff --git a/scripts/tests/fixtures/prohibited_names/legal-notice.txt b/scripts/tests/fixtures/prohibited_names/legal-notice.txt new file mode 100644 index 0000000..e97ae1b --- /dev/null +++ b/scripts/tests/fixtures/prohibited_names/legal-notice.txt @@ -0,0 +1 @@ +Copyright 2020 The Alacritty Project diff --git a/scripts/tests/fixtures/prohibited_names/ordinary-comparison.md b/scripts/tests/fixtures/prohibited_names/ordinary-comparison.md new file mode 100644 index 0000000..38423df --- /dev/null +++ b/scripts/tests/fixtures/prohibited_names/ordinary-comparison.md @@ -0,0 +1 @@ +Pebrel is faster than Ghostty. diff --git a/scripts/tests/fixtures/prohibited_names/protocol.rs b/scripts/tests/fixtures/prohibited_names/protocol.rs new file mode 100644 index 0000000..fa7a1cd --- /dev/null +++ b/scripts/tests/fixtures/prohibited_names/protocol.rs @@ -0,0 +1,3 @@ +const KITTY_KEYBOARD_PROTOCOL: bool = true; +const TMUX_ESCAPE: &str = "\\x1bPtmux;"; +const FORMAT = ThemeFormat::Kitty; diff --git a/scripts/tests/test_prohibited_names.py b/scripts/tests/test_prohibited_names.py new file mode 100644 index 0000000..e051dfc --- /dev/null +++ b/scripts/tests/test_prohibited_names.py @@ -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()