Skip to content

Instantly share code, notes, and snippets.

@zelaznik
Last active January 14, 2026 16:36
Show Gist options
  • Select an option

  • Save zelaznik/506fbcfec02ea17ec26282a43cd862fb to your computer and use it in GitHub Desktop.

Select an option

Save zelaznik/506fbcfec02ea17ec26282a43cd862fb to your computer and use it in GitHub Desktop.
#!/usr/bin/env bash
# red-green-refactor-validator.sh
# Validates test-driven development red-green-refactor workflow
#
# Exit codes:
# 0 - Validation successful (tests failed on old code, passed on new code)
# 1 - Validation failed (no tests changed, or tests didn't follow red-green pattern)
# 2 - Precondition failed (unstaged changes exist)
# 3 - Script error (stash/restore failed)
set -euo pipefail
# Exit codes
readonly EXIT_SUCCESS=0
readonly EXIT_VALIDATION_FAILED=1
readonly EXIT_UNSTAGED_CHANGES=2
readonly EXIT_SCRIPT_ERROR=3
# State tracking for cleanup
STASH_CREATED=false
STASH_REF=""
# Function: Check for unstaged changes in tracked files
check_no_unstaged_changes() {
echo "Checking for unstaged changes in tracked files..." >&2
# Check for modified tracked files that aren't staged
if ! git diff --quiet; then
echo "" >&2
echo "ERROR: You have unstaged changes in tracked files." >&2
echo "Please stage or stash all changes before running validation." >&2
echo "" >&2
echo "Unstaged changes:" >&2
git diff --name-status >&2
return 1
fi
echo "✓ No unstaged changes detected" >&2
return 0
}
# Function: Get list of changed spec files to run with rspec
get_changed_spec_files() {
echo "Identifying changed spec files..." >&2
# Get staged spec files ending in _spec.rb (these will be run with rspec)
local spec_files
spec_files=$(git diff --cached --name-status HEAD | \
egrep -v '^D' | \
awk '{print $NF}' | \
egrep '_spec\.rb$' || true)
if [[ -z "$spec_files" ]]; then
echo "" >&2
echo "ERROR: No spec files changed in staged changes." >&2
echo "TDD requires writing tests. You must modify at least one _spec.rb file." >&2
echo "" >&2
exit "$EXIT_VALIDATION_FAILED"
fi
echo "$spec_files"
}
# Function: Get list of changed application files (non-spec directory files)
get_changed_app_files() {
# Get staged files outside ./spec/ directory (application code to stash)
local app_files
app_files=$(git diff --cached --name-status HEAD | \
egrep -v '^D' | \
awk '{print $NF}' | \
egrep -v '^spec/' || true)
echo "$app_files"
}
# Function: Stash only application code (non-spec files) to restore old code
stash_app_files() {
local app_files="$1"
if [[ -z "$app_files" ]]; then
echo "✓ No application files to stash (spec-only changes)" >&2
return 0
fi
echo "Stashing application code changes..." >&2
echo "$app_files" | sed 's/^/ - /' >&2
# Create a timestamped stash of application files only
# This keeps spec changes in place so we can test new specs against old app code
local stash_message="red-green-refactor-validation-$(date +%s)"
# Convert newline-separated files to arguments for git stash push
if echo "$app_files" | xargs git stash push -m "$stash_message" --quiet --; then
STASH_CREATED=true
STASH_REF=$(git rev-parse stash@{0})
echo "✓ Application code stashed: $STASH_REF" >&2
return 0
else
echo "ERROR: Failed to stash application code" >&2
return 1
fi
}
# Function: Restore application code from stash
restore_app_files() {
if [[ "$STASH_CREATED" == true ]] && [[ -n "$STASH_REF" ]]; then
echo "Restoring application code from stash..." >&2
# Pop the stash without --index since we kept spec files staged
# This avoids conflicts with the modified staging area
if git stash pop --quiet 2>&1; then
echo "✓ Application code restored" >&2
# Re-stage all changes (both app files and spec files)
# This ensures everything is properly staged for commit
if git add -u; then
echo "✓ Changes re-staged" >&2
fi
STASH_CREATED=false
return 0
else
echo "" >&2
echo "ERROR: Failed to restore stash." >&2
echo "Your changes are safe at: $STASH_REF" >&2
echo "Manually restore with: git stash apply $STASH_REF" >&2
return 1
fi
fi
return 0
}
# Function: Run tests expecting failure (RED phase)
run_tests_expect_failure() {
local test_files="$1"
echo "" >&2
echo "========================================" >&2
echo "RED Phase: New specs + Old app code" >&2
echo "========================================" >&2
echo "Expecting tests to FAIL..." >&2
echo "" >&2
# Run tests and capture exit code
local exit_code=0
echo "$test_files" | xargs bundle exec rspec || exit_code=$?
if [[ $exit_code -eq 0 ]]; then
echo "" >&2
echo "ERROR: New specs PASSED with old application code, but should have FAILED." >&2
echo "This violates red-green-refactor: you must start with failing tests." >&2
echo "" >&2
return 1
else
echo "" >&2
echo "✓ New specs failed with old app code as expected (RED phase confirmed)" >&2
return 0
fi
}
# Function: Run tests expecting success (GREEN phase)
run_tests_expect_success() {
local test_files="$1"
echo "" >&2
echo "========================================" >&2
echo "GREEN Phase: New specs + New app code" >&2
echo "========================================" >&2
echo "Expecting tests to PASS..." >&2
echo "" >&2
# Run tests and capture exit code
if echo "$test_files" | xargs bundle exec rspec; then
echo "" >&2
echo "✓ New specs passed with new app code as expected (GREEN phase confirmed)" >&2
return 0
else
echo "" >&2
echo "ERROR: New specs FAILED with new application code." >&2
echo "Fix your code so tests pass before committing." >&2
echo "" >&2
return 1
fi
}
# Function: Cleanup handler to restore state on exit
cleanup() {
local exit_code=$?
# Only run cleanup if stash was created
if [[ "$STASH_CREATED" == true ]]; then
echo "" >&2
echo "Cleanup: Restoring application code..." >&2
if ! restore_app_files; then
echo "" >&2
echo "CRITICAL: Failed to restore changes." >&2
echo "Stash saved at: $STASH_REF" >&2
echo "Manually restore with: git stash apply $STASH_REF" >&2
exit "$EXIT_SCRIPT_ERROR"
fi
fi
exit "$exit_code"
}
# Register cleanup handler for any exit (success, error, interrupt)
trap cleanup EXIT INT TERM
# Main execution
main() {
echo "" >&2
echo "========================================" >&2
echo "Red-Green-Refactor Validation" >&2
echo "========================================" >&2
echo "" >&2
# Step 1: Verify preconditions - no unstaged changes
if ! check_no_unstaged_changes; then
exit "$EXIT_UNSTAGED_CHANGES"
fi
echo "" >&2
# Step 2: Identify changed spec files (to run with rspec)
local spec_files
spec_files=$(get_changed_spec_files)
echo "✓ Found changed spec files:" >&2
echo "$spec_files" | sed 's/^/ - /' >&2
echo "" >&2
# Step 3: Identify changed application files (to stash)
local app_files
app_files=$(get_changed_app_files)
if [[ -n "$app_files" ]]; then
echo "✓ Found changed application files:" >&2
echo "$app_files" | sed 's/^/ - /' >&2
echo "" >&2
fi
# Step 4: Stash application code (keep spec changes in working directory)
if ! stash_app_files "$app_files"; then
exit "$EXIT_SCRIPT_ERROR"
fi
# Step 5: Run tests on old app code with new specs - expecting failure
if ! run_tests_expect_failure "$spec_files"; then
# Cleanup handler will restore application code
exit "$EXIT_VALIDATION_FAILED"
fi
# Step 6: Restore application code (new code)
if ! restore_app_files; then
exit "$EXIT_SCRIPT_ERROR"
fi
# Step 7: Run tests on new code - expecting success
if ! run_tests_expect_success "$spec_files"; then
# Staged changes are already restored
exit "$EXIT_VALIDATION_FAILED"
fi
# Success!
echo "" >&2
echo "========================================" >&2
echo "✓ Red-Green-Refactor validation PASSED" >&2
echo "========================================" >&2
echo "" >&2
echo "Your changes follow proper TDD workflow:" >&2
echo " ✓ New specs failed with old application code (RED)" >&2
echo " ✓ New specs passed with new application code (GREEN)" >&2
echo "" >&2
exit "$EXIT_SUCCESS"
}
# Execute main function
main "$@"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment