diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f77c272..117a53b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,3 +39,17 @@ jobs: - name: Build package run: uv build + + setup: + name: Setup and real analyzers + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Check out source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Use shared setup action + uses: ./ + - name: Verify installed CLI and real analyzers + run: bash tests/integration/smoke_setup.sh diff --git a/README.md b/README.md index 8f08253..e0ba6f0 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,7 @@ Configure only the commands your project uses. `doctor` checks readiness without | --- | --- | | `gauntlet` or `gauntlet check` | Check staged, unstaged, and untracked source changes | | `gauntlet check --all` | Check the entire repository | +| `gauntlet check --base main` | Check committed changes from the merge base with `main` to the checked-out `HEAD` | | `gauntlet check src/domain` | Check a specific file or directory | | `gauntlet scan` | Inspect hotspots without tests, coverage, or mutation execution | | `gauntlet check --json` | Get JSON-only output for an agent or CI | @@ -124,6 +125,33 @@ Do not bypass or weaken the checks. For machine-readable results, run `gauntlet check --json`. It prints only JSON to stdout and also saves `.gauntlet/results.json`. The same command works in CI, where a non-zero exit code fails the build on blocking findings. +On a clean CI checkout, use `gauntlet check --base BASE_SHA --json`; working-tree +`--changed` does not select committed PR changes. Fetch base/head history first. +`--base` requires a clean tracked working tree, excludes untracked/deleted files, +and preserves committed line ranges for CRAP policy. JSON records the resolved +base, head, and merge-base SHAs. It can also be used with `scan` and explicit paths. + +For explicit installation of Gauntlet and compatible pinned analyzers, run +`bash scripts/setup-tools.sh /path/to/tool-environment` from this checkout, +then activate that environment. Setup uses the network; analysis does not invoke +setup. The [setup action](action.yml) runs the same installer in GitHub Actions: + +```yaml +- uses: actions/checkout@v4 + with: + fetch-depth: 0 + persist-credentials: false +- uses: matheuseabra/gauntlet-cli@ +- run: gauntlet check --base "$BASE_SHA" --json + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} +``` + +Run this example in a secretless `pull_request` job with read-only permissions. +Install project runtimes and locked dependencies separately. Local/cloud agents +must run setup inside their own environment; GitHub runner installation does +not provision a remote agent. + ## More details - [Tutorial](https://github.com/matheuseabra/gauntlet-cli/blob/main/docs/tutorial.md): build a sample project and repair a test gap. diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..519880d --- /dev/null +++ b/action.yml @@ -0,0 +1,31 @@ +name: Set up Gauntlet +description: Install this Gauntlet revision and its pinned external analyzers in one isolated environment. +inputs: + python-version: + description: Compatible Python version for the quality tools. + default: '3.12' + environment-path: + description: Optional virtual environment location (defaults to runner temporary storage). + default: '' +outputs: + bin-path: + description: Directory containing Gauntlet and analyzer executables. + value: ${{ steps.setup.outputs.bin_path }} +runs: + using: composite + steps: + - uses: actions/setup-python@v5 + with: + python-version: ${{ inputs.python-version }} + - name: Install quality tools + id: setup + shell: bash + env: + QUALITY_ENVIRONMENT: ${{ inputs.environment-path || format('{0}/gauntlet-tools', runner.temp) }} + GAUNTLET_PYTHON: python + run: | + set -euo pipefail + bash "$GITHUB_ACTION_PATH/scripts/setup-tools.sh" "$QUALITY_ENVIRONMENT" + quality_bin="$(cd "$QUALITY_ENVIRONMENT/bin" && pwd)" + echo "$quality_bin" >> "$GITHUB_PATH" + echo "bin_path=$quality_bin" >> "$GITHUB_OUTPUT" diff --git a/docs/architecture.md b/docs/architecture.md index abb88ba..96594d9 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@ # Gauntlet Architecture -Gauntlet coordinates local repository checks and normalizes their results. It owns configuration, source selection, command execution, policy, and reporting. Crapper, Mutator, and Dryer own complexity, mutation, and similarity analysis. Gauntlet does not implement those analyzers, install them, modify project source, or upload reports. +Gauntlet coordinates local repository checks and normalizes their results. It owns configuration, source selection, command execution, policy, and reporting. Crapper, Mutator, and Dryer own complexity, mutation, and similarity analysis. Checks do not install analyzers, modify project source, or upload reports. A separate explicit setup script/action installs compatible tools before checks. ## Components and boundaries @@ -33,6 +33,17 @@ flowchart TB Project detection can supply test and coverage commands. Explicit commands from `gauntlet.toml` override detected commands. Enabled analyzers are resolved before project commands run. A missing executable therefore fails preflight instead of starting a partial check. +`--base REF` selects committed changes from the unique merge base with checked-out +HEAD. Git validates a clean tracked working tree and resolves immutable base/head +SHAs. The context carries the merge base to the policy runner so function spans +are compared with committed changed lines, not an empty working-tree diff. The +report records all three SHAs. This mode uses explicit analyzer paths rather than +their native working-tree `--changed` flag. + +`scripts/setup-tools.sh` and `action.yml` are explicit network-enabled setup +surfaces. They share `requirements-tools.txt`; the CLI never invokes them. Cloud +agents run setup in their own runtime, independently of their dispatcher. + ```mermaid sequenceDiagram actor User diff --git a/docs/reference.md b/docs/reference.md index f6768a8..0f840b7 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -125,15 +125,44 @@ missing domain abstraction. Re-run Gauntlet after making repairs. ``` -CI can run the same local command without a Gauntlet server or provider API: +CI can run committed PR comparisons without a Gauntlet server or provider API: ```yaml - name: Gauntlet - run: gauntlet check --changed --json + run: gauntlet check --base "$BASE_SHA" --json + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} ``` Archive `.gauntlet/results.json` as a CI artifact if useful. Gauntlet does not upload source or findings. +`--base REF` is mutually exclusive with `--changed` and `--all`. It resolves REF +and checked-out HEAD, requires a clean tracked working tree, and selects existing +files changed since their unique merge base. Untracked files and deletions are +excluded. Paths can further limit this selection. Missing/shallow history, +invalid refs, or ambiguous merge bases fail with exit 4; no fallback full scan is +performed. An empty selection passes without analysis. Changed line ranges come +from the committed comparison, so high CRAP in an unchanged function remains +nonblocking when function spans are available. This is not historical CRAP score +comparison; Mutator still examines selected files rather than changed lines only. + +Results add `repository.base_sha`, `head_sha`, and `merge_base_sha` for these runs. +The report schema stays at version 1 with additive repository metadata. + +## Explicit tool setup + +`bash scripts/setup-tools.sh ENVIRONMENT_PATH` installs this checkout's Gauntlet +and external analyzers from `requirements-tools.txt` in one isolated environment. +Python 3.12+ is required; use `GAUNTLET_PYTHON` to select an interpreter. It also +prefetches supported parser grammars. Setup is network-enabled and separate from +the CLI: checks never invoke it, clone dependencies, or silently install tools. +Use the root composite action at a reviewed immutable commit for the same setup +in Actions; it adds executables to PATH and exposes a `bin-path` output. + +Project dependencies and commands remain the consumer's responsibility. Bootstrap +each cloud agent inside its own runtime, or use a provisioned environment; setup +on the GitHub runner is insufficient for remote execution. + ## Exit codes | Code | Meaning | diff --git a/requirements-tools.txt b/requirements-tools.txt new file mode 100644 index 0000000..2fcbead --- /dev/null +++ b/requirements-tools.txt @@ -0,0 +1,7 @@ +# Explicit setup dependencies. Checks never install tools or access the network. +crapper @ git+https://github.com/unclebob/crapper.git@9f1bead298b5a9d576bdd6319289fcf426e5b18a +mutator @ git+https://github.com/unclebob/mutator.git@c57f03879a08d2afe8c7e044e86c80bb164afd30 +dryer @ git+https://github.com/unclebob/dryer.git@66ff6d21a42c04afcad89c78a80066176d1294b0 +tree-sitter-language-pack==1.20.0 +tree-sitter==0.26.0 +coverage==7.16.2 diff --git a/scripts/setup-tools.sh b/scripts/setup-tools.sh new file mode 100644 index 0000000..08219ee --- /dev/null +++ b/scripts/setup-tools.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# Explicit, network-enabled setup for local agents and CI. Never called by check. +set -euo pipefail + +if [ "$#" -ne 1 ]; then + printf 'Usage: bash scripts/setup-tools.sh ENVIRONMENT_PATH\n' >&2 + exit 4 +fi +setup_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +if [ -n "${GAUNTLET_PYTHON:-}" ]; then + setup_python="$GAUNTLET_PYTHON" +elif command -v python3.12 >/dev/null 2>&1; then + setup_python=python3.12 +else + setup_python=python3 +fi +"$setup_python" -c 'import sys; sys.exit(0 if sys.version_info >= (3, 12) else "Gauntlet setup requires Python 3.12+; set GAUNTLET_PYTHON to a compatible interpreter")' +"$setup_python" -m venv "$1" +environment_path="$(cd "$1" && pwd)" +"$environment_path/bin/python" -m pip install \ + -r "$setup_root/requirements-tools.txt" "$setup_root" +"$environment_path/bin/python" -c \ + 'from tree_sitter_language_pack import prefetch; prefetch(["python", "typescript", "tsx", "javascript", "go", "rust", "java", "clojure"])' +for tool in gauntlet crapper mutator dryer; do + if [ ! -x "$environment_path/bin/$tool" ]; then + printf 'Setup did not provide %s in %s/bin\n' "$tool" "$environment_path" >&2 + exit 5 + fi +done +printf 'Quality tools ready. Activate %s/bin/activate in your shell.\n' "$environment_path" diff --git a/src/gauntlet/cli.py b/src/gauntlet/cli.py index ee6ec45..89579fa 100644 --- a/src/gauntlet/cli.py +++ b/src/gauntlet/cli.py @@ -38,6 +38,9 @@ def parser() -> argparse.ArgumentParser: mode = sub.add_mutually_exclusive_group() mode.add_argument("--changed", dest="mode", action="store_const", const="changed") mode.add_argument("--all", dest="mode", action="store_const", const="all") + mode.add_argument( + "--base", metavar="REF", help="Check committed changes from REF's merge base to HEAD" + ) sub.add_argument("--json", action="store_true", help="Emit only JSON to stdout") sub.add_argument("--output", help="JSON report path, relative to repository root") sub.add_argument("--quiet", action="store_true", help="Suppress terminal output") @@ -59,15 +62,22 @@ def _check(args: argparse.Namespace) -> Results: root = git.root(Path.cwd()) config = load(root) project = detect(root) - changed = git.changed_files(root) - mode = args.mode or ("all" if args.paths else config.mode) + comparison = git.compare(root, args.base) if args.base is not None else None + changed = comparison.files if comparison else git.changed_files(root) + mode = "changed" if comparison else args.mode or ("all" if args.paths else config.mode) candidates = changed if mode == "changed" else source_files(root) selected = select(root, candidates, config, args.paths) tools = dict(config.tools) for name, disabled in zip(TOOLS, (args.no_crap, args.no_mutate, args.no_dry), strict=True): tools[name] = replace(tools[name], enabled=False) if disabled else tools[name] config = replace(config, tools=tools, commands=project.commands | config.commands) - native = mode == "changed" and not args.paths and not config.include and not config.exclude + native = ( + comparison is None + and mode == "changed" + and not args.paths + and not config.include + and not config.exclude + ) context = RunContext( root, mode, @@ -78,8 +88,15 @@ def _check(args: argparse.Namespace) -> Results: args.command == "scan", args.quiet, args.verbose, + comparison_base=comparison.merge_base_sha if comparison else None, ) results = run(context) + if comparison: + results.repository.update( + base_sha=comparison.base_sha, + head_sha=comparison.head_sha, + merge_base_sha=comparison.merge_base_sha, + ) results.repository["project"] = { "languages": project.languages, "package_manager": project.package_manager, diff --git a/src/gauntlet/git.py b/src/gauntlet/git.py index ef881e6..6df41dc 100644 --- a/src/gauntlet/git.py +++ b/src/gauntlet/git.py @@ -1,10 +1,69 @@ import re +from dataclasses import dataclass from pathlib import Path from gauntlet.errors import GauntletError from gauntlet.process import run +@dataclass(frozen=True) +class Comparison: + base_sha: str + head_sha: str + merge_base_sha: str + files: list[str] + + +def resolve_commit(directory: Path, ref: str) -> str: + result = run( + ["git", "rev-parse", "--verify", "--end-of-options", f"{ref}^{{commit}}"], directory + ) + if result.exit_code or result.timed_out: + raise GauntletError(f"Cannot resolve Git commit {ref!r}; fetch its history first", 4) + return result.stdout.strip() + + +def compare(directory: Path, base: str) -> Comparison: + """Select committed changes from the merge base to the checked-out HEAD.""" + status = run(["git", "status", "--porcelain=v1", "--untracked-files=no"], directory) + if status.exit_code or status.timed_out: + raise GauntletError("Git could not inspect the working tree", 1) + if status.stdout: + raise GauntletError( + "--base requires a clean tracked working tree; commit or stash changes", 4 + ) + base_sha, head_sha = resolve_commit(directory, base), resolve_commit(directory, "HEAD") + ancestor = run(["git", "merge-base", "--all", base_sha, head_sha], directory) + ancestors = ancestor.stdout.splitlines() + if ancestor.exit_code or ancestor.timed_out or len(ancestors) != 1: + raise GauntletError( + "Git comparison needs one merge base; fetch complete base/head history", 4 + ) + merge_base = ancestors[0] + result = run( + [ + "git", + "diff", + "--no-ext-diff", + "--no-textconv", + "--no-renames", + "--name-only", + "--diff-filter=ACMRT", + "-z", + merge_base, + head_sha, + "--", + ], + directory, + ) + if result.exit_code or result.timed_out: + raise GauntletError("Git could not enumerate committed changes", 1) + files = sorted( + {name for name in result.stdout.split("\0") if name and (directory / name).is_file()} + ) + return Comparison(base_sha, head_sha, merge_base, files) + + def root(directory: Path) -> Path: result = run(["git", "rev-parse", "--show-toplevel"], directory) if result.exit_code or result.timed_out: @@ -31,7 +90,9 @@ def changed_files(directory: Path) -> list[str]: return [name for name in parse_changed(result.stdout) if (directory / name).is_file()] -def changed_lines(directory: Path, files: list[str]) -> dict[str, list[tuple[int, int]]]: +def changed_lines( + directory: Path, files: list[str], base: str | None = None +) -> dict[str, list[tuple[int, int]]]: head = run(["git", "rev-parse", "--verify", "HEAD"], directory) ranges = {} for name in files: @@ -39,8 +100,19 @@ def changed_lines(directory: Path, files: list[str]) -> dict[str, list[tuple[int if head.exit_code or tracked.exit_code: ranges[name] = [(1, len((directory / name).read_bytes().splitlines()) + 1)] continue + comparison = [base, "HEAD"] if base else ["HEAD"] diff = run( - ["git", "diff", "HEAD", "--unified=0", "--no-ext-diff", "--no-textconv", "--", name], + [ + "git", + "diff", + *comparison, + "--unified=0", + "--no-ext-diff", + "--no-textconv", + "--no-renames", + "--", + name, + ], directory, ) if diff.exit_code or diff.timed_out: diff --git a/src/gauntlet/models.py b/src/gauntlet/models.py index e1a2557..c03dea2 100644 --- a/src/gauntlet/models.py +++ b/src/gauntlet/models.py @@ -86,3 +86,4 @@ class RunContext: quiet: bool = False verbose: bool = False function_files: list[str] | None = None + comparison_base: str | None = None diff --git a/src/gauntlet/pipeline/runner.py b/src/gauntlet/pipeline/runner.py index 0aebcd7..846b24e 100644 --- a/src/gauntlet/pipeline/runner.py +++ b/src/gauntlet/pipeline/runner.py @@ -75,7 +75,7 @@ def _analyze(context: RunContext, results: Results, accept: dict[str, str]) -> N ) if name == "mutator" and not context.scan: enrich(results.findings, adapter.snapshots, context) - ranges = git.changed_lines(context.root, context.changed_files) + ranges = git.changed_lines(context.root, context.changed_files, context.comparison_base) results.diagnostics.extend( policy.apply( results.findings, context.config, accept, context.changed_files, context.mode, ranges diff --git a/tests/integration/smoke_setup.sh b/tests/integration/smoke_setup.sh new file mode 100644 index 0000000..a6e86ea --- /dev/null +++ b/tests/integration/smoke_setup.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +# Exercise the installed CLI and real pinned analyzers in an isolated repository. +set -euo pipefail +fixture="$(mktemp -d)" +trap 'rm -rf "$fixture"' EXIT +cd "$fixture" +git init -q +git config user.name Fixture +git config user.email fixture@example.invalid +mkdir -p src tests +touch src/__init__.py tests/__init__.py +cat > .gitignore <<'EOF' +.gauntlet/ +.metrics/ +.coverage +target/ +__pycache__/ +EOF +cat > src/rules.py <<'EOF' +def eligible(age): + return age >= 17 +EOF +cat > tests/test_rules.py <<'EOF' +import unittest +from src.rules import eligible + +class RulesTest(unittest.TestCase): + def test_eligibility(self): + self.assertFalse(eligible(16)) + self.assertTrue(eligible(19)) +EOF +cat > gauntlet.toml <<'EOF' +version = 1 +[commands] +test = "python -m unittest discover" +coverage = "python -m coverage run -m unittest discover && python -m coverage lcov -o target/coverage/python/lcov.info" +[tools.mutator] +max_workers = 2 +EOF +git add . +git commit -qm baseline +base="$(git rev-parse HEAD)" +gauntlet check --base "$base" --json > empty.json +python -c 'import json; assert json.load(open("empty.json"))["repository"]["selected_files"] == []' +cat > src/rules.py <<'EOF' +def eligible(age): + return age >= 18 +EOF +git add src/rules.py +git commit -qm 'change threshold without boundary assertion' +status=0 +gauntlet check --base "$base" --json > weak.json || status=$? +test "$status" -eq 3 +python - <<'PY' +import json +data = json.load(open("weak.json")) +assert data["repository"]["selected_files"] == ["src/rules.py"] +assert any(f["blocking"] and f["rule"] == "surviving-mutant" + and f["metadata"]["original"] == ">=" and f["metadata"]["replacement"] == ">" + for f in data["findings"]) +PY +cat > tests/test_rules.py <<'EOF' +import unittest +from src.rules import eligible + +class RulesTest(unittest.TestCase): + def test_eligibility(self): + self.assertFalse(eligible(17)) + self.assertTrue(eligible(18)) + self.assertTrue(eligible(19)) +EOF +git add tests/test_rules.py +git commit -qm 'specify exact boundary behavior' +gauntlet check --base "$base" --json > strong.json +python - <<'PY' +import json +data = json.load(open("strong.json")) +assert data["summary"]["blocking"] == 0 +assert all(data["checks"][tool] == "passed" for tool in ("crapper", "mutator", "dryer")) +assert data["repository"]["base_sha"] == data["repository"]["merge_base_sha"] +PY +printf 'Real tool setup, committed survivor, and boundary repair passed.\n' diff --git a/tests/integration/test_cli.py b/tests/integration/test_cli.py index 8a3c361..ab9b700 100644 --- a/tests/integration/test_cli.py +++ b/tests/integration/test_cli.py @@ -139,6 +139,60 @@ def test_full_pipeline_normalizes_survivor_crap_duplicate_and_acceptance(self): all(f["accepted"] and not f["blocking"] for f in accepted_data["findings"]) ) + # A clean committed head must retain the changed lines for CRAP + # policy, not fall back to the now-empty working-tree diff. + accept_path.unlink() + git(root, "add", "src/retry.py") + git(root, "commit", "-qm", "committed change") + committed = call(root, "check", "--base", "HEAD~1", "--json") + committed_data = json.loads(committed.stdout) + self.assertEqual(committed.returncode, 3, committed.stdout + committed.stderr) + committed_crap = next( + f for f in committed_data["findings"] if f["rule"] == "crap-score" + ) + self.assertTrue(committed_crap["blocking"]) + self.assertTrue(committed_crap["metadata"]["changed"]) + self.assertEqual(committed_data["repository"]["selected_files"], ["src/retry.py"]) + self.assertEqual(len(committed_data["repository"]["head_sha"]), 40) + + # A later edit outside the unchanged complex function must not + # block its existing CRAP finding. Only accept the fixture survivor. + accept_path.write_text( + f'[[finding]]\nid = "{survivor["id"]}"\nreason = "Reviewed fixture."\n' + ) + source.write_text(source.read_text() + "\n# documentation outside the function\n") + git(root, "add", "src/retry.py") + git(root, "commit", "-qm", "comment only") + outside = call(root, "check", "--base", "HEAD~1", "--json") + outside_data = json.loads(outside.stdout) + self.assertEqual(outside.returncode, 0, outside.stdout + outside.stderr) + outside_crap = next(f for f in outside_data["findings"] if f["rule"] == "crap-score") + self.assertFalse(outside_crap["blocking"]) + self.assertFalse(outside_crap["metadata"]["changed"]) + + def test_base_empty_scope_stays_empty_and_conflicting_flags_fail(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + git(root, "init", "-q") + git(root, "config", "user.email", "tests@example.invalid") + git(root, "config", "user.name", "Tests") + (root / "existing.py").write_text("def value(): return 1\n") + git(root, "add", ".") + git(root, "commit", "-qm", "baseline") + result = call(root, "check", "--base", "HEAD", "--json") + data = json.loads(result.stdout) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertEqual(data["repository"]["selected_files"], []) + self.assertEqual(data["checks"], {}) + self.assertEqual(data, json.loads((root / ".gauntlet/results.json").read_text())) + for flag in ("--changed", "--all"): + self.assertEqual( + call(root, "check", "--base", "HEAD", flag, "--json").returncode, 4 + ) + self.assertEqual(call(root, "scan", "--base", "HEAD", "--json").returncode, 0) + self.assertEqual(call(root, "check", "--base", "not-a-ref", "--json").returncode, 4) + self.assertEqual(call(root, "check", "--base", "", "--json").returncode, 4) + CRAPPER_TOOL = """#!/usr/bin/env python3 from pathlib import Path diff --git a/tests/unit/test_git_scope.py b/tests/unit/test_git_scope.py index 60d7ff7..d223f79 100644 --- a/tests/unit/test_git_scope.py +++ b/tests/unit/test_git_scope.py @@ -6,7 +6,7 @@ from gauntlet.config import Config from gauntlet.discovery import source_file from gauntlet.errors import GauntletError -from gauntlet.git import changed_files, changed_lines, parse_changed, root +from gauntlet.git import changed_files, changed_lines, compare, parse_changed, root from gauntlet.scope import select @@ -68,5 +68,70 @@ def test_scope_filters_source_and_globs(self): self.assertEqual(select(root_path, files, config, []), ["src/domain/rules.py"]) +class CommittedGitTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.repo = Path(self.temp.name) + git(self.repo, "init", "-q") + git(self.repo, "config", "user.email", "tests@example.invalid") + git(self.repo, "config", "user.name", "Tests") + (self.repo / "old.py").write_text("def old():\n return 1\n") + self.base = self.commit() + + def commit(self): + git(self.repo, "add", ".") + git(self.repo, "commit", "-qm", "fixture") + return subprocess.check_output( + ["git", "rev-parse", "HEAD"], cwd=self.repo, text=True + ).strip() + + def test_committed_diff_preserves_names_and_excludes_deleted_and_untracked(self): + names = ["a space.py", "a\nnewline.py", "-option.py"] + for name in names: + (self.repo / name).write_text("value = 2\n") + (self.repo / "old.py").unlink() + head = self.commit() + (self.repo / "untracked.py").write_text("value = 3\n") + comparison = compare(self.repo, self.base) + self.assertCountEqual(comparison.files, names) + self.assertEqual(comparison.head_sha, head) + self.assertEqual(comparison.merge_base_sha, self.base) + self.assertEqual(changed_lines(self.repo, names, self.base)["-option.py"], [(1, 1)]) + + def test_renamed_file_is_analyzed_at_new_path(self): + git(self.repo, "mv", "old.py", "new.py") + self.commit() + self.assertEqual(compare(self.repo, self.base).files, ["new.py"]) + + def test_divergent_base_uses_common_ancestor(self): + git(self.repo, "checkout", "-qb", "base-branch") + (self.repo / "base-only.py").write_text("value = 1\n") + base_tip = self.commit() + git(self.repo, "checkout", "-qb", "feature", self.base) + (self.repo / "feature.py").write_text("value = 2\n") + self.commit() + comparison = compare(self.repo, base_tip) + self.assertEqual(comparison.files, ["feature.py"]) + self.assertEqual(comparison.base_sha, base_tip) + self.assertEqual(comparison.merge_base_sha, self.base) + + def test_empty_comparison_and_invalid_ref(self): + self.assertEqual(compare(self.repo, self.base).files, []) + for ref in ("0" * 40, "--help"): + with self.subTest(ref=ref), self.assertRaises(GauntletError) as error: + compare(self.repo, ref) + self.assertEqual(error.exception.code, 4) + + def test_staged_and_unstaged_changes_fail_closed(self): + (self.repo / "old.py").write_text("def old():\n return 2\n") + for staged in (False, True): + if staged: + git(self.repo, "add", "old.py") + with self.subTest(staged=staged), self.assertRaises(GauntletError) as error: + compare(self.repo, self.base) + self.assertEqual(error.exception.code, 4) + + if __name__ == "__main__": unittest.main() diff --git a/tests/unit/test_setup.py b/tests/unit/test_setup.py new file mode 100644 index 0000000..f8aeee2 --- /dev/null +++ b/tests/unit/test_setup.py @@ -0,0 +1,72 @@ +import json +import os +import subprocess +import tempfile +import unittest +from pathlib import Path + +PROJECT = Path(__file__).resolve().parents[2] + + +class SetupTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.python = self.root / "fake-python" + self.python.write_text(FAKE_PYTHON) + self.python.chmod(0o755) + self.log = self.root / "calls.jsonl" + self.environment = self.root / "tools with spaces" + self.env = os.environ | {"GAUNTLET_PYTHON": str(self.python), "SETUP_LOG": str(self.log)} + + def setup_tools(self): + return subprocess.run( + ["bash", str(PROJECT / "scripts/setup-tools.sh"), str(self.environment)], + cwd=self.root, + env=self.env, + capture_output=True, + text=True, + ) + + def calls(self): + return [json.loads(line) for line in self.log.read_text().splitlines()] + + def test_installation_is_independent_of_cwd_and_supports_environment_spaces(self): + result = self.setup_tools() + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertTrue((self.environment / "bin/gauntlet").is_file()) + pip = next(call for call in self.calls() if "pip" in call) + self.assertIn(str(PROJECT / "requirements-tools.txt"), pip) + self.assertIn(str(PROJECT), pip) + + def test_pip_failure_stops_before_grammar_setup(self): + self.env["SETUP_PIP_EXIT"] = "7" + self.assertEqual(self.setup_tools().returncode, 7) + self.assertFalse(any("prefetch" in " ".join(call) for call in self.calls())) + + def test_incompatible_python_fails_before_creating_environment(self): + self.env["SETUP_OLD_PYTHON"] = "1" + self.assertNotEqual(self.setup_tools().returncode, 0) + self.assertFalse(self.environment.exists()) + + +FAKE_PYTHON = """#!/usr/bin/env python3 +import json, os, shutil, sys +from pathlib import Path +with Path(os.environ['SETUP_LOG']).open('a') as log: + log.write(json.dumps(sys.argv[1:]) + '\\n') +if 'sys.version_info' in ' '.join(sys.argv) and os.environ.get('SETUP_OLD_PYTHON'): + sys.exit(1) +if sys.argv[1:3] == ['-m', 'venv']: + bin_dir = Path(sys.argv[3]) / 'bin' + bin_dir.mkdir(parents=True) + shutil.copyfile(sys.argv[0], bin_dir / 'python') + (bin_dir / 'python').chmod(0o755) + for name in ('gauntlet', 'crapper', 'mutator', 'dryer'): + path = bin_dir / name + path.write_text('#!/bin/sh\\nexit 0\\n') + path.chmod(0o755) +if sys.argv[1:3] == ['-m', 'pip']: + sys.exit(int(os.environ.get('SETUP_PIP_EXIT', '0'))) +"""