diff --git a/.changelog/034.yaml b/.changelog/034.yaml new file mode 100644 index 0000000..fb29285 --- /dev/null +++ b/.changelog/034.yaml @@ -0,0 +1,7 @@ +name: validate_no_changes +ts: 2026-03-01 07:26:09.172446+00:00 +type: fix +author: EspenAlbert +changelog_message: 'fix: Allows using GITHUB_BASE_REF when validating no changes' +message: 'fix: Allows using GITHUB_BASE_REF when validating no changes' +short_sha: '257563' diff --git a/README.md b/README.md index 53bda99..d6dd15a 100644 --- a/README.md +++ b/README.md @@ -80,8 +80,10 @@ By default, prompts before each git operation. See [Usage Scenarios](#usage-scen uvx path-sync validate-no-changes -b main ``` +In CI, when the workflow runs on `pull_request`, the comparison branch is the PR base. On `push` or locally, pass `-b` with the branch to compare against (e.g. `-b SDLC` for a branch based on SDLC). + Options: -- `-b, --branch` - Default branch to compare against (default: main) +- `-b, --branch` - Branch to compare against (default: main). When `GITHUB_BASE_REF` is set (e.g. in GitHub Actions), it overrides the default so CI can use the PR base without passing `-b`. If you set `GITHUB_BASE_REF`, use a non-empty branch name. - `--skip-sections` - Comma-separated `path:section_id` pairs to skip (e.g., `justfile:coverage`) ## Usage Scenarios @@ -358,7 +360,10 @@ jobs: with: fetch-depth: 0 - uses: astral-sh/setup-uv@v5 - - run: uvx path-sync validate-no-changes -b main + - name: Validate no changes to synced files + env: + GITHUB_BASE_REF: ${{ github.base_ref || 'main' }} + run: uvx path-sync validate-no-changes ``` **Validation skips automatically when:** diff --git a/docs/index.md b/docs/index.md index 53bda99..d6dd15a 100644 --- a/docs/index.md +++ b/docs/index.md @@ -80,8 +80,10 @@ By default, prompts before each git operation. See [Usage Scenarios](#usage-scen uvx path-sync validate-no-changes -b main ``` +In CI, when the workflow runs on `pull_request`, the comparison branch is the PR base. On `push` or locally, pass `-b` with the branch to compare against (e.g. `-b SDLC` for a branch based on SDLC). + Options: -- `-b, --branch` - Default branch to compare against (default: main) +- `-b, --branch` - Branch to compare against (default: main). When `GITHUB_BASE_REF` is set (e.g. in GitHub Actions), it overrides the default so CI can use the PR base without passing `-b`. If you set `GITHUB_BASE_REF`, use a non-empty branch name. - `--skip-sections` - Comma-separated `path:section_id` pairs to skip (e.g., `justfile:coverage`) ## Usage Scenarios @@ -358,7 +360,10 @@ jobs: with: fetch-depth: 0 - uses: astral-sh/setup-uv@v5 - - run: uvx path-sync validate-no-changes -b main + - name: Validate no changes to synced files + env: + GITHUB_BASE_REF: ${{ github.base_ref || 'main' }} + run: uvx path-sync validate-no-changes ``` **Validation skips automatically when:** diff --git a/docs/validate_no_changes/index.md b/docs/validate_no_changes/index.md index 83acc13..f117953 100644 --- a/docs/validate_no_changes/index.md +++ b/docs/validate_no_changes/index.md @@ -23,15 +23,16 @@ Validate no unauthorized changes to synced files. **CLI Options:** -| Flag | Type | Default | Description | -|---|---|---|---| -| `-b`, `--branch` | `str` | `'main'` | Default branch to compare against | -| `--skip-sections` | `str` | `''` | Comma-separated path:section_id pairs to skip (e.g., 'justfile:coverage,pyproject.toml:default') | -| `--src-root` | `str` | `''` | Source repo root (default: find git root from cwd) | +| Flag | Type | Default | Env Var | Description | +|---|---|---|---|---| +| `-b`, `--branch` | `str` | `'main'` | `GITHUB_BASE_REF` | Branch to compare against (default: main; uses GITHUB_BASE_REF when set and -b not passed) | +| `--skip-sections` | `str` | `''` | - | Comma-separated path:section_id pairs to skip (e.g., 'justfile:coverage,pyproject.toml:default') | +| `--src-root` | `str` | `''` | - | Source repo root (default: find git root from cwd) | ### Changes | Version | Change | |---------|--------| +| unreleased | fix: Allows using GITHUB_BASE_REF when validating no changes | | 0.4.1 | Made public | \ No newline at end of file diff --git a/justfile b/justfile index 3c03ebb..bf1b86c 100644 --- a/justfile +++ b/justfile @@ -37,7 +37,7 @@ vulture: # === DO_NOT_EDIT: path-sync path-sync === path-sync-validate: - uv run path-sync validate-no-changes -n python-template + uv run path-sync validate-no-changes # === OK_EDIT: path-sync path-sync === # === DO_NOT_EDIT: path-sync coverage === diff --git a/path_sync/_internal/cmd_validate.py b/path_sync/_internal/cmd_validate.py index c8320af..22b1ed2 100644 --- a/path_sync/_internal/cmd_validate.py +++ b/path_sync/_internal/cmd_validate.py @@ -15,7 +15,13 @@ @app.command("validate-no-changes") def validate_no_changes( - branch: str = typer.Option("main", "-b", "--branch", help="Default branch to compare against"), + branch: str = typer.Option( + "main", + "-b", + "--branch", + help="Branch to compare against (default: main; uses GITHUB_BASE_REF when set and -b not passed)", + envvar="GITHUB_BASE_REF", + ), skip_sections_opt: str = typer.Option( "", "--skip-sections", @@ -36,9 +42,10 @@ def validate_no_changes( logger.info(f"On sync branch {current_branch}, validation skipped") return if current_branch == branch: - logger.info(f"On default branch {branch}, validation skipped") + logger.info(f"On base branch {branch}, validation skipped") return + logger.info(f"Validating changes against branch {branch}") skip_sections = parse_skip_sections(skip_sections_opt) if skip_sections_opt else None unauthorized = validate_no_unauthorized_changes(repo_root, branch, skip_sections) diff --git a/path_sync/_internal/cmd_validate_test.py b/path_sync/_internal/cmd_validate_test.py new file mode 100644 index 0000000..a458ba5 --- /dev/null +++ b/path_sync/_internal/cmd_validate_test.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +from pathlib import Path +from unittest.mock import MagicMock, patch + +from path_sync._internal.cmd_validate import validate_no_changes +from path_sync._internal.validation import validate_no_unauthorized_changes + +MODULE = validate_no_changes.__module__ + + +def test_validate_no_changes_passes_branch_to_validation(tmp_path: Path): + mock_repo = MagicMock() + mock_repo.active_branch.name = "feature-branch" + + with ( + patch(f"{MODULE}.find_repo_root", return_value=tmp_path), + patch(f"{MODULE}.git_ops") as git_ops, + patch(f"{MODULE}.{validate_no_unauthorized_changes.__name__}") as validate_fn, + ): + git_ops.get_repo.return_value = mock_repo + validate_fn.return_value = [] + + validate_no_changes( + branch="develop", + skip_sections_opt="", + src_root_opt=str(tmp_path), + ) + + validate_fn.assert_called_once() + call_kw = validate_fn.call_args + assert call_kw[0][0] == tmp_path + assert call_kw[0][1] == "develop" + + +def test_validate_no_changes_skips_when_on_sync_branch(tmp_path: Path): + mock_repo = MagicMock() + mock_repo.active_branch.name = "sync/python-template" + + with ( + patch(f"{MODULE}.find_repo_root", return_value=tmp_path), + patch(f"{MODULE}.git_ops") as git_ops, + patch(f"{MODULE}.{validate_no_unauthorized_changes.__name__}") as validate_fn, + ): + git_ops.get_repo.return_value = mock_repo + + validate_no_changes( + branch="main", + skip_sections_opt="", + src_root_opt=str(tmp_path), + ) + + validate_fn.assert_not_called() + + +def test_validate_no_changes_skips_when_on_base_branch(tmp_path: Path): + mock_repo = MagicMock() + mock_repo.active_branch.name = "main" + + with ( + patch(f"{MODULE}.find_repo_root", return_value=tmp_path), + patch(f"{MODULE}.git_ops") as git_ops, + patch(f"{MODULE}.{validate_no_unauthorized_changes.__name__}") as validate_fn, + ): + git_ops.get_repo.return_value = mock_repo + + validate_no_changes( + branch="main", + skip_sections_opt="", + src_root_opt=str(tmp_path), + ) + + validate_fn.assert_not_called() diff --git a/path_sync/_internal/validation_test.py b/path_sync/_internal/validation_test.py new file mode 100644 index 0000000..13e536b --- /dev/null +++ b/path_sync/_internal/validation_test.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +import pytest + +from path_sync._internal.validation import parse_skip_sections + + +def test_parse_skip_sections_empty_string_returns_empty(): + assert parse_skip_sections("") == {} + + +def test_parse_skip_sections_single_entry(): + assert parse_skip_sections("justfile:coverage") == {"justfile": {"coverage"}} + + +def test_parse_skip_sections_multiple_entries_same_path(): + result = parse_skip_sections("justfile:coverage,justfile:default") + assert result == {"justfile": {"coverage", "default"}} + + +def test_parse_skip_sections_multiple_paths(): + result = parse_skip_sections("justfile:coverage,pyproject.toml:default") + assert result == {"justfile": {"coverage"}, "pyproject.toml": {"default"}} + + +def test_parse_skip_sections_strips_whitespace(): + assert parse_skip_sections(" a:b , c:d ") == {"a": {"b"}, "c": {"d"}} + + +def test_parse_skip_sections_rsplit_uses_last_colon(): + assert parse_skip_sections("path:to:section_id") == {"path:to": {"section_id"}} + + +def test_parse_skip_sections_invalid_format_raises(): + with pytest.raises(ValueError, match="Invalid format 'no_colon'"): + parse_skip_sections("no_colon")