Skip to content

Commit 667e81c

Browse files
CM-72985 Add scan local-diff command for IDE plugin integration (#550)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 22ca5c0 commit 667e81c

11 files changed

Lines changed: 886 additions & 74 deletions

File tree

‎README.md‎

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ This guide walks you through both installation and usage.
4848
4. [Commit History Scan](#commit-history-scan)
4949
1. [Commit Range Option (Diff Scanning)](#commit-range-option-diff-scanning)
5050
5. [Pre-Commit Scan](#pre-commit-scan)
51+
1. [Local Diff Scanning (IDE Integrations)](#local-diff-scanning-ide-integrations)
5152
6. [Pre-Push Scan](#pre-push-scan)
5253
2. [Scan Results](#scan-results)
5354
1. [Show/Hide Secrets](#showhide-secrets)
@@ -798,12 +799,12 @@ The Cycode CLI application offers several types of scans so that you can choose
798799
| `--maven-settings-file` | For Maven only, allows using a custom [settings.xml](https://maven.apache.org/settings.html) file when scanning for dependencies |
799800
| `--help` | Show options for given command. |
800801

801-
| Command | Description |
802-
|----------------------------------------|-----------------------------------------------------------------------|
803-
| [commit-history](#commit-history-scan) | Scan commit history or perform diff scanning between specific commits |
804-
| [path](#path-scan) | Scan the files in the path supplied in the command |
805-
| [pre-commit](#pre-commit-scan) | Use this command to scan the content that was not committed yet |
806-
| [repository](#repository-scan) | Scan git repository including its history |
802+
| Command | Description |
803+
|-----------------------------------------|----------------------------------------------------------------------------------------------------|
804+
| [commit-history](#commit-history-scan) | Scan commit history or perform diff scanning between specific commits |
805+
| [path](#path-scan) | Scan the files in the path supplied in the command |
806+
| [pre-commit](#pre-commit-scan) | Scan content that was not committed yet; also supports local diff scanning via flags (IDE-friendly) |
807+
| [repository](#repository-scan) | Scan git repository including its history |
807808

808809
### Options
809810

@@ -1085,6 +1086,36 @@ After installing the pre-commit hook, you may occasionally wish to skip scanning
10851086
SKIP=cycode git commit -m <your commit message>`
10861087
```
10871088
1089+
The following options are available for use with this command:
1090+
1091+
| Option | Description |
1092+
|-----------------------|---------------------------------------------------------------------------------------------------------------------|
1093+
| `-b, --base-ref TEXT` | Git ref (commit, branch, or tag) to diff against; defaults to `HEAD`, matching the pre-commit hook behavior |
1094+
| `--include-unstaged` | Also scan unstaged changes to tracked files, not just what is staged; off by default |
1095+
| `--path PATH` | Optional path(s) to scope the diff scan to; repeatable; defaults to the entire working directory |
1096+
1097+
#### Local Diff Scanning (IDE Integrations)
1098+
1099+
`--base-ref` and `--include-unstaged` turn `pre-commit` into a general-purpose **local diff scan**: comparing any commit (default `HEAD`) against your current working directory — including edits that aren't staged yet. This is intended for IDE plugins and other tools that need continuous, real-time feedback as you work, rather than the git hook flow. Combined with `--include-unstaged`, `--path` lets an IDE scope the scan to just the file currently open in the editor.
1100+
1101+
> [!NOTE]
1102+
> Local diff scanning (via these flags) is not available for IaC scans.
1103+
1104+
**Scan everything currently changed (staged + unstaged) against the last commit:**
1105+
```bash
1106+
cycode scan pre-commit --include-unstaged
1107+
```
1108+
1109+
**Scan changes against a specific commit or branch:**
1110+
```bash
1111+
cycode scan pre-commit --include-unstaged --base-ref main
1112+
```
1113+
1114+
**Scan only a specific file (e.g., the file currently open in your IDE):**
1115+
```bash
1116+
cycode scan pre-commit --include-unstaged --path src/app.py
1117+
```
1118+
10881119
### Pre-Push Scan
10891120
10901121
A pre-push scan automatically identifies any issues before you push changes to the remote repository. This hook runs on the client side and scans only the commits that are about to be pushed, making it efficient for catching issues before they reach the remote repository.

‎cycode/cli/apps/scan/__init__.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,8 @@
2828
)
2929
app.command(
3030
name='pre-commit',
31-
short_help='Use this command in pre-commit hook to scan any content that was not committed yet.',
31+
short_help='Use this command in pre-commit hook to scan any content that was not committed yet. '
32+
'Also supports IDE-style local diff scanning via --base-ref/--include-unstaged/--path.',
3233
rich_help_panel=_AUTOMATION_COMMANDS_RICH_HELP_PANEL,
3334
)(pre_commit_command)
3435
app.command(

‎cycode/cli/apps/scan/commit_range_scanner.py‎

Lines changed: 70 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -24,23 +24,18 @@
2424
from cycode.cli.files_collector.commit_range_documents import (
2525
collect_commit_range_diff_documents,
2626
get_commit_range_modified_documents,
27-
get_diff_file_content,
28-
get_diff_file_path,
2927
get_pre_commit_modified_documents,
30-
get_staged_diff_index,
3128
parse_commit_range,
3229
)
3330
from cycode.cli.files_collector.documents_walk_ignore import filter_documents_with_cycodeignore
3431
from cycode.cli.files_collector.file_excluder import excluder
3532
from cycode.cli.files_collector.models.in_memory_zip import InMemoryZip
3633
from cycode.cli.files_collector.sca.sca_file_collector import (
3734
perform_sca_pre_commit_range_scan_actions,
38-
perform_sca_pre_hook_range_scan_actions,
35+
perform_sca_pre_commit_scan_actions,
3936
)
4037
from cycode.cli.files_collector.zip_documents import zip_documents
4138
from cycode.cli.models import Document
42-
from cycode.cli.utils.git_proxy import git_proxy
43-
from cycode.cli.utils.path_utils import get_path_by_os
4439
from cycode.cli.utils.progress_bar import ScanProgressBarSection
4540
from cycode.cli.utils.scan_utils import (
4641
generate_unique_scan_id,
@@ -329,84 +324,103 @@ def scan_commit_range(ctx: typer.Context, repo_path: str, commit_range: str, **k
329324
_SCAN_TYPE_TO_COMMIT_RANGE_HANDLER[scan_type](ctx, repo_path, commit_range, **kwargs)
330325

331326

332-
def _scan_sca_pre_commit(ctx: typer.Context, repo_path: str) -> None:
333-
scan_parameters = get_scan_parameters(ctx)
327+
def _scan_sca_pre_commit(
328+
ctx: typer.Context,
329+
repo_path: str,
330+
base_ref: str = consts.GIT_HEAD_COMMIT_REV,
331+
include_unstaged: bool = False,
332+
paths: Optional[list[str]] = None,
333+
) -> None:
334+
scan_parameters = get_scan_parameters(ctx, (repo_path,))
334335

335-
git_head_documents, pre_committed_documents, _ = get_pre_commit_modified_documents(
336+
from_ref_documents, working_copy_documents, _diff_documents = get_pre_commit_modified_documents(
336337
progress_bar=ctx.obj['progress_bar'],
337338
progress_bar_section=ScanProgressBarSection.PREPARE_LOCAL_FILES,
338339
repo_path=repo_path,
340+
base_ref=base_ref,
341+
include_unstaged=include_unstaged,
342+
paths=paths,
339343
)
340344

341-
git_head_documents = excluder.exclude_irrelevant_documents_to_scan(consts.SCA_SCAN_TYPE, git_head_documents)
342-
pre_committed_documents = excluder.exclude_irrelevant_documents_to_scan(
343-
consts.SCA_SCAN_TYPE, pre_committed_documents
344-
)
345+
from_ref_documents = excluder.exclude_irrelevant_documents_to_scan(consts.SCA_SCAN_TYPE, from_ref_documents)
346+
working_copy_documents = excluder.exclude_irrelevant_documents_to_scan(consts.SCA_SCAN_TYPE, working_copy_documents)
345347

346348
is_cycodeignore_allowed = is_cycodeignore_allowed_by_scan_config(ctx)
347-
git_head_documents = filter_documents_with_cycodeignore(git_head_documents, repo_path, is_cycodeignore_allowed)
348-
pre_committed_documents = filter_documents_with_cycodeignore(
349-
pre_committed_documents, repo_path, is_cycodeignore_allowed
349+
from_ref_documents = filter_documents_with_cycodeignore(from_ref_documents, repo_path, is_cycodeignore_allowed)
350+
working_copy_documents = filter_documents_with_cycodeignore(
351+
working_copy_documents, repo_path, is_cycodeignore_allowed
350352
)
351353

352-
perform_sca_pre_hook_range_scan_actions(repo_path, git_head_documents, pre_committed_documents)
354+
perform_sca_pre_commit_scan_actions(repo_path, from_ref_documents, base_ref, working_copy_documents)
353355

354356
_scan_commit_range_documents(
355357
ctx,
356-
git_head_documents,
357-
pre_committed_documents,
358+
from_ref_documents,
359+
working_copy_documents,
358360
scan_parameters,
359361
configuration_manager.get_sca_pre_commit_timeout_in_seconds(),
360362
)
361363

362364

363-
def _scan_secret_pre_commit(ctx: typer.Context, repo_path: str) -> None:
364-
progress_bar = ctx.obj['progress_bar']
365-
repo = git_proxy.get_repo(repo_path)
366-
_, diff_index = get_staged_diff_index(repo)
367-
368-
progress_bar.set_section_length(ScanProgressBarSection.PREPARE_LOCAL_FILES, len(diff_index))
369-
370-
documents_to_scan = []
371-
for diff in diff_index:
372-
progress_bar.update(ScanProgressBarSection.PREPARE_LOCAL_FILES)
373-
documents_to_scan.append(
374-
Document(
375-
get_path_by_os(get_diff_file_path(diff, repo=repo)),
376-
get_diff_file_content(diff),
377-
is_git_diff_format=True,
378-
)
379-
)
365+
def _scan_secret_pre_commit(
366+
ctx: typer.Context,
367+
repo_path: str,
368+
base_ref: str = consts.GIT_HEAD_COMMIT_REV,
369+
include_unstaged: bool = False,
370+
paths: Optional[list[str]] = None,
371+
) -> None:
372+
# collect_file_contents=False: the secret scan only ever uses diff_documents below, so skip
373+
# building from_ref_documents/working_copy_documents (a disk read per changed file it would
374+
# otherwise discard immediately).
375+
_from_ref_documents, _working_copy_documents, diff_documents = get_pre_commit_modified_documents(
376+
progress_bar=ctx.obj['progress_bar'],
377+
progress_bar_section=ScanProgressBarSection.PREPARE_LOCAL_FILES,
378+
repo_path=repo_path,
379+
base_ref=base_ref,
380+
include_unstaged=include_unstaged,
381+
paths=paths,
382+
collect_file_contents=False,
383+
)
380384

381-
documents_to_scan = excluder.exclude_irrelevant_documents_to_scan(consts.SECRET_SCAN_TYPE, documents_to_scan)
385+
diff_documents = excluder.exclude_irrelevant_documents_to_scan(consts.SECRET_SCAN_TYPE, diff_documents)
382386

383387
is_cycodeignore_allowed = is_cycodeignore_allowed_by_scan_config(ctx)
384-
documents_to_scan = filter_documents_with_cycodeignore(documents_to_scan, repo_path, is_cycodeignore_allowed)
388+
diff_documents = filter_documents_with_cycodeignore(diff_documents, repo_path, is_cycodeignore_allowed)
385389

386-
scan_documents(ctx, documents_to_scan, get_scan_parameters(ctx), is_git_diff=True)
390+
scan_documents(ctx, diff_documents, get_scan_parameters(ctx, (repo_path,)), is_git_diff=True)
387391

388392

389-
def _scan_sast_pre_commit(ctx: typer.Context, repo_path: str, **_) -> None:
393+
def _scan_sast_pre_commit(
394+
ctx: typer.Context,
395+
repo_path: str,
396+
base_ref: str = consts.GIT_HEAD_COMMIT_REV,
397+
include_unstaged: bool = False,
398+
paths: Optional[list[str]] = None,
399+
**_,
400+
) -> None:
390401
scan_parameters = get_scan_parameters(ctx, (repo_path,))
391402

392-
_, pre_committed_documents, diff_documents = get_pre_commit_modified_documents(
403+
_from_ref_documents, working_copy_documents, diff_documents = get_pre_commit_modified_documents(
393404
progress_bar=ctx.obj['progress_bar'],
394405
progress_bar_section=ScanProgressBarSection.PREPARE_LOCAL_FILES,
395406
repo_path=repo_path,
407+
base_ref=base_ref,
408+
include_unstaged=include_unstaged,
409+
paths=paths,
396410
)
397411

398-
pre_committed_documents = excluder.exclude_irrelevant_documents_to_scan(
399-
consts.SAST_SCAN_TYPE, pre_committed_documents
412+
working_copy_documents = excluder.exclude_irrelevant_documents_to_scan(
413+
consts.SAST_SCAN_TYPE, working_copy_documents
400414
)
401415
diff_documents = excluder.exclude_irrelevant_documents_to_scan(consts.SAST_SCAN_TYPE, diff_documents)
402416

403417
is_cycodeignore_allowed = is_cycodeignore_allowed_by_scan_config(ctx)
404-
pre_committed_documents = filter_documents_with_cycodeignore(
405-
pre_committed_documents, repo_path, is_cycodeignore_allowed
418+
working_copy_documents = filter_documents_with_cycodeignore(
419+
working_copy_documents, repo_path, is_cycodeignore_allowed
406420
)
407421
diff_documents = filter_documents_with_cycodeignore(diff_documents, repo_path, is_cycodeignore_allowed)
408422

409-
_scan_commit_range_documents(ctx, pre_committed_documents, diff_documents, scan_parameters=scan_parameters)
423+
_scan_commit_range_documents(ctx, working_copy_documents, diff_documents, scan_parameters=scan_parameters)
410424

411425

412426
_SCAN_TYPE_TO_PRE_COMMIT_HANDLER = {
@@ -416,10 +430,18 @@ def _scan_sast_pre_commit(ctx: typer.Context, repo_path: str, **_) -> None:
416430
}
417431

418432

419-
def scan_pre_commit(ctx: typer.Context, repo_path: str) -> None:
433+
def scan_pre_commit(
434+
ctx: typer.Context,
435+
repo_path: str,
436+
base_ref: str = consts.GIT_HEAD_COMMIT_REV,
437+
include_unstaged: bool = False,
438+
paths: Optional[list[str]] = None,
439+
) -> None:
420440
scan_type = ctx.obj['scan_type']
421441
if scan_type not in _SCAN_TYPE_TO_PRE_COMMIT_HANDLER:
422442
raise click.ClickException(f'Pre-commit scanning for {scan_type.upper()} is not supported')
423443

424-
_SCAN_TYPE_TO_PRE_COMMIT_HANDLER[scan_type](ctx, repo_path)
444+
_SCAN_TYPE_TO_PRE_COMMIT_HANDLER[scan_type](
445+
ctx, repo_path, base_ref=base_ref, include_unstaged=include_unstaged, paths=paths
446+
)
425447
logger.debug('Pre-commit scan completed successfully')
Lines changed: 84 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,100 @@
11
import os
2+
from pathlib import Path
23
from typing import Annotated, Optional
34

45
import typer
56

7+
from cycode.cli import consts
68
from cycode.cli.apps.scan.commit_range_scanner import scan_pre_commit
9+
from cycode.cli.exceptions.custom_exceptions import ScanPathOutsideRepositoryError, UnresolvedGitRefError
10+
from cycode.cli.exceptions.handle_scan_errors import handle_scan_exception
11+
from cycode.cli.logger import logger
12+
from cycode.cli.utils.git_proxy import git_proxy
13+
14+
15+
def _resolve_repo_root(cwd: str) -> str:
16+
"""Resolve the repository root from `cwd`, which may be any subdirectory of the repo.
17+
18+
`git_proxy.get_repo()` requires an exact match (the root or a `.git` dir) unless told to
19+
search parent directories, so without this, running the command from anywhere but the repo
20+
root raises a misleading "not a git repository" error. The git pre-commit hook framework
21+
always invokes from the repo root, so this is a no-op there; it matters for IDE-style
22+
invocations (--include-unstaged etc.), which may run from any subdirectory.
23+
"""
24+
repo = git_proxy.get_repo(cwd, search_parent_directories=True)
25+
return repo.working_tree_dir or cwd
26+
27+
28+
def _validate_base_ref(repo_path: str, base_ref: str) -> None:
29+
"""Raise a clear, user-facing error for an unresolvable `--base-ref`.
30+
31+
A repository with no commits at all is a valid state (the diff falls back to comparing
32+
against the empty tree), so validation is skipped in that case.
33+
"""
34+
repo = git_proxy.get_repo(repo_path)
35+
36+
try:
37+
repo.rev_parse(consts.GIT_HEAD_COMMIT_REV)
38+
except Exception as e:
39+
logger.debug('Repository has no commits yet; skipping --base-ref validation', exc_info=e)
40+
return
41+
42+
try:
43+
repo.commit(base_ref)
44+
except Exception as e:
45+
raise UnresolvedGitRefError(base_ref) from e
746

847

948
def pre_commit_command(
1049
ctx: typer.Context,
1150
_: Annotated[Optional[list[str]], typer.Argument(help='Ignored arguments', hidden=True)] = None,
51+
base_ref: Annotated[
52+
str,
53+
typer.Option(
54+
'--base-ref',
55+
'-b',
56+
help='Git ref (commit, branch, or tag) to diff against. Defaults to HEAD, matching the '
57+
'pre-commit hook behavior. Combine with --include-unstaged for IDE-style local diff scanning.',
58+
),
59+
] = consts.GIT_HEAD_COMMIT_REV,
60+
include_unstaged: Annotated[
61+
bool,
62+
typer.Option(
63+
'--include-unstaged',
64+
help='Also scan unstaged changes to tracked files, not just what is staged. '
65+
'Off by default, so the pre-commit hook flow is unaffected.',
66+
),
67+
] = False,
68+
paths: Annotated[
69+
Optional[list[Path]],
70+
typer.Option(
71+
'--path',
72+
help='Optional paths to scope the diff scan to (e.g. the file currently open in an IDE). '
73+
'Defaults to the entire working directory. Repeatable.',
74+
show_default=False,
75+
resolve_path=True,
76+
),
77+
] = None,
1278
) -> None:
13-
repo_path = os.getcwd() # change locally for easy testing
79+
try:
80+
repo_path = _resolve_repo_root(os.getcwd())
81+
_validate_base_ref(repo_path, base_ref)
82+
str_paths = [str(path) for path in paths] if paths else None
83+
# realpath both sides: repo_path (from GitPython) and str_path (from Click's
84+
# resolve_path=True) can disagree on 8.3 short-name vs long-name form on Windows,
85+
# which would otherwise make an in-repo path look like it's outside the repo.
86+
repo_path_real = os.path.realpath(repo_path)
87+
for str_path in str_paths or []:
88+
if os.path.commonpath([repo_path_real, os.path.realpath(str_path)]) != repo_path_real:
89+
raise ScanPathOutsideRepositoryError(str_path, repo_path)
90+
except Exception as e:
91+
handle_scan_exception(ctx, e)
92+
return
1493

1594
progress_bar = ctx.obj['progress_bar']
1695
progress_bar.start()
1796

18-
scan_pre_commit(ctx, repo_path)
97+
try:
98+
scan_pre_commit(ctx, repo_path, base_ref=base_ref, include_unstaged=include_unstaged, paths=str_paths)
99+
except Exception as e:
100+
handle_scan_exception(ctx, e)

‎cycode/cli/exceptions/custom_exceptions.py‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,25 @@ def __str__(self) -> str:
103103
return f'Error occurred while parsing terraform plan file. Path: {self.file_path}'
104104

105105

106+
class ScanPathOutsideRepositoryError(CycodeError):
107+
def __init__(self, path: str, repo_path: str) -> None:
108+
self.path = path
109+
self.repo_path = repo_path
110+
super().__init__()
111+
112+
def __str__(self) -> str:
113+
return f'The path {self.path!r} is outside the repository {self.repo_path!r}'
114+
115+
116+
class UnresolvedGitRefError(CycodeError):
117+
def __init__(self, ref: str) -> None:
118+
self.ref = ref
119+
super().__init__()
120+
121+
def __str__(self) -> str:
122+
return f'Could not resolve git ref: {self.ref!r}'
123+
124+
106125
_SSL_ERROR_CA_BUNDLE_HINT = (
107126
'set the REQUESTS_CA_BUNDLE (or CURL_CA_BUNDLE) environment variable to the path of a valid .pem or similar'
108127
)

0 commit comments

Comments
 (0)