Skip to content

feat: add standalone CLI interface for use outside GitHub Actions 🐐 #579

Draft
jamacku wants to merge 2 commits intoredhat-plumbers-in-action:mainfrom
jamacku:cli
Draft

feat: add standalone CLI interface for use outside GitHub Actions 🐐 #579
jamacku wants to merge 2 commits intoredhat-plumbers-in-action:mainfrom
jamacku:cli

Conversation

@jamacku
Copy link
Copy Markdown
Member

@jamacku jamacku commented Apr 20, 2026

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

Comment thread src/cli.sh Fixed
Comment thread src/cli.sh Fixed
Comment thread src/cli.sh Fixed
Comment thread src/cli.sh Fixed
Comment thread src/cli.sh Fixed
Comment thread src/functions.sh Fixed
Comment thread src/index.sh
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
Comment thread src/index.sh
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
Comment thread src/index.sh Fixed
Comment thread src/validation.sh
# 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}"
@jamacku jamacku marked this pull request as draft April 20, 2026 08:53
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.sh with getopt-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.

Comment thread src/cli.sh
Comment on lines +315 to +328

# 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
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
# 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

Copilot uses AI. Check for mistakes.
Comment thread src/index.sh Outdated
Comment on lines 47 to 48
git ls-tree -r --name-only -z "${GITHUB_REF_NAME-"main"}" > "${WORK_DIR}files.txt"

Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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

Copilot uses AI. Check for mistakes.
Comment thread src/validation.sh
Comment on lines +91 to +95
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
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines 14 to 18
source "${PROJECT_ROOT}/src/validation.sh"

GITHUB_ACTIONS="1"
INPUT_SEVERITY="style"
gather_statistics "./test/fixtures/print_statistics/defects.log"
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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”.

Copilot uses AI. Check for mistakes.
Comment on lines 14 to 18
source "${PROJECT_ROOT}/src/validation.sh"

GITHUB_ACTIONS="1"
INPUT_SEVERITY="style"
cp ./test/fixtures/evaluate_and_print_defects/defects.log ../defects.log
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread test/cli.bats Outdated
Comment on lines +15 to +20
# 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
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
# 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"

Copilot uses AI. Check for mistakes.
Comment thread src/cli.sh
export INPUT_BASE="${INPUT_BASE:-}" INPUT_HEAD="${INPUT_HEAD:-}"
export RUNNER_DEBUG="${RUNNER_DEBUG:-0}"

is_unit_tests && return 0
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
is_unit_tests && return 0
is_unit_tests && return 0
return 0

Copilot uses AI. Check for mistakes.
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>
Comment thread src/index.sh Fixed
Comment thread src/index.sh Fixed
Comment thread src/cli.sh
# 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>
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/cli.sh
;;
--full-scan)
CLI_FULL_SCAN="1"
INPUT_DIFF_SCAN="false"
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
INPUT_DIFF_SCAN="false"
INPUT_DIFF_SCAN="false"
INPUT_BASE="${INPUT_BASE:-HEAD}"
INPUT_HEAD="${INPUT_HEAD:-HEAD}"

Copilot uses AI. Check for mistakes.
Comment thread src/cli.sh
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"
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
CLI_MODE="full-scan"
CLI_MODE="full-scan"
INPUT_HEAD=$(git rev-parse HEAD 2>/dev/null) || return 1
INPUT_BASE="${INPUT_HEAD}"

Copilot uses AI. Check for mistakes.
Comment thread src/cli.sh
Comment on lines +351 to +353
local summary_text
summary_text="$(summary)"
emit_summary "${summary_text}"
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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}.

Copilot uses AI. Check for mistakes.
Comment thread src/functions.sh
Comment on lines +331 to +346
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
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread src/index.sh
CLI_FILES=()
fi

# Make directory $GITHUB_WORKSPACE (/github/workspace) git-save
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
# Make directory $GITHUB_WORKSPACE (/github/workspace) git-save
# Make directory $GITHUB_WORKSPACE (/github/workspace) a Git safe.directory

Copilot uses AI. Check for mistakes.
Comment thread src/index.sh
# 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:-}"
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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

Copilot uses AI. Check for mistakes.
Comment thread test/cli.bats
Comment on lines +82 to +87
@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"
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
@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}" ]

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Accept command line arguments instead of environment variables

3 participants