diff --git a/.github/workflows/quality_check.yml b/.github/workflows/quality_check.yml index 0238c7f..050b7e6 100644 --- a/.github/workflows/quality_check.yml +++ b/.github/workflows/quality_check.yml @@ -4,8 +4,8 @@ jobs: quality-check: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 - - uses: actions/setup-python@v6 + - uses: actions/checkout@v7 + - uses: actions/setup-python@v7 with: python-version: "3.11" - name: Install dependencies diff --git a/.github/workflows/status_check.yml b/.github/workflows/status_check.yml index 9d85ecc..a86b48f 100644 --- a/.github/workflows/status_check.yml +++ b/.github/workflows/status_check.yml @@ -7,19 +7,23 @@ jobs: matrix: os: [ ubuntu-latest, macos-latest, windows-latest ] steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: ref: ${{ github.head_ref }} fetch-depth: 0 - - uses: actions/setup-python@v6 + - uses: actions/setup-python@v7 with: python-version: "3.11" - name: Get the commit message run: | - echo 'commit_message<> $GITHUB_ENV - git log --format=%B -n 1 ${{ github.event.after }} >> $GITHUB_ENV - echo 'EOF' >> $GITHUB_ENV + delimiter="$(python -c 'import uuid; print(uuid.uuid4())')" + { + echo "commit_message<<${delimiter}" + git log --format=%B -n 1 HEAD + echo "${delimiter}" + } >> "$GITHUB_ENV" shell: bash - - uses: ccdc-opensource/commit-hooks@main + - uses: ./ with: commitMessage: ${{ env.commit_message }} + licenseCheck: true diff --git a/README.md b/README.md index f38d38c..cc73bad 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,9 @@ -This repository contains files that can be used as a github action and local -git hooks. +This repository provides code quality and compliance tooling for CCDC repositories: -It does a few checks on source code to ensure compliance with some general -CCDC coding standard. +1. **Native Git Hooks** (`main/`): Local hooks configured globally (`commit-msg`, `pre-commit`, `pre-merge-commit`) for standard Git workflows. +2. **GitHub Action** (`action.yml`): Composite GitHub Action for CI workflows to validate copyright headers and repository compliance on PRs/commits. + +It does a few checks on source code to ensure compliance with general CCDC coding standards. The commit will be flagged if it includes certain text files with: @@ -12,59 +13,82 @@ The commit will be flagged if it includes certain text files with: * Tabs * Missing terminating newline for certain files * Certain C++ #include patterns and std::exception +* Missing or non-compliant CCDC copyright and license headers (when using the GitHub Action or local copywrite integration) The commit will also be flagged if the commit message does not include a Jira ID (unless marked with NO_JIRA or a Copilot Autofix co-author line), or if the size of new or modified files exceeds a threshold. -# Github action +# GitHub Actions + +This repository provides a composite GitHub Action for validating copyright +headers and file compliance rules in CI. ## Usage + ```yaml -- uses: ccdc-opensource/commit-hooks@v7 +- name: Checkout repository + uses: actions/checkout@v7 with: - commitMessage: 'The commit message' -``` + ref: ${{ github.event_name == 'pull_request' && github.head_ref || github.ref }} + fetch-depth: 0 -## Scenarios -### Check files in pull request for merge to main -```yaml -name: Check pull request files -on: - pull_request - branches: [ main ] -jobs: - Pull-request-files-check: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - with: - ref: ${{ github.head_ref }} - fetch-depth: 0 - - uses: actions/setup-python@v6 - with: - python-version: "3.11" - - name: Get the commit message - run: | - echo "commit_message=$(git log --format=%B -n 1 ${{ github.event.after }})" >> $GITHUB_ENV - shell: bash - - uses: ccdc-opensource/commit-hooks@v7 - with: - commitMessage: ${{ env.commit_message }} +- name: Set up Python + uses: actions/setup-python@v7 + with: + python-version: "3.11" + +- name: Extract commit message + shell: bash + run: | + delimiter="$(python -c 'import uuid; print(uuid.uuid4())')" + { + echo "commit_message<<${delimiter}" + git log --format=%B -n 1 HEAD + echo "${delimiter}" + } >> "$GITHUB_ENV" + +- uses: ccdc-opensource/commit-hooks@v8 + with: + commitMessage: ${{ env.commit_message }} + # Optional: enable CCDC license header validation on PR changed files + licenseCheck: true # default: false (opt-in) ``` -# commit-hooks -You can use this as git hooks for local repositories. +A complete workflow template for CI is available in [templates/compliance.yml](templates/compliance.yml). + +# Native Git Hooks + +To enable CCDC commit checks (Jira ID, CRLF, line endings, DO NOT COMMIT, file size, and automatic copyright headers) globally for all repositories on your machine: + +1. Clone this repository. +2. Run: + ```bash + git config --global core.hooksPath /main + ``` +3. (Optional) Install `copywrite` to automatically add and format CCDC copyright headers on commit: + * **Windows:** `choco install copywrite` + * **macOS:** `brew install hashicorp/tap/copywrite` + * **Linux:** `go install github.com/hashicorp/copywrite@latest` + +> **Note:** If `copywrite` is not installed on your machine, native hooks will continue to run all other standard checks and display a gentle warning without failing your commit. + +## Configuring Copywrite Behavior + +Developers can customise the copywrite hook using Git configuration: -A set of hooks include: -* commit-msg -* pre-commit -* pre-merge-commit +* **Enable / Disable Copywrite:** + ```bash + git config --global hooks.copywrite true # opt-in: enable copywrite integration + git config --global hooks.copywrite false # default: disabled + ``` -## Setting up -1. Clone this repo -1. `git config --global core.hooksPath /main` +* **Set Mode (`fix` vs `check`):** + ```bash + git config --global hooks.copywriteMode fix # default: automatically inserts/updates headers on commit + git config --global hooks.copywriteMode check # read-only check (warns/fails if headers are missing) + ``` ## Recommended settings ### To ensure the line endings are correctly converted: diff --git a/action.yml b/action.yml index 3d7bbd1..095af22 100644 --- a/action.yml +++ b/action.yml @@ -1,3 +1,18 @@ +# +# This code is Copyright (C) 2026 The Cambridge Crystallographic Data Centre (CCDC) +# of 12 Union Road, Cambridge CB2 1EZ, UK and a proprietary work of CCDC. This +# code may not be used, reproduced, translated, modified, disassembled or +# copied, except in accordance with a valid licence agreement with CCDC and +# may NOT be disclosed or redistributed in any form, either in whole or in +# part, to any third party. All copies of this code made in accordance with a +# valid licence agreement as referred to above must contain this copyright +# notice. +# +# No representations, warranties, or liabilities are expressed or implied in +# the supply of this code by CCDC, its servants or agents, except where such +# exclusion or limitation is prohibited, void or unenforceable under governing +# law. +# name: CCDC File Checks author: CCDC description: Check changed files for compliance @@ -5,13 +20,85 @@ inputs: commitMessage: description: 'The commit message' required: true + licenseCheck: + description: 'Validate CCDC copyright and license headers on changed files (true/false)' + required: false + default: 'false' runs: - using: "composite" + using: composite steps: - - run: python3 $GITHUB_ACTION_PATH/main.py + - name: Install copywrite + if: ${{ inputs.licenseCheck == 'true' }} + uses: hashicorp/setup-copywrite@v1.1.3 + + - name: Validate Header Compliance + if: ${{ inputs.licenseCheck == 'true' }} + shell: bash + env: + COPYWRITE_HOOK_ROOT: ${{ github.action_path }} + GITHUB_EVENT_BEFORE: ${{ github.event.before }} + run: | + copywrite --version + + CHANGED_FILES=() + if [ "${GITHUB_EVENT_NAME:-}" = "pull_request" ] && [ -n "${GITHUB_BASE_REF:-}" ]; then + DIFF_REF="origin/${GITHUB_BASE_REF}" + if ! git rev-parse --verify "$DIFF_REF" >/dev/null 2>&1; then + DIFF_REF="remotes/origin/${GITHUB_BASE_REF}" + fi + if ! git rev-parse --verify "$DIFF_REF" >/dev/null 2>&1; then + DIFF_REF="${GITHUB_BASE_REF}" + fi + if ! git rev-parse --verify "$DIFF_REF" >/dev/null 2>&1; then + echo "Unable to resolve pull request base ref: ${GITHUB_BASE_REF}" >&2 + exit 1 + fi + while IFS= read -r file; do + [ -n "$file" ] && CHANGED_FILES+=("$file") + done < <(git diff --name-only --diff-filter=d "${DIFF_REF}...HEAD") + elif [ "${GITHUB_EVENT_NAME:-}" = "push" ]; then + if [[ "${GITHUB_EVENT_BEFORE:-}" =~ ^0+$ ]]; then + while IFS= read -r file; do + [ -n "$file" ] && CHANGED_FILES+=("$file") + done < <(git diff-tree --root --no-commit-id --name-only --diff-filter=d -r HEAD) + else + if [ -z "${GITHUB_EVENT_BEFORE:-}" ] || ! git rev-parse --verify "${GITHUB_EVENT_BEFORE}^{commit}" >/dev/null 2>&1; then + echo "Unable to resolve push baseline: ${GITHUB_EVENT_BEFORE:-}" >&2 + exit 1 + fi + while IFS= read -r file; do + [ -n "$file" ] && CHANGED_FILES+=("$file") + done < <(git diff --name-only --diff-filter=d "${GITHUB_EVENT_BEFORE}..HEAD") + fi + elif git rev-parse --verify HEAD^ >/dev/null 2>&1; then + while IFS= read -r file; do + [ -n "$file" ] && CHANGED_FILES+=("$file") + done < <(git diff --name-only --diff-filter=d HEAD^..HEAD) + else + while IFS= read -r file; do + [ -n "$file" ] && CHANGED_FILES+=("$file") + done < <(git diff-tree --root --no-commit-id --name-only --diff-filter=d -r HEAD) + fi + + if [ ${#CHANGED_FILES[@]} -gt 0 ]; then + echo "Checking ${#CHANGED_FILES[@]} changed file(s) for header compliance..." + copywrite headers \ + --config "$GITHUB_ACTION_PATH/main/copywrite/.copywrite.hcl" \ + --plan \ + -- \ + "${CHANGED_FILES[@]}" + else + echo "No added or modified files found to check for header compliance." + fi + + - name: Run file compliance check shell: bash env: INPUT_COMMITMESSAGE: ${{ inputs.commitMessage }} + GITHUB_EVENT_BEFORE: ${{ github.event.before }} + run: | + python3 "$GITHUB_ACTION_PATH/main.py" + branding: - icon: 'check-square' - color: 'green' + icon: check-square + color: green diff --git a/main.py b/main.py index ba28722..d6cc21a 100755 --- a/main.py +++ b/main.py @@ -1,4 +1,19 @@ #!/usr/bin/env python3 +# +# This code is Copyright (C) 2026 The Cambridge Crystallographic Data Centre (CCDC) +# of 12 Union Road, Cambridge CB2 1EZ, UK and a proprietary work of CCDC. This +# code may not be used, reproduced, translated, modified, disassembled or +# copied, except in accordance with a valid licence agreement with CCDC and +# may NOT be disclosed or redistributed in any form, either in whole or in +# part, to any third party. All copies of this code made in accordance with a +# valid licence agreement as referred to above must contain this copyright +# notice. +# +# No representations, warranties, or liabilities are expressed or implied in +# the supply of this code by CCDC, its servants or agents, except where such +# exclusion or limitation is prohibited, void or unenforceable under governing +# law. +# ''' This is a github action entry point. diff --git a/main/commit-msg.py b/main/commit-msg.py index 0de1961..c0f5bfe 100755 --- a/main/commit-msg.py +++ b/main/commit-msg.py @@ -1,4 +1,19 @@ #!/usr/bin/env python3 +# +# This code is Copyright (C) 2026 The Cambridge Crystallographic Data Centre (CCDC) +# of 12 Union Road, Cambridge CB2 1EZ, UK and a proprietary work of CCDC. This +# code may not be used, reproduced, translated, modified, disassembled or +# copied, except in accordance with a valid licence agreement with CCDC and +# may NOT be disclosed or redistributed in any form, either in whole or in +# part, to any third party. All copies of this code made in accordance with a +# valid licence agreement as referred to above must contain this copyright +# notice. +# +# No representations, warranties, or liabilities are expressed or implied in +# the supply of this code by CCDC, its servants or agents, except where such +# exclusion or limitation is prohibited, void or unenforceable under governing +# law. +# ''' A hook to check commit massage. diff --git a/main/copywrite/.copywrite.hcl b/main/copywrite/.copywrite.hcl new file mode 100644 index 0000000..c76c316 --- /dev/null +++ b/main/copywrite/.copywrite.hcl @@ -0,0 +1,37 @@ +schema_version = 1 + +project { + copyright_holder = "The Cambridge Crystallographic Data Centre (CCDC)" + + header_ignore = [ + ".git/**", + ".github/**", + "test/**", + "tests/**", + "templates/**", + "**/bin/**", + "**/obj/**", + "**/packages/**", + "**/node_modules/**", + "**/dist/**", + "**/build/**", + "**/.venv/**", + "**/venv/**", + "**/__pycache__/**", + "**/*.Designer.cs", + "**/*.g.cs", + "**/*.generated.*", + "**/*.min.js", + "**/*.lock", + ] +} + +rule { + paths = ["**/*.py", "**/*.sh", "**/*.bash", "**/*.yaml", "**/*.yml"] + license_header = "${COPYWRITE_HOOK_ROOT}/main/copywrite/headers/ccdc_hash.tmpl" +} + +rule { + paths = ["**/*.js", "**/*.ts", "**/*.cs", "**/*.cpp", "**/*.cxx", "**/*.cc", "**/*.h", "**/*.hpp"] + license_header = "${COPYWRITE_HOOK_ROOT}/main/copywrite/headers/ccdc_slash.tmpl" +} \ No newline at end of file diff --git a/main/copywrite/headers/ccdc_hash.tmpl b/main/copywrite/headers/ccdc_hash.tmpl new file mode 100644 index 0000000..a0b4795 --- /dev/null +++ b/main/copywrite/headers/ccdc_hash.tmpl @@ -0,0 +1,15 @@ +# +# This code is Copyright (C) {{ .Year }} The Cambridge Crystallographic Data Centre (CCDC) +# of 12 Union Road, Cambridge CB2 1EZ, UK and a proprietary work of CCDC. This +# code may not be used, reproduced, translated, modified, disassembled or +# copied, except in accordance with a valid licence agreement with CCDC and +# may NOT be disclosed or redistributed in any form, either in whole or in +# part, to any third party. All copies of this code made in accordance with a +# valid licence agreement as referred to above must contain this copyright +# notice. +# +# No representations, warranties, or liabilities are expressed or implied in +# the supply of this code by CCDC, its servants or agents, except where such +# exclusion or limitation is prohibited, void or unenforceable under governing +# law. +# diff --git a/main/copywrite/headers/ccdc_slash.tmpl b/main/copywrite/headers/ccdc_slash.tmpl new file mode 100644 index 0000000..7d6d3f8 --- /dev/null +++ b/main/copywrite/headers/ccdc_slash.tmpl @@ -0,0 +1,15 @@ +// +// This code is Copyright (C) {{ .Year }} The Cambridge Crystallographic Data Centre (CCDC) +// of 12 Union Road, Cambridge CB2 1EZ, UK and a proprietary work of CCDC. This +// code may not be used, reproduced, translated, modified, disassembled or +// copied, except in accordance with a valid licence agreement with CCDC and +// may NOT be disclosed or redistributed in any form, either in whole or in +// part, to any third party. All copies of this code made in accordance with a +// valid licence agreement as referred to above must contain this copyright +// notice. +// +// No representations, warranties, or liabilities are expressed or implied in +// the supply of this code by CCDC, its servants or agents, except where such +// exclusion or limitation is prohibited, void or unenforceable under governing +// law. +// diff --git a/main/githooks.py b/main/githooks.py index 35fba84..839e24f 100644 --- a/main/githooks.py +++ b/main/githooks.py @@ -1,4 +1,19 @@ #!/usr/bin/env python3 +# +# This code is Copyright (C) 2026 The Cambridge Crystallographic Data Centre (CCDC) +# of 12 Union Road, Cambridge CB2 1EZ, UK and a proprietary work of CCDC. This +# code may not be used, reproduced, translated, modified, disassembled or +# copied, except in accordance with a valid licence agreement with CCDC and +# may NOT be disclosed or redistributed in any form, either in whole or in +# part, to any third party. All copies of this code made in accordance with a +# valid licence agreement as referred to above must contain this copyright +# notice. +# +# No representations, warranties, or liabilities are expressed or implied in +# the supply of this code by CCDC, its servants or agents, except where such +# exclusion or limitation is prohibited, void or unenforceable under governing +# law. +# ''' Module for a git hook. @@ -12,6 +27,7 @@ import os import platform import re +import shutil import subprocess import unittest import sys @@ -192,7 +208,13 @@ def get_commit_files(): if _is_pull_request(): commands += [f'remotes/origin/{os.environ["GITHUB_BASE_REF"]}..remotes/origin/{os.environ["GITHUB_HEAD_REF"]}','--'] else: - commands += ['HEAD~..', '--'] + before_sha = os.environ.get('GITHUB_EVENT_BEFORE', '') + if before_sha and set(before_sha) == {'0'}: + commands = ['git', 'diff-tree', '--root', '--no-commit-id', '--name-status', '-r', 'HEAD', '--'] + elif before_sha: + commands += [f'{before_sha}..HEAD', '--'] + else: + commands += ['HEAD~..', '--'] else: commands = ['git', 'diff-index', '--ignore-submodules', 'HEAD', '--cached'] @@ -259,9 +281,15 @@ def get_changed_lines(modified_file): if _is_pull_request(): commands += [f'remotes/origin/{os.environ["GITHUB_BASE_REF"]}..remotes/origin/{os.environ["GITHUB_HEAD_REF"]}', '--',f'{modified_file}'] else: - commands += ['HEAD~', f'{modified_file}'] + before_sha = os.environ.get('GITHUB_EVENT_BEFORE', '') + if before_sha and set(before_sha) == {'0'}: + commands = ['git', 'diff-tree', '--root', '--no-commit-id', '--unified=0', '-r', 'HEAD', '--', modified_file] + elif before_sha: + commands += [f'{before_sha}..HEAD', '--', modified_file] + else: + commands += ['HEAD~', '--', modified_file] else: - commands = [f'git', 'diff-index', 'HEAD', '--unified=0', f'{modified_file}'] + commands = [f'git', 'diff-index', 'HEAD', '--unified=0', '--', f'{modified_file}'] output = _get_output(commands) lines = [] @@ -272,6 +300,44 @@ def get_changed_lines(modified_file): return lines +class TestPushRanges(unittest.TestCase): + def _assert_ranges(self, before_sha, expected_files_command, expected_lines_command): + environment = {'GITHUB_EVENT_NAME': 'push'} + if before_sha is not None: + environment['GITHUB_EVENT_BEFORE'] = before_sha + + with patch.dict(os.environ, environment, clear=True), patch('githooks._get_output') as get_output: + get_output.return_value = 'M\texample.py\n' + self.assertEqual(['example.py'], get_commit_files()['M']) + get_output.assert_called_once_with(expected_files_command) + + get_output.reset_mock() + get_output.return_value = '@@ -1 +1 @@\n' + self.assertEqual(['1'], get_changed_lines('example.py')) + get_output.assert_called_once_with(expected_lines_command) + + def test_push_uses_event_before_sha(self): + self._assert_ranges( + 'abc123', + ['git', 'diff', '--ignore-submodules', '--name-status', 'abc123..HEAD', '--'], + ['git', 'diff', '--unified=0', 'abc123..HEAD', '--', 'example.py'] + ) + + def test_initial_push_uses_root_diff(self): + self._assert_ranges( + '0' * 40, + ['git', 'diff-tree', '--root', '--no-commit-id', '--name-status', '-r', 'HEAD', '--'], + ['git', 'diff-tree', '--root', '--no-commit-id', '--unified=0', '-r', 'HEAD', '--', 'example.py'] + ) + + def test_push_without_before_uses_previous_commit(self): + self._assert_ranges( + None, + ['git', 'diff', '--ignore-submodules', '--name-status', 'HEAD~..', '--'], + ['git', 'diff', '--unified=0', 'HEAD~', '--', 'example.py'] + ) + + def yield_changed_lines(changed_lines): '''Yield individual line numbers from list returned by get_changed_lines''' for line_num_range in changed_lines: @@ -967,6 +1033,163 @@ def _test(input, is_good=True): _test('Close but no cigar abc-1234', False) +def run_copywrite(files): + '''Run copywrite to automatically check or fix license headers. + + Configurable via git config: + - `git config --global hooks.copywrite true|false` (default: false, opt-in) + - `git config --global hooks.copywriteMode fix|check` (default: fix) + - fix: automatically adds/updates headers and restages files + - check: checks header compliance and warns/fails without modifying + + If copywrite is not installed and the hook is enabled, print a soft warning and return 0 (do not block commit). + ''' + if not files: + return 0 + + # Opt-in: only run if explicitly enabled in git config + enabled_setting = get_config_setting('hooks.copywrite') + if enabled_setting is None or enabled_setting.lower() not in ['true', '1', 'yes', 'on']: + return 0 + + copywrite_exe = shutil.which('copywrite') + if not copywrite_exe: + print(' WARNING: "copywrite" not found on PATH. Skipping copyright header check.') + print(' To enable automatic copyright formatting, install copywrite:') + print(' - Windows: choco install copywrite') + print(' - macOS: brew install hashicorp/tap/copywrite') + print(' - Linux: go install github.com/hashicorp/copywrite@latest') + return 0 + + hook_root = Path(__file__).resolve().parent.parent + config_path = hook_root / 'main' / 'copywrite' / '.copywrite.hcl' + if not config_path.is_file(): + _fail(f'Copywrite configuration not found: {config_path}') + return 1 + + env = os.environ.copy() + env['COPYWRITE_HOOK_ROOT'] = str(hook_root) + + mode = (get_config_setting('hooks.copywriteMode') or 'fix').lower() + if mode not in ['fix', 'check', 'plan', 'verify']: + _fail(f'Unsupported hooks.copywriteMode value: {mode}') + return 1 + is_check_mode = mode in ['check', 'plan', 'verify'] + + cmd = [copywrite_exe, 'headers', f'--config={config_path}'] + if is_check_mode: + cmd.append('--plan') + cmd.append('--') + cmd.extend(files) + + try: + if not is_check_mode: + unstaged = subprocess.run( + ['git', 'diff', '--quiet', '--'] + files, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + text=True + ) + if unstaged.returncode == 1: + _fail('Copywrite fix mode cannot run with unstaged changes in staged files. ' + 'Stage or stash those changes, or use hooks.copywriteMode check.') + return 1 + if unstaged.returncode != 0: + _fail(f'Unable to inspect unstaged changes:\n{unstaged.stderr.strip()}') + return 1 + + proc = subprocess.run( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + env=env + ) + if proc.returncode != 0: + details = '\n'.join(output.strip() for output in [proc.stdout, proc.stderr] if output.strip()) + _fail(f'Copyright header update failed:\n{details}') + return 1 + if not is_check_mode: + restage = subprocess.run( + ['git', 'add', '--'] + files, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + text=True + ) + if restage.returncode != 0: + _fail(f'Unable to restage files updated by Copywrite:\n{restage.stderr.strip()}') + return 1 + except (OSError, subprocess.SubprocessError) as error: + _fail(f'Failed to run Copywrite: {error}') + return 1 + + return 0 + + +class TestRunCopywrite(unittest.TestCase): + @patch('githooks.get_config_setting', return_value=None) + def test_disabled(self, _config): + self.assertEqual(0, run_copywrite(['example.py'])) + + @patch('githooks.shutil.which', return_value=None) + @patch('githooks.get_config_setting', return_value='true') + def test_missing_executable_is_soft_failure(self, _config, _which): + self.assertEqual(0, run_copywrite(['example.py'])) + + @patch('githooks.Path.is_file', return_value=True) + @patch('githooks.shutil.which', return_value='copywrite') + @patch('githooks.get_config_setting', side_effect=['true', 'check']) + @patch('githooks.subprocess.run') + def test_check_failure_blocks_commit(self, run, _config, _which, _is_file): + run.return_value = subprocess.CompletedProcess([], 1, 'stdout', 'stderr') + self.assertEqual(1, run_copywrite(['example.py'])) + + @patch('githooks.Path.is_file', return_value=True) + @patch('githooks.shutil.which', return_value='copywrite') + @patch('githooks.get_config_setting', side_effect=['true', 'fix']) + @patch('githooks.subprocess.run') + def test_fix_restages_files(self, run, _config, _which, _is_file): + run.side_effect = [ + subprocess.CompletedProcess([], 0, '', ''), + subprocess.CompletedProcess([], 0, '', ''), + subprocess.CompletedProcess([], 0, '', ''), + ] + self.assertEqual(0, run_copywrite(['example.py'])) + self.assertEqual( + ['copywrite', 'headers', unittest.mock.ANY, '--', 'example.py'], + run.call_args_list[1].args[0] + ) + self.assertEqual(['git', 'add', '--', 'example.py'], run.call_args_list[-1].args[0]) + + @patch('githooks.Path.is_file', return_value=True) + @patch('githooks.shutil.which', return_value='copywrite') + @patch('githooks.get_config_setting', side_effect=['true', 'fix']) + @patch('githooks.subprocess.run') + def test_restage_failure_blocks_commit(self, run, _config, _which, _is_file): + run.side_effect = [ + subprocess.CompletedProcess([], 0, '', ''), + subprocess.CompletedProcess([], 0, '', ''), + subprocess.CompletedProcess([], 1, '', 'cannot add'), + ] + self.assertEqual(1, run_copywrite(['example.py'])) + + @patch('githooks.Path.is_file', return_value=True) + @patch('githooks.shutil.which', return_value='copywrite') + @patch('githooks.get_config_setting', side_effect=['true', 'fix']) + @patch('githooks.subprocess.run') + def test_fix_rejects_partially_staged_files(self, run, _config, _which, _is_file): + run.return_value = subprocess.CompletedProcess([], 1, '', '') + self.assertEqual(1, run_copywrite(['example.py'])) + run.assert_called_once() + + @patch('githooks.Path.is_file', return_value=True) + @patch('githooks.shutil.which', return_value='copywrite') + @patch('githooks.get_config_setting', side_effect=['true', 'check']) + @patch('githooks.subprocess.run', side_effect=OSError('cannot execute')) + def test_subprocess_error_blocks_commit(self, _run, _config, _which, _is_file): + self.assertEqual(1, run_copywrite(['example.py'])) + + def commit_hook(merge=False): retval = 0 files = get_commit_files() @@ -979,14 +1202,19 @@ def commit_hook(merge=False): retval += check_do_not_merge(files['M']) retval += check_do_not_merge(files['A'], new_files=True) else: + staged_files = files['M'] + files['A'] + + print(' Check and update copyright headers ...') + retval += run_copywrite(staged_files) + print(' Check filenames ...') - retval += check_filenames(files['M'] + files['A']) + retval += check_filenames(staged_files) print(' Check line endings ...') - retval += check_eol(files['M'] + files['A']) + retval += check_eol(staged_files) print(' Check file content ...') - retval += check_content(files['M'] + files['A']) + retval += check_content(staged_files) return retval diff --git a/main/pre-commit.py b/main/pre-commit.py index 1e18904..5a35066 100755 --- a/main/pre-commit.py +++ b/main/pre-commit.py @@ -1,4 +1,19 @@ #!/usr/bin/env python3 +# +# This code is Copyright (C) 2026 The Cambridge Crystallographic Data Centre (CCDC) +# of 12 Union Road, Cambridge CB2 1EZ, UK and a proprietary work of CCDC. This +# code may not be used, reproduced, translated, modified, disassembled or +# copied, except in accordance with a valid licence agreement with CCDC and +# may NOT be disclosed or redistributed in any form, either in whole or in +# part, to any third party. All copies of this code made in accordance with a +# valid licence agreement as referred to above must contain this copyright +# notice. +# +# No representations, warranties, or liabilities are expressed or implied in +# the supply of this code by CCDC, its servants or agents, except where such +# exclusion or limitation is prohibited, void or unenforceable under governing +# law. +# ''' This is a git hook migrated from hg. diff --git a/main/pre-merge-commit.py b/main/pre-merge-commit.py index 93c154b..8f3a8a3 100755 --- a/main/pre-merge-commit.py +++ b/main/pre-merge-commit.py @@ -1,4 +1,19 @@ #!/usr/bin/env python3 +# +# This code is Copyright (C) 2026 The Cambridge Crystallographic Data Centre (CCDC) +# of 12 Union Road, Cambridge CB2 1EZ, UK and a proprietary work of CCDC. This +# code may not be used, reproduced, translated, modified, disassembled or +# copied, except in accordance with a valid licence agreement with CCDC and +# may NOT be disclosed or redistributed in any form, either in whole or in +# part, to any third party. All copies of this code made in accordance with a +# valid licence agreement as referred to above must contain this copyright +# notice. +# +# No representations, warranties, or liabilities are expressed or implied in +# the supply of this code by CCDC, its servants or agents, except where such +# exclusion or limitation is prohibited, void or unenforceable under governing +# law. +# ''' This is a git hook migrated from hg. diff --git a/templates/compliance.yml b/templates/compliance.yml new file mode 100644 index 0000000..fc618cd --- /dev/null +++ b/templates/compliance.yml @@ -0,0 +1,39 @@ +name: Header & Compliance Checks + +on: + pull_request: + branches: [main] + push: + branches: [main] + +jobs: + compliance-check: + name: Validate Headers & Code Compliance + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v7 + with: + ref: ${{ github.head_ref }} + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v7 + with: + python-version: "3.11" + + - name: Extract commit message + shell: bash + run: | + delimiter="$(python -c 'import uuid; print(uuid.uuid4())')" + { + echo "commit_message<<${delimiter}" + git log --format=%B -n 1 HEAD + echo "${delimiter}" + } >> "$GITHUB_ENV" + + - name: Run CCDC Commit Hooks & Header Checks + uses: ccdc-opensource/commit-hooks@v8 + with: + commitMessage: ${{ env.commit_message }} + licenseCheck: true