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