From 8968ee40885ec2e5832e4b50988496a404b924e0 Mon Sep 17 00:00:00 2001 From: Manish Maharjan <112875432+mmaharjan-ccdc@users.noreply.github.com> Date: Fri, 4 Sep 2026 09:10:52 +0100 Subject: [PATCH 1/3] SYS-8690 base daily update check --- README.md | 16 ++++ main/githooks.py | 187 ++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 202 insertions(+), 1 deletion(-) 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..bcbe792 100644 --- a/main/githooks.py +++ b/main/githooks.py @@ -24,12 +24,16 @@ from pathlib import Path from tempfile import NamedTemporaryFile 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.error +import urllib.request try: import licence_headers @@ -80,6 +84,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 +1208,184 @@ 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_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 = repo_dir / '.update_cache.json' + 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 or not latest_tag: + 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)) + + @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): + temp_dir = NamedTemporaryFile().name + temp_path = Path(temp_dir) + temp_path.mkdir(parents=True, exist_ok=True) + try: + 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() + finally: + if (temp_path / '.update_cache.json').exists(): + (temp_path / '.update_cache.json').unlink() + temp_path.rmdir() + + @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): + temp_dir = NamedTemporaryFile().name + temp_path = Path(temp_dir) + temp_path.mkdir(parents=True, exist_ok=True) + try: + 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()) + finally: + if (temp_path / '.update_cache.json').exists(): + (temp_path / '.update_cache.json').unlink() + temp_path.rmdir() + + def commit_hook(merge=False): + check_for_updates() retval = 0 files = get_commit_files() From e2a8afbbe4797ecd29acc8aaab90179136e5fd66 Mon Sep 17 00:00:00 2001 From: Manish Maharjan <112875432+mmaharjan-ccdc@users.noreply.github.com> Date: Fri, 4 Sep 2026 10:13:20 +0100 Subject: [PATCH 2/3] SYS-8690 use TemporaryDirectory --- main/githooks.py | 43 ++++++++++++++++++++++++------------------- 1 file changed, 24 insertions(+), 19 deletions(-) diff --git a/main/githooks.py b/main/githooks.py index bcbe792..981e573 100644 --- a/main/githooks.py +++ b/main/githooks.py @@ -22,7 +22,7 @@ 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 @@ -32,7 +32,6 @@ import sys import time import unittest -import urllib.error import urllib.request try: @@ -1213,6 +1212,15 @@ def _get_hooks_repo_dir(): return Path(__file__).resolve().parent.parent +def _get_update_cache_file(): + '''Return path to the update check cache file, preferring .git directory if present.''' + repo_dir = _get_hooks_repo_dir() + git_dir = repo_dir / '.git' + if git_dir.is_dir(): + return git_dir / 'ccdc_hooks_update_cache.json' + 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() @@ -1269,7 +1277,7 @@ def check_for_updates(): try: repo_dir = _get_hooks_repo_dir() - cache_file = repo_dir / '.update_cache.json' + cache_file = _get_update_cache_file() now = time.time() cached_data = {} @@ -1327,6 +1335,15 @@ def test_parse_version_tuple(self): self.assertEqual((), _parse_version_tuple('')) self.assertEqual((), _parse_version_tuple(None)) + def test_update_cache_file_prefers_git_dir(self): + with TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + with patch('githooks._get_hooks_repo_dir', return_value=temp_path): + self.assertEqual(temp_path / '.update_cache.json', _get_update_cache_file()) + git_dir = temp_path / '.git' + git_dir.mkdir() + self.assertEqual(git_dir / 'ccdc_hooks_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: @@ -1345,10 +1362,8 @@ def test_opted_out_via_config(self, _mock_config, _mock_event): @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): - temp_dir = NamedTemporaryFile().name - temp_path = Path(temp_dir) - temp_path.mkdir(parents=True, exist_ok=True) - try: + 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() @@ -1360,28 +1375,18 @@ def test_prints_warning_when_outdated(self, _fetch, _local, _config, _event): _fetch.reset_mock() check_for_updates() _fetch.assert_not_called() - finally: - if (temp_path / '.update_cache.json').exists(): - (temp_path / '.update_cache.json').unlink() - temp_path.rmdir() @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): - temp_dir = NamedTemporaryFile().name - temp_path = Path(temp_dir) - temp_path.mkdir(parents=True, exist_ok=True) - try: + 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()) - finally: - if (temp_path / '.update_cache.json').exists(): - (temp_path / '.update_cache.json').unlink() - temp_path.rmdir() def commit_hook(merge=False): From b771ad83b7ac9a2396b0c6719e7a874e937b9f40 Mon Sep 17 00:00:00 2001 From: Manish Maharjan <112875432+mmaharjan-ccdc@users.noreply.github.com> Date: Fri, 4 Sep 2026 11:27:58 +0100 Subject: [PATCH 3/3] SYS-8690 Use git rev-parse --git-dir to locate real git dir --- main/githooks.py | 51 ++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 41 insertions(+), 10 deletions(-) diff --git a/main/githooks.py b/main/githooks.py index 981e573..035d75e 100644 --- a/main/githooks.py +++ b/main/githooks.py @@ -1213,11 +1213,17 @@ def _get_hooks_repo_dir(): def _get_update_cache_file(): - '''Return path to the update check cache file, preferring .git directory if present.''' + '''Return path to the update check cache file in the hooks repo's git directory.''' repo_dir = _get_hooks_repo_dir() - git_dir = repo_dir / '.git' - if git_dir.is_dir(): - return git_dir / 'ccdc_hooks_update_cache.json' + 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' @@ -1290,7 +1296,7 @@ def check_for_updates(): last_checked = cached_data.get('last_checked', 0) latest_tag = cached_data.get('latest_tag') - if now - last_checked > UPDATE_CACHE_TTL_SECONDS or not latest_tag: + if now - last_checked > UPDATE_CACHE_TTL_SECONDS: latest_tag = _fetch_latest_remote_version() cached_data = { 'last_checked': now, @@ -1335,14 +1341,21 @@ def test_parse_version_tuple(self): self.assertEqual((), _parse_version_tuple('')) self.assertEqual((), _parse_version_tuple(None)) - def test_update_cache_file_prefers_git_dir(self): + 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): - self.assertEqual(temp_path / '.update_cache.json', _get_update_cache_file()) - git_dir = temp_path / '.git' - git_dir.mkdir() - self.assertEqual(git_dir / 'ccdc_hooks_update_cache.json', _get_update_cache_file()) + 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): @@ -1376,6 +1389,24 @@ def test_prints_warning_when_outdated(self, _fetch, _local, _config, _event): 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')