Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<path-to-cloned-repo>" 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:
Expand Down
225 changes: 223 additions & 2 deletions main/githooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
Copilot marked this conversation as resolved.

try:
import licence_headers
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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()

Expand Down