From 3947a1a0678c37dc9c067aba4c270f89784ce242 Mon Sep 17 00:00:00 2001 From: "omer.roth" Date: Wed, 30 Sep 2026 10:25:54 +0300 Subject: [PATCH] CM-73654 fixed pre-push issue around root commit push --- .pre-commit-hooks.yaml | 6 + README.md | 23 ++-- .../apps/scan/pre_push/pre_push_command.py | 37 ++++-- cycode/cli/apps/scan/scan_result.py | 15 ++- cycode/cli/consts.py | 10 ++ cycode/cli/exceptions/custom_exceptions.py | 5 + cycode/cli/exceptions/handle_scan_errors.py | 6 + .../files_collector/commit_range_documents.py | 37 +++++- .../cli/printers/utils/code_snippet_syntax.py | 3 +- .../commands/scan/test_pre_push_command.py | 107 ++++++++++++++++++ tests/cli/commands/scan/test_scan_result.py | 33 +++++- .../test_commit_range_documents.py | 65 +++++++++++ 12 files changed, 318 insertions(+), 29 deletions(-) create mode 100644 tests/cli/commands/scan/test_pre_push_command.py diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml index fd2bfbed..6064d0e4 100644 --- a/.pre-commit-hooks.yaml +++ b/.pre-commit-hooks.yaml @@ -23,6 +23,8 @@ entry: cycode args: [ '-o', 'text', '--no-progress-meter', 'scan', '-t', 'secret', 'pre-push' ] stages: [pre-push] + always_run: true + pass_filenames: false - id: cycode-sca-pre-push name: Cycode SCA pre-push defender language: python @@ -30,6 +32,8 @@ entry: cycode args: [ '-o', 'text', '--no-progress-meter', 'scan', '-t', 'sca', 'pre-push' ] stages: [pre-push] + always_run: true + pass_filenames: false - id: cycode-sast-pre-push name: Cycode SAST pre-push defender language: python @@ -37,3 +41,5 @@ entry: cycode args: [ '-o', 'text', '--no-progress-meter', 'scan', '-t', 'sast', 'pre-push' ] stages: [pre-push] + always_run: true + pass_filenames: false diff --git a/README.md b/README.md index da428ec8..11f728e2 100644 --- a/README.md +++ b/README.md @@ -258,7 +258,7 @@ Perform the following steps to install the pre-commit hook: ```yaml repos: - repo: https://github.com/cycodehq/cycode-cli - rev: v3.5.0 + rev: v3.25.0 hooks: - id: cycode stages: [pre-commit] @@ -269,7 +269,7 @@ Perform the following steps to install the pre-commit hook: ```yaml repos: - repo: https://github.com/cycodehq/cycode-cli - rev: v3.5.0 + rev: v3.25.0 hooks: - id: cycode stages: [pre-commit] @@ -308,7 +308,7 @@ To install the pre-push hook in addition to or instead of the pre-commit hook: ```yaml repos: - repo: https://github.com/cycodehq/cycode-cli - rev: v3.5.0 + rev: v3.25.0 hooks: - id: cycode-pre-push stages: [pre-push] @@ -1109,7 +1109,7 @@ To set up the pre-push hook using the pre-commit framework: ```yaml repos: - repo: https://github.com/cycodehq/cycode-cli - rev: v3.5.0 + rev: v3.25.0 hooks: - id: cycode-pre-push stages: [pre-push] @@ -1120,7 +1120,7 @@ To set up the pre-push hook using the pre-commit framework: ```yaml repos: - repo: https://github.com/cycodehq/cycode-cli - rev: v3.5.0 + rev: v3.25.0 hooks: - id: cycode-pre-push # Secrets scan stages: [pre-push] @@ -1146,16 +1146,15 @@ To set up the pre-push hook using the pre-commit framework: #### How Pre-Push Scanning Works -The pre-push hook: -- Receives information about what commits are being pushed -- Calculates the appropriate commit range to scan -- For new branches: scans all commits from the merge base with the default branch -- For existing branches: scans only the new commits since the last push -- Runs the same comprehensive scanning as other Cycode scan modes +The hook scans every commit being pushed, one commit at a time, so a secret added in one commit and removed in a later one is still caught. When installed through the pre-commit framework, the pushed range is taken from the framework: new commits since the remote branch for existing branches, and the commits not yet on the remote for new branches (all commits when pushing to an empty remote). When run from a hand-written `.git/hooks/pre-push`, the range is read from git's hook input, and new branches are scanned from their merge base with the default branch (see below). + +Pushes with nothing to scan, such as tags, branch deletions, and up-to-date pushes, pass without a scan. If the hook cannot determine what is being pushed, it fails and blocks the push instead of passing silently. + +The Cycode pre-push hooks set `always_run: true` and `pass_filenames: false`, because the scan covers the pushed commits rather than a list of files. Without `always_run`, the pre-commit framework skips the hook when the pushed commits add up to no changed files, such as a secret added in one commit and deleted in the next. Without `pass_filenames: false`, the framework passes changed file names the scan does not use, and on large pushes it splits them into batches and runs the full scan once per batch. If you define the hook yourself (for example, as a `repo: local` hook), set both options. #### Smart Default Branch Detection -The pre-push hook intelligently detects the default branch for merge base calculation using this priority order: +When run from a hand-written hook, the pre-push scan detects the default branch for merge base calculation using this priority order: 1. **Environment Variable**: `CYCODE_DEFAULT_BRANCH` - allows manual override 2. **Git Remote HEAD**: Uses `git symbolic-ref refs/remotes/origin/HEAD` to detect the actual remote default branch diff --git a/cycode/cli/apps/scan/pre_push/pre_push_command.py b/cycode/cli/apps/scan/pre_push/pre_push_command.py index 729f3571..3d2801bc 100644 --- a/cycode/cli/apps/scan/pre_push/pre_push_command.py +++ b/cycode/cli/apps/scan/pre_push/pre_push_command.py @@ -12,9 +12,12 @@ ) from cycode.cli.config import configuration_manager from cycode.cli.console import console +from cycode.cli.exceptions.custom_exceptions import PrePushInputNotFoundError from cycode.cli.exceptions.handle_scan_errors import handle_scan_exception from cycode.cli.files_collector.commit_range_documents import ( calculate_pre_push_commit_range, + get_pre_commit_framework_push_range, + is_invoked_by_pre_commit_framework, parse_pre_push_input, ) from cycode.cli.logger import logger @@ -43,17 +46,8 @@ def pre_push_command( command_scan_type = ctx.info_name timeout = configuration_manager.get_pre_push_command_timeout(command_scan_type) with TimeoutAfter(timeout): - push_update_details = parse_pre_push_input() - if not push_update_details: - logger.info('No pre-push input found, nothing to scan') - return - - commit_range = calculate_pre_push_commit_range(push_update_details) + commit_range = _get_pre_push_commit_range() if not commit_range: - logger.info( - 'No new commits found for pushed branch, %s', - {'push_update_details': push_update_details}, - ) return scan_commit_range( @@ -67,3 +61,26 @@ def pre_push_command( console.print(consts.PRE_RECEIVE_AND_PUSH_REMEDIATION_MESSAGE) except Exception as e: handle_scan_exception(ctx, e) + + +def _get_pre_push_commit_range() -> Optional[str]: + commit_range = get_pre_commit_framework_push_range() + if commit_range: + logger.debug('Using push details from the pre-commit framework, %s', {'commit_range': commit_range}) + return commit_range + + if is_invoked_by_pre_commit_framework(): + # the framework consumed git's stdin but did not tell us what is being pushed + raise PrePushInputNotFoundError + + push_update_details = parse_pre_push_input() + if not push_update_details: + # git runs the hook with empty input when everything is up-to-date + logger.info('No pre-push input found, nothing to scan') + return None + + commit_range = calculate_pre_push_commit_range(push_update_details) + if not commit_range: + logger.info('No new commits found for pushed branch, %s', {'push_update_details': push_update_details}) + + return commit_range diff --git a/cycode/cli/apps/scan/scan_result.py b/cycode/cli/apps/scan/scan_result.py index 015d354c..16c97a90 100644 --- a/cycode/cli/apps/scan/scan_result.py +++ b/cycode/cli/apps/scan/scan_result.py @@ -7,7 +7,7 @@ from cycode.cli.apps.scan.aggregation_report import try_get_aggregation_report_url_if_needed from cycode.cli.apps.scan.detection_excluder import exclude_irrelevant_document_detections from cycode.cli.models import Document, DocumentDetections, LocalScanResult -from cycode.cli.utils.path_utils import get_path_by_os, normalize_file_path +from cycode.cli.utils.path_utils import concat_unique_id, get_path_by_os, normalize_file_path from cycode.cyclient.models import ( Detection, DetectionSchema, @@ -28,8 +28,15 @@ def _get_document_by_file_name( documents: list[Document], file_name: str, unique_id: Optional[str] = None ) -> Optional[Document]: + normalized_file_name = normalize_file_path(file_name) for document in documents: - if normalize_file_path(document.path) == normalize_file_path(file_name) and document.unique_id == unique_id: + if normalize_file_path(document.path) == normalized_file_name and document.unique_id == unique_id: + return document + + if ( + document.unique_id + and normalize_file_path(concat_unique_id(document.path, document.unique_id)) == normalized_file_name + ): return document return None @@ -50,6 +57,10 @@ def _get_document_detections( ) document = _get_document_by_file_name(documents_to_scan, file_name, commit_id) + if document is None: + logger.debug('Failed to find the document of the violated file, %s', {'file_name': file_name}) + document = Document(file_name, '', unique_id=commit_id) + document_detections.append(DocumentDetections(document=document, detections=detections_per_file.detections)) return document_detections diff --git a/cycode/cli/consts.py b/cycode/cli/consts.py index e2d09ce1..c2153d07 100644 --- a/cycode/cli/consts.py +++ b/cycode/cli/consts.py @@ -6,6 +6,8 @@ PRE_COMMIT_COMMAND_SCAN_TYPE_OLD = 'pre_commit' PRE_RECEIVE_COMMAND_SCAN_TYPE = 'pre-receive' PRE_RECEIVE_COMMAND_SCAN_TYPE_OLD = 'pre_receive' +PRE_PUSH_COMMAND_SCAN_TYPE = 'pre-push' +PRE_PUSH_COMMAND_SCAN_TYPE_OLD = 'pre_push' COMMIT_HISTORY_COMMAND_SCAN_TYPE = 'commit-history' COMMIT_HISTORY_COMMAND_SCAN_TYPE_OLD = 'commit_history' @@ -193,6 +195,8 @@ PRE_COMMIT_COMMAND_SCAN_TYPE_OLD, PRE_RECEIVE_COMMAND_SCAN_TYPE, PRE_RECEIVE_COMMAND_SCAN_TYPE_OLD, + PRE_PUSH_COMMAND_SCAN_TYPE, + PRE_PUSH_COMMAND_SCAN_TYPE_OLD, COMMIT_HISTORY_COMMAND_SCAN_TYPE, COMMIT_HISTORY_COMMAND_SCAN_TYPE_OLD, ] @@ -311,6 +315,12 @@ GIT_PUSH_OPTION_COUNT_ENV_VAR_NAME = 'GIT_PUSH_OPTION_COUNT' GIT_PUSH_OPTION_ENV_VAR_PREFIX = 'GIT_PUSH_OPTION_' +# the pre-commit framework consumes git's pre-push stdin and exposes the push details via env vars instead +PRE_COMMIT_FRAMEWORK_ENV_VAR_NAME = 'PRE_COMMIT' +PRE_COMMIT_FROM_REF_ENV_VAR_NAME = 'PRE_COMMIT_FROM_REF' +PRE_COMMIT_TO_REF_ENV_VAR_NAME = 'PRE_COMMIT_TO_REF' +PRE_COMMIT_REMOTE_BRANCH_ENV_VAR_NAME = 'PRE_COMMIT_REMOTE_BRANCH' + SKIP_SCAN_FLAG = 'skip-cycode-scan' VERBOSE_SCAN_FLAG = 'verbose' diff --git a/cycode/cli/exceptions/custom_exceptions.py b/cycode/cli/exceptions/custom_exceptions.py index a9a1505f..f4dd787d 100644 --- a/cycode/cli/exceptions/custom_exceptions.py +++ b/cycode/cli/exceptions/custom_exceptions.py @@ -80,6 +80,11 @@ def __str__(self) -> str: return self.error_message +class PrePushInputNotFoundError(CycodeError): + def __str__(self) -> str: + return 'Neither git pre-push input nor pre-commit framework push details were found' + + class AuthProcessError(CycodeError): def __init__(self, error_message: str) -> None: self.error_message = error_message diff --git a/cycode/cli/exceptions/handle_scan_errors.py b/cycode/cli/exceptions/handle_scan_errors.py index 0b1e975b..51957d63 100644 --- a/cycode/cli/exceptions/handle_scan_errors.py +++ b/cycode/cli/exceptions/handle_scan_errors.py @@ -40,6 +40,12 @@ def handle_scan_exception(ctx: typer.Context, err: Exception, *, return_exceptio message='File collection failed. ' 'Use --no-restore to skip dependency restoration, or fix the underlying issue.', ), + custom_exceptions.PrePushInputNotFoundError: CliError( + soft_fail=False, + code='pre_push_input_not_found', + message='Could not determine which commits are being pushed, so nothing was scanned. ' + 'Run this command from a git pre-push hook', + ), custom_exceptions.TfplanKeyError: CliError( soft_fail=True, code='key_error', diff --git a/cycode/cli/files_collector/commit_range_documents.py b/cycode/cli/files_collector/commit_range_documents.py index 2fb63581..bb406541 100644 --- a/cycode/cli/files_collector/commit_range_documents.py +++ b/cycode/cli/files_collector/commit_range_documents.py @@ -85,7 +85,11 @@ def collect_commit_range_diff_documents( repo = git_proxy.get_repo(path) - normalized_commit_range = normalize_commit_range(commit_range, path) + if commit_range == consts.COMMIT_RANGE_ALL_COMMITS: + # everything reachable from HEAD, including the root commit + normalized_commit_range = consts.GIT_HEAD_COMMIT_REV + else: + normalized_commit_range = normalize_commit_range(commit_range, path) total_commits_count = int(repo.git.rev_list('--count', normalized_commit_range)) logger.debug( @@ -104,8 +108,10 @@ def collect_commit_range_diff_documents( commit_id = commit.hexsha commit_ids_to_scan.append(commit_id) - parent = commit.parents[0] if commit.parents else git_proxy.get_null_tree() - diff_index = commit.diff(parent, create_patch=True, R=True) + if commit.parents: + diff_index = commit.diff(commit.parents[0], create_patch=True, R=True) + else: + diff_index = commit.diff(git_proxy.get_null_tree(), create_patch=True) for diff in diff_index: commit_documents_to_scan.append( Document( @@ -264,6 +270,31 @@ def parse_pre_push_input() -> Optional[str]: return pre_push_input.splitlines()[0] +def is_invoked_by_pre_commit_framework() -> bool: + return os.getenv(consts.PRE_COMMIT_FRAMEWORK_ENV_VAR_NAME) == '1' + + +def get_pre_commit_framework_push_range() -> Optional[str]: + """Get the commit range to scan when invoked as a pre-push hook by the pre-commit framework. + + The pre-commit framework reads git's pre-push stdin itself and never forwards it to hooks. + Instead, it exposes the already resolved push details via environment variables. + + Returns: + Commit range string for scanning, or None if not invoked by the pre-commit framework + """ + from_ref = os.getenv(consts.PRE_COMMIT_FROM_REF_ENV_VAR_NAME) + to_ref = os.getenv(consts.PRE_COMMIT_TO_REF_ENV_VAR_NAME) + if from_ref and to_ref: + return f'{from_ref}..{to_ref}' + + # the framework omits the refs when the pushed history includes the root commit + if os.getenv(consts.PRE_COMMIT_REMOTE_BRANCH_ENV_VAR_NAME): + return consts.COMMIT_RANGE_ALL_COMMITS + + return None + + def _read_hook_input_from_stdin() -> str: """Read input from stdin when called from a hook. diff --git a/cycode/cli/printers/utils/code_snippet_syntax.py b/cycode/cli/printers/utils/code_snippet_syntax.py index 57bc084e..d6ccd1a6 100644 --- a/cycode/cli/printers/utils/code_snippet_syntax.py +++ b/cycode/cli/printers/utils/code_snippet_syntax.py @@ -89,7 +89,8 @@ def _get_code_snippet_syntax_from_git_diff( detection_position = detection_details.get('start_position', -1) violation_length = detection_details.get('length', -1) - line_content = document.content.splitlines()[detection_line] + document_content_lines = document.content.splitlines() + line_content = document_content_lines[detection_line] if 0 <= detection_line < len(document_content_lines) else '' detection_position_in_line = get_position_in_line(document.content, detection_position) if scan_type == consts.SECRET_SCAN_TYPE and obfuscate: violation = line_content[detection_position_in_line : detection_position_in_line + violation_length] diff --git a/tests/cli/commands/scan/test_pre_push_command.py b/tests/cli/commands/scan/test_pre_push_command.py new file mode 100644 index 00000000..a8f6af9f --- /dev/null +++ b/tests/cli/commands/scan/test_pre_push_command.py @@ -0,0 +1,107 @@ +from io import StringIO +from unittest.mock import MagicMock, Mock, patch + +import pytest + +from cycode.cli import consts +from cycode.cli.apps.scan.pre_push.pre_push_command import pre_push_command + +_PRE_COMMIT_ENV_VARS = ( + consts.PRE_COMMIT_FRAMEWORK_ENV_VAR_NAME, + consts.PRE_COMMIT_FROM_REF_ENV_VAR_NAME, + consts.PRE_COMMIT_TO_REF_ENV_VAR_NAME, + consts.PRE_COMMIT_REMOTE_BRANCH_ENV_VAR_NAME, +) + + +@pytest.fixture(autouse=True) +def _clean_pre_commit_env(monkeypatch: pytest.MonkeyPatch) -> None: + for env_var_name in _PRE_COMMIT_ENV_VARS: + monkeypatch.delenv(env_var_name, raising=False) + + +def _make_ctx() -> MagicMock: + ctx = MagicMock() + ctx.info_name = consts.PRE_PUSH_COMMAND_SCAN_TYPE + ctx.obj = {'console_printer': MagicMock(), 'progress_bar': MagicMock()} + return ctx + + +def _run_pre_push(ctx: MagicMock, stdin: str) -> None: + with patch('sys.stdin', StringIO(stdin)): + pre_push_command(ctx) + + +@patch('cycode.cli.apps.scan.pre_push.pre_push_command.scan_commit_range') +def test_scans_push_range_provided_by_pre_commit_framework( + mock_scan_commit_range: Mock, monkeypatch: pytest.MonkeyPatch +) -> None: + # the framework consumes git's stdin; the hook gets empty stdin and the refs via env vars + monkeypatch.setenv(consts.PRE_COMMIT_FRAMEWORK_ENV_VAR_NAME, '1') + monkeypatch.setenv(consts.PRE_COMMIT_FROM_REF_ENV_VAR_NAME, 'a' * 40) + monkeypatch.setenv(consts.PRE_COMMIT_TO_REF_ENV_VAR_NAME, 'b' * 40) + monkeypatch.setenv(consts.PRE_COMMIT_REMOTE_BRANCH_ENV_VAR_NAME, 'refs/heads/main') + + ctx = _make_ctx() + _run_pre_push(ctx, '') + + mock_scan_commit_range.assert_called_once() + assert mock_scan_commit_range.call_args.kwargs['commit_range'] == f'{"a" * 40}..{"b" * 40}' + assert not ctx.obj.get('did_fail') + + +@patch('cycode.cli.apps.scan.pre_push.pre_push_command.scan_commit_range') +def test_scans_all_commits_when_pre_commit_framework_pushes_root_commit( + mock_scan_commit_range: Mock, monkeypatch: pytest.MonkeyPatch +) -> None: + # the framework omits the refs when the pushed history includes the root commit + monkeypatch.setenv(consts.PRE_COMMIT_FRAMEWORK_ENV_VAR_NAME, '1') + monkeypatch.setenv(consts.PRE_COMMIT_REMOTE_BRANCH_ENV_VAR_NAME, 'refs/heads/main') + + _run_pre_push(_make_ctx(), '') + + mock_scan_commit_range.assert_called_once() + assert mock_scan_commit_range.call_args.kwargs['commit_range'] == consts.COMMIT_RANGE_ALL_COMMITS + + +@patch('cycode.cli.apps.scan.pre_push.pre_push_command.scan_commit_range') +def test_fails_closed_when_pre_commit_framework_gives_no_push_details( + mock_scan_commit_range: Mock, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv(consts.PRE_COMMIT_FRAMEWORK_ENV_VAR_NAME, '1') + + ctx = _make_ctx() + _run_pre_push(ctx, '') + + mock_scan_commit_range.assert_not_called() + assert ctx.obj['did_fail'] is True + error = ctx.obj['console_printer'].print_error.call_args.args[0] + assert error.code == 'pre_push_input_not_found' + assert error.soft_fail is False + + +@patch('cycode.cli.apps.scan.pre_push.pre_push_command.scan_commit_range') +def test_scans_push_range_from_git_stdin(mock_scan_commit_range: Mock) -> None: + ctx = _make_ctx() + _run_pre_push(ctx, f'refs/heads/main {"b" * 40} refs/heads/main {"a" * 40}') + + mock_scan_commit_range.assert_called_once() + assert mock_scan_commit_range.call_args.kwargs['commit_range'] == f'{"a" * 40}..{"b" * 40}' + + +@pytest.mark.parametrize( + 'stdin', + [ + # git runs the hook with empty input when everything is up-to-date + '', + f'refs/tags/v1.0.0 {"b" * 40} refs/tags/v1.0.0 {consts.EMPTY_COMMIT_SHA}', + f'(delete) {consts.EMPTY_COMMIT_SHA} refs/heads/feature {"a" * 40}', + ], +) +@patch('cycode.cli.apps.scan.pre_push.pre_push_command.scan_commit_range') +def test_passes_quietly_when_git_push_has_nothing_to_scan(mock_scan_commit_range: Mock, stdin: str) -> None: + ctx = _make_ctx() + _run_pre_push(ctx, stdin) + + mock_scan_commit_range.assert_not_called() + assert not ctx.obj.get('did_fail') diff --git a/tests/cli/commands/scan/test_scan_result.py b/tests/cli/commands/scan/test_scan_result.py index e85ca116..6b992f95 100644 --- a/tests/cli/commands/scan/test_scan_result.py +++ b/tests/cli/commands/scan/test_scan_result.py @@ -1,7 +1,11 @@ import os +from unittest.mock import Mock -from cycode.cli.apps.scan.scan_result import _get_file_name_from_detection +from cycode.cli.apps.scan.scan_result import _get_document_detections, _get_file_name_from_detection from cycode.cli.consts import IAC_SCAN_TYPE, SAST_SCAN_TYPE, SCA_SCAN_TYPE, SECRET_SCAN_TYPE +from cycode.cli.models import Document +from cycode.cli.utils.path_utils import concat_unique_id +from cycode.cyclient.models import DetectionsPerFile, ZippedFileScanResult def test_get_file_name_from_detection_sca_uses_file_path() -> None: @@ -45,3 +49,30 @@ def test_get_file_name_from_detection_secret_uses_file_path_and_file_name() -> N } result = _get_file_name_from_detection(SECRET_SCAN_TYPE, raw_detection) assert result == os.path.join('/repo/src', '.env') + + +def _scan_result_for(file_name: str, commit_id: str) -> ZippedFileScanResult: + return ZippedFileScanResult( + did_detect=True, + detections_per_file=[DetectionsPerFile(file_name=file_name, detections=[Mock()], commit_id=commit_id)], + ) + + +def test_get_document_detections_matches_commit_document_by_archived_name() -> None: + # commit range documents are archived as '/' and the server reports that name back + commit_id = 'a' * 40 + document = Document(os.path.join(os.sep, 'repo', 'creds.txt'), 'content', unique_id=commit_id) + scan_result = _scan_result_for(concat_unique_id(document.path, commit_id), commit_id) + + document_detections = _get_document_detections(scan_result, [document]) + + assert document_detections[0].document is document + + +def test_get_document_detections_keeps_detection_when_document_is_not_found() -> None: + scan_result = _scan_result_for('unknown.txt', 'a' * 40) + + document_detections = _get_document_detections(scan_result, []) + + assert document_detections[0].document.path == 'unknown.txt' + assert len(document_detections[0].detections) == 1 diff --git a/tests/cli/files_collector/test_commit_range_documents.py b/tests/cli/files_collector/test_commit_range_documents.py index d972144c..4cde5ec4 100644 --- a/tests/cli/files_collector/test_commit_range_documents.py +++ b/tests/cli/files_collector/test_commit_range_documents.py @@ -15,6 +15,7 @@ calculate_pre_receive_commit_range, collect_commit_range_diff_documents, get_diff_file_path, + get_pre_commit_framework_push_range, get_safe_head_reference_for_diff, get_staged_diff_index, parse_commit_range, @@ -780,6 +781,34 @@ def test_calculate_range_with_tag_update_returns_none(self) -> None: assert result is None +class TestGetPreCommitFrameworkPushRange: + """Test the push range resolution from the env vars the pre-commit framework passes to pre-push hooks.""" + + @pytest.fixture(autouse=True) + def _clean_env(self, monkeypatch: pytest.MonkeyPatch) -> None: + for env_var_name in ( + consts.PRE_COMMIT_FROM_REF_ENV_VAR_NAME, + consts.PRE_COMMIT_TO_REF_ENV_VAR_NAME, + consts.PRE_COMMIT_REMOTE_BRANCH_ENV_VAR_NAME, + ): + monkeypatch.delenv(env_var_name, raising=False) + + def test_returns_range_from_refs(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv(consts.PRE_COMMIT_FROM_REF_ENV_VAR_NAME, DUMMY_SHA_A) + monkeypatch.setenv(consts.PRE_COMMIT_TO_REF_ENV_VAR_NAME, DUMMY_SHA_B) + monkeypatch.setenv(consts.PRE_COMMIT_REMOTE_BRANCH_ENV_VAR_NAME, 'refs/heads/main') + + assert get_pre_commit_framework_push_range() == f'{DUMMY_SHA_A}..{DUMMY_SHA_B}' + + def test_returns_all_commits_when_only_branches_are_set(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv(consts.PRE_COMMIT_REMOTE_BRANCH_ENV_VAR_NAME, 'refs/heads/main') + + assert get_pre_commit_framework_push_range() == consts.COMMIT_RANGE_ALL_COMMITS + + def test_returns_none_without_env_vars(self) -> None: + assert get_pre_commit_framework_push_range() is None + + class TestPrePushHookIntegration: """Integration tests for pre-push hook functionality.""" @@ -1167,3 +1196,39 @@ def test_collect_with_various_commit_range_formats(self) -> None: commit_range = a_commit.hexsha documents = collect_commit_range_diff_documents(mock_ctx, temp_dir, commit_range) assert len(documents) == 2, f'Expected 2 documents from single commit A, got {len(documents)}' + + def test_collect_all_commits_includes_root_commit(self) -> None: + """Test that '--all' (a push to an empty remote) collects the root commit too.""" + with temporary_git_repository() as (temp_dir, repo): + with open(os.path.join(temp_dir, 'creds.txt'), 'w') as f: + f.write('secret') + repo.index.add(['creds.txt']) + root_commit = repo.index.commit('root') + + mock_ctx = Mock() + mock_ctx.obj = {'progress_bar': Mock()} + + documents = collect_commit_range_diff_documents(mock_ctx, temp_dir, consts.COMMIT_RANGE_ALL_COMMITS) + assert [doc.unique_id for doc in documents] == [root_commit.hexsha] + # the root commit's files must be reported as added, otherwise their secrets are dropped as removed + assert '+secret' in documents[0].content + assert '-secret' not in documents[0].content + + def test_collect_reports_added_lines_for_child_commit(self) -> None: + with temporary_git_repository() as (temp_dir, repo): + with open(os.path.join(temp_dir, 'a.txt'), 'w') as f: + f.write('root') + repo.index.add(['a.txt']) + root_commit = repo.index.commit('root') + + with open(os.path.join(temp_dir, 'creds.txt'), 'w') as f: + f.write('secret') + repo.index.add(['creds.txt']) + repo.index.commit('child') + + mock_ctx = Mock() + mock_ctx.obj = {'progress_bar': Mock()} + + documents = collect_commit_range_diff_documents(mock_ctx, temp_dir, f'{root_commit.hexsha}..HEAD') + assert len(documents) == 1 + assert '+secret' in documents[0].content