diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 1c8ae3d..8b6a6d1 100644 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -55,5 +55,19 @@ if [[ -n "${STAGED_FILES}" ]] && [[ -n "${CGW_LINT_CMD:-}" ]]; then fi fi +# Optional: typecheck using CGW_TYPECHECK_CMD (non-blocking) +if [[ -n "${STAGED_FILES}" ]] && [[ -n "${CGW_TYPECHECK_CMD:-}" ]]; then + echo "" + echo "Checking types (non-blocking)..." + logfile=/dev/null + if cgw_run_typecheck >/dev/null 2>&1; then + echo " [PASS] Typecheck passed" + else + echo " [WARN] Type errors found" + echo " Run '${CGW_TYPECHECK_CMD} ${CGW_TYPECHECK_CHECK_ARGS:-check}' to see details" + echo " (Commit proceeds — fix types before pushing)" + fi +fi + echo "" exit 0 diff --git a/CONTEXT.md b/CONTEXT.md index 1d9a9d3..c62bed6 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -82,3 +82,18 @@ The shared module for all binary yes/no confirmation prompts in CGW scripts. Con - `--non-interactive abort|accept|deny`: explicit non-interactive policy declared at the call site. When `CGW_NON_INTERACTIVE=1`: `abort` prints a message and exits 1; `accept` returns 0 silently; `deny` returns 1 silently. Callers own the `CGW_NON_INTERACTIVE=1` assignment from their `[[ ! -t 0 ]]` check — `cgw_confirm` does not test TTY internally. **Callers**: every binary confirmation prompt in `bisect_helper.sh`, `branch_cleanup.sh`, `cherry_pick_commits.sh`, `commit_enhanced.sh`, `configure.sh`, `create_release.sh`, `merge_docs.sh`, `merge_with_validation.sh`, `push_validated.sh`, `rebase_safe.sh`, `rollback_merge.sh`, `setup_attributes.sh`, `stash_work.sh`, `sync_branches.sh`, `undo_last.sh`. The 3-way `(yes/no/skip)` prompt in `commit_enhanced.sh` stays inline — the helper is binary only. + +--- + +## remote status + +The shared module for querying remote reachability, remote branch existence, and commit distance between two refs. Concentrates a seam previously scattered across ~10 inline `git rev-list --count` and `git ls-remote` call sites in 6+ scripts, several of which were untested (notably `repo_health.sh`). + +**Implementation seam** — three silent helpers in `scripts/git/_common.sh`: +- `cgw_rev_count ` — outputs `git rev-list --count "base..tip"` to stdout; exits non-zero on error (bad refs, git failure). No fallback — callers own their own `|| count=0` or `|| exit 1`. Accepts any git ref (branch names, remote tracking refs, SHAs). +- `cgw_remote_reachable ` — exits 0 if the remote is reachable (probes via `git ls-remote HEAD`), non-zero otherwise. +- `cgw_remote_branch_exists ` — exits 0 if `` exists on ``; builds `refs/heads/` internally so callers pass plain branch names. + +All three helpers are silent: no stdout/stderr beyond `cgw_rev_count`'s count. Callers own all user-facing error messages. + +**Callers**: `push_validated.sh` (remote reachability + ahead/behind), `sync_branches.sh` (ahead/behind), `create_pr.sh` (remote branch existence + commit distance), `validate_branches.sh` (ahead/behind), `repo_health.sh` (bidirectional ahead/behind per branch), `rebase_safe.sh` (ahead/behind), `undo_last.sh` (ahead/behind). diff --git a/cgw.conf.example b/cgw.conf.example index a4bdcee..0536f8f 100644 --- a/cgw.conf.example +++ b/cgw.conf.example @@ -115,6 +115,38 @@ CGW_FORMAT_EXCLUDES="--exclude logs --exclude .venv" # CGW_LINT_CMD="" # CGW_FORMAT_CMD="" +# ============================================================================ +# TYPECHECK CONFIGURATION +# ============================================================================ +# Optional non-blocking typecheck step run by the pre-commit hook. +# Set CGW_TYPECHECK_CMD="" to disable (default). +# +# ── Python / pyrefly (recommended for new Python projects) ─────────────────── +# CGW_TYPECHECK_CMD="pyrefly" +# CGW_TYPECHECK_CHECK_ARGS="check" +# CGW_TYPECHECK_EXCLUDES="" +# +# ── Python / pyright ───────────────────────────────────────────────────────── +# CGW_TYPECHECK_CMD="pyright" +# CGW_TYPECHECK_CHECK_ARGS="" +# CGW_TYPECHECK_EXCLUDES="" +# +# ── Python / mypy ──────────────────────────────────────────────────────────── +# CGW_TYPECHECK_CMD="mypy" +# CGW_TYPECHECK_CHECK_ARGS="." +# CGW_TYPECHECK_EXCLUDES="" +# +# ── TypeScript / tsc ───────────────────────────────────────────────────────── +# CGW_TYPECHECK_CMD="tsc" +# CGW_TYPECHECK_CHECK_ARGS="--noEmit" +# CGW_TYPECHECK_EXCLUDES="" +# +# Skip at runtime with CGW_SKIP_TYPECHECK=1. + +CGW_TYPECHECK_CMD="" +CGW_TYPECHECK_CHECK_ARGS="check" +CGW_TYPECHECK_EXCLUDES="" + # ============================================================================ # MODIFIED-ONLY LINT FILE EXTENSIONS (check_lint.sh, fix_lint.sh) # ============================================================================ diff --git a/command/auto-git-workflow.md b/command/auto-git-workflow.md index 3b6f216..10317e4 100644 --- a/command/auto-git-workflow.md +++ b/command/auto-git-workflow.md @@ -86,7 +86,7 @@ git diff --quiet && git diff --cached --quiet ./scripts/git/check_lint.sh ``` -- Ignore errors in local-only files (CLAUDE.md, MEMORY.md, etc.) — never committed +- Lint failures in local-only files (CLAUDE.md, MEMORY.md, etc.) are safe to ignore — see SKILL.md Rule 3. - If still fails: Stop workflow - If passes: Continue to Phase 2 @@ -123,12 +123,7 @@ git add . ``` Replace message with appropriate conventional commit (feat:, fix:, docs:, chore:, test:). - -`commit_enhanced.sh` automatically: -- Unstages local-only files (configured via `CGW_LOCAL_FILES`) -- Validates commit message format -- Runs lint check -- Respects pre-staged files: if you staged specific files and have other unstaged changes, commits pre-staged only (use `--all` to override) +For staging intent and local-file protection behavior, see SKILL.md Rules 3 and 5. **Step 2.3: Capture commit info for final report** @@ -173,14 +168,8 @@ echo "${CGW_MERGE_MODE:-direct}" ./scripts/git/merge_with_validation.sh --non-interactive ``` -`merge_with_validation.sh` automatically: -- Creates backup tag -- Handles modify/delete conflicts (auto-resolved) -- Stops on content conflicts (requires manual resolution) -- Validates docs CI policy (if configured) - - If exit code 0: Continue to Phase 5 -- If exit code ≠ 0: Check output for conflict type, stop workflow +- If exit code ≠ 0: Check output for conflict type, stop workflow and see `references/error-recovery.md` --- @@ -265,45 +254,11 @@ instead of bypassing the wrappers — the Core Rules in SKILL.md are mandatory. --- -## Error Handling - -### Lint Failures - -```bash -./scripts/git/fix_lint.sh -./scripts/git/check_lint.sh -``` - -### Local-Only Files Staged - -`commit_enhanced.sh` auto-unstages these. If using raw git: -```bash -git reset HEAD CLAUDE.md MEMORY.md -``` - -### Modify/Delete Conflicts - -**Status**: EXPECTED — auto-resolved by `merge_with_validation.sh` - -### Content Conflicts (UU) - -Manual resolution required: -```bash -# Edit files to resolve -git add -git commit +## Error Recovery -# Or abort -git merge --abort -git checkout "${CGW_SOURCE_BRANCH:-development}" -``` - -### Push Failures - -```bash -./scripts/git/sync_branches.sh # sync with remote first -./scripts/git/push_validated.sh # retry push -``` +For lint failures, conflict types (modify/delete vs content), push errors, and +lock-file issues — see +[`references/error-recovery.md`](../skills/auto-git-workflow/references/error-recovery.md). --- diff --git a/docs/configuration.md b/docs/configuration.md index d7f174b..744e984 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -70,6 +70,10 @@ cp cgw.conf.example .cgw.conf | `CGW_MARKDOWNLINT_ARGS` | `**/*.md !CLAUDE.md !MEMORY.md` | Arguments passed to markdown lint tool | | `CGW_SKIP_LINT` | `(unset)` | Set to `1` to skip all lint checks at runtime | | `CGW_SKIP_MD_LINT` | `(unset)` | Set to `1` to skip only the markdown lint step | +| `CGW_TYPECHECK_CMD` | `` | Typecheck tool; set to e.g. `pyrefly` to enable (`""` to disable) | +| `CGW_TYPECHECK_CHECK_ARGS` | `check` | Arguments passed to the typecheck tool | +| `CGW_TYPECHECK_EXCLUDES` | `` | Exclusion flags appended to the typecheck command | +| `CGW_SKIP_TYPECHECK` | `(unset)` | Set to `1` to skip the typecheck step at runtime | | `CGW_STAGED_ONLY` | `0` | Set to `1` to commit only pre-staged files (`commit_enhanced.sh`) | | `CGW_ALL` | `(unset)` | Set to `1` to force-stage all tracked changes, overriding pre-staged-only logic (`commit_enhanced.sh`) | | `CGW_EXTRA_PREFIXES` | `` | Extra commit prefixes (pipe-separated, e.g. `cuda\|tensorrt`) | @@ -160,3 +164,43 @@ CGW_FORMAT_FIX_ARGS="-i -r ." CGW_LINT_CMD="" CGW_FORMAT_CMD="" ``` + +--- + +## Typecheck + +The pre-commit hook runs a non-blocking typecheck step when `CGW_TYPECHECK_CMD` is set. Like the lint step, it surfaces warnings but never blocks the commit. Set `CGW_SKIP_TYPECHECK=1` to skip it at runtime (e.g. in CI where a dedicated type-check job runs separately). + +### Python / pyrefly (recommended) + +```bash +CGW_TYPECHECK_CMD="pyrefly" +CGW_TYPECHECK_CHECK_ARGS="check" +``` + +### Python / pyright + +```bash +CGW_TYPECHECK_CMD="pyright" +CGW_TYPECHECK_CHECK_ARGS="" +``` + +### Python / mypy + +```bash +CGW_TYPECHECK_CMD="mypy" +CGW_TYPECHECK_CHECK_ARGS="." +``` + +### TypeScript / tsc + +```bash +CGW_TYPECHECK_CMD="tsc" +CGW_TYPECHECK_CHECK_ARGS="--noEmit" +``` + +### Disable typecheck + +```bash +CGW_TYPECHECK_CMD="" +``` diff --git a/hooks/pre-commit b/hooks/pre-commit index 1c8ae3d..8b6a6d1 100644 --- a/hooks/pre-commit +++ b/hooks/pre-commit @@ -55,5 +55,19 @@ if [[ -n "${STAGED_FILES}" ]] && [[ -n "${CGW_LINT_CMD:-}" ]]; then fi fi +# Optional: typecheck using CGW_TYPECHECK_CMD (non-blocking) +if [[ -n "${STAGED_FILES}" ]] && [[ -n "${CGW_TYPECHECK_CMD:-}" ]]; then + echo "" + echo "Checking types (non-blocking)..." + logfile=/dev/null + if cgw_run_typecheck >/dev/null 2>&1; then + echo " [PASS] Typecheck passed" + else + echo " [WARN] Type errors found" + echo " Run '${CGW_TYPECHECK_CMD} ${CGW_TYPECHECK_CHECK_ARGS:-check}' to see details" + echo " (Commit proceeds — fix types before pushing)" + fi +fi + echo "" exit 0 diff --git a/scripts/dev/sync-skill.sh b/scripts/dev/sync-skill.sh new file mode 100644 index 0000000..b659dc7 --- /dev/null +++ b/scripts/dev/sync-skill.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# scripts/dev/sync-skill.sh +# Re-sync the git-ignored local .claude/ install from the canonical sources. +# Run this after editing skill/ or command/ to refresh the active in-project +# Claude Code skill and slash command without re-running the full configure.sh. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" + +echo "Syncing skill/ and command/ → .claude/ ..." + +mkdir -p "$REPO_ROOT/.claude/skills/auto-git-workflow/references" +mkdir -p "$REPO_ROOT/.claude/commands" + +cp "$REPO_ROOT/skill/SKILL.md" "$REPO_ROOT/.claude/skills/auto-git-workflow/SKILL.md" +cp "$REPO_ROOT/skill/references/"*.md "$REPO_ROOT/.claude/skills/auto-git-workflow/references/" +cp "$REPO_ROOT/command/auto-git-workflow.md" "$REPO_ROOT/.claude/commands/auto-git-workflow.md" + +echo "Done. Local install matches source." diff --git a/scripts/git/_common.sh b/scripts/git/_common.sh index bb96666..2fd7fc6 100644 --- a/scripts/git/_common.sh +++ b/scripts/git/_common.sh @@ -272,6 +272,28 @@ validate_branch_pair() { fi } +# cgw_rev_count +# Outputs the number of commits reachable from but not from . +# Accepts any git ref (branch names, remote-tracking refs, SHAs). +# Exits non-zero on git failure; callers own their fallback (e.g. || echo "0"). +cgw_rev_count() { + git rev-list --count "${1}..${2}" 2>/dev/null +} + +# cgw_remote_reachable +# Exits 0 when is reachable, non-zero otherwise. Silent. +# Uses git ls-remote with no ref pattern — exits non-zero on any connection failure. +cgw_remote_reachable() { + git ls-remote "${1}" >/dev/null 2>&1 +} + +# cgw_remote_branch_exists +# Exits 0 when exists on , non-zero otherwise. Silent. +# Accepts a plain branch name; builds refs/heads/ internally. +cgw_remote_branch_exists() { + git ls-remote --exit-code "${1}" "refs/heads/${2}" >/dev/null 2>&1 +} + # ensure_no_stale_index_lock - Detect and auto-remove abandoned .git/index.lock files. # # Stale locks (left by crashed/killed git processes) cause: @@ -693,7 +715,7 @@ cgw_print_conflict_summary() { # ── lint pipeline module ─────────────────────────────────────────────────────── # Shared helpers for venv-aware binary resolution, file-list selection, lint -# check, format check, lint/format fix, and markdownlint. Callers: +# check, format check, lint/format fix, markdownlint, and typecheck. Callers: # commit_enhanced.sh, check_lint.sh, fix_lint.sh, .githooks/pre-commit. # # cgw_resolve_lint_binary @@ -840,6 +862,35 @@ cgw_run_markdownlint_check() { fi } +# cgw_run_typecheck [files...] +# Runs ${CGW_TYPECHECK_CMD} against the project (no files) or a given file +# list (strips trailing path token from CGW_TYPECHECK_CHECK_ARGS when files +# given). Honors CGW_SKIP_TYPECHECK=1 and empty CGW_TYPECHECK_CMD (returns 0, +# emits skip line). Reads ${logfile} from caller scope. Returns 0 = clean, +# 1 = errors found. +cgw_run_typecheck() { + if [[ "${CGW_SKIP_TYPECHECK:-0}" == "1" ]]; then + echo " (typecheck skipped -- CGW_SKIP_TYPECHECK=1)" + return 0 + fi + if [[ -z "${CGW_TYPECHECK_CMD:-}" ]]; then + echo " (typecheck skipped -- CGW_TYPECHECK_CMD not set)" + return 0 + fi + get_python_path 2>/dev/null || true + local tc_bin + tc_bin=$(cgw_resolve_lint_binary "${CGW_TYPECHECK_CMD}") + if [[ $# -gt 0 ]]; then + local stripped_args + stripped_args=$(cgw_strip_path_arg "${CGW_TYPECHECK_CHECK_ARGS-check}") + # shellcheck disable=SC2086 # Word splitting intentional: stripped_args contains multiple flags + run_tool_with_logging "TYPECHECK" "${logfile}" "${tc_bin}" ${stripped_args} "$@" + else + # shellcheck disable=SC2086 # Word splitting intentional: CGW_TYPECHECK_CHECK_ARGS/CGW_TYPECHECK_EXCLUDES contain multiple flags + run_tool_with_logging "TYPECHECK" "${logfile}" "${tc_bin}" ${CGW_TYPECHECK_CHECK_ARGS-check} ${CGW_TYPECHECK_EXCLUDES:-} + fi +} + # ── commit-message format module ─────────────────────────────────────────────── # Validates commit messages against the conventional-commit prefix grammar. # diff --git a/scripts/git/configure.sh b/scripts/git/configure.sh index 42dc8e3..ccbda9f 100644 --- a/scripts/git/configure.sh +++ b/scripts/git/configure.sh @@ -173,6 +173,39 @@ _detect_format_tool() { esac } +_detect_typecheck_tool() { + # Python project: prefer [tool.*] declarations in pyproject.toml over command availability. + if [[ -f "pyproject.toml" ]] || [[ -f "setup.py" ]] || [[ -f "setup.cfg" ]] || [[ -f "requirements.txt" ]]; then + if grep -q '^\[tool\.pyrefly\]' "pyproject.toml" 2>/dev/null; then + echo "pyrefly"; return 0 + fi + if grep -q '^\[tool\.pyright\]' "pyproject.toml" 2>/dev/null; then + echo "pyright"; return 0 + fi + if grep -q '^\[tool\.mypy\]' "pyproject.toml" 2>/dev/null; then + echo "mypy"; return 0 + fi + if command -v pyrefly &>/dev/null; then + echo "pyrefly"; return 0 + fi + if command -v pyright &>/dev/null; then + echo "pyright"; return 0 + fi + if command -v mypy &>/dev/null; then + echo "mypy"; return 0 + fi + # Python project but no typechecker found — use sentinel so config can include the hint. + echo "none-python"; return 0 + fi + # JavaScript/TypeScript project + if [[ -f "tsconfig.json" ]] || [[ -f "package.json" ]]; then + if command -v tsc &>/dev/null; then + echo "tsc"; return 0 + fi + fi + echo "" +} + _detect_local_files() { # Scan for files that exist on disk but are not tracked by git local files=() @@ -300,6 +333,43 @@ _build_lint_config() { esac } +_build_typecheck_config() { + local tc_tool="$1" + + case "${tc_tool}" in + pyrefly) + echo "CGW_TYPECHECK_CMD=\"pyrefly\"" + echo "CGW_TYPECHECK_CHECK_ARGS=\"check\"" + echo "CGW_TYPECHECK_EXCLUDES=\"\"" + ;; + pyright) + echo "CGW_TYPECHECK_CMD=\"pyright\"" + echo "CGW_TYPECHECK_CHECK_ARGS=\"\"" + echo "CGW_TYPECHECK_EXCLUDES=\"\"" + ;; + mypy) + echo "CGW_TYPECHECK_CMD=\"mypy\"" + echo "CGW_TYPECHECK_CHECK_ARGS=\".\"" + echo "CGW_TYPECHECK_EXCLUDES=\"\"" + ;; + tsc) + echo "CGW_TYPECHECK_CMD=\"tsc\"" + echo "CGW_TYPECHECK_CHECK_ARGS=\"--noEmit\"" + echo "CGW_TYPECHECK_EXCLUDES=\"\"" + ;; + none-python) + echo "CGW_TYPECHECK_CMD=\"\" # install pyrefly to enable: pip install pyrefly" + echo "CGW_TYPECHECK_CHECK_ARGS=\"check\"" + echo "CGW_TYPECHECK_EXCLUDES=\"\"" + ;; + *) + echo "CGW_TYPECHECK_CMD=\"\"" + echo "CGW_TYPECHECK_CHECK_ARGS=\"\"" + echo "CGW_TYPECHECK_EXCLUDES=\"\"" + ;; + esac +} + _install_hook() { local hooks_template_dir="${SCRIPT_DIR}/../../hooks" @@ -631,7 +701,7 @@ main() { # -- Detection phase ------------------------------------------------------ echo "Scanning project..." - echo " Detecting branch names, lint tools, virtual environment, and local-only files..." + echo " Detecting branch names, lint tools, typecheck tool, virtual environment, and local-only files..." echo "" local detected_target @@ -649,9 +719,16 @@ main() { local detected_local_files detected_local_files="$(_detect_local_files)" + local detected_typecheck + detected_typecheck="$(_detect_typecheck_tool)" + + local _tc_display="${detected_typecheck}" + [[ "${_tc_display}" == "none-python" ]] && _tc_display="none detected (Tip: pip install pyrefly to enable)" + echo " Target branch (stable): ${detected_target}" echo " Source branch (dev): ${detected_source}" echo " Lint tool: ${detected_lint:-none detected}" + echo " Typecheck tool: ${_tc_display:-none detected}" echo " Venv directory: ${detected_venv:-none found}" echo " Local-only files: ${detected_local_files:-none found}" echo "" @@ -708,6 +785,9 @@ main() { echo "# Lint configuration (auto-detected)" _build_lint_config "${detected_lint}" "${detected_venv}" echo "" + echo "# Typecheck configuration (auto-detected)" + _build_typecheck_config "${detected_typecheck}" + echo "" echo "# Commit message prefix extras (pipe-separated, e.g. \"cuda|tensorrt\")" echo "CGW_EXTRA_PREFIXES=\"\"" echo "" diff --git a/scripts/git/create_pr.sh b/scripts/git/create_pr.sh index 7507c92..98c12d6 100644 --- a/scripts/git/create_pr.sh +++ b/scripts/git/create_pr.sh @@ -177,7 +177,7 @@ main() { exit 1 fi - if ! git ls-remote --exit-code "${CGW_REMOTE}" "refs/heads/${src_branch}" >/dev/null 2>&1; then + if ! cgw_remote_branch_exists "${CGW_REMOTE}" "${src_branch}"; then err "Source branch '${src_branch}' not pushed to ${CGW_REMOTE}" echo " Push it with: ./scripts/git/push_validated.sh" >&2 log_section_end "BRANCH VALIDATION" "$logfile" "1" @@ -185,7 +185,7 @@ main() { fi # Verify target branch exists on remote - if ! git ls-remote --exit-code "${CGW_REMOTE}" "refs/heads/${tgt_branch}" >/dev/null 2>&1; then + if ! cgw_remote_branch_exists "${CGW_REMOTE}" "${tgt_branch}"; then err "Target branch '${tgt_branch}' does not exist on ${CGW_REMOTE}" echo " Create it with: ./scripts/git/push_validated.sh --branch ${tgt_branch}" >&2 log_section_end "BRANCH VALIDATION" "$logfile" "1" @@ -208,7 +208,7 @@ main() { # Check for commits ahead of target local commits_ahead - if ! commits_ahead=$(git rev-list --count "${CGW_REMOTE}/${tgt_branch}..${CGW_REMOTE}/${src_branch}" 2>/dev/null); then + if ! commits_ahead=$(cgw_rev_count "${CGW_REMOTE}/${tgt_branch}" "${CGW_REMOTE}/${src_branch}"); then err "Cannot determine commit distance between ${CGW_REMOTE}/${tgt_branch} and ${CGW_REMOTE}/${src_branch}" log_section_end "BRANCH VALIDATION" "$logfile" "1" exit 1 diff --git a/scripts/git/push_validated.sh b/scripts/git/push_validated.sh index 004df9c..d3fd0ef 100644 --- a/scripts/git/push_validated.sh +++ b/scripts/git/push_validated.sh @@ -156,7 +156,7 @@ main() { log_section_start "REMOTE CHECK" "$logfile" echo "Checking remote ${CGW_REMOTE}..." | tee -a "$logfile" - if ! git ls-remote --exit-code "${CGW_REMOTE}" HEAD >/dev/null 2>&1; then + if ! cgw_remote_reachable "${CGW_REMOTE}"; then err "Remote '${CGW_REMOTE}' is not reachable. Check network/auth." log_section_end "REMOTE CHECK" "$logfile" "1" exit 1 @@ -166,7 +166,7 @@ main() { # Check if local is behind remote git fetch "${CGW_REMOTE}" "${target_branch}" >>"$logfile" 2>&1 || true local behind - behind=$(git rev-list --count "HEAD..${CGW_REMOTE}/${target_branch}" 2>/dev/null || echo "0") + behind=$(cgw_rev_count "HEAD" "${CGW_REMOTE}/${target_branch}" || echo "0") if [[ "${behind}" -gt 0 ]]; then echo "[!] WARNING: Local branch is ${behind} commit(s) behind ${CGW_REMOTE}/${target_branch}" | tee -a "$logfile" echo " A normal push may fail or overwrite remote changes." | tee -a "$logfile" @@ -207,7 +207,7 @@ main() { # [4/5] Show what will be pushed echo "[4/5] Commits to be pushed:" | tee -a "$logfile" local ahead - ahead=$(git rev-list --count "${CGW_REMOTE}/${target_branch}..HEAD" 2>/dev/null || echo "unknown") + ahead=$(cgw_rev_count "${CGW_REMOTE}/${target_branch}" "HEAD" || echo "unknown") echo " Local ahead of ${CGW_REMOTE}/${target_branch}: ${ahead} commit(s)" | tee -a "$logfile" if [[ "${ahead}" != "0" ]] && [[ "${ahead}" != "unknown" ]]; then git log "${CGW_REMOTE}/${target_branch}..HEAD" --oneline 2>/dev/null | tee -a "$logfile" || true diff --git a/scripts/git/rebase_safe.sh b/scripts/git/rebase_safe.sh index 1405685..99f7e50 100644 --- a/scripts/git/rebase_safe.sh +++ b/scripts/git/rebase_safe.sh @@ -302,12 +302,12 @@ _cmd_rebase_onto() { # Count pushed commits (commits on current branch not on origin/current_branch) local pushed_count=0 if git rev-parse "${CGW_REMOTE}/${current_branch}" >/dev/null 2>&1; then - pushed_count=$(git rev-list --count "${CGW_REMOTE}/${current_branch}..HEAD" 2>/dev/null || echo "0") + pushed_count=$(cgw_rev_count "${CGW_REMOTE}/${current_branch}" "HEAD" || echo "0") fi # Count commits that would be rebased local rebase_commit_count - rebase_commit_count=$(git rev-list --count "${onto_ref}..HEAD" 2>/dev/null || echo "?") + rebase_commit_count=$(cgw_rev_count "${onto_ref}" "HEAD" || echo "?") # Show plan echo " Current branch: ${current_branch}" | tee -a "$logfile" @@ -427,7 +427,7 @@ _cmd_squash_last() { local pushed_count=0 if git rev-parse "${CGW_REMOTE}/${current_branch}" >/dev/null 2>&1; then # Count how many of the last N commits exist on origin - pushed_count=$(git rev-list --count "${CGW_REMOTE}/${current_branch}..HEAD" 2>/dev/null || echo "0") + pushed_count=$(cgw_rev_count "${CGW_REMOTE}/${current_branch}" "HEAD" || echo "0") # Clamp to squash range if [[ "${pushed_count}" -gt "${squash_n}" ]]; then pushed_count="${squash_n}" diff --git a/scripts/git/repo_health.sh b/scripts/git/repo_health.sh index 46b2758..8ad0118 100644 --- a/scripts/git/repo_health.sh +++ b/scripts/git/repo_health.sh @@ -179,10 +179,9 @@ main() { for branch in "${CGW_SOURCE_BRANCH}" "${CGW_TARGET_BRANCH}"; do if git show-ref --verify --quiet "refs/heads/${branch}" 2>/dev/null; then - local ahead behind remote_ref="refs/remotes/${CGW_REMOTE}/${branch}" - if git show-ref --verify --quiet "${remote_ref}" 2>/dev/null; then - ahead=$(git rev-list --count "${CGW_REMOTE}/${branch}..${branch}" 2>/dev/null || echo "?") - behind=$(git rev-list --count "${branch}..${CGW_REMOTE}/${branch}" 2>/dev/null || echo "?") + local ahead behind + if ahead=$(cgw_rev_count "${CGW_REMOTE}/${branch}" "${branch}") && \ + behind=$(cgw_rev_count "${branch}" "${CGW_REMOTE}/${branch}"); then echo " ${branch}: ${ahead} ahead, ${behind} behind ${CGW_REMOTE}" else echo " ${branch}: (no remote tracking branch)" diff --git a/scripts/git/sync_branches.sh b/scripts/git/sync_branches.sh index 4151130..150c657 100644 --- a/scripts/git/sync_branches.sh +++ b/scripts/git/sync_branches.sh @@ -77,16 +77,16 @@ sync_one_branch() { fi local behind ahead - behind=$(git rev-list --count "HEAD..${CGW_REMOTE}/${branch}" 2>/dev/null || echo "0") - ahead=$(git rev-list --count "${CGW_REMOTE}/${branch}..HEAD" 2>/dev/null || echo "0") + behind=$(cgw_rev_count "HEAD" "${CGW_REMOTE}/${branch}" || echo "0") + ahead=$(cgw_rev_count "${CGW_REMOTE}/${branch}" "HEAD" || echo "0") # In dry-run mode, report status and skip the actual sync if [[ ${_sync_dry_run} -eq 1 ]]; then if [[ "${current_branch}" != "${branch}" ]]; then # Use remote ref directly for accurate counts when not on this branch local remote_behind remote_ahead - remote_behind=$(git rev-list --count "refs/heads/${branch}..${CGW_REMOTE}/${branch}" 2>/dev/null || echo "0") - remote_ahead=$(git rev-list --count "${CGW_REMOTE}/${branch}..refs/heads/${branch}" 2>/dev/null || echo "0") + remote_behind=$(cgw_rev_count "refs/heads/${branch}" "${CGW_REMOTE}/${branch}" || echo "0") + remote_ahead=$(cgw_rev_count "${CGW_REMOTE}/${branch}" "refs/heads/${branch}" || echo "0") behind="${remote_behind}" ahead="${remote_ahead}" fi @@ -107,8 +107,8 @@ sync_one_branch() { echo " Switched to ${branch}" | tee -a "$logfile" _sync_did_checkout=1 # Recompute ahead/behind from this branch's perspective - behind=$(git rev-list --count "HEAD..${CGW_REMOTE}/${branch}" 2>/dev/null || echo "0") - ahead=$(git rev-list --count "${CGW_REMOTE}/${branch}..HEAD" 2>/dev/null || echo "0") + behind=$(cgw_rev_count "HEAD" "${CGW_REMOTE}/${branch}" || echo "0") + ahead=$(cgw_rev_count "${CGW_REMOTE}/${branch}" "HEAD" || echo "0") fi echo " Local: ${ahead} ahead, ${behind} behind ${CGW_REMOTE}/${branch}" | tee -a "$logfile" diff --git a/scripts/git/undo_last.sh b/scripts/git/undo_last.sh index c01ce73..db4210e 100644 --- a/scripts/git/undo_last.sh +++ b/scripts/git/undo_last.sh @@ -147,7 +147,7 @@ _cmd_undo_commit() { upstream_ref="refs/remotes/${CGW_REMOTE}/${current_branch}" if git show-ref --verify --quiet "${upstream_ref}" 2>/dev/null; then local ahead - ahead=$(git rev-list --count "${CGW_REMOTE}/${current_branch}..HEAD" 2>/dev/null || echo "0") + ahead=$(cgw_rev_count "${CGW_REMOTE}/${current_branch}" "HEAD" || echo "0") if [[ "${ahead}" -eq 0 ]]; then echo "[!] WARNING: The last commit appears to have been pushed to ${CGW_REMOTE}." echo " Undoing it locally will create a diverged state requiring force-push." @@ -358,7 +358,7 @@ _cmd_amend_message() { upstream_ref="refs/remotes/${CGW_REMOTE}/${current_branch}" if git show-ref --verify --quiet "${upstream_ref}" 2>/dev/null; then local ahead - ahead=$(git rev-list --count "${CGW_REMOTE}/${current_branch}..HEAD" 2>/dev/null || echo "0") + ahead=$(cgw_rev_count "${CGW_REMOTE}/${current_branch}" "HEAD" || echo "0") if [[ "${ahead}" -eq 0 ]]; then echo " [!] WARNING: This commit appears to have been pushed. Amending will require force-push." if ! cgw_confirm "Amend anyway?" --non-interactive abort; then diff --git a/scripts/git/validate_branches.sh b/scripts/git/validate_branches.sh index f20d8af..f04c287 100644 --- a/scripts/git/validate_branches.sh +++ b/scripts/git/validate_branches.sh @@ -117,8 +117,8 @@ main() { log_section_start "BRANCH RELATIONSHIP CHECK" "$logfile" local source_ahead target_ahead - source_ahead=$(git rev-list --count "${CGW_TARGET_BRANCH}..${CGW_SOURCE_BRANCH}" 2>/dev/null || echo "unknown") - target_ahead=$(git rev-list --count "${CGW_SOURCE_BRANCH}..${CGW_TARGET_BRANCH}" 2>/dev/null || echo "unknown") + source_ahead=$(cgw_rev_count "${CGW_TARGET_BRANCH}" "${CGW_SOURCE_BRANCH}" || echo "unknown") + target_ahead=$(cgw_rev_count "${CGW_SOURCE_BRANCH}" "${CGW_TARGET_BRANCH}" || echo "unknown") echo "${CGW_SOURCE_BRANCH} ahead of ${CGW_TARGET_BRANCH}: $source_ahead commits" | tee -a "$logfile" echo "${CGW_TARGET_BRANCH} ahead of ${CGW_SOURCE_BRANCH}: $target_ahead commits" | tee -a "$logfile" diff --git a/skill/references/script-reference.md b/skill/references/script-reference.md index 35f8190..629831a 100644 --- a/skill/references/script-reference.md +++ b/skill/references/script-reference.md @@ -67,6 +67,12 @@ Auto-detects project type (Python, TouchDesigner, GLSL, images/assets). Safe to | `--glsl` | Limit to GLSL/shader artifacts (`.spv`, compiled shaders) | | `--all` | All project types | +**`check_local_files.sh`** — Verify no local-only files are tracked *(CI helper)* +```bash +./scripts/git/check_local_files.sh +``` +Exits 1 if any `CGW_LOCAL_FILES` entry is tracked in git. Used by `branch-protection.yml`; safe to run locally before a merge or release. + --- ## Commit Workflow diff --git a/tests/helpers/setup.bash b/tests/helpers/setup.bash index b60f18e..718bd18 100644 --- a/tests/helpers/setup.bash +++ b/tests/helpers/setup.bash @@ -132,6 +132,26 @@ setup_file_create_test_repo_with_remote() { git -C "${TEST_REPO_DIR}" push --quiet --set-upstream origin development } +# ── Environment requirement guards ─────────────────────────────────────────── +# Call at the top of a @test body; issues bats `skip` when a prerequisite is absent. + +# _require_jq — skip when jq is not on PATH. +# Use for tests that exercise jq-dependent behaviour (guardrail parsing, +# configure.sh settings-merge). Without jq the code under test fails open +# by design, so the test cannot assert the blocking/merge outcome. +_require_jq() { command -v jq >/dev/null 2>&1 || skip "requires jq"; } + +# _require_no_typechecker — skip when any Python typechecker is on PATH. +# Use for tests whose precondition is "no typechecker detected". configure.sh +# probes command -v pyrefly/pyright/mypy; if one is present it detects it +# correctly and suppresses the "pip install pyrefly" hint, defeating the test. +_require_no_typechecker() { + local tc + for tc in pyrefly pyright mypy; do + command -v "${tc}" >/dev/null 2>&1 && skip "typechecker '${tc}' on PATH — cannot simulate 'none'" + done +} + # ── Script path helpers ──────────────────────────────────────────────────────── # script_path — returns absolute path to scripts/git/ diff --git a/tests/integration/cc_guardrail.bats b/tests/integration/cc_guardrail.bats index 39a00ad..1f5e997 100644 --- a/tests/integration/cc_guardrail.bats +++ b/tests/integration/cc_guardrail.bats @@ -40,6 +40,7 @@ _run_configure() { # ── Guardrail script: blocked commands ─────────────────────────────────────── @test "blocks raw git commit" { + _require_jq run _run_guardrail "git commit -m 'test'" [ "${status}" -eq 2 ] [[ "${output}" == *"BLOCKED"* ]] @@ -47,54 +48,64 @@ _run_configure() { } @test "blocks git commit with no args" { + _require_jq run _run_guardrail "git commit" [ "${status}" -eq 2 ] } @test "blocks --no-verify flag" { + _require_jq run _run_guardrail "git push --no-verify origin main" [ "${status}" -eq 2 ] [[ "${output}" == *"BLOCKED"* ]] } @test "blocks --no-verify on commit" { + _require_jq run _run_guardrail "git commit --no-verify -m 'skip hooks'" [ "${status}" -eq 2 ] } @test "blocks git push --force" { + _require_jq run _run_guardrail "git push --force origin main" [ "${status}" -eq 2 ] [[ "${output}" == *"push_validated.sh"* ]] } @test "blocks git reset --hard" { + _require_jq run _run_guardrail "git reset --hard HEAD~1" [ "${status}" -eq 2 ] } @test "blocks git clean -f" { + _require_jq run _run_guardrail "git clean -fd" [ "${status}" -eq 2 ] } @test "blocks git branch -D" { + _require_jq run _run_guardrail "git branch -D old-feature" [ "${status}" -eq 2 ] [[ "${output}" == *"branch_cleanup.sh"* ]] } @test "blocks rm -rf .git" { + _require_jq run _run_guardrail "rm -rf .git" [ "${status}" -eq 2 ] } @test "blocks rm -rf path/to/.git/" { + _require_jq run _run_guardrail "rm -rf /tmp/repo/.git/" [ "${status}" -eq 2 ] } @test "blocks git filter-branch" { + _require_jq run _run_guardrail "git filter-branch --tree-filter 'rm -f secrets.txt'" [ "${status}" -eq 2 ] } @@ -195,6 +206,7 @@ EOF } @test "settings.json merge preserves existing entries" { + _require_jq mkdir -p "${TEST_REPO_DIR}/.claude" # Pre-populate settings.json with an existing entry cat > "${TEST_REPO_DIR}/.claude/settings.json" << 'EOF' diff --git a/tests/integration/check_local_files.bats b/tests/integration/check_local_files.bats index cdd472a..92fc6a8 100644 --- a/tests/integration/check_local_files.bats +++ b/tests/integration/check_local_files.bats @@ -69,7 +69,7 @@ _run_check() { @test "tracked file inside .claude/ is caught when CGW_LOCAL_FILES='.claude/'" { mkdir -p "${TEST_REPO_DIR}/.claude" echo "settings" > "${TEST_REPO_DIR}/.claude/settings.local.json" - git -C "${TEST_REPO_DIR}" add .claude/settings.local.json + git -C "${TEST_REPO_DIR}" add -f .claude/settings.local.json git -C "${TEST_REPO_DIR}" commit --quiet -m "chore: leak .claude/" run _run_check 'export CGW_LOCAL_FILES=".claude/"' diff --git a/tests/integration/configure.bats b/tests/integration/configure.bats index 7e5e244..a371232 100644 --- a/tests/integration/configure.bats +++ b/tests/integration/configure.bats @@ -156,3 +156,41 @@ EOF _run_configure "--non-interactive" [ -f "${TEST_REPO_DIR}/.githooks/pre-commit" ] } + +# ── Typecheck tool detection ────────────────────────────────────────────────── + +@test "detects pyrefly when [tool.pyrefly] declared in pyproject.toml" { + printf '[tool.pyrefly]\nsearch_path = ["."]\n' > "${TEST_REPO_DIR}/pyproject.toml" + _run_configure "--non-interactive" || true + if [ -f "${TEST_REPO_DIR}/.cgw.conf" ]; then + grep -q 'CGW_TYPECHECK_CMD="pyrefly"' "${TEST_REPO_DIR}/.cgw.conf" + fi +} + +@test "detects mypy when [tool.mypy] declared and pyrefly/pyright absent" { + printf '[tool.mypy]\n' > "${TEST_REPO_DIR}/pyproject.toml" + _run_configure "--non-interactive" || true + if [ -f "${TEST_REPO_DIR}/.cgw.conf" ]; then + grep -q 'CGW_TYPECHECK_CMD="mypy"' "${TEST_REPO_DIR}/.cgw.conf" + fi +} + +@test "emits pyrefly hint comment when Python project has no typechecker" { + # pyproject.toml present but no [tool.*] typechecker section, none on PATH + _require_no_typechecker + printf '[build-system]\nrequires = ["setuptools"]\n' > "${TEST_REPO_DIR}/pyproject.toml" + _run_configure "--non-interactive" || true + if [ -f "${TEST_REPO_DIR}/.cgw.conf" ]; then + grep -q 'pip install pyrefly' "${TEST_REPO_DIR}/.cgw.conf" + fi +} + +@test "--reconfigure adds CGW_TYPECHECK_CMD when pyrefly declared" { + # Simulate a pre-existing .cgw.conf without typecheck vars, then reconfigure + printf '[tool.pyrefly]\n' > "${TEST_REPO_DIR}/pyproject.toml" + printf 'CGW_SOURCE_BRANCH="development"\nCGW_TARGET_BRANCH="main"\n' > "${TEST_REPO_DIR}/.cgw.conf" + _run_configure "--non-interactive --reconfigure" || true + if [ -f "${TEST_REPO_DIR}/.cgw.conf" ]; then + grep -q 'CGW_TYPECHECK_CMD="pyrefly"' "${TEST_REPO_DIR}/.cgw.conf" + fi +} diff --git a/tests/integration/typecheck.bats b/tests/integration/typecheck.bats new file mode 100644 index 0000000..f0715e1 --- /dev/null +++ b/tests/integration/typecheck.bats @@ -0,0 +1,76 @@ +#!/usr/bin/env bats +# tests/integration/typecheck.bats - Integration tests for CGW_TYPECHECK_CMD pre-commit step +# Verifies: (a) no-op when disabled, (b) [PASS] on clean, (c) [WARN] on errors (non-blocking), +# (d) CGW_SKIP_TYPECHECK=1 skip path. +# Runs: bats tests/integration/typecheck.bats + +bats_require_minimum_version 1.5.0 +load '../helpers/setup' + +setup() { + create_test_repo + mkdir -p "${TEST_REPO_DIR}/scripts/git" + cp "${CGW_PROJECT_ROOT}/scripts/git/_common.sh" "${TEST_REPO_DIR}/scripts/git/_common.sh" + cp "${CGW_PROJECT_ROOT}/scripts/git/_config.sh" "${TEST_REPO_DIR}/scripts/git/_config.sh" + cp "${CGW_PROJECT_ROOT}/hooks/pre-commit" "${TEST_REPO_DIR}/.git/hooks/pre-commit" + chmod +x "${TEST_REPO_DIR}/.git/hooks/pre-commit" + git -C "${TEST_REPO_DIR}" checkout development +} + +teardown() { + cleanup_test_repo +} + +@test "pre-commit hook: CGW_TYPECHECK_CMD empty skips typecheck silently" { + printf 'CGW_TYPECHECK_CMD=""\n' > "${TEST_REPO_DIR}/.cgw.conf" + echo "x = 1" > "${TEST_REPO_DIR}/foo.py" + git -C "${TEST_REPO_DIR}" add foo.py + run git -C "${TEST_REPO_DIR}" commit -m "feat: add foo.py" + [ "${status}" -eq 0 ] + [[ "${output}" != *"Checking types"* ]] + [[ "${output}" != *"[WARN]"* ]] +} + +@test "pre-commit hook: typecheck passes — exits 0 and prints [PASS]" { + local fake_tc + fake_tc="$(mktemp)" + printf '#!/usr/bin/env bash\nexit 0\n' > "${fake_tc}" + chmod +x "${fake_tc}" + printf 'CGW_TYPECHECK_CMD="%s"\nCGW_TYPECHECK_CHECK_ARGS=""\n' "${fake_tc}" > "${TEST_REPO_DIR}/.cgw.conf" + echo "x = 1" > "${TEST_REPO_DIR}/foo.py" + git -C "${TEST_REPO_DIR}" add foo.py + run git -C "${TEST_REPO_DIR}" commit -m "feat: add foo.py" + [ "${status}" -eq 0 ] + [[ "${output}" == *"[PASS] Typecheck passed"* ]] + rm -f "${fake_tc}" +} + +@test "pre-commit hook: typecheck fails — prints [WARN] but exits 0 (non-blocking)" { + local fake_tc + fake_tc="$(mktemp)" + printf '#!/usr/bin/env bash\necho "foo.py:1:1: error: missing return type"\nexit 1\n' > "${fake_tc}" + chmod +x "${fake_tc}" + printf 'CGW_TYPECHECK_CMD="%s"\nCGW_TYPECHECK_CHECK_ARGS=""\n' "${fake_tc}" > "${TEST_REPO_DIR}/.cgw.conf" + echo "x = 1" > "${TEST_REPO_DIR}/foo.py" + git -C "${TEST_REPO_DIR}" add foo.py + run git -C "${TEST_REPO_DIR}" commit -m "feat: add foo.py" + [ "${status}" -eq 0 ] + [[ "${output}" == *"[WARN] Type errors found"* ]] + [[ "${output}" != *"[PASS]"* ]] + rm -f "${fake_tc}" +} + +@test "pre-commit hook: CGW_SKIP_TYPECHECK=1 skips typecheck even when CGW_TYPECHECK_CMD is set" { + local fake_tc + fake_tc="$(mktemp)" + printf '#!/usr/bin/env bash\nexit 1\n' > "${fake_tc}" + chmod +x "${fake_tc}" + printf 'CGW_TYPECHECK_CMD="%s"\nCGW_TYPECHECK_CHECK_ARGS=""\nCGW_SKIP_TYPECHECK=1\n' "${fake_tc}" > "${TEST_REPO_DIR}/.cgw.conf" + echo "x = 1" > "${TEST_REPO_DIR}/foo.py" + git -C "${TEST_REPO_DIR}" add foo.py + run git -C "${TEST_REPO_DIR}" commit -m "feat: add foo.py" + [ "${status}" -eq 0 ] + [[ "${output}" != *"[WARN]"* ]] + [[ "${output}" != *"[PASS]"* ]] + rm -f "${fake_tc}" +} diff --git a/tests/unit/common.bats b/tests/unit/common.bats index d166e09..37aa0d6 100644 --- a/tests/unit/common.bats +++ b/tests/unit/common.bats @@ -1043,3 +1043,194 @@ UU b.py " 2>/dev/null [ "${status}" -eq 1 ] } + +# ── cgw_rev_count() ──────────────────────────────────────────────────────────── +# Each test spins up its own throwaway repo so results are independent of +# commits accumulated by earlier tests in the shared file-scope repo. + +@test "cgw_rev_count: tip 1 ahead of base returns 1" { + run bash -c " + tmp=\$(mktemp -d) + git init --quiet \"\${tmp}\" + git -C \"\${tmp}\" config core.autocrlf false + git -C \"\${tmp}\" config user.email t@t.com + git -C \"\${tmp}\" config user.name T + echo x > \"\${tmp}/f\" && git -C \"\${tmp}\" add f + git -C \"\${tmp}\" commit --quiet -m 'init' + git -C \"\${tmp}\" checkout --quiet -b main 2>/dev/null || \ + git -C \"\${tmp}\" branch -m main 2>/dev/null || true + git -C \"\${tmp}\" checkout --quiet -b dev + echo y >> \"\${tmp}/f\" && git -C \"\${tmp}\" add f + git -C \"\${tmp}\" commit --quiet -m 'dev' + cd \"\${tmp}\" + export SCRIPT_DIR='${CGW_PROJECT_ROOT}/scripts/git' + source '${CGW_PROJECT_ROOT}/scripts/git/_common.sh' + out=\$(cgw_rev_count main dev); ec=\$? + rm -rf \"\${tmp}\" + echo \"\${out}\"; exit \${ec} + " + [ "${status}" -eq 0 ] + [ "${output}" = "1" ] +} + +@test "cgw_rev_count: base not ahead of tip returns 0" { + run bash -c " + tmp=\$(mktemp -d) + git init --quiet \"\${tmp}\" + git -C \"\${tmp}\" config core.autocrlf false + git -C \"\${tmp}\" config user.email t@t.com + git -C \"\${tmp}\" config user.name T + echo x > \"\${tmp}/f\" && git -C \"\${tmp}\" add f + git -C \"\${tmp}\" commit --quiet -m 'init' + git -C \"\${tmp}\" checkout --quiet -b main 2>/dev/null || \ + git -C \"\${tmp}\" branch -m main 2>/dev/null || true + git -C \"\${tmp}\" checkout --quiet -b dev + echo y >> \"\${tmp}/f\" && git -C \"\${tmp}\" add f + git -C \"\${tmp}\" commit --quiet -m 'dev' + cd \"\${tmp}\" + export SCRIPT_DIR='${CGW_PROJECT_ROOT}/scripts/git' + source '${CGW_PROJECT_ROOT}/scripts/git/_common.sh' + out=\$(cgw_rev_count dev main); ec=\$? + rm -rf \"\${tmp}\" + echo \"\${out}\"; exit \${ec} + " + [ "${status}" -eq 0 ] + [ "${output}" = "0" ] +} + +@test "cgw_rev_count: same ref returns 0" { + run bash -c " + tmp=\$(mktemp -d) + git init --quiet \"\${tmp}\" + git -C \"\${tmp}\" config core.autocrlf false + git -C \"\${tmp}\" config user.email t@t.com + git -C \"\${tmp}\" config user.name T + echo x > \"\${tmp}/f\" && git -C \"\${tmp}\" add f + git -C \"\${tmp}\" commit --quiet -m 'init' + git -C \"\${tmp}\" checkout --quiet -b main 2>/dev/null || \ + git -C \"\${tmp}\" branch -m main 2>/dev/null || true + cd \"\${tmp}\" + export SCRIPT_DIR='${CGW_PROJECT_ROOT}/scripts/git' + source '${CGW_PROJECT_ROOT}/scripts/git/_common.sh' + out=\$(cgw_rev_count main main); ec=\$? + rm -rf \"\${tmp}\" + echo \"\${out}\"; exit \${ec} + " + [ "${status}" -eq 0 ] + [ "${output}" = "0" ] +} + +@test "cgw_rev_count: bad base ref exits non-zero" { + run bash -c " + tmp=\$(mktemp -d) + git init --quiet \"\${tmp}\" + git -C \"\${tmp}\" config core.autocrlf false + git -C \"\${tmp}\" config user.email t@t.com + git -C \"\${tmp}\" config user.name T + echo x > \"\${tmp}/f\" && git -C \"\${tmp}\" add f + git -C \"\${tmp}\" commit --quiet -m 'init' + cd \"\${tmp}\" + export SCRIPT_DIR='${CGW_PROJECT_ROOT}/scripts/git' + source '${CGW_PROJECT_ROOT}/scripts/git/_common.sh' + cgw_rev_count nonexistent HEAD 2>/dev/null; ec=\$? + rm -rf \"\${tmp}\"; exit \${ec} + " + [ "${status}" -ne 0 ] +} + +@test "cgw_rev_count: bad tip ref exits non-zero" { + run bash -c " + tmp=\$(mktemp -d) + git init --quiet \"\${tmp}\" + git -C \"\${tmp}\" config core.autocrlf false + git -C \"\${tmp}\" config user.email t@t.com + git -C \"\${tmp}\" config user.name T + echo x > \"\${tmp}/f\" && git -C \"\${tmp}\" add f + git -C \"\${tmp}\" commit --quiet -m 'init' + cd \"\${tmp}\" + export SCRIPT_DIR='${CGW_PROJECT_ROOT}/scripts/git' + source '${CGW_PROJECT_ROOT}/scripts/git/_common.sh' + cgw_rev_count HEAD nonexistent 2>/dev/null; ec=\$? + rm -rf \"\${tmp}\"; exit \${ec} + " + [ "${status}" -ne 0 ] +} + +# ── cgw_remote_reachable() and cgw_remote_branch_exists() ───────────────────── + +@test "cgw_remote_reachable: reachable remote returns 0" { + run bash -c " + tmp=\$(mktemp -d) + bare=\"\${tmp}/remote.git\" + repo=\"\${tmp}/repo\" + git init --bare --quiet \"\${bare}\" + git init --quiet \"\${repo}\" + git -C \"\${repo}\" config user.email t@t.com + git -C \"\${repo}\" config user.name T + echo x > \"\${repo}/f\" + git -C \"\${repo}\" add f + git -C \"\${repo}\" commit --quiet -m 'init' + git -C \"\${repo}\" remote add origin \"\${bare}\" + git -C \"\${repo}\" push --quiet origin HEAD:main + cd \"\${repo}\" + export SCRIPT_DIR='${CGW_PROJECT_ROOT}/scripts/git' + source '${CGW_PROJECT_ROOT}/scripts/git/_common.sh' + cgw_remote_reachable origin; ec=\$? + rm -rf \"\${tmp}\" + exit \${ec} + " + [ "${status}" -eq 0 ] +} + +@test "cgw_remote_reachable: unknown remote exits non-zero" { + run cgw_remote_reachable "no-such-remote" + [ "${status}" -ne 0 ] +} + +@test "cgw_remote_branch_exists: existing branch returns 0" { + run bash -c " + tmp=\$(mktemp -d) + bare=\"\${tmp}/remote.git\" + repo=\"\${tmp}/repo\" + git init --bare --quiet \"\${bare}\" + git init --quiet \"\${repo}\" + git -C \"\${repo}\" config user.email t@t.com + git -C \"\${repo}\" config user.name T + echo x > \"\${repo}/f\" + git -C \"\${repo}\" add f + git -C \"\${repo}\" commit --quiet -m 'init' + git -C \"\${repo}\" remote add origin \"\${bare}\" + git -C \"\${repo}\" push --quiet origin HEAD:main + cd \"\${repo}\" + export SCRIPT_DIR='${CGW_PROJECT_ROOT}/scripts/git' + source '${CGW_PROJECT_ROOT}/scripts/git/_common.sh' + cgw_remote_branch_exists origin main; ec=\$? + rm -rf \"\${tmp}\" + exit \${ec} + " + [ "${status}" -eq 0 ] +} + +@test "cgw_remote_branch_exists: missing branch exits non-zero" { + run bash -c " + tmp=\$(mktemp -d) + bare=\"\${tmp}/remote.git\" + repo=\"\${tmp}/repo\" + git init --bare --quiet \"\${bare}\" + git init --quiet \"\${repo}\" + git -C \"\${repo}\" config user.email t@t.com + git -C \"\${repo}\" config user.name T + echo x > \"\${repo}/f\" + git -C \"\${repo}\" add f + git -C \"\${repo}\" commit --quiet -m 'init' + git -C \"\${repo}\" remote add origin \"\${bare}\" + git -C \"\${repo}\" push --quiet origin HEAD:main + cd \"\${repo}\" + export SCRIPT_DIR='${CGW_PROJECT_ROOT}/scripts/git' + source '${CGW_PROJECT_ROOT}/scripts/git/_common.sh' + cgw_remote_branch_exists origin nonexistent-branch; ec=\$? + rm -rf \"\${tmp}\" + exit \${ec} + " + [ "${status}" -ne 0 ] +} diff --git a/tests/verify_skill_commands.sh b/tests/verify_skill_commands.sh new file mode 100644 index 0000000..358bea0 --- /dev/null +++ b/tests/verify_skill_commands.sh @@ -0,0 +1,137 @@ +#!/usr/bin/env bash +# tests/verify_skill_commands.sh +# Verify every ./scripts/git/*.sh invocation documented in skill/SKILL.md and +# command/auto-git-workflow.md against the actual scripts: +# 1. Every documented script exists. +# 2. Every --flag on lines that call a given script appears in that script. +# 3. A set of offline dry-run executions passes against a scratch git repo. +# +# Usage: bash tests/verify_skill_commands.sh +# Exit: 0 = all checks pass, 1 = one or more failures (printed to stderr). +set -uo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +DOCS=( + "$REPO_ROOT/skill/SKILL.md" + "$REPO_ROOT/command/auto-git-workflow.md" +) +pass=0 +fail=0 + +_pass() { printf " PASS %s\n" "$*"; (( pass++ )) || true; } +_fail() { printf " FAIL %s\n" "$*" >&2; (( fail++ )) || true; } + +# ───────────────────────────────────────────────────────────────────────────── +# 1. Every documented script must exist +# ───────────────────────────────────────────────────────────────────────────── +echo "=== 1. Script existence ===" + +mapfile -t documented_scripts < <( + grep -hoE '\./scripts/git/[a-z_]+\.sh' "${DOCS[@]}" 2>/dev/null | + sed 's|.*/||' | sort -u +) + +if (( ${#documented_scripts[@]} == 0 )); then + echo " WARN No script invocations found in docs — check doc paths" >&2 +else + for script in "${documented_scripts[@]}"; do + if [[ -f "$REPO_ROOT/scripts/git/$script" ]]; then + _pass "$script exists" + else + _fail "$script MISSING from scripts/git/" + fi + done +fi + +# ───────────────────────────────────────────────────────────────────────────── +# 2. Every --flag on lines invoking a given script must appear in that script +# ───────────────────────────────────────────────────────────────────────────── +echo "" +echo "=== 2. Flag verification ===" + +# Flags universal to (almost) every script — tested implicitly by dry-run runs +_is_universal() { + case "$1" in + --non-interactive|--dry-run|--skip-lint|--no-venv|--help) return 0 ;; + *) return 1 ;; + esac +} + +for script in "${documented_scripts[@]}"; do + script_path="$REPO_ROOT/scripts/git/$script" + [[ -f "$script_path" ]] || continue + + # Collect all --flags from lines mentioning this script name in the docs. + # Multi-line continuation flags (indented --flag after \) are not captured + # here; they are exercised by dry-run runs in section 3. + mapfile -t flags < <( + grep -h "$script" "${DOCS[@]}" 2>/dev/null | + grep -oE -- '--[a-z][a-z_-]+' | sort -u + ) + + checked=0 + for flag in "${flags[@]}"; do + _is_universal "$flag" && continue + (( checked++ )) || true + if grep -qF -- "$flag" "$script_path" 2>/dev/null; then + _pass "$script $flag" + else + _fail "$script $flag (not found in script source)" + fi + done + (( checked == 0 )) && _pass "$script (no non-universal flags to verify)" +done + +# ───────────────────────────────────────────────────────────────────────────── +# 3. Offline dry-run execution in a scratch git repo +# ───────────────────────────────────────────────────────────────────────────── +echo "" +echo "=== 3. Offline dry-run execution ===" + +SCRATCH="$(mktemp -d)" +trap 'rm -rf "$SCRATCH"' EXIT INT TERM + +cd "$SCRATCH" +git init -q +git config user.email "verify@test.local" +git config user.name "Verify" + +git checkout -qb development 2>/dev/null || git checkout -q development +echo "initial" > README.md +git add README.md +git commit -q -m "feat: initial commit" +git checkout -qb main 2>/dev/null || git checkout -q main +git checkout -q development + +cat > .cgw.conf <<'EOF' +CGW_SOURCE_BRANCH="development" +CGW_TARGET_BRANCH="main" +CGW_LOCAL_FILES="logs/ .claude/" +EOF + +mkdir -p scripts +ln -s "$REPO_ROOT/scripts/git" scripts/git + +_dry() { + local label="$1"; shift + if CGW_NON_INTERACTIVE=1 bash "$REPO_ROOT/scripts/git/$@" >/dev/null 2>&1; then + _pass "$label" + else + _fail "$label (exit $?)" + fi +} + +_dry "setup_attributes.sh --dry-run" setup_attributes.sh --dry-run +_dry "clean_build.sh (dry-run default)" clean_build.sh +_dry "branch_cleanup.sh (dry-run default)" branch_cleanup.sh +_dry "merge_with_validation.sh --dry-run" merge_with_validation.sh --dry-run +_dry "changelog_generate.sh --from main" changelog_generate.sh --from main + +cd "$REPO_ROOT" + +# ───────────────────────────────────────────────────────────────────────────── +# Summary +# ───────────────────────────────────────────────────────────────────────────── +echo "" +echo " Passed: $pass Failed: $fail" +(( fail == 0 ))