diff --git a/README.md b/README.md index 57dfda4..3f2e6b5 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,22 @@ Developers can customise the licence header hook using Git configuration (use `- git config --global hooks.licenceCheckMode check # read-only check (warns/fails if headers are missing) ``` +## Automatic Update Notifications + +The native commit hooks periodically check (at most once every 24 hours) if a newer release of `commit-hooks` is available on GitHub. + +If a new version is detected, a non-blocking informational notice will be displayed in the terminal during commit: +```text + [INFO] A newer version of CCDC commit-hooks is available: v8.1 (current: v7.3) + To update, run: git -C "" pull +``` + +* This check is completely non-blocking and will never fail or interrupt a commit. +* You can disable update notifications via Git configuration if desired: + ```bash + git config --global hooks.checkUpdates false + ``` + ### Supported and Excluded Files When licence header validation/formatting is enabled, files are filtered using the following rules: diff --git a/main/githooks.py b/main/githooks.py index 05bc266..035d75e 100644 --- a/main/githooks.py +++ b/main/githooks.py @@ -22,14 +22,17 @@ from collections import defaultdict from io import StringIO from pathlib import Path -from tempfile import NamedTemporaryFile +from tempfile import NamedTemporaryFile, TemporaryDirectory from unittest.mock import patch +import json import os import platform import re import subprocess -import unittest import sys +import time +import unittest +import urllib.request try: import licence_headers @@ -80,6 +83,10 @@ ] # File types that need a terminating newline TERMINATING_NEWLINE_EXTS = ['.c', '.cpp', '.h', '.inl'] +# Update check TTL in seconds (24 hours) +UPDATE_CACHE_TTL_SECONDS = 86400 +# GitHub API URL for the latest release of commit-hooks +GITHUB_LATEST_RELEASE_URL = 'https://api.github.com/repos/ccdc-opensource/commit-hooks/releases/latest' def _get_output(command_list, cwd='.'): return subprocess.check_output(command_list, cwd=cwd).decode(errors='replace') @@ -1200,7 +1207,221 @@ def test_missing_module_fails_when_enabled(self, _config): self.assertEqual(1, run_licence_check(['example.py'])) +def _get_hooks_repo_dir(): + '''Return the root directory of the commit-hooks repository.''' + return Path(__file__).resolve().parent.parent + + +def _get_update_cache_file(): + '''Return path to the update check cache file in the hooks repo's git directory.''' + repo_dir = _get_hooks_repo_dir() + try: + git_dir_raw = _get_output(['git', '-C', str(repo_dir), 'rev-parse', '--git-dir']).strip() + git_dir = Path(git_dir_raw) + if not git_dir.is_absolute(): + git_dir = (repo_dir / git_dir).resolve() + if git_dir.is_dir(): + return git_dir / 'ccdc_hooks_update_cache.json' + except Exception: + pass + return repo_dir / '.update_cache.json' + + +def _get_local_version(): + '''Get current version/tag or commit SHA of the local hooks repository.''' + repo_dir = _get_hooks_repo_dir() + try: + return _get_output( + ['git', '-C', str(repo_dir), 'describe', '--tags', '--always'] + ).strip() + except Exception: + return None + + +def _fetch_latest_remote_version(): + '''Fetch the latest release tag from GitHub API with a short timeout.''' + req = urllib.request.Request( + GITHUB_LATEST_RELEASE_URL, + headers={ + 'User-Agent': 'ccdc-commit-hooks', + 'Accept': 'application/vnd.github.v3+json', + } + ) + with urllib.request.urlopen(req, timeout=1.5) as response: + if response.status == 200: + data = json.loads(response.read().decode('utf-8')) + return data.get('tag_name') + return None + + +def _parse_version_tuple(version_str): + '''Parse a version string like "v8.1" or "8.1.2" into a tuple of ints.''' + if not version_str: + return () + clean = version_str.lstrip('v').split('-')[0].split('+')[0] + parts = [] + for segment in clean.split('.'): + if segment.isdigit(): + parts.append(int(segment)) + else: + break + return tuple(parts) + + +def check_for_updates(): + '''Check if an update is available and display a subtle notification if outdated. + + This check is completely non-blocking, cached for 24 hours, and fails silently + if network/API access is unavailable. + ''' + if _is_github_event(): + return + + check_setting = get_config_setting('hooks.checkUpdates') + if check_setting is not None and check_setting.lower() in ['false', '0', 'no', 'off']: + return + + try: + repo_dir = _get_hooks_repo_dir() + cache_file = _get_update_cache_file() + now = time.time() + + cached_data = {} + if cache_file.exists(): + try: + cached_data = json.loads(cache_file.read_text(encoding='utf-8')) + except Exception: + pass + + last_checked = cached_data.get('last_checked', 0) + latest_tag = cached_data.get('latest_tag') + + if now - last_checked > UPDATE_CACHE_TTL_SECONDS: + latest_tag = _fetch_latest_remote_version() + cached_data = { + 'last_checked': now, + 'latest_tag': latest_tag + } + try: + cache_file.write_text(json.dumps(cached_data), encoding='utf-8') + except Exception: + pass + + if not latest_tag: + return + + local_version = _get_local_version() + if not local_version: + return + + local_tuple = _parse_version_tuple(local_version) + remote_tuple = _parse_version_tuple(latest_tag) + + if local_tuple and remote_tuple: + is_outdated = local_tuple < remote_tuple + else: + clean_local = local_version.lstrip('v') + clean_remote = latest_tag.lstrip('v') + is_outdated = clean_local != clean_remote and not clean_local.startswith(clean_remote) + + if is_outdated: + print(f'\n' + f' [INFO] A newer version of CCDC commit-hooks is available: {latest_tag} (current: {local_version})\n' + f' To update, run: git -C "{repo_dir}" pull\n') + except Exception: + # Failsafe: never break the commit hook + pass + + +class TestUpdateCheck(unittest.TestCase): + def test_parse_version_tuple(self): + self.assertEqual((8, 1), _parse_version_tuple('v8.1')) + self.assertEqual((8, 1, 2), _parse_version_tuple('8.1.2')) + self.assertEqual((7, 3), _parse_version_tuple('v7.3-4-g1234abc')) + self.assertEqual((), _parse_version_tuple('')) + self.assertEqual((), _parse_version_tuple(None)) + + def test_update_cache_file_locates_git_dir(self): + with TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + git_dir = temp_path / 'real_git_dir' + git_dir.mkdir() + with patch('githooks._get_hooks_repo_dir', return_value=temp_path): + with patch('githooks._get_output', return_value=str(git_dir)): + self.assertEqual(git_dir / 'ccdc_hooks_update_cache.json', _get_update_cache_file()) + + def test_update_cache_file_fallback_when_git_fails(self): + with TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + with patch('githooks._get_hooks_repo_dir', return_value=temp_path): + with patch('githooks._get_output', side_effect=subprocess.CalledProcessError(1, 'git')): + self.assertEqual(temp_path / '.update_cache.json', _get_update_cache_file()) + + @patch('githooks._is_github_event', return_value=True) + def test_skipped_on_github_event(self, _mock_event): + with patch('sys.stdout', new=StringIO()) as tmp_stdout: + check_for_updates() + self.assertEqual('', tmp_stdout.getvalue()) + + @patch('githooks._is_github_event', return_value=False) + @patch('githooks.get_config_setting', return_value='false') + def test_opted_out_via_config(self, _mock_config, _mock_event): + with patch('sys.stdout', new=StringIO()) as tmp_stdout: + check_for_updates() + self.assertEqual('', tmp_stdout.getvalue()) + + @patch('githooks._is_github_event', return_value=False) + @patch('githooks.get_config_setting', return_value=None) + @patch('githooks._get_local_version', return_value='v7.3') + @patch('githooks._fetch_latest_remote_version', return_value='v8.1') + def test_prints_warning_when_outdated(self, _fetch, _local, _config, _event): + with TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + with patch('githooks._get_hooks_repo_dir', return_value=temp_path): + with patch('sys.stdout', new=StringIO()) as tmp_stdout: + check_for_updates() + output = tmp_stdout.getvalue() + self.assertIn('A newer version of CCDC commit-hooks is available: v8.1 (current: v7.3)', output) + self.assertIn('git -C', output) + + # Second run within TTL uses cache without calling fetch again + _fetch.reset_mock() + check_for_updates() + _fetch.assert_not_called() + + @patch('githooks._is_github_event', return_value=False) + @patch('githooks.get_config_setting', return_value=None) + @patch('githooks._get_local_version', return_value='v7.3') + @patch('githooks._fetch_latest_remote_version', return_value=None) + def test_respects_ttl_even_on_failed_fetch(self, _fetch, _local, _config, _event): + with TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + with patch('githooks._get_hooks_repo_dir', return_value=temp_path): + with patch('sys.stdout', new=StringIO()) as tmp_stdout: + check_for_updates() + self.assertEqual('', tmp_stdout.getvalue()) + self.assertEqual(1, _fetch.call_count) + + # Second run within TTL respects cache and does not attempt network call + _fetch.reset_mock() + check_for_updates() + _fetch.assert_not_called() + + @patch('githooks._is_github_event', return_value=False) + @patch('githooks.get_config_setting', return_value=None) + @patch('githooks._get_local_version', return_value='v8.1') + @patch('githooks._fetch_latest_remote_version', return_value='v8.1') + def test_no_warning_when_up_to_date(self, _fetch, _local, _config, _event): + with TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + with patch('githooks._get_hooks_repo_dir', return_value=temp_path): + with patch('sys.stdout', new=StringIO()) as tmp_stdout: + check_for_updates() + self.assertEqual('', tmp_stdout.getvalue()) + + def commit_hook(merge=False): + check_for_updates() retval = 0 files = get_commit_files()