feat: add standalone CLI interface for use outside GitHub Actions 🐐 #579
feat: add standalone CLI interface for use outside GitHub Actions 🐐 #579jamacku wants to merge 2 commits intoredhat-plumbers-in-action:mainfrom
Conversation
| if ! git diff --name-only -z --diff-filter=db "${BASE}".."${HEAD}" > "${WORK_DIR}changed-files.txt"; then | ||
| emit_warning "Please check if the repository was cloned with \`fetch-depth: 0\`. Differential ShellCheck needs the entire history to work correctly." | ||
| exit 1 | ||
| fi |
| if ! git diff --name-only -z --diff-filter=db "${BASE}".."${HEAD}" > "${WORK_DIR}changed-files.txt"; then | ||
| emit_warning "Please check if the repository was cloned with \`fetch-depth: 0\`. Differential ShellCheck needs the entire history to work correctly." | ||
| exit 1 | ||
| fi |
| # it requires gather_statistics to be called first | ||
| print_statistics () { | ||
| echo -e "::group::📊 ${WHITE}Statistics of defects${NOCOLOR}" | ||
| emit_group_start "📊 ${WHITE}Statistics of defects${NOCOLOR}" |
There was a problem hiding this comment.
Pull request overview
Adds a standalone differential-shellcheck CLI wrapper (usable outside GitHub Actions) with argument parsing, pre-commit integration, and packaging/docs support.
Changes:
- Introduce
src/cli.shwithgetopt-based argument parsing, auto base detection, and a worktree-diff mode for pre-commit usage. - Add an output abstraction layer (
emit_*) and update existing scripts to support both GitHub Actions and CLI output behaviors. - Add install/manpage/RPM/pre-commit hook metadata plus CI workflows to validate the new CLI/manpage.
Reviewed changes
Copilot reviewed 17 out of 18 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
src/cli.sh |
New standalone CLI entrypoint with parsing and multiple operating modes |
src/functions.sh |
Adds emit_* helpers; makes env checks more robust with ${VAR:-} |
src/index.sh |
Supports explicit CLI_FILES; routes GitHub Actions I/O via emit_* |
src/validation.sh |
Uses emit_group_* for stats output (Actions vs CLI formatting) |
test/cli.bats |
New unit tests for CLI parsing/modes |
test/emit_helpers.bats |
New tests for emit_* output behavior |
test/print_statistics.bats |
Updates/extends tests for CLI vs Actions stats output |
test/evaluate_and_print_defects.bats |
Updates tests for Actions-mode grouping output |
docs/differential-shellcheck.1.md |
New man page content |
Makefile |
Adds man, install, uninstall targets for system installation |
.pre-commit-hooks.yaml |
Declares pre-commit hook entrypoint |
differential-shellcheck.spec |
Adds Fedora RPM spec for packaging |
docs/CHANGELOG.md |
Notes upcoming release changes |
README.md |
Adds CLI usage and pre-commit integration documentation |
.github/workflows/man-page.yml |
CI job to build/verify man page generation |
.github/workflows/cli-integration.yml |
CLI smoke tests in CI |
.gitignore |
Ignores generated man page output |
VERSION |
Adds version file for CLI --version and packaging |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
|
||
| # Scan current state (HEAD) | ||
| execute_shellcheck "${only_changed_scripts[@]}" > "${WORK_DIR}head-shellcheck-raw.err" | ||
| csgrep --mode=json --embed-context 4 "${WORK_DIR}head-shellcheck-raw.err" > "${WORK_DIR}head-shellcheck.err" | ||
|
|
||
| # Stash current changes to get base state | ||
| git stash push --quiet --keep-index 2>/dev/null | ||
|
|
||
| # Scan base state | ||
| execute_shellcheck "${only_changed_scripts[@]}" > "${WORK_DIR}base-shellcheck-raw.err" | ||
| csgrep --mode=json --embed-context 4 "${WORK_DIR}base-shellcheck-raw.err" > "${WORK_DIR}base-shellcheck.err" | ||
|
|
||
| # Restore working state | ||
| git stash pop --quiet 2>/dev/null |
There was a problem hiding this comment.
In worktree-diff mode, git stash push --keep-index leaves the index/staged state in place and resets the working tree to match the index, so the subsequent “base” scan is run against the index (not HEAD). When invoked via pre-commit (which already stashes unstaged changes), this likely results in scanning the same content twice and producing an empty diff. Consider stashing/restoring in a way that produces a true HEAD-vs-working-tree (or HEAD-vs-index) comparison (e.g., stash everything and restore with --index, or use a different mechanism to read HEAD/index content).
| # Scan current state (HEAD) | |
| execute_shellcheck "${only_changed_scripts[@]}" > "${WORK_DIR}head-shellcheck-raw.err" | |
| csgrep --mode=json --embed-context 4 "${WORK_DIR}head-shellcheck-raw.err" > "${WORK_DIR}head-shellcheck.err" | |
| # Stash current changes to get base state | |
| git stash push --quiet --keep-index 2>/dev/null | |
| # Scan base state | |
| execute_shellcheck "${only_changed_scripts[@]}" > "${WORK_DIR}base-shellcheck-raw.err" | |
| csgrep --mode=json --embed-context 4 "${WORK_DIR}base-shellcheck-raw.err" > "${WORK_DIR}base-shellcheck.err" | |
| # Restore working state | |
| git stash pop --quiet 2>/dev/null | |
| local stash_before="" | |
| local stash_after="" | |
| # Scan current state (working tree/index as invoked) | |
| execute_shellcheck "${only_changed_scripts[@]}" > "${WORK_DIR}head-shellcheck-raw.err" | |
| csgrep --mode=json --embed-context 4 "${WORK_DIR}head-shellcheck-raw.err" > "${WORK_DIR}head-shellcheck.err" | |
| # Stash all current changes so the base scan runs against true HEAD. | |
| stash_before="$(git rev-parse --verify refs/stash 2>/dev/null || true)" | |
| git stash push --quiet 2>/dev/null | |
| stash_after="$(git rev-parse --verify refs/stash 2>/dev/null || true)" | |
| # Scan base state (HEAD) | |
| execute_shellcheck "${only_changed_scripts[@]}" > "${WORK_DIR}base-shellcheck-raw.err" | |
| csgrep --mode=json --embed-context 4 "${WORK_DIR}base-shellcheck-raw.err" > "${WORK_DIR}base-shellcheck.err" | |
| # Restore working tree and index if this invocation created a stash entry. | |
| if [[ "${stash_before}" != "${stash_after}" ]]; then | |
| git stash pop --quiet --index 2>/dev/null | |
| fi |
| git ls-tree -r --name-only -z "${GITHUB_REF_NAME-"main"}" > "${WORK_DIR}files.txt" | ||
|
|
There was a problem hiding this comment.
In CLI mode (non-GitHub Actions), GITHUB_REF_NAME is typically unset and the fallback to main can fail on repos whose default branch isn’t main, causing full scans to miss scripts. Consider using HEAD (or the currently checked-out branch) when not running in GitHub Actions, and optionally fail fast if git ls-tree fails instead of continuing with an empty file list.
| git ls-tree -r --name-only -z "${GITHUB_REF_NAME-"main"}" > "${WORK_DIR}files.txt" | |
| if is_github_actions; then | |
| scan_ref="${GITHUB_REF_NAME:-HEAD}" | |
| else | |
| scan_ref="HEAD" | |
| fi | |
| if ! git ls-tree -r --name-only -z "${scan_ref}" > "${WORK_DIR}files.txt"; then | |
| emit_warning "Failed to enumerate repository files from git ref '${scan_ref}'. Full scan requires a valid checked-out ref." | |
| exit 1 | |
| fi |
| emit_group_start "📊 ${WHITE}Statistics of defects${NOCOLOR}" | ||
| [[ -n ${stat_error} ]] && echo -e "Error: ${stat_error}" | ||
| [[ -n ${stat_warning} ]] && echo -e "Warning: ${stat_warning}" | ||
| [[ -n ${stat_info} ]] && echo -e "Style or Note: ${stat_info}" | ||
| echo "::endgroup::" | ||
| emit_group_end |
There was a problem hiding this comment.
print_statistics now depends on emit_group_start/emit_group_end, but validation.sh is often sourced standalone (including in unit tests) and does not define those helpers. To avoid “command not found” in that usage, either provide fallback implementations in validation.sh when the emit helpers aren’t defined, or keep the previous direct echo ::group::/::endgroup:: behavior here.
| source "${PROJECT_ROOT}/src/validation.sh" | ||
|
|
||
| GITHUB_ACTIONS="1" | ||
| INPUT_SEVERITY="style" | ||
| gather_statistics "./test/fixtures/print_statistics/defects.log" |
There was a problem hiding this comment.
This test sources src/validation.sh only, but print_statistics now calls emit_group_start/emit_group_end which are defined in src/functions.sh. Source src/functions.sh (or otherwise define the emit helpers) before invoking print_statistics to prevent the test from failing with “command not found”.
| source "${PROJECT_ROOT}/src/validation.sh" | ||
|
|
||
| GITHUB_ACTIONS="1" | ||
| INPUT_SEVERITY="style" | ||
| cp ./test/fixtures/evaluate_and_print_defects/defects.log ../defects.log |
There was a problem hiding this comment.
This test sources src/validation.sh only, but evaluate_and_print_defects calls print_statistics, which now depends on emit_group_start/emit_group_end from src/functions.sh. Source src/functions.sh (instead of or in addition to src/validation.sh) so the emit helpers are available during the test run.
| # Source cli.sh functions for parse_args testing | ||
| source "${PROJECT_ROOT}/src/functions.sh" | ||
| source "${PROJECT_ROOT}/src/cli.sh" --help 2>/dev/null || true | ||
|
|
||
| # Re-source just the functions we need (cli.sh exits on --help) | ||
| # Instead, define the functions by sourcing the file in a subshell-safe way |
There was a problem hiding this comment.
setup() sources src/cli.sh with --help, but cli.sh calls exit 0 for --help/--version. Because it’s being sourced (not executed), that exit will terminate the current Bats process and abort the test suite; || true can’t prevent that. Run this in a subshell (e.g., ( source ... )) or remove it and rely on _source_cli (which strips the main "$@" call) for function-loading.
| # Source cli.sh functions for parse_args testing | |
| source "${PROJECT_ROOT}/src/functions.sh" | |
| source "${PROJECT_ROOT}/src/cli.sh" --help 2>/dev/null || true | |
| # Re-source just the functions we need (cli.sh exits on --help) | |
| # Instead, define the functions by sourcing the file in a subshell-safe way | |
| # Source shared helper functions only. | |
| # Tests that need cli.sh functions should call _source_cli(), | |
| # which loads them without executing main. | |
| source "${PROJECT_ROOT}/src/functions.sh" |
| export INPUT_BASE="${INPUT_BASE:-}" INPUT_HEAD="${INPUT_HEAD:-}" | ||
| export RUNNER_DEBUG="${RUNNER_DEBUG:-0}" | ||
|
|
||
| is_unit_tests && return 0 |
There was a problem hiding this comment.
parse_args returns the status of the final is_unit_tests command when not running unit tests, which will be 1 and makes the CLI exit early even on successful argument parsing. Ensure parse_args returns 0 on the success path (e.g., add an unconditional return 0 at the end, and keep the unit-test early return).
| is_unit_tests && return 0 | |
| is_unit_tests && return 0 | |
| return 0 |
Allow differential-shellcheck to run as a standalone CLI tool with proper argument parsing, sensible defaults (auto-detect upstream remote), and pre-commit hook support. This enables local development workflows, CI/CD on other platforms, and Fedora RPM packaging. Closes: redhat-plumbers-in-action#396 Relates: redhat-plumbers-in-action#234 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
| # Delegate to index.sh which handles sourcing and scanning | ||
| export CLI_FILES | ||
| # shellcheck source=index.sh | ||
| . "${SCRIPT_DIR}index.sh" |
review - Fix 'local' outside function in index.sh (SC2084) — index.sh runs at top level, not inside a function - Clear color variables in tests after sourcing functions.sh so ANSI escape codes don't leak into expected output assertions - Add .markdownlintignore to exclude man page source from MD025 rule (pandoc man format requires # headings) - Fix "git" -> "Git" terminology in docs and CLI help text - Fix SC2312 in cli.sh --version case Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 18 out of 19 changed files in this pull request and generated 7 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| ;; | ||
| --full-scan) | ||
| CLI_FULL_SCAN="1" | ||
| INPUT_DIFF_SCAN="false" |
There was a problem hiding this comment.
In --full-scan modes, parse_args only sets INPUT_DIFF_SCAN=false and CLI_MODE, but it leaves INPUT_BASE/INPUT_HEAD unset unless the user provided them. Since index.sh calls pick_base_and_head_hash unconditionally (manual mode requires non-empty BASE/HEAD), differential-shellcheck --full-scan ... will fail before scanning. Consider setting sensible defaults here (e.g., INPUT_HEAD=HEAD and INPUT_BASE=HEAD, or skipping pick_base_and_head_hash in index.sh when INPUT_DIFF_SCAN=false).
| INPUT_DIFF_SCAN="false" | |
| INPUT_DIFF_SCAN="false" | |
| INPUT_BASE="${INPUT_BASE:-HEAD}" | |
| INPUT_HEAD="${INPUT_HEAD:-HEAD}" |
| INPUT_BASE=$(git rev-parse HEAD~1 2>/dev/null) || { | ||
| echo "WARNING: Cannot determine base commit. Use --base to specify." >&2 | ||
| INPUT_DIFF_SCAN="false" | ||
| CLI_MODE="full-scan" |
There was a problem hiding this comment.
auto_detect_base can switch the tool into CLI_MODE="full-scan" when it cannot determine a merge-base (e.g., shallow clone / single-commit repo), but it does not ensure INPUT_BASE/INPUT_HEAD are set to values that let index.sh proceed. This will still hit pick_base_and_head_hash and fail with “BASE and/or HEAD isn't set”. If you keep this fallback, also initialize INPUT_BASE/INPUT_HEAD for the full-scan path (or short-circuit to a dedicated full-scan runner that doesn't require BASE/HEAD).
| CLI_MODE="full-scan" | |
| CLI_MODE="full-scan" | |
| INPUT_HEAD=$(git rev-parse HEAD 2>/dev/null) || return 1 | |
| INPUT_BASE="${INPUT_HEAD}" |
| local summary_text | ||
| summary_text="$(summary)" | ||
| emit_summary "${summary_text}" |
There was a problem hiding this comment.
cli.sh enables set -o nounset, but run_worktree_diff calls summary, and summary() expects FULL_SCAN to be set (it does [[ ${FULL_SCAN} -eq 0 ]]). In worktree-diff mode FULL_SCAN is never initialized, so this will error with an unbound variable. Initialize FULL_SCAN (and any other globals summary depends on) before calling summary, or update summary.sh to use a default like ${FULL_SCAN:-1}.
| emit_output () { | ||
| [[ $# -le 1 ]] && return 1 | ||
| is_github_actions || return 0 | ||
| # shellcheck disable=SC2154 | ||
| echo "${1}=${2}" >> "${GITHUB_OUTPUT}" | ||
| } | ||
|
|
||
| # Write summary text to GitHub Actions step summary or stdout | ||
| emit_summary () { | ||
| [[ $# -le 0 ]] && return 1 | ||
| if is_github_actions; then | ||
| # shellcheck disable=SC2154 | ||
| echo -e "${1}" >> "${GITHUB_STEP_SUMMARY}" | ||
| else | ||
| echo -e "${1}" | ||
| fi |
There was a problem hiding this comment.
emit_output/emit_summary assume ${GITHUB_OUTPUT} / ${GITHUB_STEP_SUMMARY} are set whenever is_github_actions is true. If GITHUB_ACTIONS is set but those file paths are missing/empty (or not writable), the redirection will fail. Consider guarding with [[ -n "${GITHUB_OUTPUT:-}" ]] / [[ -n "${GITHUB_STEP_SUMMARY:-}" ]] (and returning non-zero or emitting a warning) to make failures clearer and avoid writing to an empty path.
| CLI_FILES=() | ||
| fi | ||
|
|
||
| # Make directory $GITHUB_WORKSPACE (/github/workspace) git-save |
There was a problem hiding this comment.
This comment says “git-save”, but the code and intent appear to be about Git’s “safe.directory” configuration. Consider correcting the typo to avoid confusion.
| # Make directory $GITHUB_WORKSPACE (/github/workspace) git-save | |
| # Make directory $GITHUB_WORKSPACE (/github/workspace) a Git safe.directory |
| # Make directory $GITHUB_WORKSPACE (/github/workspace) git-save | ||
| git config --global --add safe.directory "${GITHUB_WORKSPACE:-}" | ||
| if is_github_actions; then | ||
| git config --global --add safe.directory "${GITHUB_WORKSPACE:-}" |
There was a problem hiding this comment.
When running in GitHub Actions, this adds a safe.directory entry even if GITHUB_WORKSPACE is empty/unset, which can cause git config to fail (or add an invalid entry). Consider checking [[ -n "${GITHUB_WORKSPACE:-}" ]] before calling git config and emitting a warning if it’s missing.
| git config --global --add safe.directory "${GITHUB_WORKSPACE:-}" | |
| if [[ -n "${GITHUB_WORKSPACE:-}" ]]; then | |
| git config --global --add safe.directory "${GITHUB_WORKSPACE}" | |
| else | |
| emit_warning "GITHUB_WORKSPACE is empty or unset; skipping git safe.directory configuration." | |
| fi |
| @test "cli --full-scan sets INPUT_DIFF_SCAN=false" { | ||
| _source_cli | ||
|
|
||
| parse_args --full-scan | ||
| assert_equal "${INPUT_DIFF_SCAN}" "false" | ||
| assert_equal "${CLI_MODE}" "full-scan" |
There was a problem hiding this comment.
The --full-scan tests assert INPUT_DIFF_SCAN=false and CLI_MODE=full-scan, but they don’t cover that --full-scan can actually proceed into scanning (i.e., that required inputs like INPUT_BASE/INPUT_HEAD are set such that index.sh doesn’t fail early). Adding an assertion for INPUT_HEAD/INPUT_BASE defaults (or an integration-style test that runs src/cli.sh --full-scan in a fixture repo) would prevent regressions here.
| @test "cli --full-scan sets INPUT_DIFF_SCAN=false" { | |
| _source_cli | |
| parse_args --full-scan | |
| assert_equal "${INPUT_DIFF_SCAN}" "false" | |
| assert_equal "${CLI_MODE}" "full-scan" | |
| @test "cli --full-scan sets INPUT_DIFF_SCAN=false and initializes scan refs" { | |
| _source_cli | |
| parse_args --full-scan | |
| assert_equal "${INPUT_DIFF_SCAN}" "false" | |
| assert_equal "${CLI_MODE}" "full-scan" | |
| assert [ -n "${INPUT_BASE}" ] | |
| assert [ -n "${INPUT_HEAD}" ] |
Allow differential-shellcheck to run as a standalone CLI tool with proper argument parsing, sensible defaults (auto-detect upstream remote), and pre-commit hook support. This enables local development workflows, CI/CD on other platforms, and Fedora RPM packaging.
Closes: #396
Relates: #234