From 8a9a4d7b406538594db1fe203d1b568bea4d4d6e Mon Sep 17 00:00:00 2001 From: forkni Date: Thu, 9 Apr 2026 11:54:54 -0400 Subject: [PATCH 1/8] feat: add bisect_helper.sh and rebase_safe.sh (Pro Git Ch3/Ch7) --- README.md | 48 ++ cgw.conf.example | 31 ++ scripts/git/_common.sh | 12 +- scripts/git/_config.sh | 15 + scripts/git/bisect_helper.sh | 404 +++++++++++++++++ scripts/git/check_lint.sh | 12 +- scripts/git/cherry_pick_commits.sh | 2 +- scripts/git/commit_enhanced.sh | 26 +- scripts/git/configure.sh | 37 +- scripts/git/fix_lint.sh | 12 +- scripts/git/install_hooks.sh | 32 +- scripts/git/merge_with_validation.sh | 16 +- scripts/git/rebase_safe.sh | 633 +++++++++++++++++++++++++++ scripts/git/rollback_merge.sh | 117 +++-- scripts/git/sync_branches.sh | 27 +- 15 files changed, 1347 insertions(+), 77 deletions(-) create mode 100644 scripts/git/bisect_helper.sh create mode 100644 scripts/git/rebase_safe.sh diff --git a/README.md b/README.md index 3a7f574..eb51f18 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,11 @@ No manual config editing required for common setups. `configure.sh` auto-detects | `fix_lint.sh` | Auto-fix lint issues | | `create_pr.sh` | Create GitHub PR from source → target (triggers Charlie CI + GitHub Actions) | | `install_hooks.sh` | Install git pre-commit hooks | +| `setup_attributes.sh` | Generate `.gitattributes` for binary and text files (Python, TouchDesigner, GLSL, assets) | +| `clean_build.sh` | Safe cleanup of build artifacts with dry-run default (Python, TouchDesigner, GLSL) | +| `create_release.sh` | Create annotated version tags to trigger the GitHub Release workflow | +| `stash_work.sh` | Safe stash wrapper with untracked file support, named stashes, and logging | +| `repo_health.sh` | Repository health: integrity check, size report, large file detection, gc | Internal modules (not user-facing): `_common.sh` (shared utilities, sourced by every script), `_config.sh` (three-tier config resolution, sourced by `_common.sh`). @@ -107,6 +112,9 @@ cp cgw.conf.example .cgw.conf | `CGW_DEV_ONLY_FILES` | `` | Files to warn about in cherry-pick (space-separated) | | `CGW_MERGE_MODE` | `direct` | Promotion mode: `direct` (merge locally) or `pr` (create GitHub PR) | | `CGW_PROTECTED_BRANCHES` | `main` | Branches requiring `--force` for force-push | +| `CGW_LINT_EXTENSIONS` | `*.py` | File globs for `--modified-only` lint mode (e.g. `*.js *.ts`) | +| `CGW_MERGE_CONFLICT_STYLE` | `` | Set to `diff3` to show base version in conflict markers | +| `CGW_MERGE_IGNORE_WHITESPACE` | `0` | Set to `1` to ignore whitespace differences during merge | --- @@ -211,6 +219,46 @@ Set `CGW_MERGE_MODE="pr"` in `.cgw.conf` to use PRs by default. ./scripts/git/cherry_pick_commits.sh --commit abc1234 # non-interactive ``` +### Stash work in progress + +```bash +./scripts/git/stash_work.sh push "wip: half-done refactor" +./scripts/git/stash_work.sh list +./scripts/git/stash_work.sh pop +./scripts/git/stash_work.sh apply stash@{1} # apply without removing +``` + +### Create a release + +```bash +./scripts/git/create_release.sh v1.2.3 # tag only +./scripts/git/create_release.sh v1.2.3 --push # tag + push (triggers release.yml) +./scripts/git/create_release.sh v1.2.3 --dry-run # preview +``` + +### Configure .gitattributes (Python, TouchDesigner, GLSL) + +```bash +./scripts/git/setup_attributes.sh --dry-run # preview +./scripts/git/setup_attributes.sh # write .gitattributes +``` + +### Clean build artifacts + +```bash +./scripts/git/clean_build.sh # dry-run (safe preview) +./scripts/git/clean_build.sh --execute # actually delete +./scripts/git/clean_build.sh --td --execute # TouchDesigner artifacts only +``` + +### Repository health check + +```bash +./scripts/git/repo_health.sh # integrity, size, large files +./scripts/git/repo_health.sh --gc # also run garbage collection +./scripts/git/repo_health.sh --large 5 # report files >5MB +``` + --- ## Configuration Examples diff --git a/cgw.conf.example b/cgw.conf.example index 36ae5f6..435477c 100644 --- a/cgw.conf.example +++ b/cgw.conf.example @@ -104,6 +104,37 @@ CGW_FORMAT_EXCLUDES="--exclude logs --exclude .venv" # CGW_LINT_CMD="" # CGW_FORMAT_CMD="" +# ============================================================================ +# MODIFIED-ONLY LINT FILE EXTENSIONS (check_lint.sh, fix_lint.sh) +# ============================================================================ +# Space-separated glob patterns controlling which files are matched in +# --modified-only mode. Override for non-Python projects. +# +# Default (Python): +CGW_LINT_EXTENSIONS="*.py" +# +# JavaScript / TypeScript: +# CGW_LINT_EXTENSIONS="*.js *.ts *.jsx *.tsx" +# +# Go: +# CGW_LINT_EXTENSIONS="*.go" +# +# C / C++: +# CGW_LINT_EXTENSIONS="*.c *.cpp *.h *.hpp" + +# ============================================================================ +# MERGE STRATEGY OPTIONS (merge_with_validation.sh) +# ============================================================================ +# CGW_MERGE_CONFLICT_STYLE: Set to "diff3" to show the base version in conflict +# markers (recommended — makes manual resolution easier). +# Empty = git default two-way markers. +CGW_MERGE_CONFLICT_STYLE="" +# CGW_MERGE_CONFLICT_STYLE="diff3" + +# CGW_MERGE_IGNORE_WHITESPACE: Set to "1" to add -Xignore-space-change to merge. +# Prevents false conflicts from whitespace-only differences (tabs/spaces, line endings). +CGW_MERGE_IGNORE_WHITESPACE="0" + # ============================================================================ # MARKDOWN LINT (commit_enhanced.sh, check_lint.sh, push_validated.sh) # ============================================================================ diff --git a/scripts/git/_common.sh b/scripts/git/_common.sh index 80cdc39..5e6f579 100644 --- a/scripts/git/_common.sh +++ b/scripts/git/_common.sh @@ -48,17 +48,21 @@ get_timestamp() { init_logging() { local script_name="$1" + # Use PROJECT_ROOT for an absolute path so logs land in the right place + # even when the script is invoked from a subdirectory. PROJECT_ROOT is set + # by _config.sh before any script calls init_logging. + local log_dir="${PROJECT_ROOT}/logs" - if [[ ! -d "logs" ]]; then - mkdir -p "logs" + if [[ ! -d "${log_dir}" ]]; then + mkdir -p "${log_dir}" fi get_timestamp # shellcheck disable=SC2034 - logfile="logs/${script_name}_${timestamp}.log" + logfile="${log_dir}/${script_name}_${timestamp}.log" # shellcheck disable=SC2034 - reportfile="logs/${script_name}_analysis_${timestamp}.log" + reportfile="${log_dir}/${script_name}_analysis_${timestamp}.log" } get_lint_exclusions() { diff --git a/scripts/git/_config.sh b/scripts/git/_config.sh index 0118e02..39edcb8 100644 --- a/scripts/git/_config.sh +++ b/scripts/git/_config.sh @@ -117,6 +117,21 @@ CGW_FORMAT_EXCLUDES="${CGW_FORMAT_EXCLUDES:---exclude logs --exclude .venv}" CGW_MARKDOWNLINT_CMD="${CGW_MARKDOWNLINT_CMD:-}" CGW_MARKDOWNLINT_ARGS="${CGW_MARKDOWNLINT_ARGS:-**/*.md !CLAUDE.md !MEMORY.md}" +# --- Modified-only lint file extensions --- +# Space-separated glob patterns used by check_lint.sh / fix_lint.sh --modified-only. +# Default matches Python files. Override for other languages (e.g. "*.js *.ts" or "*.go"). +CGW_LINT_EXTENSIONS="${CGW_LINT_EXTENSIONS:-*.py}" + +# --- Merge conflict style (merge_with_validation.sh) --- +# Set to "diff3" to show the base version in conflict markers (Pro Git recommended). +# Empty = git default (two-way markers). +CGW_MERGE_CONFLICT_STYLE="${CGW_MERGE_CONFLICT_STYLE:-}" + +# --- Merge whitespace handling (merge_with_validation.sh) --- +# Set to "1" to add -Xignore-space-change to the merge command. +# Prevents false conflicts caused by whitespace-only differences. +CGW_MERGE_IGNORE_WHITESPACE="${CGW_MERGE_IGNORE_WHITESPACE:-0}" + # --- Docs CI validation (merge_with_validation.sh) --- # Extended regex for allowed doc filenames. Empty = skip validation entirely. # Example: "^(README\.md|.*_GUIDE\.md|.*_REFERENCE\.md)$" diff --git a/scripts/git/bisect_helper.sh b/scripts/git/bisect_helper.sh new file mode 100644 index 0000000..d3749a0 --- /dev/null +++ b/scripts/git/bisect_helper.sh @@ -0,0 +1,404 @@ +#!/usr/bin/env bash +# bisect_helper.sh - Guided git bisect workflow for automated bug hunting +# Purpose: Wrap git bisect with a backup tag, auto-detect good/bad refs, +# support automated test commands (git bisect run), and clean up +# safely on interruption. See Pro Git Ch7 Debugging p.301-303. +# Usage: ./scripts/git/bisect_helper.sh [OPTIONS] +# +# Globals: +# SCRIPT_DIR - Directory containing this script +# PROJECT_ROOT - Auto-detected git repo root (set by _config.sh) +# logfile - Set by init_logging +# Arguments: +# --good Known-good ref (default: latest semver tag, or HEAD~10) +# --bad Known-bad ref (default: HEAD) +# --run Shell command to run per commit (exit 0 = good, non-0 = bad) +# --abort Abort an in-progress bisect session and clean up +# --continue Show current bisect state and continue guidance +# --non-interactive Skip confirmation prompts (requires --run) +# --dry-run Show what would happen without starting bisect +# -h, --help Show help +# Returns: +# 0 on success, 1 on failure + +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${SCRIPT_DIR}/_common.sh" + +init_logging "bisect_helper" + +_bisect_original_branch="" + +_cleanup_bisect() { + # If bisect is active and we were interrupted, abort it and return to original branch + if git rev-parse --git-dir >/dev/null 2>&1; then + if git bisect log >/dev/null 2>&1; then + echo "" >&2 + echo "⚠ Interrupted — aborting bisect session" >&2 + git bisect reset 2>/dev/null || true + fi + fi + if [[ -n "${_bisect_original_branch}" ]]; then + local current + current=$(git branch --show-current 2>/dev/null || true) + if [[ -n "${current}" ]] && [[ "${current}" != "${_bisect_original_branch}" ]]; then + git checkout "${_bisect_original_branch}" 2>/dev/null || true + fi + fi +} +trap _cleanup_bisect EXIT INT TERM + +_show_help() { + echo "Usage: ./scripts/git/bisect_helper.sh [OPTIONS]" + echo "" + echo "Guided git bisect for finding the commit that introduced a bug." + echo "Creates a backup tag before starting and cleans up on interruption." + echo "" + echo "Options:" + echo " --good Known-good commit/tag (default: latest semver tag or HEAD~10)" + echo " --bad Known-bad commit/tag (default: HEAD)" + echo " --run Test command: exit 0 = good commit, non-0 = bad commit" + echo " Enables automated bisect (git bisect run)" + echo " --abort Abort an in-progress bisect session" + echo " --continue Show current bisect status" + echo " --non-interactive Skip prompts (requires --run)" + echo " --dry-run Show plan without starting bisect" + echo " -h, --help Show this help" + echo "" + echo "Examples:" + echo " # Automated: find first bad commit using a test script" + echo " ./scripts/git/bisect_helper.sh --good v1.0.0 --run 'bash tests/smoke_test.sh'" + echo "" + echo " # Manual: interactive guided bisect" + echo " ./scripts/git/bisect_helper.sh --good v1.0.0 --bad HEAD" + echo " # Then: git bisect good / git bisect bad after each checkout" + echo "" + echo " # Abort an in-progress session" + echo " ./scripts/git/bisect_helper.sh --abort" + echo "" + echo "Notes:" + echo " - A backup tag is created before bisect starts (pre-bisect-TIMESTAMP)" + echo " - git bisect run requires the test command to be repeatable and exit cleanly" + echo " - Exit code 125 in --run command = skip this commit (git bisect skip)" + echo " - On completion, bisect is reset and you are returned to your original branch" +} + +main() { + if [[ $# -eq 0 ]] || [[ "${1:-}" == "--help" ]] || [[ "${1:-}" == "-h" ]]; then + _show_help + exit 0 + fi + + local good_ref="" + local bad_ref="HEAD" + local run_cmd="" + local non_interactive=0 + local dry_run=0 + local do_abort=0 + local do_continue=0 + + [[ "${CGW_NON_INTERACTIVE:-0}" == "1" ]] && non_interactive=1 + + while [[ $# -gt 0 ]]; do + case "$1" in + --help | -h) _show_help; exit 0 ;; + --good) good_ref="${2:-}"; shift ;; + --bad) bad_ref="${2:-HEAD}"; shift ;; + --run) run_cmd="${2:-}"; shift ;; + --abort) do_abort=1 ;; + --continue) do_continue=1 ;; + --non-interactive) non_interactive=1 ;; + --dry-run) dry_run=1 ;; + *) + err "Unknown flag: $1" + echo "Run with --help to see available options" >&2 + exit 1 + ;; + esac + shift + done + + cd "${PROJECT_ROOT}" || { + err "Cannot find project root" + exit 1 + } + + { + echo "=========================================" + echo "Bisect Helper Log" + echo "=========================================" + echo "Start Time: $(date)" + echo "Branch: $(git branch --show-current 2>/dev/null || echo 'detached')" + } >"$logfile" + + # ── Handle --abort ───────────────────────────────────────────────────────── + if [[ ${do_abort} -eq 1 ]]; then + _cmd_abort + return $? + fi + + # ── Handle --continue ────────────────────────────────────────────────────── + if [[ ${do_continue} -eq 1 ]]; then + _cmd_status + return $? + fi + + # ── Validate: --non-interactive requires --run ───────────────────────────── + if [[ ${non_interactive} -eq 1 ]] && [[ -z "${run_cmd}" ]]; then + err "--non-interactive requires --run (automated bisect)" + err "Without a test command, bisect requires interactive good/bad marking" + exit 1 + fi + + # ── Check for already-active bisect ─────────────────────────────────────── + if git bisect log >/dev/null 2>&1; then + echo "⚠ An active bisect session is already in progress." >&2 + echo " Run './scripts/git/bisect_helper.sh --abort' to stop it first." >&2 + if [[ ${non_interactive} -eq 0 ]]; then + read -r -p " Abort existing session and start fresh? (yes/no): " fresh_confirm + if [[ "${fresh_confirm}" != "yes" ]]; then + echo "Cancelled" + exit 0 + fi + git bisect reset 2>/dev/null || true + else + err "Cannot start bisect — session already active (abort it first)" + exit 1 + fi + fi + + # ── Auto-detect good_ref ─────────────────────────────────────────────────── + if [[ -z "${good_ref}" ]]; then + good_ref=$(git tag -l "v[0-9]*" | sort -V | tail -1 2>/dev/null || true) + if [[ -z "${good_ref}" ]]; then + # Fall back to HEAD~10 (or root if fewer than 10 commits) + local commit_count + commit_count=$(git rev-list --count HEAD 2>/dev/null || echo "0") + if [[ "${commit_count}" -gt 10 ]]; then + good_ref="HEAD~10" + else + good_ref=$(git rev-list --max-parents=0 HEAD 2>/dev/null | head -1 || true) + fi + fi + echo "Auto-detected good ref: ${good_ref}" | tee -a "$logfile" + fi + + # ── Validate refs ───────────────────────────────────────────────────────── + if ! git rev-parse "${bad_ref}" >/dev/null 2>&1; then + err "Invalid --bad ref: ${bad_ref}" + exit 1 + fi + if [[ -z "${good_ref}" ]] || ! git rev-parse "${good_ref}" >/dev/null 2>&1; then + err "Invalid --good ref: ${good_ref:-}" + err "Specify with --good (tag, commit hash, or branch name)" + exit 1 + fi + + # ── Compute commit range ────────────────────────────────────────────────── + local commit_count_range + commit_count_range=$(git rev-list --count "${good_ref}..${bad_ref}" 2>/dev/null || echo "?") + local steps_estimate="?" + if [[ "${commit_count_range}" != "?" ]] && [[ "${commit_count_range}" -gt 0 ]]; then + # log2(N) steps estimate using awk + steps_estimate=$(awk "BEGIN{printf \"%d\", log(${commit_count_range})/log(2) + 1}") + fi + + # ── Show plan ───────────────────────────────────────────────────────────── + echo "=== Git Bisect Helper ===" | tee -a "$logfile" + echo "" | tee -a "$logfile" + echo " Good ref: ${good_ref} ($(git log -1 --format='%h %s' "${good_ref}" 2>/dev/null || echo 'unknown'))" | tee -a "$logfile" + echo " Bad ref: ${bad_ref} ($(git log -1 --format='%h %s' "${bad_ref}" 2>/dev/null || echo 'unknown'))" | tee -a "$logfile" + echo " Range: ${commit_count_range} commits to search (~${steps_estimate} bisect steps)" | tee -a "$logfile" + if [[ -n "${run_cmd}" ]]; then + echo " Test cmd: ${run_cmd}" | tee -a "$logfile" + else + echo " Mode: Manual (mark commits good/bad interactively)" | tee -a "$logfile" + fi + echo "" | tee -a "$logfile" + + if [[ "${dry_run}" -eq 1 ]]; then + echo "--- Dry run: no changes made ---" + echo "Would run:" + echo " git bisect start" + echo " git bisect bad ${bad_ref}" + echo " git bisect good ${good_ref}" + if [[ -n "${run_cmd}" ]]; then + echo " git bisect run ${run_cmd}" + else + echo " (manual: git bisect good | git bisect bad for each checkout)" + fi + exit 0 + fi + + if [[ ${non_interactive} -eq 0 ]] && [[ -z "${run_cmd}" ]]; then + echo "Manual bisect mode — after each checkout, run your test then:" + echo " git bisect good (if the bug is NOT present in this commit)" + echo " git bisect bad (if the bug IS present in this commit)" + echo " git bisect skip (if you cannot test this commit)" + echo "" + read -r -p "Start bisect session? (yes/no): " start_confirm + if [[ "${start_confirm}" != "yes" ]]; then + echo "Cancelled" + exit 0 + fi + fi + + # ── Save original branch ────────────────────────────────────────────────── + _bisect_original_branch=$(git branch --show-current 2>/dev/null || true) + + # ── Create backup tag ───────────────────────────────────────────────────── + get_timestamp + local backup_tag="pre-bisect-${timestamp}" + if git tag "${backup_tag}" 2>/dev/null; then + echo "✓ Backup tag: ${backup_tag}" | tee -a "$logfile" + else + echo "⚠ Could not create backup tag (continuing)" | tee -a "$logfile" + fi + echo "" | tee -a "$logfile" + + log_section_start "GIT BISECT" "$logfile" + + # ── Start bisect ───────────────────────────────────────────────────────── + if ! git bisect start 2>&1 | tee -a "$logfile"; then + err "Failed to start bisect session" + log_section_end "GIT BISECT" "$logfile" "1" + exit 1 + fi + + if ! git bisect bad "${bad_ref}" 2>&1 | tee -a "$logfile"; then + err "Failed to mark bad ref: ${bad_ref}" + git bisect reset 2>/dev/null || true + log_section_end "GIT BISECT" "$logfile" "1" + exit 1 + fi + + if ! git bisect good "${good_ref}" 2>&1 | tee -a "$logfile"; then + err "Failed to mark good ref: ${good_ref}" + git bisect reset 2>/dev/null || true + log_section_end "GIT BISECT" "$logfile" "1" + exit 1 + fi + + # ── Automated or manual ─────────────────────────────────────────────────── + local bisect_result=0 + + if [[ -n "${run_cmd}" ]]; then + echo "Running automated bisect: git bisect run ${run_cmd}" | tee -a "$logfile" + echo "" | tee -a "$logfile" + # git bisect run exits 0 when first-bad-commit is found, non-0 on error + # shellcheck disable=SC2086 # run_cmd intentionally word-splits (it's a shell command) + if git bisect run ${run_cmd} 2>&1 | tee -a "$logfile"; then + bisect_result=0 + else + bisect_result=1 + fi + log_section_end "GIT BISECT" "$logfile" "${bisect_result}" + + # Capture the identified commit before reset + local first_bad + first_bad=$(git bisect log 2>/dev/null | grep "^# first bad commit:" | tail -1 | sed 's/^# first bad commit: \[//' | sed 's/\].*//' || true) + + # Reset bisect (returns to original branch) + git bisect reset 2>/dev/null || true + _bisect_original_branch="" # Already reset, don't cleanup again + + echo "" | tee -a "$logfile" + if [[ ${bisect_result} -eq 0 ]]; then + echo "✓ BISECT COMPLETE" | tee -a "$logfile" + if [[ -n "${first_bad}" ]]; then + echo " First bad commit: ${first_bad}" | tee -a "$logfile" + echo " $(git log -1 --oneline "${first_bad}" 2>/dev/null || true)" | tee -a "$logfile" + fi + else + echo "✗ Bisect run encountered errors — check log: $logfile" | tee -a "$logfile" + fi + else + # Manual mode — bisect is active, user marks good/bad interactively + log_section_end "GIT BISECT" "$logfile" "0" + echo "" | tee -a "$logfile" + echo "✓ BISECT STARTED" | tee -a "$logfile" + echo "" | tee -a "$logfile" + echo " git has checked out a commit for you to test." + echo " Current commit: $(git log -1 --oneline 2>/dev/null || true)" + echo "" + echo " After testing, run:" + echo " git bisect good — bug not present" + echo " git bisect bad — bug is present" + echo " git bisect skip — cannot test this commit" + echo "" + echo " To abort at any time:" + echo " ./scripts/git/bisect_helper.sh --abort" + echo "" + echo " Restore point (if needed):" + echo " git checkout ${backup_tag} # or: git bisect reset" + echo "" + # Don't run cleanup trap — bisect is intentionally left active + _bisect_original_branch="" + fi + + { + echo "" + echo "End Time: $(date)" + } >>"$logfile" + + echo "Full log: $logfile" + return ${bisect_result} +} + +# --------------------------------------------------------------------------- +# Abort an active bisect session +# --------------------------------------------------------------------------- +_cmd_abort() { + echo "=== Abort Bisect Session ===" | tee -a "$logfile" + echo "" + + if ! git bisect log >/dev/null 2>&1; then + echo " No active bisect session found." + exit 0 + fi + + echo " Active bisect log:" + git bisect log 2>/dev/null | head -10 | sed 's/^/ /' + echo "" + + if git bisect reset 2>&1 | tee -a "$logfile"; then + echo "" + echo "✓ Bisect aborted — returned to original branch" + echo " Current branch: $(git branch --show-current 2>/dev/null || echo 'detached')" + else + err "bisect reset failed — run 'git bisect reset' manually" + exit 1 + fi +} + +# --------------------------------------------------------------------------- +# Show status of active bisect session +# --------------------------------------------------------------------------- +_cmd_status() { + echo "=== Bisect Session Status ===" | tee -a "$logfile" + echo "" + + if ! git bisect log >/dev/null 2>&1; then + echo " No active bisect session." + echo "" + echo " Start one with:" + echo " ./scripts/git/bisect_helper.sh --good [--run ]" + exit 0 + fi + + echo " Current commit: $(git log -1 --oneline 2>/dev/null || true)" + echo "" + echo " Bisect log:" + git bisect log 2>/dev/null | sed 's/^/ /' + echo "" + echo " To mark current commit:" + echo " git bisect good — bug not present here" + echo " git bisect bad — bug is present here" + echo " git bisect skip — cannot test this commit" + echo "" + echo " To abort:" + echo " ./scripts/git/bisect_helper.sh --abort" +} + +main "$@" diff --git a/scripts/git/check_lint.sh b/scripts/git/check_lint.sh index efb2e83..3cd88ad 100644 --- a/scripts/git/check_lint.sh +++ b/scripts/git/check_lint.sh @@ -105,7 +105,9 @@ main() { exit 0 fi local modified_files - modified_files=$(git diff --name-only --diff-filter=ACMR HEAD -- '*.py') + # CGW_LINT_EXTENSIONS controls which files are considered (default: *.py) + # shellcheck disable=SC2086 + modified_files=$(git diff --name-only --diff-filter=ACMR HEAD -- ${CGW_LINT_EXTENSIONS:-*.py}) if [[ -z "$modified_files" ]]; then echo "[OK] No modified files to check" exit 0 @@ -118,14 +120,18 @@ main() { local EXIT_CODE=0 echo "[LINT CHECK]" + # Build check args: strip trailing path token (.) and append specific files + local lint_check_cmd_args="${CGW_LINT_CHECK_ARGS% *}" # shellcheck disable=SC2086 - "${lint_cmd}" check $modified_files || EXIT_CODE=1 + "${lint_cmd}" ${lint_check_cmd_args} $modified_files || EXIT_CODE=1 if [[ -n "${CGW_FORMAT_CMD}" ]]; then echo "" echo "[FORMAT CHECK]" + # Build format check args: strip trailing path token (.) and append specific files + local fmt_check_cmd_args="${CGW_FORMAT_CHECK_ARGS% *}" # shellcheck disable=SC2086 - "${CGW_FORMAT_CMD}" format --check $modified_files || EXIT_CODE=1 + "${CGW_FORMAT_CMD}" ${fmt_check_cmd_args} $modified_files || EXIT_CODE=1 fi exit $EXIT_CODE diff --git a/scripts/git/cherry_pick_commits.sh b/scripts/git/cherry_pick_commits.sh index e1b4c2c..ed814ee 100644 --- a/scripts/git/cherry_pick_commits.sh +++ b/scripts/git/cherry_pick_commits.sh @@ -42,7 +42,7 @@ _cleanup_cherry_pick() { git checkout "${_cp_original_branch}" 2>/dev/null || true fi } -trap _cleanup_cherry_pick INT TERM +trap _cleanup_cherry_pick EXIT INT TERM main() { local non_interactive=0 diff --git a/scripts/git/commit_enhanced.sh b/scripts/git/commit_enhanced.sh index aa1a54c..caae5a3 100644 --- a/scripts/git/commit_enhanced.sh +++ b/scripts/git/commit_enhanced.sh @@ -215,14 +215,14 @@ main() { if [[ ${staged_only} -eq 1 ]]; then echo "[--staged-only] Using pre-staged files only" elif [[ ${non_interactive} -eq 1 ]]; then - echo "[Non-interactive] Auto-staging all changes..." - git add . + echo "[Non-interactive] Auto-staging tracked changes..." + git add -u unstage_local_only_files echo "[OK] Changes staged" else read -rp "Stage all changes? (yes/no): " stage_all if [[ "$stage_all" == "yes" ]]; then - git add . + git add -u unstage_local_only_files echo "[OK] Changes staged" else @@ -259,6 +259,16 @@ main() { echo "[OK] Staged files validated" echo "" + # [2.5] Whitespace check (non-blocking — warns but does not abort) + if git diff --cached --check >/dev/null 2>&1; then + : # no whitespace issues + else + echo "[WARN] Whitespace issues detected in staged files:" | tee -a "$logfile" + git diff --cached --check 2>&1 | head -20 | tee -a "$logfile" + echo " (continuing — fix with: git diff --cached --check)" | tee -a "$logfile" + echo "" + fi + # [3] Code quality check echo "[3/6] Checking code quality..." @@ -329,7 +339,7 @@ main() { fi if [[ ${staged_only} -eq 0 ]]; then - git add . + git add -u unstage_local_only_files fi @@ -360,7 +370,7 @@ main() { # shellcheck disable=SC2086 # Word splitting intentional: CGW_FORMAT_FIX_ARGS/CGW_FORMAT_EXCLUDES contain multiple flags "${format_cmd}" ${CGW_FORMAT_FIX_ARGS} ${CGW_FORMAT_EXCLUDES} fi - git add . + git add -u unstage_local_only_files ;; skip | s) @@ -427,7 +437,11 @@ main() { if ! echo "$commit_msg" | grep -qE "^(${CGW_ALL_PREFIXES}):"; then echo "[!] WARNING: Message doesn't follow conventional format" echo " Configured types: ${CGW_ALL_PREFIXES/|/, }" - if [[ ${non_interactive} -eq 0 ]]; then + if [[ ${non_interactive} -eq 1 ]]; then + err "Commit message must follow conventional format in non-interactive mode" + err "Use --skip-lint or set CGW_EXTRA_PREFIXES if you need a custom prefix" + exit 1 + else read -rp "Continue anyway? (yes/no): " continue_commit if [[ "$continue_commit" != "yes" ]]; then echo "Commit cancelled" diff --git a/scripts/git/configure.sh b/scripts/git/configure.sh index 8c72cf6..52b0e77 100644 --- a/scripts/git/configure.sh +++ b/scripts/git/configure.sh @@ -309,7 +309,7 @@ _install_hook() { files_pattern="${files_pattern}${escaped}" done - # Create .githooks/ and write patched hook + # Create .githooks/ and write patched pre-commit hook # Escape backslashes first, then & (sed replacement special char), then | (sed delimiter) local sed_files_pattern="${files_pattern//\\/\\\\}" sed_files_pattern="${sed_files_pattern//&/\\&}" @@ -319,11 +319,22 @@ _install_hook() { "${hook_template}" >"${PROJECT_ROOT}/.githooks/pre-commit" chmod +x "${PROJECT_ROOT}/.githooks/pre-commit" + # Also install pre-push hook if template exists alongside pre-commit + local pre_push_template="${hooks_template_dir}/pre-push" + if [[ -f "${pre_push_template}" ]]; then + # Build CGW_ALL_PREFIXES for substitution into pre-push template + local all_prefixes_escaped="${CGW_ALL_PREFIXES//|/\\|}" + sed -e "s|__CGW_LOCAL_FILES_PATTERN__|${sed_files_pattern}|g" \ + -e "s|__CGW_ALL_PREFIXES__|${all_prefixes_escaped}|g" \ + "${pre_push_template}" >"${PROJECT_ROOT}/.githooks/pre-push" + chmod +x "${PROJECT_ROOT}/.githooks/pre-push" + fi + # Run install_hooks.sh to copy to .git/hooks/ if bash "${SCRIPT_DIR}/install_hooks.sh" >/dev/null 2>&1; then - echo " ✓ Pre-commit hook installed" + echo " ✓ Git hooks installed (pre-commit + pre-push)" else - echo " ⚠ Hook installed to .githooks/ but failed to copy to .git/hooks/" + echo " ⚠ Hooks written to .githooks/ but failed to copy to .git/hooks/" echo " Run: ./scripts/git/install_hooks.sh" fi } @@ -551,6 +562,26 @@ main() { fi fi + # ── Enable git rerere ───────────────────────────────────────────────────── + # rerere (reuse recorded resolution) auto-replays known conflict resolutions. + # Recommended for two-branch models where the same conflicts recur across merges. + + local enable_rerere="yes" + if [[ ${non_interactive} -eq 0 ]]; then + read -r -p "Enable git rerere (auto-replay conflict resolutions)? (yes/no) [yes]: " answer + case "${answer,,}" in + n | no) enable_rerere="no" ;; + esac + fi + + if [[ "${enable_rerere}" == "yes" ]]; then + if git config rerere.enabled true 2>/dev/null; then + echo " ✓ rerere.enabled = true (conflict resolutions will be remembered)" + else + echo " Note: Could not enable rerere — run: git config rerere.enabled true" + fi + fi + # ── Update .gitignore ───────────────────────────────────────────────────── echo "Updating .gitignore..." diff --git a/scripts/git/fix_lint.sh b/scripts/git/fix_lint.sh index 6ad6b3b..8501d24 100644 --- a/scripts/git/fix_lint.sh +++ b/scripts/git/fix_lint.sh @@ -85,7 +85,9 @@ main() { # Handle --modified-only mode if [[ "${modified_only}" -eq 1 ]]; then local modified_files - modified_files=$(git diff --name-only --diff-filter=ACMR HEAD -- '*.py') + # CGW_LINT_EXTENSIONS controls which files are considered (default: *.py) + # shellcheck disable=SC2086 + modified_files=$(git diff --name-only --diff-filter=ACMR HEAD -- ${CGW_LINT_EXTENSIONS:-*.py}) if [[ -z "$modified_files" ]]; then echo "[OK] No modified files to fix" exit 0 @@ -98,14 +100,18 @@ main() { local EXIT_CODE=0 echo "[LINT FIX]" + # Build fix args: strip trailing path token (.) and append specific files + local lint_fix_cmd_args="${CGW_LINT_FIX_ARGS% *}" # shellcheck disable=SC2086 - "${lint_cmd}" ${CGW_LINT_FIX_ARGS%% *} $modified_files || EXIT_CODE=1 + "${lint_cmd}" ${lint_fix_cmd_args} $modified_files || EXIT_CODE=1 if [[ -n "${CGW_FORMAT_CMD}" ]]; then echo "" echo "[FORMAT FIX]" + # Build format fix args: strip trailing path token (.) and append specific files + local fmt_fix_cmd_args="${CGW_FORMAT_FIX_ARGS% *}" # shellcheck disable=SC2086 - "${CGW_FORMAT_CMD}" format $modified_files || EXIT_CODE=1 + "${CGW_FORMAT_CMD}" ${fmt_fix_cmd_args} $modified_files || EXIT_CODE=1 fi exit $EXIT_CODE diff --git a/scripts/git/install_hooks.sh b/scripts/git/install_hooks.sh index 7ee1248..e47980f 100644 --- a/scripts/git/install_hooks.sh +++ b/scripts/git/install_hooks.sh @@ -64,17 +64,16 @@ main() { log_section_start "INSTALL HOOKS" "$logfile" + local hooks_ok=0 + if [[ -f ".githooks/pre-commit" ]]; then echo "Installing pre-commit hook..." | tee -a "$logfile" - if cp ".githooks/pre-commit" ".git/hooks/pre-commit" >>"$logfile" 2>&1; then chmod +x ".git/hooks/pre-commit" >>"$logfile" 2>&1 - echo " pre-commit hook installed" | tee -a "$logfile" - log_section_end "INSTALL HOOKS" "$logfile" "0" + echo " ✓ pre-commit installed" | tee -a "$logfile" else - echo " Failed to install pre-commit hook" | tee -a "$logfile" - log_section_end "INSTALL HOOKS" "$logfile" "1" - exit 1 + echo " ✗ Failed to install pre-commit hook" | tee -a "$logfile" + hooks_ok=1 fi else err "pre-commit template not found at .githooks/pre-commit" @@ -83,6 +82,19 @@ main() { exit 1 fi + if [[ -f ".githooks/pre-push" ]]; then + echo "Installing pre-push hook..." | tee -a "$logfile" + if cp ".githooks/pre-push" ".git/hooks/pre-push" >>"$logfile" 2>&1; then + chmod +x ".git/hooks/pre-push" >>"$logfile" 2>&1 + echo " ✓ pre-push installed" | tee -a "$logfile" + else + echo " ⚠ Failed to install pre-push hook (non-fatal)" | tee -a "$logfile" + fi + fi + + log_section_end "INSTALL HOOKS" "$logfile" "${hooks_ok}" + [[ ${hooks_ok} -ne 0 ]] && exit 1 + echo "" | tee -a "$logfile" { echo "========================================" @@ -93,14 +105,14 @@ main() { echo "" | tee -a "$logfile" echo "Active hooks:" - echo " - pre-commit: Blocks local-only files" - echo " Optional lint check (non-blocking)" + echo " - pre-commit: Blocks local-only files, optional lint check" + echo " - pre-push: Validates conventional commit format on unpushed commits" echo "" echo "To bypass temporarily (not recommended):" - echo " git commit --no-verify" + echo " git commit --no-verify / git push --no-verify" echo "" echo "To uninstall:" - echo " rm .git/hooks/pre-commit" + echo " rm .git/hooks/pre-commit .git/hooks/pre-push" echo "" { diff --git a/scripts/git/merge_with_validation.sh b/scripts/git/merge_with_validation.sh index ed34621..adc9609 100644 --- a/scripts/git/merge_with_validation.sh +++ b/scripts/git/merge_with_validation.sh @@ -43,7 +43,7 @@ _cleanup_merge() { git checkout "${_merge_original_branch}" 2>/dev/null || true fi } -trap _cleanup_merge INT TERM +trap _cleanup_merge EXIT INT TERM # ============================================================================ # HELPER FUNCTIONS @@ -288,7 +288,17 @@ main() { # [4/7] Perform merge log_section_start "GIT MERGE" "$logfile" - if run_git_with_logging "GIT MERGE SOURCE" "$logfile" merge "${CGW_SOURCE_BRANCH}" --no-ff -m "Merge ${CGW_SOURCE_BRANCH} into ${CGW_TARGET_BRANCH}"; then + # Build optional merge strategy flags from config + local merge_extra_args=() + if [[ -n "${CGW_MERGE_CONFLICT_STYLE:-}" ]]; then + merge_extra_args+=("--conflict=${CGW_MERGE_CONFLICT_STYLE}") + fi + if [[ "${CGW_MERGE_IGNORE_WHITESPACE:-0}" == "1" ]]; then + merge_extra_args+=("-Xignore-space-change") + fi + + # shellcheck disable=SC2068 # Intentional: empty array expands to zero words (${arr[@]+...} is Bash 3.x portable) + if run_git_with_logging "GIT MERGE SOURCE" "$logfile" merge "${CGW_SOURCE_BRANCH}" --no-ff -m "Merge ${CGW_SOURCE_BRANCH} into ${CGW_TARGET_BRANCH}" ${merge_extra_args[@]+"${merge_extra_args[@]}"}; then echo "✓ Merge completed without conflicts" | tee -a "$logfile" log_section_end "GIT MERGE" "$logfile" "0" @@ -296,7 +306,7 @@ main() { cleanup_tests_dir "amend" else - local merge_exit_code=$? + local merge_exit_code="${GIT_EXIT_CODE:-1}" echo "" | tee -a "$logfile" echo "⚠ Merge conflicts detected - analyzing..." | tee -a "$logfile" log_section_end "GIT MERGE" "$logfile" "${merge_exit_code}" diff --git a/scripts/git/rebase_safe.sh b/scripts/git/rebase_safe.sh new file mode 100644 index 0000000..f287d32 --- /dev/null +++ b/scripts/git/rebase_safe.sh @@ -0,0 +1,633 @@ +#!/usr/bin/env bash +# rebase_safe.sh - Safe rebase wrapper with backup tag and validation +# Purpose: Wrap common rebase workflows (rebase onto branch, squash last N commits, +# abort/continue in-progress rebase) with a backup tag before any +# destructive operation, pre-rebase pushed-commit detection, and +# autostash for dirty working trees. +# See Pro Git Ch3 Rebasing p.101-110, Ch7 Rewriting History p.249-256. +# Usage: ./scripts/git/rebase_safe.sh [OPTIONS] +# +# Globals: +# SCRIPT_DIR - Directory containing this script +# PROJECT_ROOT - Auto-detected git repo root (set by _config.sh) +# logfile - Set by init_logging +# CGW_TARGET_BRANCH - Default upstream ref for --onto if not specified +# Arguments: +# --onto Rebase current branch onto this branch (default: CGW_TARGET_BRANCH) +# --squash-last Interactive squash of last N commits (opens editor or autosquash) +# --autosquash Apply fixup!/squash! commit prefixes automatically +# --autostash Auto-stash dirty working tree before rebase (restore after) +# --abort Abort an in-progress rebase +# --continue Continue after resolving conflicts +# --skip Skip the current conflicting commit +# --non-interactive Skip confirmation prompts +# --dry-run Show what would happen without rebasing +# -h, --help Show help +# Returns: +# 0 on success, 1 on failure or conflict + +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${SCRIPT_DIR}/_common.sh" + +init_logging "rebase_safe" + +_rebase_original_branch="" +_rebase_stash_created=0 + +_cleanup_rebase() { + # Only restore stash if rebase was aborted mid-way and we created one + if [[ ${_rebase_stash_created} -eq 1 ]]; then + if git rebase --show-current-patch >/dev/null 2>&1 || [[ -d "${PROJECT_ROOT}/.git/rebase-merge" ]] || [[ -d "${PROJECT_ROOT}/.git/rebase-apply" ]]; then + # Rebase still in progress — don't auto-pop stash, user needs to resolve + echo "" >&2 + echo "⚠ Rebase was interrupted with uncommitted changes stashed." >&2 + echo " Resolve conflicts, then: git rebase --continue" >&2 + echo " To restore your stash: git stash pop" >&2 + fi + fi +} +trap _cleanup_rebase EXIT INT TERM + +_show_help() { + echo "Usage: ./scripts/git/rebase_safe.sh [OPTIONS]" + echo "" + echo "Safe rebase wrapper. Creates a backup tag before any destructive operation." + echo "Refuses to rebase commits that have already been pushed (history-safe by default)." + echo "" + echo "Options:" + echo " --onto Rebase current branch onto this branch" + echo " (default: ${CGW_TARGET_BRANCH})" + echo " --squash-last Interactively squash the last N commits" + echo " --autosquash Apply fixup!/squash! commit prefixes automatically" + echo " (used with --squash-last)" + echo " --autostash Auto-stash dirty working tree before rebase" + echo " --abort Abort the current in-progress rebase" + echo " --continue Continue after manually resolving conflicts" + echo " --skip Skip the current conflicting commit" + echo " --non-interactive Skip confirmation prompts" + echo " --dry-run Show what would happen without rebasing" + echo " -h, --help Show this help" + echo "" + echo "Examples:" + echo " # Rebase feature branch onto main" + echo " ./scripts/git/rebase_safe.sh --onto main" + echo "" + echo " # Squash last 3 commits into one (opens editor)" + echo " ./scripts/git/rebase_safe.sh --squash-last 3" + echo "" + echo " # Squash with auto-applied fixup!/squash! markers" + echo " ./scripts/git/rebase_safe.sh --squash-last 5 --autosquash" + echo "" + echo " # Rebase with dirty working tree (auto-stash)" + echo " ./scripts/git/rebase_safe.sh --onto main --autostash" + echo "" + echo " # Abort an in-progress rebase" + echo " ./scripts/git/rebase_safe.sh --abort" + echo "" + echo " # Continue after resolving conflicts" + echo " ./scripts/git/rebase_safe.sh --continue" + echo "" + echo "⚠ WARNING: Rebasing rewrites history. Never rebase commits already pushed" + echo " to a shared branch. This script will warn you if that is the case." +} + +main() { + if [[ $# -eq 0 ]] || [[ "${1:-}" == "--help" ]] || [[ "${1:-}" == "-h" ]]; then + _show_help + exit 0 + fi + + local onto_ref="" + local squash_last=0 + local autosquash=0 + local autostash=0 + local do_abort=0 + local do_continue=0 + local do_skip=0 + local non_interactive=0 + local dry_run=0 + + [[ "${CGW_NON_INTERACTIVE:-0}" == "1" ]] && non_interactive=1 + + while [[ $# -gt 0 ]]; do + case "$1" in + --help | -h) _show_help; exit 0 ;; + --onto) onto_ref="${2:-}"; shift ;; + --squash-last) squash_last="${2:-0}"; shift ;; + --autosquash) autosquash=1 ;; + --autostash) autostash=1 ;; + --abort) do_abort=1 ;; + --continue) do_continue=1 ;; + --skip) do_skip=1 ;; + --non-interactive) non_interactive=1 ;; + --dry-run) dry_run=1 ;; + *) + err "Unknown flag: $1" + echo "Run with --help to see available options" >&2 + exit 1 + ;; + esac + shift + done + + cd "${PROJECT_ROOT}" || { + err "Cannot find project root" + exit 1 + } + + { + echo "=========================================" + echo "Rebase Safe Log" + echo "=========================================" + echo "Start Time: $(date)" + echo "Branch: $(git branch --show-current 2>/dev/null || echo 'detached')" + } >"$logfile" + + # ── Handle in-progress rebase operations ────────────────────────────────── + if [[ ${do_abort} -eq 1 ]]; then + _cmd_abort + return $? + fi + if [[ ${do_continue} -eq 1 ]]; then + _cmd_continue + return $? + fi + if [[ ${do_skip} -eq 1 ]]; then + _cmd_skip + return $? + fi + + # ── Validate: mutually exclusive main operations ─────────────────────────── + local has_onto=0 + local has_squash=0 + [[ -n "${onto_ref}" ]] && has_onto=1 + [[ "${squash_last}" -gt 0 ]] && has_squash=1 + + if [[ ${has_onto} -eq 0 ]] && [[ ${has_squash} -eq 0 ]]; then + err "Specify an operation: --onto or --squash-last " + echo "Run with --help to see available options" >&2 + exit 1 + fi + if [[ ${has_onto} -eq 1 ]] && [[ ${has_squash} -eq 1 ]]; then + err "Use either --onto or --squash-last, not both" + exit 1 + fi + + # ── Check for already-active rebase ─────────────────────────────────────── + if [[ -d "${PROJECT_ROOT}/.git/rebase-merge" ]] || [[ -d "${PROJECT_ROOT}/.git/rebase-apply" ]]; then + echo "⚠ A rebase is already in progress." >&2 + echo " Resolve conflicts then:" >&2 + echo " ./scripts/git/rebase_safe.sh --continue" >&2 + echo " ./scripts/git/rebase_safe.sh --abort" >&2 + exit 1 + fi + + # ── Set default onto_ref ─────────────────────────────────────────────────── + if [[ ${has_onto} -eq 1 ]] && [[ -z "${onto_ref}" ]]; then + onto_ref="${CGW_TARGET_BRANCH}" + echo "Using default onto ref: ${onto_ref}" | tee -a "$logfile" + fi + + if [[ ${has_onto} -eq 1 ]]; then + _cmd_rebase_onto "${onto_ref}" "${autostash}" "${non_interactive}" "${dry_run}" + else + _cmd_squash_last "${squash_last}" "${autosquash}" "${autostash}" "${non_interactive}" "${dry_run}" + fi +} + +# --------------------------------------------------------------------------- +# Shared: create backup tag before any destructive operation +# --------------------------------------------------------------------------- +_create_backup_tag() { + get_timestamp + local backup_tag="pre-rebase-${timestamp}" + if git tag "${backup_tag}" 2>/dev/null; then + echo "✓ Backup tag: ${backup_tag}" | tee -a "$logfile" + echo " To restore: git checkout ${backup_tag}" | tee -a "$logfile" + else + echo "⚠ Could not create backup tag (continuing)" | tee -a "$logfile" + fi + echo "" | tee -a "$logfile" + echo "${backup_tag}" +} + +# --------------------------------------------------------------------------- +# Shared: warn if current branch has commits already pushed to origin +# --------------------------------------------------------------------------- +_check_pushed_commits() { + local upstream_count="${1}" + local non_interactive="${2}" + + if [[ "${upstream_count}" -gt 0 ]]; then + echo "⚠ WARNING: ${upstream_count} commit(s) on this branch have already been pushed." | tee -a "$logfile" + echo " Rebasing will rewrite history — you will need to force-push after rebase." | tee -a "$logfile" + echo " This is SAFE only on personal/feature branches, NEVER on shared branches." | tee -a "$logfile" + echo "" | tee -a "$logfile" + if [[ "${non_interactive}" -eq 1 ]]; then + err "Refusing to rebase pushed commits in non-interactive mode (history-safety)" + err "Use interactive mode or acknowledge the risk with --non-interactive after force-push consent" + exit 1 + fi + read -r -p " Rebase anyway? (yes/no): " pushed_confirm + if [[ "${pushed_confirm}" != "yes" ]]; then + echo "Cancelled" + exit 0 + fi + fi +} + +# --------------------------------------------------------------------------- +# Shared: handle dirty working tree +# --------------------------------------------------------------------------- +_handle_dirty_tree() { + local autostash="${1}" + + if ! git diff-index --quiet HEAD -- 2>/dev/null; then + if [[ "${autostash}" -eq 1 ]]; then + echo " Stashing uncommitted changes..." | tee -a "$logfile" + if git stash push -m "rebase_safe auto-stash $(date +%Y%m%d_%H%M%S)" 2>&1 | tee -a "$logfile"; then + _rebase_stash_created=1 + echo " ✓ Changes stashed" | tee -a "$logfile" + else + err "Failed to stash changes — resolve conflicts first" + exit 1 + fi + else + err "Working tree has uncommitted changes. Use --autostash or commit/stash first." + git diff --stat | head -10 | sed 's/^/ /' + exit 1 + fi + fi +} + +# --------------------------------------------------------------------------- +# Shared: restore stash after successful rebase +# --------------------------------------------------------------------------- +_restore_stash_if_needed() { + if [[ ${_rebase_stash_created} -eq 1 ]]; then + echo "" | tee -a "$logfile" + echo " Restoring stashed changes..." | tee -a "$logfile" + if git stash pop 2>&1 | tee -a "$logfile"; then + _rebase_stash_created=0 + echo " ✓ Stash restored" | tee -a "$logfile" + else + echo " ⚠ Stash pop had conflicts — resolve manually with: git stash show / git stash pop" | tee -a "$logfile" + fi + fi +} + +# --------------------------------------------------------------------------- +# Operation: --onto +# --------------------------------------------------------------------------- +_cmd_rebase_onto() { + local onto_ref="$1" autostash="$2" non_interactive="$3" dry_run="$4" + + echo "=== Rebase onto ${onto_ref} ===" | tee -a "$logfile" + echo "" | tee -a "$logfile" + + local current_branch + current_branch=$(git branch --show-current 2>/dev/null || true) + + if [[ -z "${current_branch}" ]]; then + err "Cannot rebase in detached HEAD state. Check out a branch first." + exit 1 + fi + + # Validate onto_ref + if ! git rev-parse "${onto_ref}" >/dev/null 2>&1; then + err "Invalid --onto ref: ${onto_ref}" + exit 1 + fi + + # Check if onto_ref is a local or remote branch and fetch latest + if git rev-parse "origin/${onto_ref}" >/dev/null 2>&1; then + echo " Fetching latest ${onto_ref} from origin..." | tee -a "$logfile" + git fetch origin "${onto_ref}" 2>&1 | tee -a "$logfile" || true + fi + + # Count pushed commits (commits on current branch not on origin/current_branch) + local pushed_count=0 + if git rev-parse "origin/${current_branch}" >/dev/null 2>&1; then + pushed_count=$(git rev-list --count "origin/${current_branch}..HEAD" 2>/dev/null || 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 "?") + + # Show plan + echo " Current branch: ${current_branch}" | tee -a "$logfile" + echo " Onto: ${onto_ref} ($(git log -1 --format='%h %s' "${onto_ref}" 2>/dev/null || echo 'unknown'))" | tee -a "$logfile" + echo " Commits to rebase: ${rebase_commit_count}" | tee -a "$logfile" + echo "" | tee -a "$logfile" + + if [[ "${rebase_commit_count}" == "0" ]]; then + echo " Already up to date with ${onto_ref} — nothing to rebase." + exit 0 + fi + + if [[ "${dry_run}" -eq 1 ]]; then + echo "--- Dry run: no changes made ---" + echo "Would run:" + echo " git rebase ${onto_ref}" + if [[ "${pushed_count}" -gt 0 ]]; then + echo " (then: git push --force-with-lease — ${pushed_count} commits already pushed)" + fi + exit 0 + fi + + # Warn about pushed commits + _check_pushed_commits "${pushed_count}" "${non_interactive}" + + # Handle dirty tree + _handle_dirty_tree "${autostash}" + + # Confirmation + if [[ "${non_interactive}" -eq 0 ]]; then + read -r -p " Rebase ${current_branch} onto ${onto_ref}? (yes/no): " rebase_confirm + if [[ "${rebase_confirm}" != "yes" ]]; then + echo "Cancelled" + _restore_stash_if_needed + exit 0 + fi + fi + + # Create backup + _create_backup_tag + + log_section_start "GIT REBASE ONTO" "$logfile" + + local rebase_exit=0 + if ! git rebase "${onto_ref}" 2>&1 | tee -a "$logfile"; then + rebase_exit=1 + fi + + log_section_end "GIT REBASE ONTO" "$logfile" "${rebase_exit}" + + if [[ ${rebase_exit} -ne 0 ]]; then + echo "" | tee -a "$logfile" + echo "✗ REBASE HIT CONFLICTS" | tee -a "$logfile" + echo "" | tee -a "$logfile" + echo " Conflicting files:" + git diff --name-only --diff-filter=U 2>/dev/null | sed 's/^/ /' || true + echo "" + echo " Resolve conflicts, then:" + echo " git add " + echo " ./scripts/git/rebase_safe.sh --continue" + echo "" + echo " To abort and restore:" + echo " ./scripts/git/rebase_safe.sh --abort" + echo "" + echo "Full log: $logfile" + # Don't restore stash — user needs to resolve rebase first + _rebase_stash_created=0 + exit 1 + fi + + _restore_stash_if_needed + + echo "" | tee -a "$logfile" + echo "✓ REBASE COMPLETE" | tee -a "$logfile" + echo " $(git log -1 --oneline)" | tee -a "$logfile" + echo "" | tee -a "$logfile" + + if [[ "${pushed_count}" -gt 0 ]]; then + echo " ⚠ Your branch was previously pushed — force-push required:" + echo " ./scripts/git/push_validated.sh --force-with-lease" + echo "" + fi + + echo "Full log: $logfile" +} + +# --------------------------------------------------------------------------- +# Operation: --squash-last +# --------------------------------------------------------------------------- +_cmd_squash_last() { + local squash_n="$1" autosquash="$2" autostash="$3" non_interactive="$4" dry_run="$5" + + echo "=== Squash Last ${squash_n} Commits ===" | tee -a "$logfile" + echo "" | tee -a "$logfile" + + local current_branch + current_branch=$(git branch --show-current 2>/dev/null || true) + + if [[ -z "${current_branch}" ]]; then + err "Cannot rebase in detached HEAD state. Check out a branch first." + exit 1 + fi + + # Validate N + if ! [[ "${squash_n}" =~ ^[0-9]+$ ]] || [[ "${squash_n}" -lt 2 ]]; then + err "--squash-last requires a number >= 2 (got: ${squash_n})" + exit 1 + fi + + local commit_count + commit_count=$(git rev-list --count HEAD 2>/dev/null || echo "0") + if [[ "${commit_count}" -lt "${squash_n}" ]]; then + err "Cannot squash ${squash_n} commits — branch only has ${commit_count} commit(s)" + exit 1 + fi + + # Count pushed commits in the squash range + local pushed_count=0 + if git rev-parse "origin/${current_branch}" >/dev/null 2>&1; then + # Count how many of the last N commits exist on origin + pushed_count=$(git rev-list --count "origin/${current_branch}..HEAD" 2>/dev/null || echo "0") + # Clamp to squash range + if [[ "${pushed_count}" -gt "${squash_n}" ]]; then + pushed_count="${squash_n}" + fi + fi + + # Show commits to be squashed + echo " Commits to squash:" | tee -a "$logfile" + git log --oneline -"${squash_n}" 2>/dev/null | sed 's/^/ /' | tee -a "$logfile" + echo "" | tee -a "$logfile" + + if [[ "${dry_run}" -eq 1 ]]; then + echo "--- Dry run: no changes made ---" + local squash_flag="" + [[ "${autosquash}" -eq 1 ]] && squash_flag=" --autosquash" + echo "Would run:" + echo " git rebase -i${squash_flag} HEAD~${squash_n}" + if [[ "${pushed_count}" -gt 0 ]]; then + echo " (then: git push --force-with-lease — ${pushed_count} commits already pushed)" + fi + exit 0 + fi + + # Warn about pushed commits + _check_pushed_commits "${pushed_count}" "${non_interactive}" + + # Handle dirty tree + _handle_dirty_tree "${autostash}" + + if [[ "${non_interactive}" -eq 0 ]] && [[ "${autosquash}" -eq 0 ]]; then + echo " An editor will open for you to mark commits (squash, fixup, reword, etc.)" + echo " Change 'pick' to 'squash' (or 's') to fold a commit into the one above it." + echo "" + read -r -p " Open interactive rebase for last ${squash_n} commits? (yes/no): " squash_confirm + if [[ "${squash_confirm}" != "yes" ]]; then + echo "Cancelled" + _restore_stash_if_needed + exit 0 + fi + elif [[ "${non_interactive}" -eq 1 ]] && [[ "${autosquash}" -eq 0 ]]; then + err "Interactive squash requires an editor — use --autosquash for non-interactive squash" + err "(commits must be prefixed with 'squash!' or 'fixup!' for --autosquash to work)" + exit 1 + fi + + # Create backup + _create_backup_tag + + log_section_start "GIT REBASE INTERACTIVE" "$logfile" + + local rebase_exit=0 + local rebase_args=(-i "HEAD~${squash_n}") + [[ "${autosquash}" -eq 1 ]] && rebase_args=(-i --autosquash "HEAD~${squash_n}") + + # shellcheck disable=SC2068 # Intentional: rebase_args expands correctly + if ! git rebase "${rebase_args[@]}" 2>&1 | tee -a "$logfile"; then + rebase_exit=1 + fi + + log_section_end "GIT REBASE INTERACTIVE" "$logfile" "${rebase_exit}" + + if [[ ${rebase_exit} -ne 0 ]]; then + echo "" | tee -a "$logfile" + echo "✗ INTERACTIVE REBASE HIT CONFLICTS" | tee -a "$logfile" + echo "" | tee -a "$logfile" + echo " Resolve conflicts, then:" + echo " git add " + echo " ./scripts/git/rebase_safe.sh --continue" + echo "" + echo " To abort and restore:" + echo " ./scripts/git/rebase_safe.sh --abort" + echo "" + echo "Full log: $logfile" + _rebase_stash_created=0 + exit 1 + fi + + _restore_stash_if_needed + + echo "" | tee -a "$logfile" + echo "✓ SQUASH COMPLETE" | tee -a "$logfile" + echo " $(git log -1 --oneline)" | tee -a "$logfile" + echo "" | tee -a "$logfile" + + if [[ "${pushed_count}" -gt 0 ]]; then + echo " ⚠ Your branch was previously pushed — force-push required:" + echo " ./scripts/git/push_validated.sh --force-with-lease" + echo "" + fi + + echo "Full log: $logfile" +} + +# --------------------------------------------------------------------------- +# Operation: --abort +# --------------------------------------------------------------------------- +_cmd_abort() { + echo "=== Abort Rebase ===" | tee -a "$logfile" + echo "" + + if [[ ! -d "${PROJECT_ROOT}/.git/rebase-merge" ]] && [[ ! -d "${PROJECT_ROOT}/.git/rebase-apply" ]]; then + echo " No rebase in progress." + exit 0 + fi + + echo " Aborting rebase..." | tee -a "$logfile" + if git rebase --abort 2>&1 | tee -a "$logfile"; then + echo "" + echo "✓ Rebase aborted — returned to: $(git branch --show-current 2>/dev/null || echo 'previous state')" + if [[ ${_rebase_stash_created} -eq 1 ]]; then + echo "" + echo " Your auto-stash is still saved. To restore:" + echo " git stash pop" + fi + else + err "rebase --abort failed — run 'git rebase --abort' manually" + exit 1 + fi +} + +# --------------------------------------------------------------------------- +# Operation: --continue +# --------------------------------------------------------------------------- +_cmd_continue() { + echo "=== Continue Rebase ===" | tee -a "$logfile" + echo "" + + if [[ ! -d "${PROJECT_ROOT}/.git/rebase-merge" ]] && [[ ! -d "${PROJECT_ROOT}/.git/rebase-apply" ]]; then + echo " No rebase in progress." + exit 0 + fi + + # Check for unresolved conflicts + local unresolved + unresolved=$(git diff --name-only --diff-filter=U 2>/dev/null || true) + if [[ -n "${unresolved}" ]]; then + err "Unresolved conflicts still present — resolve and 'git add' them first:" + echo "${unresolved}" | sed 's/^/ /' + exit 1 + fi + + echo " Continuing rebase..." | tee -a "$logfile" + if GIT_EDITOR=true git rebase --continue 2>&1 | tee -a "$logfile"; then + echo "" + echo "✓ Rebase continued" + # Check if rebase is now complete + if [[ ! -d "${PROJECT_ROOT}/.git/rebase-merge" ]] && [[ ! -d "${PROJECT_ROOT}/.git/rebase-apply" ]]; then + echo " Rebase complete!" + _restore_stash_if_needed + else + echo " More conflicts to resolve — fix them, then run --continue again." + fi + else + err "rebase --continue failed — check for remaining conflicts" + echo "" + echo " Conflicting files:" + git diff --name-only --diff-filter=U 2>/dev/null | sed 's/^/ /' || true + echo "" + echo " To abort: ./scripts/git/rebase_safe.sh --abort" + exit 1 + fi +} + +# --------------------------------------------------------------------------- +# Operation: --skip +# --------------------------------------------------------------------------- +_cmd_skip() { + echo "=== Skip Rebase Commit ===" | tee -a "$logfile" + echo "" + + if [[ ! -d "${PROJECT_ROOT}/.git/rebase-merge" ]] && [[ ! -d "${PROJECT_ROOT}/.git/rebase-apply" ]]; then + echo " No rebase in progress." + exit 0 + fi + + echo " ⚠ Skipping current commit — its changes will be dropped." + echo " Current patch:" + git log ORIG_HEAD -1 --oneline 2>/dev/null | sed 's/^/ /' || true + echo "" + + if git rebase --skip 2>&1 | tee -a "$logfile"; then + echo "" + echo "✓ Commit skipped" + if [[ ! -d "${PROJECT_ROOT}/.git/rebase-merge" ]] && [[ ! -d "${PROJECT_ROOT}/.git/rebase-apply" ]]; then + echo " Rebase complete!" + _restore_stash_if_needed + fi + else + err "rebase --skip failed" + exit 1 + fi +} + +main "$@" diff --git a/scripts/git/rollback_merge.sh b/scripts/git/rollback_merge.sh index 419b521..a751842 100644 --- a/scripts/git/rollback_merge.sh +++ b/scripts/git/rollback_merge.sh @@ -29,12 +29,13 @@ _cleanup_rollback() { echo " git log --oneline -5" >&2 echo " git status" >&2 } -trap _cleanup_rollback INT TERM +trap _cleanup_rollback EXIT INT TERM main() { local non_interactive=0 local dry_run=0 local rollback_target_flag="" + local use_revert=0 while [[ $# -gt 0 ]]; do case "${1}" in @@ -48,16 +49,20 @@ main() { echo " --non-interactive Skip prompts; auto-selects latest backup tag if --target omitted" echo " --target Commit hash, tag name, or HEAD~1 to roll back to" echo " --dry-run Show rollback target without resetting" + echo " --revert Safe mode: use 'git revert -m 1' instead of 'git reset --hard'" + echo " Preserves history — safe for shared repos where commits are pushed" echo " -h, --help Show this help" echo "" echo "Environment:" echo " CGW_NON_INTERACTIVE=1 Same as --non-interactive" echo "" - echo "CAUTION: This operation rewrites branch history. Force-push required after." + echo "CAUTION: Without --revert, this rewrites branch history. Force-push required after." + echo " With --revert, history is preserved — no force-push needed." exit 0 ;; --non-interactive) non_interactive=1 ;; --dry-run) dry_run=1 ;; + --revert) use_revert=1 ;; --target) rollback_target_flag="${2:-}" shift @@ -262,39 +267,83 @@ main() { exit 0 fi - log_section_start "GIT RESET" "$logfile" - - if run_git_with_logging "GIT RESET HARD" "$logfile" reset --hard "${rollback_target}"; then - log_section_end "GIT RESET" "$logfile" "0" - echo "" | tee -a "$logfile" - { - echo "========================================" - echo "[ROLLBACK SUMMARY]" - echo "========================================" - } | tee -a "$logfile" - echo "✓ ROLLBACK SUCCESSFUL" | tee -a "$logfile" - echo "" | tee -a "$logfile" - echo "Summary:" | tee -a "$logfile" - git log --oneline -1 | while read -r line; do echo " Current HEAD: $line" | tee -a "$logfile"; done - echo "" | tee -a "$logfile" - echo "Next steps:" | tee -a "$logfile" - echo " 1. Verify rollback: git log --oneline -5" | tee -a "$logfile" - echo " 2. If correct, force push: git push origin ${CGW_TARGET_BRANCH} --force-with-lease" | tee -a "$logfile" - echo " 3. If issues, contact maintainer" | tee -a "$logfile" - echo "" | tee -a "$logfile" - echo " ⚠ WARNING: Force push will rewrite remote history!" | tee -a "$logfile" - { - echo "" - echo "End Time: $(date)" - } | tee -a "$logfile" - echo "" | tee -a "$logfile" - echo "Full log: $logfile" + if [[ ${use_revert} -eq 1 ]]; then + # Safe revert mode: creates a new commit that undoes the merge. + # Preserves history — no force-push needed (Pro Git p.288-289). + # git revert -m 1 requires a merge commit (2+ parents); validate before attempting. + local parent_count + parent_count=$(git cat-file -p "${rollback_target}" 2>/dev/null | grep -c "^parent " || echo "0") + if [[ "${parent_count}" -lt 2 ]]; then + err "--revert requires a merge commit (2+ parents), but ${rollback_target} has ${parent_count} parent(s)" + err "Use plain rollback (omit --revert) or provide a merge commit hash with --target" + exit 1 + fi + log_section_start "GIT REVERT" "$logfile" + if run_git_with_logging "GIT REVERT MERGE" "$logfile" revert -m 1 --no-edit "${rollback_target}"; then + log_section_end "GIT REVERT" "$logfile" "0" + echo "" | tee -a "$logfile" + { + echo "========================================" + echo "[ROLLBACK SUMMARY — REVERT MODE]" + echo "========================================" + } | tee -a "$logfile" + echo "✓ REVERT SUCCESSFUL" | tee -a "$logfile" + echo "" | tee -a "$logfile" + echo "Summary:" | tee -a "$logfile" + git log --oneline -1 | while read -r line; do echo " Current HEAD: $line" | tee -a "$logfile"; done + echo "" | tee -a "$logfile" + echo "Next steps:" | tee -a "$logfile" + echo " 1. Verify revert: git log --oneline -5" | tee -a "$logfile" + echo " 2. Push normally: git push origin ${CGW_TARGET_BRANCH}" | tee -a "$logfile" + echo " (no force-push needed — history is preserved)" | tee -a "$logfile" + { + echo "" + echo "End Time: $(date)" + } | tee -a "$logfile" + echo "" | tee -a "$logfile" + echo "Full log: $logfile" + else + log_section_end "GIT REVERT" "$logfile" "1" + echo "" | tee -a "$logfile" + echo "✗ Revert failed" | tee -a "$logfile" + echo "Please manually revert: git revert -m 1 ${rollback_target}" + exit 1 + fi else - log_section_end "GIT RESET" "$logfile" "1" - echo "" | tee -a "$logfile" - echo "✗ Rollback failed" | tee -a "$logfile" - echo "Please manually reset: git reset --hard ${rollback_target}" - exit 1 + log_section_start "GIT RESET" "$logfile" + + if run_git_with_logging "GIT RESET HARD" "$logfile" reset --hard "${rollback_target}"; then + log_section_end "GIT RESET" "$logfile" "0" + echo "" | tee -a "$logfile" + { + echo "========================================" + echo "[ROLLBACK SUMMARY]" + echo "========================================" + } | tee -a "$logfile" + echo "✓ ROLLBACK SUCCESSFUL" | tee -a "$logfile" + echo "" | tee -a "$logfile" + echo "Summary:" | tee -a "$logfile" + git log --oneline -1 | while read -r line; do echo " Current HEAD: $line" | tee -a "$logfile"; done + echo "" | tee -a "$logfile" + echo "Next steps:" | tee -a "$logfile" + echo " 1. Verify rollback: git log --oneline -5" | tee -a "$logfile" + echo " 2. If correct, force push: git push origin ${CGW_TARGET_BRANCH} --force-with-lease" | tee -a "$logfile" + echo " 3. If issues, contact maintainer" | tee -a "$logfile" + echo "" | tee -a "$logfile" + echo " ⚠ WARNING: Force push will rewrite remote history!" | tee -a "$logfile" + { + echo "" + echo "End Time: $(date)" + } | tee -a "$logfile" + echo "" | tee -a "$logfile" + echo "Full log: $logfile" + else + log_section_end "GIT RESET" "$logfile" "1" + echo "" | tee -a "$logfile" + echo "✗ Rollback failed" | tee -a "$logfile" + echo "Please manually reset: git reset --hard ${rollback_target}" + exit 1 + fi fi } diff --git a/scripts/git/sync_branches.sh b/scripts/git/sync_branches.sh index 557692d..93d3093 100644 --- a/scripts/git/sync_branches.sh +++ b/scripts/git/sync_branches.sh @@ -31,10 +31,11 @@ _cleanup_sync() { if [[ -n "${_sync_original_branch}" ]] && [[ "${current}" != "${_sync_original_branch}" ]]; then echo "" >&2 echo "⚠ Interrupted — returning to: ${_sync_original_branch}" >&2 + git rebase --abort 2>/dev/null || true git checkout "${_sync_original_branch}" 2>/dev/null || true fi } -trap _cleanup_sync INT TERM +trap _cleanup_sync EXIT INT TERM # sync_one_branch - Fetch and rebase a single branch against origin. # Arguments: @@ -81,7 +82,9 @@ sync_one_branch() { echo " ⚠ Diverged: ${ahead} local commits will be rebased on top of ${behind} remote commits" | tee -a "$logfile" fi - if run_git_with_logging "GIT REBASE ${branch}" "$logfile" pull --rebase origin "${branch}"; then + local rebase_args=(pull --rebase origin "${branch}") + [[ "${_SYNC_AUTOSTASH:-0}" == "1" ]] && rebase_args=(pull --rebase --autostash origin "${branch}") + if run_git_with_logging "GIT REBASE ${branch}" "$logfile" "${rebase_args[@]}"; then echo " ✓ ${branch} synced successfully" | tee -a "$logfile" return 0 else @@ -156,18 +159,22 @@ main() { _sync_original_branch=$(git branch --show-current) if ! git diff-index --quiet HEAD -- 2>/dev/null; then - echo "⚠ WARNING: Uncommitted changes detected" | tee -a "$logfile" + echo "⚠ Uncommitted changes detected — will auto-stash during rebase" | tee -a "$logfile" git status --short | tee -a "$logfile" echo "" | tee -a "$logfile" if [[ ${non_interactive} -eq 1 ]]; then - err "Aborting — commit or stash changes before syncing" - exit 1 - fi - read -r -p "Changes may be lost during rebase. Continue? (yes/no): " uncommitted_choice - if [[ "${uncommitted_choice}" != "yes" ]]; then - echo "Aborted" | tee -a "$logfile" - exit 0 + echo "[Non-interactive] Auto-stash enabled (--autostash)" | tee -a "$logfile" + else + read -r -p "Auto-stash changes and sync? (yes/no): " uncommitted_choice + if [[ "${uncommitted_choice}" != "yes" ]]; then + echo "Aborted — commit or stash manually before syncing" | tee -a "$logfile" + exit 0 + fi fi + # Pass --autostash to pull --rebase to handle dirty working tree cleanly + export _SYNC_AUTOSTASH=1 + else + export _SYNC_AUTOSTASH=0 fi # [1] Fetch all remotes From 4d32ecef8d1b8f259a1f6c7064de5ca89636d536 Mon Sep 17 00:00:00 2001 From: forkni Date: Thu, 9 Apr 2026 11:55:16 -0400 Subject: [PATCH 2/8] feat: add branch_cleanup, changelog_generate, undo_last, pre-push hook (Pro Git audit) --- .githooks/pre-commit | 59 +++++ .githooks/pre-push | 94 +++++++ scripts/git/branch_cleanup.sh | 259 +++++++++++++++++++ scripts/git/changelog_generate.sh | 310 ++++++++++++++++++++++ scripts/git/undo_last.sh | 415 ++++++++++++++++++++++++++++++ 5 files changed, 1137 insertions(+) create mode 100644 .githooks/pre-commit create mode 100644 .githooks/pre-push create mode 100644 scripts/git/branch_cleanup.sh create mode 100644 scripts/git/changelog_generate.sh create mode 100644 scripts/git/undo_last.sh diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100644 index 0000000..ae4ecf9 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,59 @@ +#!/bin/sh +# Pre-commit hook — blocks local-only files from being committed +# Generated by claude-git-workflow configure.sh +# +# Blocks ADDITIONS (A) and MODIFICATIONS (M) of local-only files. +# DELETIONS (D) are allowed (removing from git tracking is fine). +# +# The pattern below is populated by configure.sh from CGW_LOCAL_FILES. +# To regenerate after changing .cgw.conf, run: ./scripts/git/configure.sh --skip-skill + +echo "Checking for local-only files..." + +PROBLEMATIC_FILES=$(git diff --cached --name-status | grep -E "^[AM]\s+(CLAUDE\.md|SESSION_LOG\.md|\.claude|logs)") + +if [ -n "$PROBLEMATIC_FILES" ]; then + echo "ERROR: Attempting to add or modify local-only files!" + echo "" + echo "The following files must remain local only:" + echo "$PROBLEMATIC_FILES" + echo "" + echo "DELETIONS are allowed (removing from git tracking)" + echo "ADDITIONS/MODIFICATIONS are blocked" + echo "" + echo "To fix: git reset HEAD " + echo "" + exit 1 +fi + +DELETED_FILES=$(git diff --cached --name-status | grep -E "^D\s+(CLAUDE\.md|SESSION_LOG\.md|\.claude|logs)") +if [ -n "$DELETED_FILES" ]; then + echo " Local-only files being removed from git tracking (allowed)" +fi + +echo " No local-only files detected" + +# Optional: lint check for staged files (non-blocking) +STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM) + +if [ -n "$STAGED_FILES" ]; then + # Python lint (ruff) + if command -v ruff > /dev/null 2>&1; then + PY_FILES=$(echo "$STAGED_FILES" | grep '\.py$' || true) + if [ -n "$PY_FILES" ]; then + echo "" + echo "Checking Python lint (non-blocking)..." + # shellcheck disable=SC2086 + if ! ruff check $PY_FILES > /dev/null 2>&1; then + echo " WARNING: lint issues in staged Python files" + echo " Run 'ruff check --fix .' to auto-fix" + echo " (Commit proceeds — fix lint before pushing)" + else + echo " Lint check passed" + fi + fi + fi +fi + +echo "" +exit 0 diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100644 index 0000000..b83dbaf --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,94 @@ +#!/bin/sh +# Pre-push hook — validates commits before they leave local repo +# Generated by claude-git-workflow configure.sh +# +# Checks all commits about to be pushed: +# 1. No local-only files tracked in any unpushed commit +# 2. All commit messages follow conventional format +# +# This complements push_validated.sh (CGW wrapper) by catching issues +# even when users bypass the wrapper and run git push directly. +# See Pro Git Ch8 p.362 — pre-push hook. +# +# The patterns below are populated by configure.sh from CGW_LOCAL_FILES +# and CGW_ALL_PREFIXES. To regenerate, run: ./scripts/git/configure.sh --skip-skill +# +# CONVENTIONAL PREFIXES: __CGW_ALL_PREFIXES__ +# LOCAL FILES PATTERN: __CGW_LOCAL_FILES_PATTERN__ + +# --------------------------------------------------------------------------- +# Read stdin: remote , url , +# --------------------------------------------------------------------------- + +LOCAL_FILES_PATTERN="__CGW_LOCAL_FILES_PATTERN__" +ALL_PREFIXES="__CGW_ALL_PREFIXES__" + +# Collect push info from stdin (git passes it to pre-push) +while read -r LOCAL_REF LOCAL_SHA REMOTE_REF REMOTE_SHA; do + # Skip deletions (empty sha = 0000...0) + if [ "${LOCAL_SHA}" = "0000000000000000000000000000000000000000" ]; then + continue + fi + + # Determine commit range to check + if [ "${REMOTE_SHA}" = "0000000000000000000000000000000000000000" ]; then + # New branch being pushed — check all commits not in any remote branch + RANGE="${LOCAL_SHA}" + COMMITS=$(git rev-list "${RANGE}" --not --remotes 2>/dev/null || true) + else + RANGE="${REMOTE_SHA}..${LOCAL_SHA}" + COMMITS=$(git rev-list "${RANGE}" 2>/dev/null || true) + fi + + [ -z "${COMMITS}" ] && continue + + echo "pre-push: checking $(echo "${COMMITS}" | wc -l | tr -d ' ') commit(s) in ${LOCAL_REF}..." + + # ── [1] Local-only file check ────────────────────────────────────────── + if [ -n "${LOCAL_FILES_PATTERN}" ]; then + for COMMIT in ${COMMITS}; do + FILES_IN_COMMIT=$(git diff-tree --no-commit-id -r --name-only "${COMMIT}" 2>/dev/null || true) + PROBLEM=$(echo "${FILES_IN_COMMIT}" | grep -E "${LOCAL_FILES_PATTERN}" || true) + if [ -n "${PROBLEM}" ]; then + echo "" + echo "ERROR [pre-push]: Commit ${COMMIT} contains local-only files:" + echo "${PROBLEM}" | sed 's/^/ /' + echo "" + echo "These files must not be pushed: ${LOCAL_FILES_PATTERN}" + echo "To fix: git rebase -i HEAD~N and drop/edit the offending commit" + echo "" + exit 1 + fi + done + fi + + # ── [2] Conventional commit format check ────────────────────────────── + if [ -n "${ALL_PREFIXES}" ]; then + for COMMIT in ${COMMITS}; do + MSG=$(git log -1 --format="%s" "${COMMIT}" 2>/dev/null || true) + + # Skip merge commits (they often have non-conventional messages) + PARENT_COUNT=$(git cat-file -p "${COMMIT}" 2>/dev/null | grep -c "^parent " || true) + [ "${PARENT_COUNT}" -ge 2 ] && continue + + if ! echo "${MSG}" | grep -qE "^(${ALL_PREFIXES}):"; then + echo "" + echo "WARNING [pre-push]: Commit ${COMMIT} has non-conventional message:" + echo " ${MSG}" + echo "" + echo "Expected format: : " + echo "Types: ${ALL_PREFIXES}" + echo "" + echo "Push blocked. Fix with: ./scripts/git/undo_last.sh amend-message ': '" + echo "Or bypass (not recommended): git push --no-verify" + echo "" + exit 1 + fi + done + fi + +done + +echo "pre-push: all checks passed" +echo "" +exit 0 diff --git a/scripts/git/branch_cleanup.sh b/scripts/git/branch_cleanup.sh new file mode 100644 index 0000000..a5f4bb9 --- /dev/null +++ b/scripts/git/branch_cleanup.sh @@ -0,0 +1,259 @@ +#!/usr/bin/env bash +# branch_cleanup.sh - Prune stale local and remote branches +# Purpose: Delete local branches already merged into the target branch, +# prune stale remote-tracking refs, and optionally clean up old +# backup tags. Safe by default (--dry-run). See Pro Git Ch3 p.85-87. +# Usage: ./scripts/git/branch_cleanup.sh [OPTIONS] +# +# Globals: +# SCRIPT_DIR - Directory containing this script +# PROJECT_ROOT - Auto-detected git repo root (set by _config.sh) +# CGW_TARGET_BRANCH - Branch used as merge base (default: main) +# CGW_PROTECTED_BRANCHES - Branches never deleted (default: $CGW_TARGET_BRANCH) +# CGW_SOURCE_BRANCH - Source branch (also protected by default) +# Arguments: +# --dry-run Preview changes without deleting anything (default) +# --execute Actually delete branches and prune refs +# --remote Also prune stale remote-tracking refs (git remote prune) +# --tags Also clean up old CGW backup tags (pre-merge-backup-*) +# --older-than Only delete backup tags older than N days (default: 30) +# --non-interactive Skip confirmation prompts +# -h, --help Show help +# Returns: +# 0 on success, 1 on failure + +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${SCRIPT_DIR}/_common.sh" + +main() { + local execute=0 + local prune_remote=0 + local clean_tags=0 + local older_than_days=30 + local non_interactive=0 + + [[ "${CGW_NON_INTERACTIVE:-0}" == "1" ]] && non_interactive=1 + + while [[ $# -gt 0 ]]; do + case "$1" in + --help | -h) + echo "Usage: ./scripts/git/branch_cleanup.sh [OPTIONS]" + echo "" + echo "Clean up merged local branches, stale remote-tracking refs, and backup tags." + echo "Defaults to dry-run (preview only). Pass --execute to actually delete." + echo "" + echo "Options:" + echo " --execute Delete merged branches and prune (without this, only previews)" + echo " --remote Prune stale remote-tracking refs (git remote prune origin)" + echo " --tags Clean up old CGW backup tags (pre-merge-backup-*, pre-cherry-pick-*)" + echo " --older-than Only delete backup tags older than N days (default: 30)" + echo " --non-interactive Skip confirmation prompts" + echo " -h, --help Show this help" + echo "" + echo "Protected branches (never deleted):" + echo " ${CGW_TARGET_BRANCH}, ${CGW_SOURCE_BRANCH}" + echo " Plus: CGW_PROTECTED_BRANCHES setting" + echo "" + echo "Examples:" + echo " ./scripts/git/branch_cleanup.sh # dry-run preview" + echo " ./scripts/git/branch_cleanup.sh --execute # delete merged branches" + echo " ./scripts/git/branch_cleanup.sh --execute --remote # also prune remote refs" + echo " ./scripts/git/branch_cleanup.sh --execute --tags --older-than 14" + exit 0 + ;; + --execute) execute=1 ;; + --remote) prune_remote=1 ;; + --tags) clean_tags=1 ;; + --older-than) + older_than_days="${2:-30}" + shift + ;; + --non-interactive) non_interactive=1 ;; + *) + echo "[ERROR] Unknown flag: $1" >&2 + exit 1 + ;; + esac + shift + done + + cd "${PROJECT_ROOT}" || { + err "Cannot find project root" + exit 1 + } + + local mode_label="DRY RUN" + [[ ${execute} -eq 1 ]] && mode_label="EXECUTE" + + echo "=== Branch Cleanup [${mode_label}] ===" + echo "" + [[ ${execute} -eq 0 ]] && echo " (dry run — pass --execute to actually delete)" && echo "" + + # Build set of protected branches + local -a protected=("${CGW_TARGET_BRANCH}" "${CGW_SOURCE_BRANCH}") + for pb in ${CGW_PROTECTED_BRANCHES:-}; do + protected+=("${pb}") + done + + local current_branch + current_branch=$(git branch --show-current 2>/dev/null || echo "") + + # ── [1] Merged local branches ───────────────────────────────────────────── + echo "--- [1] Merged Local Branches (merged into ${CGW_TARGET_BRANCH}) ---" + + local -a merged_branches=() + while IFS= read -r branch; do + branch="${branch# }" # strip leading spaces from git branch output + branch="${branch#* }" # strip leading asterisk+space for current branch + + # Skip empty + [[ -z "${branch}" ]] && continue + + # Skip protected branches + local is_protected=0 + for pb in "${protected[@]}"; do + [[ "${branch}" == "${pb}" ]] && is_protected=1 && break + done + [[ ${is_protected} -eq 1 ]] && continue + + # Skip current branch + [[ "${branch}" == "${current_branch}" ]] && continue + + merged_branches+=("${branch}") + done < <(git branch --merged "${CGW_TARGET_BRANCH}" 2>/dev/null) + + if [[ ${#merged_branches[@]} -eq 0 ]]; then + echo " ✓ No merged local branches to clean up" + else + echo " Branches merged into ${CGW_TARGET_BRANCH}:" + for branch in "${merged_branches[@]}"; do + local last_commit + last_commit=$(git log -1 --format="%h %s (%ar)" "${branch}" 2>/dev/null || echo "(unknown)") + echo " ${branch} — ${last_commit}" + done + echo "" + + if [[ ${execute} -eq 1 ]]; then + if [[ ${non_interactive} -eq 0 ]]; then + read -r -p " Delete ${#merged_branches[@]} merged branch(es)? (yes/no): " confirm + if [[ "${confirm}" != "yes" ]]; then + echo " Skipped local branch deletion" + else + _delete_local_branches "${merged_branches[@]}" + fi + else + _delete_local_branches "${merged_branches[@]}" + fi + else + echo " Would delete: ${#merged_branches[@]} branch(es)" + fi + fi + echo "" + + # ── [2] Remote-tracking refs ────────────────────────────────────────────── + if [[ ${prune_remote} -eq 1 ]]; then + echo "--- [2] Stale Remote-Tracking Refs ---" + + if [[ ${execute} -eq 1 ]]; then + echo " Pruning stale refs from origin..." + if git remote prune origin 2>&1 | grep -E "pruned|\\[pruned\\]" | sed 's/^/ /'; then + : # output shown inline + else + echo " ✓ No stale remote-tracking refs" + fi + else + # Dry-run: show what would be pruned + local stale_count=0 + while IFS= read -r ref; do + echo " Would prune: ${ref}" + ((stale_count++)) || true + done < <(git remote prune --dry-run origin 2>&1 | grep "\\[would prune\\]" | awk '{print $NF}') + [[ ${stale_count} -eq 0 ]] && echo " ✓ No stale remote-tracking refs" + fi + echo "" + fi + + # ── [3] Backup tags ─────────────────────────────────────────────────────── + if [[ ${clean_tags} -eq 1 ]]; then + echo "--- [3] Old Backup Tags (older than ${older_than_days} days) ---" + + local cutoff_epoch + # POSIX-compatible date arithmetic: go back N days in seconds + cutoff_epoch=$(date +%s) + cutoff_epoch=$((cutoff_epoch - older_than_days * 86400)) + + local -a old_tags=() + while IFS= read -r tag; do + local tag_epoch + tag_epoch=$(git log -1 --format="%ct" "${tag}" 2>/dev/null || echo "0") + if [[ ${tag_epoch} -lt ${cutoff_epoch} ]]; then + old_tags+=("${tag}") + fi + done < <(git tag -l "pre-merge-backup-*" "pre-cherry-pick-*" 2>/dev/null | sort) + + if [[ ${#old_tags[@]} -eq 0 ]]; then + echo " ✓ No backup tags older than ${older_than_days} days" + else + echo " Old backup tags:" + local total_all + total_all=$(git tag -l "pre-merge-backup-*" "pre-cherry-pick-*" 2>/dev/null | wc -l | tr -d ' ') + for tag in "${old_tags[@]}"; do + local tag_date + tag_date=$(git log -1 --format="%ar" "${tag}" 2>/dev/null || echo "unknown") + echo " ${tag} (${tag_date})" + done + echo "" + echo " (${#old_tags[@]} old / ${total_all} total backup tags)" + + if [[ ${execute} -eq 1 ]]; then + if [[ ${non_interactive} -eq 0 ]]; then + read -r -p " Delete ${#old_tags[@]} old backup tag(s)? (yes/no): " confirm_tags + if [[ "${confirm_tags}" != "yes" ]]; then + echo " Skipped backup tag deletion" + else + _delete_tags "${old_tags[@]}" + fi + else + _delete_tags "${old_tags[@]}" + fi + else + echo " Would delete: ${#old_tags[@]} backup tag(s)" + fi + fi + echo "" + fi + + echo "=== Done ===" + [[ ${execute} -eq 0 ]] && echo "Run with --execute to apply changes." +} + +_delete_local_branches() { + local deleted=0 failed=0 + for branch in "$@"; do + if git branch -d "${branch}" 2>/dev/null; then + echo " ✓ Deleted: ${branch}" + ((deleted++)) || true + else + echo " ✗ Failed: ${branch} (may not be fully merged — use git branch -D to force)" + ((failed++)) || true + fi + done + echo " Deleted: ${deleted}, Failed: ${failed}" +} + +_delete_tags() { + local deleted=0 + for tag in "$@"; do + if git tag -d "${tag}" 2>/dev/null; then + echo " ✓ Deleted: ${tag}" + ((deleted++)) || true + else + echo " ✗ Failed to delete: ${tag}" >&2 + fi + done + echo " Deleted: ${deleted} tag(s)" +} + +main "$@" diff --git a/scripts/git/changelog_generate.sh b/scripts/git/changelog_generate.sh new file mode 100644 index 0000000..2f6764f --- /dev/null +++ b/scripts/git/changelog_generate.sh @@ -0,0 +1,310 @@ +#!/usr/bin/env bash +# changelog_generate.sh - Generate changelog from conventional commits +# Purpose: Parse conventional commit messages (feat/fix/docs/etc.) between two +# refs and produce a categorized markdown or plain-text changelog. +# Leverages the commit discipline enforced by commit_enhanced.sh. +# See Pro Git Ch5 p.156-170 (git shortlog, git describe, release prep). +# Usage: ./scripts/git/changelog_generate.sh [OPTIONS] +# +# Globals: +# SCRIPT_DIR - Directory containing this script +# PROJECT_ROOT - Auto-detected git repo root (set by _config.sh) +# CGW_TARGET_BRANCH - Default "to" ref if --to not specified +# CGW_ALL_PREFIXES - Recognized conventional commit prefixes +# Arguments: +# --from Start ref (exclusive) — default: latest semver tag or first commit +# --to End ref (inclusive) — default: HEAD +# --format Output format: md (default) or text +# --output Write to file instead of stdout +# --no-merge Exclude merge commits (default: excluded) +# -h, --help Show help +# Returns: +# 0 on success, 1 on failure + +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${SCRIPT_DIR}/_common.sh" + +main() { + local from_ref="" + local to_ref="HEAD" + local output_format="md" + local output_file="" + local include_merges=0 + + while [[ $# -gt 0 ]]; do + case "$1" in + --help | -h) + echo "Usage: ./scripts/git/changelog_generate.sh [OPTIONS]" + echo "" + echo "Generate a categorized changelog from conventional commits." + echo "" + echo "Options:" + echo " --from Start ref (exclusive; default: latest semver tag or root)" + echo " --to End ref inclusive (default: HEAD)" + echo " --format Output format: md (default) or text" + echo " --output Write to file (default: stdout)" + echo " --include-merges Also include merge commits (default: skipped)" + echo " -h, --help Show this help" + echo "" + echo "Commit types recognized (CGW_ALL_PREFIXES):" + echo " feat, fix, docs, chore, test, refactor, style, perf" + echo " Plus any extras configured via CGW_EXTRA_PREFIXES" + echo "" + echo "Examples:" + echo " ./scripts/git/changelog_generate.sh" + echo " ./scripts/git/changelog_generate.sh --from v1.0.0 --to v1.1.0" + echo " ./scripts/git/changelog_generate.sh --from v1.0.0 --output CHANGELOG.md" + exit 0 + ;; + --from) from_ref="${2:-}"; shift ;; + --to) to_ref="${2:-}"; shift ;; + --format) output_format="${2:-md}"; shift ;; + --output) output_file="${2:-}"; shift ;; + --include-merges) include_merges=1 ;; + *) + echo "[ERROR] Unknown flag: $1" >&2 + exit 1 + ;; + esac + shift + done + + cd "${PROJECT_ROOT}" || { + err "Cannot find project root" + exit 1 + } + + # Validate output format + case "${output_format}" in + md | text) ;; + *) + err "Unknown format: ${output_format} (use 'md' or 'text')" + exit 1 + ;; + esac + + # Auto-detect from_ref: latest semver tag + if [[ -z "${from_ref}" ]]; then + from_ref=$(git tag -l "v[0-9]*" | sort -V | tail -1 2>/dev/null || true) + if [[ -z "${from_ref}" ]]; then + # No semver tags — use root commit (all history) + from_ref=$(git rev-list --max-parents=0 HEAD 2>/dev/null | head -1 || true) + fi + fi + + # Validate refs + if ! git rev-parse "${to_ref}" >/dev/null 2>&1; then + err "Invalid --to ref: ${to_ref}" + exit 1 + fi + if [[ -n "${from_ref}" ]]; then + if ! git rev-parse "${from_ref}" >/dev/null 2>&1; then + err "Invalid --from ref: ${from_ref}" + exit 1 + fi + fi + + # Determine git log range + local log_range + if [[ -n "${from_ref}" ]]; then + log_range="${from_ref}..${to_ref}" + else + log_range="${to_ref}" + fi + + # Get to_ref description for header + local to_desc + to_desc=$(git describe --tags --exact-match "${to_ref}" 2>/dev/null || \ + git log -1 --format="%h" "${to_ref}" 2>/dev/null || echo "${to_ref}") + local to_date + to_date=$(git log -1 --format="%ad" --date=short "${to_ref}" 2>/dev/null || date +%Y-%m-%d) + + # Collect commits in range + local merge_flag="--no-merges" + [[ ${include_merges} -eq 1 ]] && merge_flag="" + + # shellcheck disable=SC2086 # merge_flag intentionally word-splits when empty + local commits + commits=$(git log ${merge_flag} --format="%H|%s|%b" "${log_range}" 2>/dev/null || true) + + if [[ -z "${commits}" ]]; then + echo "No commits found in range: ${log_range}" >&2 + exit 0 + fi + + # Categorize commits by conventional type + # Categories: feat, fix, docs, perf, refactor, style, test, chore, other + local -a cat_feat=() cat_fix=() cat_docs=() cat_perf=() + local -a cat_refactor=() cat_style=() cat_test=() cat_chore=() cat_other=() + + while IFS='|' read -r hash subject body; do + [[ -z "${hash}" ]] && continue + + local prefix rest + if echo "${subject}" | grep -qE "^[a-zA-Z]+:"; then + prefix=$(echo "${subject}" | sed 's/:.*//') + rest=$(echo "${subject}" | sed 's/^[^:]*: *//') + else + prefix="other" + rest="${subject}" + fi + + # Get short hash and PR reference if any + local short_hash + short_hash=$(git log -1 --format="%h" "${hash}" 2>/dev/null || echo "${hash:0:7}") + + local entry="${rest} (${short_hash})" + + case "${prefix}" in + feat) cat_feat+=("${entry}") ;; + fix) cat_fix+=("${entry}") ;; + docs) cat_docs+=("${entry}") ;; + perf) cat_perf+=("${entry}") ;; + refactor) cat_refactor+=("${entry}") ;; + style) cat_style+=("${entry}") ;; + test) cat_test+=("${entry}") ;; + chore) cat_chore+=("${entry}") ;; + *) cat_other+=("${entry}") ;; + esac + done <<< "${commits}" + + # Build output + local output="" + output=$(_build_changelog \ + "${output_format}" "${to_desc}" "${to_date}" "${from_ref}" \ + "${cat_feat[@]+"${cat_feat[@]}"}" \ + "BREAK_FEAT" \ + "${cat_fix[@]+"${cat_fix[@]}"}" \ + "BREAK_FIX" \ + "${cat_docs[@]+"${cat_docs[@]}"}" \ + "BREAK_DOCS" \ + "${cat_perf[@]+"${cat_perf[@]}"}" \ + "BREAK_PERF" \ + "${cat_refactor[@]+"${cat_refactor[@]}"}" \ + "BREAK_REFACTOR" \ + "${cat_style[@]+"${cat_style[@]}"}" \ + "BREAK_STYLE" \ + "${cat_test[@]+"${cat_test[@]}"}" \ + "BREAK_TEST" \ + "${cat_chore[@]+"${cat_chore[@]}"}" \ + "BREAK_CHORE" \ + "${cat_other[@]+"${cat_other[@]}"}" \ + 2>/dev/null || true) + + # Write output + if [[ -n "${output_file}" ]]; then + echo "${output}" >"${output_file}" + echo "✓ Changelog written to: ${output_file}" >&2 + else + echo "${output}" + fi +} + +_build_changelog() { + local fmt="$1" version="$2" date_str="$3" from_ref="$4" + shift 4 + + # Parse the positional args back into categories using BREAK_ sentinel tokens + local current_cat="feat" + declare -A cats + cats[feat]="" cats[fix]="" cats[docs]="" cats[perf]="" + cats[refactor]="" cats[style]="" cats[test]="" cats[chore]="" cats[other]="" + + # Re-collect — simpler: re-run git log with format per category + # (The sentinel approach doesn't work cleanly with arrays passed through positional args) + # Instead, recollect directly here. + local merge_flag="--no-merges" + + local log_range + if [[ -n "${from_ref}" ]]; then + log_range="${from_ref}..HEAD" + else + log_range="HEAD" + fi + + # Read commits again (simpler than passing arrays through function args) + while IFS='|' read -r hash subject; do + [[ -z "${hash}" ]] && continue + local prefix rest short_hash + prefix=$(echo "${subject}" | grep -oE "^[a-zA-Z]+" || echo "other") + rest=$(echo "${subject}" | sed 's/^[^:]*: *//') + short_hash=$(git log -1 --format="%h" "${hash}" 2>/dev/null || echo "${hash:0:7}") + local entry=" - ${rest} (${short_hash})" + case "${prefix}" in + feat) cats[feat]+="${entry}"$'\n' ;; + fix) cats[fix]+="${entry}"$'\n' ;; + docs) cats[docs]+="${entry}"$'\n' ;; + perf) cats[perf]+="${entry}"$'\n' ;; + refactor) cats[refactor]+="${entry}"$'\n' ;; + style) cats[style]+="${entry}"$'\n' ;; + test) cats[test]+="${entry}"$'\n' ;; + chore) cats[chore]+="${entry}"$'\n' ;; + *) cats[other]+="${entry}"$'\n' ;; + esac + done < <(git log --no-merges --format="%H|%s" "${log_range}" 2>/dev/null || true) + + local from_label + from_label="${from_ref:-root}" + + if [[ "${fmt}" == "md" ]]; then + echo "## ${version} (${date_str})" + echo "" + [[ -n "${from_ref}" ]] && echo "> Changes since \`${from_ref}\`" && echo "" + + local section_map=( + "feat:New Features" + "fix:Bug Fixes" + "perf:Performance Improvements" + "docs:Documentation" + "refactor:Refactoring" + "test:Tests" + "style:Code Style" + "chore:Maintenance" + "other:Other Changes" + ) + + local has_any=0 + for entry in "${section_map[@]}"; do + local key="${entry%%:*}" + local title="${entry#*:}" + if [[ -n "${cats[${key}]}" ]]; then + echo "### ${title}" + echo "" + echo "${cats[${key}]}" + has_any=1 + fi + done + + [[ ${has_any} -eq 0 ]] && echo "_No categorized commits found in this range._" + else + # Plain text + echo "${version} (${date_str})" + echo "$(printf '=%.0s' {1..40})" + [[ -n "${from_ref}" ]] && echo "Changes since ${from_ref}" && echo "" + + local section_map=( + "feat:New Features" + "fix:Bug Fixes" + "perf:Performance" + "docs:Documentation" + "refactor:Refactoring" + "test:Tests" + "style:Style" + "chore:Maintenance" + "other:Other" + ) + + for entry in "${section_map[@]}"; do + local key="${entry%%:*}" + local title="${entry#*:}" + if [[ -n "${cats[${key}]}" ]]; then + echo "${title}:" + echo "${cats[${key}]}" + fi + done + fi +} + +main "$@" diff --git a/scripts/git/undo_last.sh b/scripts/git/undo_last.sh new file mode 100644 index 0000000..f25e4db --- /dev/null +++ b/scripts/git/undo_last.sh @@ -0,0 +1,415 @@ +#!/usr/bin/env bash +# undo_last.sh - Safe undo wrapper for common git operations +# Purpose: Provide safe, guided undo operations: undo last commit (keep changes +# staged), unstage specific files, discard file changes. Creates backup +# tags before any destructive operation. See Pro Git Ch2 p.52-55 and +# Ch7 Reset Demystified p.257-276. +# Usage: ./scripts/git/undo_last.sh [SUBCOMMAND] [OPTIONS] +# +# Globals: +# SCRIPT_DIR - Directory containing this script +# PROJECT_ROOT - Auto-detected git repo root (set by _config.sh) +# logfile - Set by init_logging +# Subcommands: +# commit Undo last commit — keeps all changes staged (git reset --soft HEAD~1) +# unstage ... Remove file(s) from staging area (git reset HEAD ) +# discard ... Discard working-tree changes to file(s) (git checkout -- ) +# amend-message Change the message of the last commit (git commit --amend -m) +# Options (all subcommands): +# --non-interactive Skip confirmation prompts +# --dry-run Show what would happen without changing anything +# -h, --help Show help +# Returns: +# 0 on success, 1 on failure + +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${SCRIPT_DIR}/_common.sh" + +_show_help() { + echo "Usage: ./scripts/git/undo_last.sh [OPTIONS]" + echo "" + echo "Safe undo operations with backup tags." + echo "" + echo "Subcommands:" + echo " commit Undo the last commit, keeping all changes staged" + echo " (git reset --soft HEAD~1 — history-rewriting, local only)" + echo " unstage ... Remove file(s) from staging area" + echo " discard ... Discard working-tree changes to file(s)" + echo " WARNING: This cannot be undone!" + echo " amend-message Replace the last commit message" + echo " (local only — do not use after pushing)" + echo "" + echo "Options:" + echo " --non-interactive Skip confirmation prompts" + echo " --dry-run Preview without making changes" + echo " -h, --help Show this help" + echo "" + echo "Examples:" + echo " ./scripts/git/undo_last.sh commit" + echo " ./scripts/git/undo_last.sh unstage src/file.py" + echo " ./scripts/git/undo_last.sh discard src/file.py" + echo " ./scripts/git/undo_last.sh amend-message 'fix: correct typo in header'" +} + +main() { + if [[ $# -eq 0 ]] || [[ "${1:-}" == "--help" ]] || [[ "${1:-}" == "-h" ]]; then + _show_help + exit 0 + fi + + local subcommand="$1" + shift + + local non_interactive=0 + local dry_run=0 + + [[ "${CGW_NON_INTERACTIVE:-0}" == "1" ]] && non_interactive=1 + + # Pre-scan remaining args for global flags + local -a positional=() + while [[ $# -gt 0 ]]; do + case "$1" in + --non-interactive) non_interactive=1 ;; + --dry-run) dry_run=1 ;; + --help | -h) _show_help; exit 0 ;; + --*) echo "[ERROR] Unknown flag: $1" >&2; exit 1 ;; + *) positional+=("$1") ;; + esac + shift + done + + cd "${PROJECT_ROOT}" || { + err "Cannot find project root" + exit 1 + } + + init_logging "undo_last" + + { + echo "=========================================" + echo "Undo Last Log" + echo "=========================================" + echo "Start Time: $(date)" + echo "Branch: $(git branch --show-current)" + } >"$logfile" + + case "${subcommand}" in + commit) _cmd_undo_commit "${non_interactive}" "${dry_run}" ;; + unstage) _cmd_unstage "${non_interactive}" "${dry_run}" "${positional[@]+"${positional[@]}"}" ;; + discard) _cmd_discard "${non_interactive}" "${dry_run}" "${positional[@]+"${positional[@]}"}" ;; + amend-message) _cmd_amend_message "${non_interactive}" "${dry_run}" "${positional[@]+"${positional[@]}"}" ;; + *) + echo "[ERROR] Unknown subcommand: ${subcommand}" >&2 + echo "Run with --help to see available subcommands" >&2 + exit 1 + ;; + esac +} + +# --------------------------------------------------------------------------- +# Subcommand: commit +# --------------------------------------------------------------------------- +_cmd_undo_commit() { + local non_interactive="$1" dry_run="$2" + + echo "=== Undo Last Commit ===" + echo "" + + # Must have at least one commit to undo + if ! git rev-parse HEAD >/dev/null 2>&1; then + err "Repository has no commits to undo" + exit 1 + fi + + local commit_count + commit_count=$(git rev-list --count HEAD 2>/dev/null || echo "0") + if [[ "${commit_count}" -le 1 ]]; then + err "Cannot undo the initial commit (no parent to reset to)" + exit 1 + fi + + # Warn if commit appears to have been pushed + local current_branch upstream_ref + current_branch=$(git branch --show-current) + upstream_ref="refs/remotes/origin/${current_branch}" + if git show-ref --verify --quiet "${upstream_ref}" 2>/dev/null; then + local ahead + ahead=$(git rev-list --count "origin/${current_branch}..HEAD" 2>/dev/null || echo "0") + if [[ "${ahead}" -eq 0 ]]; then + echo "⚠ WARNING: The last commit appears to have been pushed to origin." + echo " Undoing it locally will create a diverged state requiring force-push." + if [[ "${non_interactive}" -eq 0 ]]; then + read -r -p " Continue anyway? (yes/no): " pushed_confirm + if [[ "${pushed_confirm}" != "yes" ]]; then + echo "Cancelled" + exit 0 + fi + else + err "Aborting — last commit has been pushed; use --revert in rollback_merge.sh instead" + exit 1 + fi + fi + fi + + echo "Last commit to undo:" + git log -1 --oneline + echo "" + echo "After undo: all changes will be staged (git reset --soft HEAD~1)" + echo "" + + if [[ "${dry_run}" -eq 1 ]]; then + echo "--- Dry run: no changes made ---" + echo "Would run: git reset --soft HEAD~1" + exit 0 + fi + + if [[ "${non_interactive}" -eq 0 ]]; then + read -r -p "Undo this commit? (yes/no): " confirm + if [[ "${confirm}" != "yes" ]]; then + echo "Cancelled" + exit 0 + fi + fi + + # Create backup tag before reset + get_timestamp + local backup_tag="pre-undo-commit-${timestamp}" + if git tag "${backup_tag}" 2>/dev/null; then + echo "✓ Backup tag: ${backup_tag}" + else + echo "⚠ Could not create backup tag (continuing)" + fi + + if git reset --soft HEAD~1; then + echo "" + echo "✓ COMMIT UNDONE" + echo " All changes are now staged." + echo " Backup: git reset --hard ${backup_tag} (to restore)" + echo " Next: review staged files, edit if needed, then re-commit" + else + err "Reset failed" + exit 1 + fi +} + +# --------------------------------------------------------------------------- +# Subcommand: unstage +# --------------------------------------------------------------------------- +_cmd_unstage() { + local non_interactive="$1" dry_run="$2" + shift 2 + local files=("$@") + + echo "=== Unstage Files ===" + echo "" + + if [[ ${#files[@]} -eq 0 ]]; then + # No files specified: show all staged and let user pick + local staged + staged=$(git diff --cached --name-only) + if [[ -z "${staged}" ]]; then + echo " Nothing is staged." + exit 0 + fi + echo " Staged files:" + echo "${staged}" | sed 's/^/ /' + echo "" + err "Specify file(s) to unstage. Example: ./scripts/git/undo_last.sh unstage " + exit 1 + fi + + # Validate each file is actually staged + local -a to_unstage=() + for f in "${files[@]}"; do + if git diff --cached --name-only | grep -qF "${f}"; then + to_unstage+=("${f}") + else + echo " ⚠ Not staged: ${f} (skipping)" + fi + done + + if [[ ${#to_unstage[@]} -eq 0 ]]; then + echo " Nothing to unstage." + exit 0 + fi + + echo " Files to unstage:" + for f in "${to_unstage[@]}"; do echo " ${f}"; done + echo "" + + if [[ "${dry_run}" -eq 1 ]]; then + echo "--- Dry run: no changes made ---" + echo "Would run: git reset HEAD ${to_unstage[*]}" + exit 0 + fi + + if [[ "${non_interactive}" -eq 0 ]]; then + read -r -p " Unstage ${#to_unstage[@]} file(s)? (yes/no): " confirm + if [[ "${confirm}" != "yes" ]]; then + echo "Cancelled" + exit 0 + fi + fi + + for f in "${to_unstage[@]}"; do + if git reset HEAD "${f}" 2>/dev/null; then + echo " ✓ Unstaged: ${f}" + else + echo " ✗ Failed: ${f}" >&2 + fi + done +} + +# --------------------------------------------------------------------------- +# Subcommand: discard +# --------------------------------------------------------------------------- +_cmd_discard() { + local non_interactive="$1" dry_run="$2" + shift 2 + local files=("$@") + + echo "=== Discard Working-Tree Changes ===" + echo "" + echo " ⚠ WARNING: This permanently discards uncommitted changes." + echo " Changes cannot be recovered after this operation." + echo "" + + if [[ ${#files[@]} -eq 0 ]]; then + err "Specify file(s) to discard. Example: ./scripts/git/undo_last.sh discard " + exit 1 + fi + + # Validate each file has modifications + local -a to_discard=() + for f in "${files[@]}"; do + if [[ ! -f "${f}" ]] && [[ ! -d "${f}" ]]; then + echo " ⚠ Not found: ${f} (skipping)" + continue + fi + if git diff --name-only -- "${f}" | grep -qF "${f}"; then + to_discard+=("${f}") + else + echo " ⚠ No unstaged changes: ${f} (skipping)" + fi + done + + if [[ ${#to_discard[@]} -eq 0 ]]; then + echo " Nothing to discard." + exit 0 + fi + + echo " Files to discard changes in:" + for f in "${to_discard[@]}"; do + git diff --stat -- "${f}" | head -3 | sed 's/^/ /' + done + echo "" + + if [[ "${dry_run}" -eq 1 ]]; then + echo "--- Dry run: no changes made ---" + echo "Would run: git checkout -- ${to_discard[*]}" + exit 0 + fi + + if [[ "${non_interactive}" -eq 0 ]]; then + read -r -p " PERMANENTLY discard changes in ${#to_discard[@]} file(s)? (yes/no): " confirm + if [[ "${confirm}" != "yes" ]]; then + echo "Cancelled" + exit 0 + fi + else + err "Refusing to discard in non-interactive mode (data loss risk)" + err "Run interactively or use: git checkout -- " + exit 1 + fi + + for f in "${to_discard[@]}"; do + if git checkout -- "${f}" 2>/dev/null; then + echo " ✓ Discarded: ${f}" + else + echo " ✗ Failed: ${f}" >&2 + fi + done +} + +# --------------------------------------------------------------------------- +# Subcommand: amend-message +# --------------------------------------------------------------------------- +_cmd_amend_message() { + local non_interactive="$1" dry_run="$2" + shift 2 + local new_msg="${1:-}" + + echo "=== Amend Last Commit Message ===" + echo "" + + if ! git rev-parse HEAD >/dev/null 2>&1; then + err "Repository has no commits" + exit 1 + fi + + if [[ -z "${new_msg}" ]]; then + err "New message required. Example: ./scripts/git/undo_last.sh amend-message 'fix: correct typo'" + exit 1 + fi + + # Validate conventional format + if ! echo "${new_msg}" | grep -qE "^(${CGW_ALL_PREFIXES}):"; then + echo " ⚠ Message does not follow conventional format: ${new_msg}" + echo " Expected: : (types: ${CGW_ALL_PREFIXES/|/, })" + if [[ "${non_interactive}" -eq 0 ]]; then + read -r -p " Continue anyway? (yes/no): " format_confirm + if [[ "${format_confirm}" != "yes" ]]; then + echo "Cancelled" + exit 0 + fi + fi + fi + + # Warn if commit has been pushed + local current_branch upstream_ref + current_branch=$(git branch --show-current) + upstream_ref="refs/remotes/origin/${current_branch}" + if git show-ref --verify --quiet "${upstream_ref}" 2>/dev/null; then + local ahead + ahead=$(git rev-list --count "origin/${current_branch}..HEAD" 2>/dev/null || echo "0") + if [[ "${ahead}" -eq 0 ]]; then + echo " ⚠ WARNING: This commit appears to have been pushed. Amending will require force-push." + if [[ "${non_interactive}" -eq 1 ]]; then + err "Refusing to amend pushed commit in non-interactive mode" + exit 1 + fi + read -r -p " Amend anyway? (yes/no): " pushed_confirm + [[ "${pushed_confirm}" != "yes" ]] && echo "Cancelled" && exit 0 + fi + fi + + echo " Current message: $(git log -1 --format='%s')" + echo " New message: ${new_msg}" + echo "" + + if [[ "${dry_run}" -eq 1 ]]; then + echo "--- Dry run: no changes made ---" + echo "Would run: git commit --amend -m '${new_msg}'" + exit 0 + fi + + if [[ "${non_interactive}" -eq 0 ]]; then + read -r -p " Amend commit message? (yes/no): " confirm + if [[ "${confirm}" != "yes" ]]; then + echo "Cancelled" + exit 0 + fi + fi + + if git commit --amend --no-edit -m "${new_msg}"; then + echo "" + echo "✓ Message updated: $(git log -1 --oneline)" + else + err "Amend failed" + exit 1 + fi +} + +main "$@" From 4a23beb886d18eb21397a01fac5f231cb2eb4313 Mon Sep 17 00:00:00 2001 From: forkni Date: Thu, 9 Apr 2026 12:13:25 -0400 Subject: [PATCH 3/8] docs: align all documentation and skill with current codebase (25 scripts, pre-push hook) --- .charlie/instructions/code-style.md | 2 +- .charlie/instructions/git-workflow.md | 2 +- README.md | 64 ++++- hooks/pre-push | 94 +++++++ install.cmd | 23 +- scripts/git/clean_build.sh | 193 ++++++++++++++ scripts/git/create_release.sh | 202 ++++++++++++++ scripts/git/repo_health.sh | 222 ++++++++++++++++ scripts/git/setup_attributes.sh | 370 ++++++++++++++++++++++++++ scripts/git/stash_work.sh | 243 +++++++++++++++++ skill/SKILL.md | 83 ++++++ skill/references/error-recovery.md | 57 ++++ skill/references/script-reference.md | 186 ++++++++++++- 13 files changed, 1725 insertions(+), 16 deletions(-) create mode 100644 hooks/pre-push create mode 100644 scripts/git/clean_build.sh create mode 100644 scripts/git/create_release.sh create mode 100644 scripts/git/repo_health.sh create mode 100644 scripts/git/setup_attributes.sh create mode 100644 scripts/git/stash_work.sh diff --git a/.charlie/instructions/code-style.md b/.charlie/instructions/code-style.md index 92ddb19..3f0f779 100644 --- a/.charlie/instructions/code-style.md +++ b/.charlie/instructions/code-style.md @@ -5,7 +5,7 @@ Charlie also reads CLAUDE.md for project context. ## Scope -`scripts/git/*.sh`, `hooks/pre-commit` +`scripts/git/*.sh`, `hooks/*`, `.githooks/*` ## Context diff --git a/.charlie/instructions/git-workflow.md b/.charlie/instructions/git-workflow.md index 4203321..ec3132d 100644 --- a/.charlie/instructions/git-workflow.md +++ b/.charlie/instructions/git-workflow.md @@ -24,7 +24,7 @@ All git operations: commits, merges, pushes, cherry-picks, rollbacks. - [R7] Wrap every logical operation in `log_section_start` / `log_section_end` for timing and audit trail - [R8] Auto-detect non-interactive mode: set `CGW_NON_INTERACTIVE=1` when `[[ ! -t 0 ]]` (no TTY) - [R9] Prefer `--force-with-lease` over `--force` for force-push operations -- [R10] Never bypass the pre-commit hook with `--no-verify` +- [R10] Never bypass git hooks (pre-commit, pre-push) with `--no-verify` ## Examples diff --git a/README.md b/README.md index eb51f18..3d32336 100644 --- a/README.md +++ b/README.md @@ -24,8 +24,7 @@ claude-git-workflow\install.cmd ```bash # 1. Copy scripts + hook template into your project cp -r claude-git-workflow/scripts/git/ your-project/scripts/git/ -mkdir -p your-project/hooks -cp claude-git-workflow/hooks/pre-commit your-project/hooks/ +cp -r claude-git-workflow/hooks/ your-project/hooks/ # 2. Auto-configure (scans project, generates config, installs hooks + skill) cd your-project && ./scripts/git/configure.sh @@ -54,12 +53,17 @@ No manual config editing required for common setups. `configure.sh` auto-detects | `check_lint.sh` | Read-only lint validation | | `fix_lint.sh` | Auto-fix lint issues | | `create_pr.sh` | Create GitHub PR from source → target (triggers Charlie CI + GitHub Actions) | -| `install_hooks.sh` | Install git pre-commit hooks | +| `install_hooks.sh` | Install git hooks (pre-commit + pre-push) | | `setup_attributes.sh` | Generate `.gitattributes` for binary and text files (Python, TouchDesigner, GLSL, assets) | | `clean_build.sh` | Safe cleanup of build artifacts with dry-run default (Python, TouchDesigner, GLSL) | | `create_release.sh` | Create annotated version tags to trigger the GitHub Release workflow | | `stash_work.sh` | Safe stash wrapper with untracked file support, named stashes, and logging | | `repo_health.sh` | Repository health: integrity check, size report, large file detection, gc | +| `bisect_helper.sh` | Guided git bisect with backup tag, auto-detect good ref, automated test support | +| `rebase_safe.sh` | Safe rebase: backup tag, pushed-commit guard, abort/continue/skip, autostash | +| `branch_cleanup.sh` | Prune merged branches, stale remote-tracking refs, and old backup tags | +| `changelog_generate.sh` | Generate categorized markdown/text changelog from conventional commits | +| `undo_last.sh` | Undo last commit (keep staged), unstage files, discard changes, amend message | Internal modules (not user-facing): `_common.sh` (shared utilities, sourced by every script), `_config.sh` (three-tier config resolution, sourced by `_common.sh`). @@ -259,6 +263,60 @@ Set `CGW_MERGE_MODE="pr"` in `.cgw.conf` to use PRs by default. ./scripts/git/repo_health.sh --large 5 # report files >5MB ``` +### Undo last commit / unstage / amend + +```bash +./scripts/git/undo_last.sh commit # undo last commit, keep changes staged +./scripts/git/undo_last.sh unstage src/file.py # remove file from staging area +./scripts/git/undo_last.sh discard src/file.py # discard working-tree changes (irreversible) +./scripts/git/undo_last.sh amend-message "fix: correct msg" # rewrite last commit message +``` + +Creates a backup tag before any destructive operation. + +### Branch cleanup + +```bash +./scripts/git/branch_cleanup.sh # dry-run preview (safe default) +./scripts/git/branch_cleanup.sh --execute # delete merged branches + prune remote refs +./scripts/git/branch_cleanup.sh --tags --execute # also remove old backup tags +./scripts/git/branch_cleanup.sh --older-than 30 --execute # only branches older than 30 days +``` + +### Safe rebase + +```bash +./scripts/git/rebase_safe.sh --onto main # rebase current branch onto main +./scripts/git/rebase_safe.sh --squash-last 3 # interactive squash of last 3 commits +./scripts/git/rebase_safe.sh --squash-last 3 --autosquash # auto-apply fixup!/squash! prefixes +./scripts/git/rebase_safe.sh --abort # abort in-progress rebase +./scripts/git/rebase_safe.sh --continue # continue after resolving conflicts +``` + +Creates a backup tag (`pre-rebase-TIMESTAMP`) before rebasing. Warns if commits already pushed. + +### Bisect a bug + +```bash +# Automated: find first-bad commit using a test script +./scripts/git/bisect_helper.sh --good v1.0.0 --run "bash tests/smoke_test.sh" + +# Manual: guided interactive bisect +./scripts/git/bisect_helper.sh --good v1.0.0 +# → git bisect good / git bisect bad after each checkout + +./scripts/git/bisect_helper.sh --abort # stop in-progress bisect session +``` + +### Generate changelog + +```bash +./scripts/git/changelog_generate.sh # since latest semver tag → stdout +./scripts/git/changelog_generate.sh --from v1.0.0 # since specific tag +./scripts/git/changelog_generate.sh --from v1.0.0 --output CHANGELOG.md +./scripts/git/changelog_generate.sh --from v1.0.0 --format text # plain text +``` + --- ## Configuration Examples diff --git a/hooks/pre-push b/hooks/pre-push new file mode 100644 index 0000000..b83dbaf --- /dev/null +++ b/hooks/pre-push @@ -0,0 +1,94 @@ +#!/bin/sh +# Pre-push hook — validates commits before they leave local repo +# Generated by claude-git-workflow configure.sh +# +# Checks all commits about to be pushed: +# 1. No local-only files tracked in any unpushed commit +# 2. All commit messages follow conventional format +# +# This complements push_validated.sh (CGW wrapper) by catching issues +# even when users bypass the wrapper and run git push directly. +# See Pro Git Ch8 p.362 — pre-push hook. +# +# The patterns below are populated by configure.sh from CGW_LOCAL_FILES +# and CGW_ALL_PREFIXES. To regenerate, run: ./scripts/git/configure.sh --skip-skill +# +# CONVENTIONAL PREFIXES: __CGW_ALL_PREFIXES__ +# LOCAL FILES PATTERN: __CGW_LOCAL_FILES_PATTERN__ + +# --------------------------------------------------------------------------- +# Read stdin: remote , url , +# --------------------------------------------------------------------------- + +LOCAL_FILES_PATTERN="__CGW_LOCAL_FILES_PATTERN__" +ALL_PREFIXES="__CGW_ALL_PREFIXES__" + +# Collect push info from stdin (git passes it to pre-push) +while read -r LOCAL_REF LOCAL_SHA REMOTE_REF REMOTE_SHA; do + # Skip deletions (empty sha = 0000...0) + if [ "${LOCAL_SHA}" = "0000000000000000000000000000000000000000" ]; then + continue + fi + + # Determine commit range to check + if [ "${REMOTE_SHA}" = "0000000000000000000000000000000000000000" ]; then + # New branch being pushed — check all commits not in any remote branch + RANGE="${LOCAL_SHA}" + COMMITS=$(git rev-list "${RANGE}" --not --remotes 2>/dev/null || true) + else + RANGE="${REMOTE_SHA}..${LOCAL_SHA}" + COMMITS=$(git rev-list "${RANGE}" 2>/dev/null || true) + fi + + [ -z "${COMMITS}" ] && continue + + echo "pre-push: checking $(echo "${COMMITS}" | wc -l | tr -d ' ') commit(s) in ${LOCAL_REF}..." + + # ── [1] Local-only file check ────────────────────────────────────────── + if [ -n "${LOCAL_FILES_PATTERN}" ]; then + for COMMIT in ${COMMITS}; do + FILES_IN_COMMIT=$(git diff-tree --no-commit-id -r --name-only "${COMMIT}" 2>/dev/null || true) + PROBLEM=$(echo "${FILES_IN_COMMIT}" | grep -E "${LOCAL_FILES_PATTERN}" || true) + if [ -n "${PROBLEM}" ]; then + echo "" + echo "ERROR [pre-push]: Commit ${COMMIT} contains local-only files:" + echo "${PROBLEM}" | sed 's/^/ /' + echo "" + echo "These files must not be pushed: ${LOCAL_FILES_PATTERN}" + echo "To fix: git rebase -i HEAD~N and drop/edit the offending commit" + echo "" + exit 1 + fi + done + fi + + # ── [2] Conventional commit format check ────────────────────────────── + if [ -n "${ALL_PREFIXES}" ]; then + for COMMIT in ${COMMITS}; do + MSG=$(git log -1 --format="%s" "${COMMIT}" 2>/dev/null || true) + + # Skip merge commits (they often have non-conventional messages) + PARENT_COUNT=$(git cat-file -p "${COMMIT}" 2>/dev/null | grep -c "^parent " || true) + [ "${PARENT_COUNT}" -ge 2 ] && continue + + if ! echo "${MSG}" | grep -qE "^(${ALL_PREFIXES}):"; then + echo "" + echo "WARNING [pre-push]: Commit ${COMMIT} has non-conventional message:" + echo " ${MSG}" + echo "" + echo "Expected format: : " + echo "Types: ${ALL_PREFIXES}" + echo "" + echo "Push blocked. Fix with: ./scripts/git/undo_last.sh amend-message ': '" + echo "Or bypass (not recommended): git push --no-verify" + echo "" + exit 1 + fi + done + fi + +done + +echo "pre-push: all checks passed" +echo "" +exit 0 diff --git a/install.cmd b/install.cmd index ad9eb75..7d0c1c2 100644 --- a/install.cmd +++ b/install.cmd @@ -82,6 +82,7 @@ rem PI-04: CGW source files complete set "SOURCE_OK=1" if not exist "%CGW_DIR%\scripts\git\configure.sh" set "SOURCE_OK=0" if not exist "%CGW_DIR%\hooks\pre-commit" set "SOURCE_OK=0" +if not exist "%CGW_DIR%\hooks\pre-push" set "SOURCE_OK=0" if not exist "%CGW_DIR%\skill\SKILL.md" set "SOURCE_OK=0" if not exist "%CGW_DIR%\command\auto-git-workflow.md" set "SOURCE_OK=0" if not "%SOURCE_OK%"=="1" goto :pi04_fail @@ -89,7 +90,7 @@ echo [PASS] PI-04 CGW source files complete goto :pi04_done :pi04_fail echo [FAIL] PI-04 CGW source missing required files -echo Expected: scripts\git\configure.sh, hooks\pre-commit, +echo Expected: scripts\git\configure.sh, hooks\pre-commit, hooks\pre-push, echo skill\SKILL.md, command\auto-git-workflow.md set "CHECKS_PASSED=0" :pi04_done @@ -129,8 +130,8 @@ rem --- Confirm --- echo --- Installation Summary --- echo. echo Will copy into: %TARGET_DIR% -echo scripts\git\ (15 shell scripts) -echo hooks\ (pre-commit template) +echo scripts\git\ (25 shell scripts) +echo hooks\ (pre-commit + pre-push templates) echo skill\ (Claude Code skill source) echo command\ (slash command source) echo cgw.conf.example (config reference) @@ -149,10 +150,14 @@ goto :end echo. -rem --- Backup existing .githooks/pre-commit --- -if not exist "%TARGET_DIR%\.githooks\pre-commit" goto :backup_done +rem --- Backup existing .githooks/ hook templates --- +if not exist "%TARGET_DIR%\.githooks\pre-commit" goto :backup_pc_done copy /y "%TARGET_DIR%\.githooks\pre-commit" "%TARGET_DIR%\.githooks\pre-commit.bak" >nul echo Backed up .githooks\pre-commit -^> .githooks\pre-commit.bak +:backup_pc_done +if not exist "%TARGET_DIR%\.githooks\pre-push" goto :backup_done +copy /y "%TARGET_DIR%\.githooks\pre-push" "%TARGET_DIR%\.githooks\pre-push.bak" >nul +echo Backed up .githooks\pre-push -^> .githooks\pre-push.bak :backup_done rem --- Copy files --- @@ -174,10 +179,12 @@ rem hooks/ if not exist "%TARGET_DIR%\hooks\" mkdir "%TARGET_DIR%\hooks\" copy /y "%CGW_DIR%\hooks\pre-commit" "%TARGET_DIR%\hooks\pre-commit" >nul if errorlevel 1 goto :cp_hooks_fail -echo [OK] Copied hooks\pre-commit template +copy /y "%CGW_DIR%\hooks\pre-push" "%TARGET_DIR%\hooks\pre-push" >nul +if errorlevel 1 goto :cp_hooks_fail +echo [OK] Copied hooks\pre-commit + hooks\pre-push templates goto :cp_hooks_done :cp_hooks_fail -echo [ERR] Failed to copy hooks\pre-commit +echo [ERR] Failed to copy hook templates from hooks\ goto :end :cp_hooks_done @@ -267,7 +274,7 @@ if not exist "%TARGET_DIR%\scripts\git\commit_enhanced.sh" goto :sum_scripts_don for /f %%c in ('dir /b "%TARGET_DIR%\scripts\git\*.sh" 2^>nul ^| find /c ".sh"') do echo Scripts: %TARGET_DIR%\scripts\git\ ^(%%c files^) :sum_scripts_done if exist "%TARGET_DIR%\.cgw.conf" echo Config: %TARGET_DIR%\.cgw.conf -if exist "%TARGET_DIR%\.git\hooks\pre-commit" echo Git hook: %TARGET_DIR%\.git\hooks\pre-commit +if exist "%TARGET_DIR%\.git\hooks\pre-commit" echo Git hooks: %TARGET_DIR%\.git\hooks\pre-commit + pre-push if exist "%TARGET_DIR%\.claude\skills\auto-git-workflow\SKILL.md" echo Claude skill: %TARGET_DIR%\.claude\skills\auto-git-workflow\ if exist "%TARGET_DIR%\.claude\commands\auto-git-workflow.md" echo Slash cmd: %TARGET_DIR%\.claude\commands\auto-git-workflow.md diff --git a/scripts/git/clean_build.sh b/scripts/git/clean_build.sh new file mode 100644 index 0000000..39e98e8 --- /dev/null +++ b/scripts/git/clean_build.sh @@ -0,0 +1,193 @@ +#!/usr/bin/env bash +# clean_build.sh - Safe cleanup of build artifacts and temporary files +# Purpose: Remove generated files that should not be committed. Uses --dry-run +# by default to prevent accidental deletion. +# Usage: ./scripts/git/clean_build.sh [OPTIONS] +# +# Globals: +# SCRIPT_DIR - Directory containing this script +# PROJECT_ROOT - Auto-detected git repo root (set by _config.sh) +# Arguments: +# --execute Actually remove files (default is dry-run) +# --python Clean Python artifacts (auto-detected if omitted) +# --td Clean TouchDesigner artifacts (auto-detected if omitted) +# --glsl Clean GLSL compiled shaders (auto-detected if omitted) +# --all Clean all known artifact types regardless of detection +# -h, --help Show help +# Returns: +# 0 on success, 1 on failure + +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${SCRIPT_DIR}/_common.sh" + +main() { + local execute=0 + local force_python=0 + local force_td=0 + local force_glsl=0 + local force_all=0 + + while [[ $# -gt 0 ]]; do + case "$1" in + --help | -h) + echo "Usage: ./scripts/git/clean_build.sh [OPTIONS]" + echo "" + echo "Clean build artifacts. Defaults to --dry-run (safe preview)." + echo "Pass --execute to actually delete files." + echo "" + echo "Options:" + echo " --execute Remove files (without this flag, only previews)" + echo " --python Include Python artifacts (__pycache__, *.pyc, dist/, etc.)" + echo " --td Include TouchDesigner artifacts (*.toe.bak, Backup/)" + echo " --glsl Include compiled GLSL shaders (*.spv)" + echo " --all Include all artifact types regardless of detection" + echo " -h, --help Show this help" + echo "" + echo "Auto-detects project type from pyproject.toml, *.toe files, *.glsl files." + exit 0 + ;; + --execute) execute=1 ;; + --python) force_python=1 ;; + --td) force_td=1 ;; + --glsl) force_glsl=1 ;; + --all) force_all=1 ;; + *) + echo "[ERROR] Unknown flag: $1" >&2 + exit 1 + ;; + esac + shift + done + + cd "${PROJECT_ROOT}" || { + err "Cannot find project root" + exit 1 + } + + # Auto-detect project types (unless --all) + local has_python=0 has_td=0 has_glsl=0 + if [[ ${force_all} -eq 0 ]]; then + [[ -f "pyproject.toml" ]] || [[ -f "setup.py" ]] || [[ -f "requirements.txt" ]] && has_python=1 + [[ -n "$(find . -maxdepth 3 -name "*.py" -print -quit 2>/dev/null)" ]] && has_python=1 + [[ -n "$(find . -maxdepth 3 \( -name "*.toe" -o -name "*.tox" \) -print -quit 2>/dev/null)" ]] && has_td=1 + [[ -n "$(find . -maxdepth 5 \( -name "*.glsl" -o -name "*.spv" \) -print -quit 2>/dev/null)" ]] && has_glsl=1 + fi + + [[ ${force_python} -eq 1 ]] && has_python=1 + [[ ${force_td} -eq 1 ]] && has_td=1 + [[ ${force_glsl} -eq 1 ]] && has_glsl=1 + [[ ${force_all} -eq 1 ]] && has_python=1 && has_td=1 && has_glsl=1 + + local mode_label="DRY RUN" + [[ ${execute} -eq 1 ]] && mode_label="EXECUTE" + + echo "=== Clean Build Artifacts [${mode_label}] ===" + echo "" + [[ ${execute} -eq 0 ]] && echo "⚠ Dry run — pass --execute to actually delete files." && echo "" + + local total_cleaned=0 + + # ── Common patterns (always) ────────────────────────────────────────────── + echo "--- Common ---" + local common_patterns=( + ".DS_Store" + "Thumbs.db" + "desktop.ini" + "*.tmp" + "*.bak" + "ehthumbs.db" + ) + for pattern in "${common_patterns[@]}"; do + while IFS= read -r f; do + echo " ${f}" + [[ ${execute} -eq 1 ]] && rm -f "${f}" + ((total_cleaned++)) || true + done < <(find . -name "${pattern}" -not -path "./.git/*" 2>/dev/null) + done + + # ── Python ──────────────────────────────────────────────────────────────── + if [[ ${has_python} -eq 1 ]]; then + echo "" + echo "--- Python ---" + # __pycache__ directories + while IFS= read -r d; do + echo " ${d}/" + [[ ${execute} -eq 1 ]] && rm -rf "${d}" + ((total_cleaned++)) || true + done < <(find . -type d -name "__pycache__" -not -path "./.git/*" 2>/dev/null) + + # .pyc / .pyo files + while IFS= read -r f; do + echo " ${f}" + [[ ${execute} -eq 1 ]] && rm -f "${f}" + ((total_cleaned++)) || true + done < <(find . \( -name "*.pyc" -o -name "*.pyo" \) -not -path "./.git/*" 2>/dev/null) + + # Build directories at project root + for d in dist build .eggs .pytest_cache .mypy_cache .ruff_cache; do + if [[ -d "${d}" ]]; then + echo " ${d}/" + [[ ${execute} -eq 1 ]] && rm -rf "${d}" + ((total_cleaned++)) || true + fi + done + + # .egg-info directories + while IFS= read -r d; do + echo " ${d}/" + [[ ${execute} -eq 1 ]] && rm -rf "${d}" + ((total_cleaned++)) || true + done < <(find . -maxdepth 3 -type d -name "*.egg-info" -not -path "./.git/*" 2>/dev/null) + fi + + # ── TouchDesigner ───────────────────────────────────────────────────────── + if [[ ${has_td} -eq 1 ]]; then + echo "" + echo "--- TouchDesigner ---" + # Auto-backup .toe.bak files + while IFS= read -r f; do + echo " ${f}" + [[ ${execute} -eq 1 ]] && rm -f "${f}" + ((total_cleaned++)) || true + done < <(find . -name "*.toe.bak" -not -path "./.git/*" 2>/dev/null) + + # Backup/ directories created by TD + while IFS= read -r d; do + echo " ${d}/" + [[ ${execute} -eq 1 ]] && rm -rf "${d}" + ((total_cleaned++)) || true + done < <(find . -maxdepth 3 -type d -name "Backup" -not -path "./.git/*" 2>/dev/null) + + # TD crash logs + while IFS= read -r f; do + echo " ${f}" + [[ ${execute} -eq 1 ]] && rm -f "${f}" + ((total_cleaned++)) || true + done < <(find . -maxdepth 2 -name "crash.*" -not -path "./.git/*" 2>/dev/null) + fi + + # ── GLSL compiled shaders ───────────────────────────────────────────────── + if [[ ${has_glsl} -eq 1 ]]; then + echo "" + echo "--- GLSL / Compiled Shaders ---" + while IFS= read -r f; do + echo " ${f}" + [[ ${execute} -eq 1 ]] && rm -f "${f}" + ((total_cleaned++)) || true + done < <(find . -name "*.spv" -not -path "./.git/*" 2>/dev/null) + fi + + # ── Summary ─────────────────────────────────────────────────────────────── + echo "" + if [[ ${total_cleaned} -eq 0 ]]; then + echo "✓ Nothing to clean" + elif [[ ${execute} -eq 1 ]]; then + echo "✓ Cleaned ${total_cleaned} item(s)" + else + echo "Found ${total_cleaned} item(s) — run with --execute to delete" + fi +} + +main "$@" diff --git a/scripts/git/create_release.sh b/scripts/git/create_release.sh new file mode 100644 index 0000000..9f2209b --- /dev/null +++ b/scripts/git/create_release.sh @@ -0,0 +1,202 @@ +#!/usr/bin/env bash +# create_release.sh - Create annotated version tags to trigger GitHub Releases +# Purpose: Create a properly annotated semver tag on the target branch. +# Annotated tags store author, date, and message — required for the +# release.yml GitHub Actions workflow and distinguishable from backup tags. +# Usage: ./scripts/git/create_release.sh [OPTIONS] +# +# Globals: +# SCRIPT_DIR - Directory containing this script +# PROJECT_ROOT - Auto-detected git repo root (set by _config.sh) +# CGW_TARGET_BRANCH - Branch that receives releases (default: main) +# Arguments: +# Version string: v1.2.3 or 1.2.3 (v prefix auto-added) +# --message Tag annotation message (default: "Release ") +# --non-interactive Skip prompts +# --dry-run Show what would happen without tagging +# --push Push the tag to origin after creation +# -h, --help Show help +# Returns: +# 0 on success, 1 on failure + +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${SCRIPT_DIR}/_common.sh" + +# validate_semver - Check that version matches vX.Y.Z or vX.Y.Z-suffix format. +validate_semver() { + local ver="$1" + if [[ ! "${ver}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([._-][a-zA-Z0-9._-]+)?$ ]]; then + echo "[ERROR] Version '${ver}' does not match semver format (e.g. v1.2.3, v1.2.3-rc1)" >&2 + return 1 + fi +} + +main() { + local version="" + local tag_message="" + local non_interactive=0 + local dry_run=0 + local push_tag=0 + + [[ "${CGW_NON_INTERACTIVE:-0}" == "1" ]] && non_interactive=1 + + while [[ $# -gt 0 ]]; do + case "$1" in + --help | -h) + echo "Usage: ./scripts/git/create_release.sh [OPTIONS] " + echo "" + echo "Create an annotated version tag to trigger the GitHub Release workflow." + echo "Must be run from the target branch (default: ${CGW_TARGET_BRANCH})." + echo "" + echo "Arguments:" + echo " Semver version: v1.2.3 or 1.2.3 (v prefix auto-added)" + echo "" + echo "Options:" + echo " --message Annotation message (default: 'Release ')" + echo " --push Push tag to origin after creation" + echo " --non-interactive Skip confirmation prompt" + echo " --dry-run Preview without creating tag" + echo " -h, --help Show this help" + echo "" + echo "The annotated tag triggers release.yml (GitHub Actions) which creates" + echo "a GitHub Release with auto-generated notes and source archives." + echo "" + echo "Examples:" + echo " ./scripts/git/create_release.sh v1.0.0" + echo " ./scripts/git/create_release.sh v1.0.0 --message 'First stable release'" + echo " ./scripts/git/create_release.sh v1.0.0 --push" + exit 0 + ;; + --message) + tag_message="${2:-}" + shift + ;; + --push) push_tag=1 ;; + --non-interactive) non_interactive=1 ;; + --dry-run) dry_run=1 ;; + -*) + echo "[ERROR] Unknown flag: $1" >&2 + exit 1 + ;; + *) + version="$1" + ;; + esac + shift + done + + cd "${PROJECT_ROOT}" || { + err "Cannot find project root" + exit 1 + } + + # Require version argument + if [[ -z "${version}" ]]; then + echo "[ERROR] Version argument required (e.g. v1.2.3)" >&2 + echo "Usage: ./scripts/git/create_release.sh v1.2.3" >&2 + exit 1 + fi + + # Normalize: add v prefix if missing + if [[ "${version}" != v* ]]; then + version="v${version}" + fi + + # Validate semver + if ! validate_semver "${version}"; then + exit 1 + fi + + # Check current branch + local current_branch + current_branch=$(git branch --show-current 2>/dev/null || true) + if [[ -z "${current_branch}" ]]; then + echo "[ERROR] Detached HEAD state — checkout ${CGW_TARGET_BRANCH} first" >&2 + exit 1 + fi + + if [[ "${current_branch}" != "${CGW_TARGET_BRANCH}" ]]; then + echo "[ERROR] Must be on target branch (${CGW_TARGET_BRANCH}), currently on: ${current_branch}" >&2 + echo " git checkout ${CGW_TARGET_BRANCH}" >&2 + exit 1 + fi + + # Check for uncommitted changes + if ! git diff-index --quiet HEAD -- 2>/dev/null; then + echo "[ERROR] Uncommitted changes present — commit before releasing" >&2 + git status --short >&2 + exit 1 + fi + + # Check tag does not already exist + if git tag -l "${version}" | grep -q "^${version}$"; then + echo "[ERROR] Tag '${version}' already exists" >&2 + echo " List tags: git tag -l 'v*'" >&2 + exit 1 + fi + + # Default annotation message + if [[ -z "${tag_message}" ]]; then + tag_message="Release ${version}" + fi + + # Show preview + echo "=== Create Release Tag ===" + echo "" + echo " Version: ${version}" + echo " Branch: ${current_branch}" + echo " Commit: $(git log -1 --format='%h %s')" + echo " Message: ${tag_message}" + echo " Push: $([ ${push_tag} -eq 1 ] && echo 'yes (after creation)' || echo 'no (manual push required)')" + echo "" + + if [[ ${dry_run} -eq 1 ]]; then + echo "--- Dry run: no tag created ---" + echo "Command would be: git tag -a '${version}' -m '${tag_message}'" + [[ ${push_tag} -eq 1 ]] && echo "Followed by: git push origin '${version}'" + exit 0 + fi + + # Confirm + if [[ ${non_interactive} -eq 0 ]]; then + read -r -p "Create annotated tag '${version}'? (yes/no): " answer + case "${answer,,}" in + y | yes) ;; + *) + echo "Cancelled" + exit 0 + ;; + esac + fi + + # Create annotated tag + if git tag -a "${version}" -m "${tag_message}"; then + echo "✓ Created annotated tag: ${version}" + else + echo "[ERROR] Failed to create tag" >&2 + exit 1 + fi + + # Push tag if requested + if [[ ${push_tag} -eq 1 ]]; then + echo "Pushing tag to origin..." + if git push origin "${version}"; then + echo "✓ Tag pushed: ${version}" + echo "" + echo "GitHub Release workflow triggered." + echo "Check: https://github.com/$(git remote get-url origin | sed 's|.*github.com[:/]||;s|\.git$||')/actions" + else + echo "[ERROR] Failed to push tag. Push manually:" >&2 + echo " git push origin ${version}" >&2 + exit 1 + fi + else + echo "" + echo "Next step — push to trigger GitHub Release:" + echo " git push origin ${version}" + fi +} + +main "$@" diff --git a/scripts/git/repo_health.sh b/scripts/git/repo_health.sh new file mode 100644 index 0000000..e3f8b9b --- /dev/null +++ b/scripts/git/repo_health.sh @@ -0,0 +1,222 @@ +#!/usr/bin/env bash +# repo_health.sh - Repository health check and maintenance +# Purpose: Run integrity checks (git fsck), trigger garbage collection (git gc), +# report repository size, detect large files, and check ref consistency. +# Especially useful for TouchDesigner projects with large binary files. +# Usage: ./scripts/git/repo_health.sh [OPTIONS] +# +# Globals: +# SCRIPT_DIR - Directory containing this script +# PROJECT_ROOT - Auto-detected git repo root (set by _config.sh) +# Arguments: +# --gc Run git gc (garbage collection) in addition to checks +# --full Run git fsck --full (slower but more thorough) +# --large Report files larger than N MB (default: 10) +# -h, --help Show help +# Returns: +# 0 on healthy repo, 1 if issues found + +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${SCRIPT_DIR}/_common.sh" + +# human_size - Convert bytes to human-readable string (POSIX: uses awk, not bc) +human_size() { + local bytes="$1" + if ((bytes >= 1073741824)); then + awk "BEGIN{printf \"%.1f GB\", ${bytes}/1073741824}" + elif ((bytes >= 1048576)); then + awk "BEGIN{printf \"%.1f MB\", ${bytes}/1048576}" + elif ((bytes >= 1024)); then + awk "BEGIN{printf \"%.1f KB\", ${bytes}/1024}" + else + printf "%d B" "${bytes}" + fi +} + +main() { + local run_gc=0 + local full_fsck=0 + local large_threshold_mb=10 + + while [[ $# -gt 0 ]]; do + case "$1" in + --help | -h) + echo "Usage: ./scripts/git/repo_health.sh [OPTIONS]" + echo "" + echo "Check repository health, find large files, and optionally run maintenance." + echo "" + echo "Options:" + echo " --gc Run git gc (garbage collection) — removes unreachable objects" + echo " --full Run git fsck --full (slower, more thorough)" + echo " --large Report files >N MB in git history (default: 10)" + echo " -h, --help Show this help" + echo "" + echo "Checks performed:" + echo " 1. Repository integrity (git fsck)" + echo " 2. Object store size" + echo " 3. Large files in git history" + echo " 4. Stale backup tags count" + echo " 5. Branch divergence summary" + exit 0 + ;; + --gc) run_gc=1 ;; + --full) full_fsck=1 ;; + --large) + large_threshold_mb="${2:-10}" + shift + ;; + *) + echo "[ERROR] Unknown flag: $1" >&2 + exit 1 + ;; + esac + shift + done + + cd "${PROJECT_ROOT}" || { + err "Cannot find project root" + exit 1 + } + + local overall_ok=0 + + echo "=== Repository Health Check ===" + echo "Repository: ${PROJECT_ROOT}" + echo "Date: $(date)" + echo "" + + # ── [1] Integrity check (git fsck) ─────────────────────────────────────── + echo "--- [1/5] Integrity Check ---" + local fsck_args=("--no-reflogs") + [[ ${full_fsck} -eq 1 ]] && fsck_args=("--full") + + local fsck_output + if fsck_output=$(git fsck "${fsck_args[@]}" 2>&1); then + echo " ✓ No integrity issues found" + else + echo " ⚠ Integrity issues detected:" + echo "${fsck_output}" | grep -v "^Checking" | head -20 + overall_ok=1 + fi + echo "" + + # ── [2] Object store size ───────────────────────────────────────────────── + echo "--- [2/5] Object Store Size ---" + local git_dir + git_dir="$(git rev-parse --git-dir 2>/dev/null)" + + # Use git count-objects (cross-platform, no GNU du needed) + local obj_size_kb=0 pack_count=0 + while IFS=': ' read -r key val; do + case "${key}" in + size) obj_size_kb=$((obj_size_kb + val)) ;; + size-pack) obj_size_kb=$((obj_size_kb + val)) ;; + packs) pack_count="${val}" ;; + esac + done < <(git count-objects -v 2>/dev/null) + local obj_size_bytes=$((obj_size_kb * 1024)) + echo " Objects: $(human_size "${obj_size_bytes}")" + echo " Packs: ${pack_count}" + + if [[ ${pack_count} -gt 3 ]]; then + echo " ⚠ Many pack files (${pack_count}) — consider running: git gc" + fi + + # Worktree size — use POSIX du -sk (1024-byte blocks), available everywhere + local wt_size_kb + wt_size_kb=$(du -sk . 2>/dev/null | cut -f1 || echo "0") + local wt_size_bytes=$((wt_size_kb * 1024)) + echo " Worktree: $(human_size "${wt_size_bytes}") (includes .git)" + echo "" + + # ── [3] Large files in history ──────────────────────────────────────────── + echo "--- [3/5] Large Files in History (>${large_threshold_mb}MB) ---" + local threshold_bytes=$((large_threshold_mb * 1048576)) + local large_count=0 + + # Use git cat-file to find large blobs + while IFS=' ' read -r size hash; do + if [[ ${size} -gt ${threshold_bytes} ]]; then + # Find the path for this blob + local path + path=$(git log --all --find-object="${hash}" --oneline --name-only 2>/dev/null | grep -v "^[0-9a-f]" | head -1 || echo "(unknown path)") + printf " %s %s\n" "$(human_size "${size}")" "${path:-${hash}}" + ((large_count++)) || true + fi + done < <(git cat-file --batch-check='%(objectsize) %(objectname)' --batch-all-objects 2>/dev/null | awk '$1 ~ /^[0-9]+$/') + + if [[ ${large_count} -eq 0 ]]; then + echo " ✓ No files exceed ${large_threshold_mb}MB threshold" + else + echo "" + echo " ⚠ ${large_count} large file(s) found in git history" + echo " Consider: git lfs track for future additions" + overall_ok=1 + fi + echo "" + + # ── [4] Backup tag count ────────────────────────────────────────────────── + echo "--- [4/5] Backup Tags ---" + local backup_count + backup_count=$(git tag -l "pre-merge-backup-*" 2>/dev/null | wc -l | tr -d ' ') + echo " Backup tags: ${backup_count}" + + if [[ ${backup_count} -gt 20 ]]; then + echo " ⚠ Many backup tags — consider cleaning old ones:" + echo " git tag -l 'pre-merge-backup-*' | head -$((backup_count - 5)) | xargs git tag -d" + elif [[ ${backup_count} -gt 0 ]]; then + echo " Most recent: $(git tag -l 'pre-merge-backup-*' | sort -r | head -1)" + fi + echo "" + + # ── [5] Branch summary ──────────────────────────────────────────────────── + echo "--- [5/5] Branch Status ---" + local current_branch + current_branch=$(git branch --show-current 2>/dev/null || echo "(detached)") + echo " Current: ${current_branch}" + + 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/origin/${branch}" + if git show-ref --verify --quiet "${remote_ref}" 2>/dev/null; then + ahead=$(git rev-list --count "origin/${branch}..${branch}" 2>/dev/null || echo "?") + behind=$(git rev-list --count "${branch}..origin/${branch}" 2>/dev/null || echo "?") + echo " ${branch}: ${ahead} ahead, ${behind} behind origin" + else + echo " ${branch}: (no remote tracking branch)" + fi + fi + done + echo "" + + # ── Garbage collection (optional) ──────────────────────────────────────── + if [[ ${run_gc} -eq 1 ]]; then + echo "--- Garbage Collection ---" + echo "Running git gc --auto..." + if git gc --auto 2>&1 | grep -v "^Auto packing\|^Counting\|^Delta\|^Compressing\|^Writing\|^Total" | head -10; then + echo " ✓ Garbage collection complete" + else + echo " ✓ Nothing to collect" + fi + echo "" + fi + + # ── Summary ─────────────────────────────────────────────────────────────── + echo "=== Summary ===" + if [[ ${overall_ok} -eq 0 ]]; then + echo "✓ Repository is healthy" + else + echo "⚠ Issues found — review output above" + echo "" + echo "Common fixes:" + echo " Integrity issues: git fsck --full" + echo " Pack files: git gc" + echo " Large files: git lfs track '*.ext'" + fi + + return ${overall_ok} +} + +main "$@" diff --git a/scripts/git/setup_attributes.sh b/scripts/git/setup_attributes.sh new file mode 100644 index 0000000..8264e39 --- /dev/null +++ b/scripts/git/setup_attributes.sh @@ -0,0 +1,370 @@ +#!/usr/bin/env bash +# setup_attributes.sh - Generate .gitattributes for binary and project-specific files +# Purpose: Configure git to handle binary files correctly (no diff, no merge conflicts) +# and set text file line-ending normalization. Supports Python, TouchDesigner, +# GLSL, and common asset types. +# Usage: ./scripts/git/setup_attributes.sh [OPTIONS] +# +# Globals: +# SCRIPT_DIR - Directory containing this script +# PROJECT_ROOT - Auto-detected git repo root (set by _config.sh) +# Arguments: +# --dry-run Show what would be written without modifying .gitattributes +# --force Overwrite existing .gitattributes without prompting +# -h, --help Show help +# Returns: +# 0 on success, 1 on failure + +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${SCRIPT_DIR}/_common.sh" + +# detect_project_types - Scan PROJECT_ROOT for known project type indicators. +# Sets global flags: has_python, has_touchdesigner, has_glsl, has_rust, has_js, has_go +detect_project_types() { + has_python=0 + has_touchdesigner=0 + has_glsl=0 + has_rust=0 + has_js=0 + has_go=0 + + # Python + [[ -f "${PROJECT_ROOT}/pyproject.toml" ]] && has_python=1 + [[ -f "${PROJECT_ROOT}/setup.py" ]] && has_python=1 + [[ -f "${PROJECT_ROOT}/requirements.txt" ]] && has_python=1 + [[ -n "$(find "${PROJECT_ROOT}" -maxdepth 3 -name "*.py" -print -quit 2>/dev/null)" ]] && has_python=1 + + # TouchDesigner + [[ -n "$(find "${PROJECT_ROOT}" -maxdepth 3 \( -name "*.toe" -o -name "*.tox" \) -print -quit 2>/dev/null)" ]] && has_touchdesigner=1 + + # GLSL / shaders + [[ -n "$(find "${PROJECT_ROOT}" -maxdepth 5 \( -name "*.glsl" -o -name "*.vert" -o -name "*.frag" -o -name "*.comp" -o -name "*.geom" -o -name "*.spv" \) -print -quit 2>/dev/null)" ]] && has_glsl=1 + + # Rust + [[ -f "${PROJECT_ROOT}/Cargo.toml" ]] && has_rust=1 + + # JavaScript / TypeScript + [[ -f "${PROJECT_ROOT}/package.json" ]] && has_js=1 + + # Go + [[ -f "${PROJECT_ROOT}/go.mod" ]] && has_go=1 +} + +# build_attributes - Build .gitattributes content based on detected project types. +# Uses has_python, has_touchdesigner, has_glsl, has_js, has_rust, has_go globals. +build_attributes() { + + cat <<'EOF' +# .gitattributes — generated by claude-git-workflow setup_attributes.sh +# Controls line endings (text files) and merge strategy (binary files). + +# ============================================================================ +# TEXT FILES — normalize line endings to LF in repo, native on checkout +# ============================================================================ + +* text=auto + +# Shell scripts always LF +*.sh text eol=lf +*.bash text eol=lf + +# Config and data files +*.json text eol=lf +*.yaml text eol=lf +*.yml text eol=lf +*.toml text eol=lf +*.ini text eol=lf +*.cfg text eol=lf +*.conf text eol=lf +*.md text eol=lf +*.txt text eol=lf +*.csv text eol=lf + +EOF + + # Python-specific + if [[ "${has_python}" -eq 1 ]]; then + cat <<'EOF' +# ============================================================================ +# PYTHON +# ============================================================================ + +*.py text eol=lf diff=python +*.pyx text eol=lf +*.pxd text eol=lf + +# Python binary artifacts — do not diff or merge +*.pyc binary +*.pyd binary +*.pyo binary +*.so binary +*.egg binary +*.whl binary + +# Python build artifacts — exclude from archives +*.egg-info/ export-ignore +dist/ export-ignore +build/ export-ignore +__pycache__/ export-ignore + +EOF + fi + + # TouchDesigner-specific + if [[ "${has_touchdesigner}" -eq 1 ]]; then + cat <<'EOF' +# ============================================================================ +# TOUCHDESIGNER +# ============================================================================ + +# .toe (project files) and .tox (components) are SQLite databases — binary +*.toe binary +*.tox binary + +# TouchDesigner auto-backups — exclude from archives +*.toe.bak export-ignore +Backup/ export-ignore + +EOF + fi + + # GLSL / shader-specific + if [[ "${has_glsl}" -eq 1 ]]; then + cat <<'EOF' +# ============================================================================ +# GLSL / SHADERS +# ============================================================================ + +# Source shader files — text, always LF +*.glsl text eol=lf +*.vert text eol=lf +*.frag text eol=lf +*.geom text eol=lf +*.tesc text eol=lf +*.tese text eol=lf +*.comp text eol=lf +*.rgen text eol=lf +*.rmiss text eol=lf +*.rchit text eol=lf + +# Compiled SPIR-V — binary, do not diff or merge +*.spv binary + +EOF + fi + + # JavaScript/TypeScript + if [[ "${has_js}" -eq 1 ]]; then + cat <<'EOF' +# ============================================================================ +# JAVASCRIPT / TYPESCRIPT +# ============================================================================ + +*.js text eol=lf +*.ts text eol=lf +*.jsx text eol=lf +*.tsx text eol=lf +*.mjs text eol=lf +*.cjs text eol=lf +*.vue text eol=lf + +# Build output — exclude from archives +node_modules/ export-ignore +dist/ export-ignore +build/ export-ignore + +EOF + fi + + # Go + if [[ "${has_go}" -eq 1 ]]; then + cat <<'EOF' +# ============================================================================ +# GO +# ============================================================================ + +*.go text eol=lf diff=golang + +EOF + fi + + # Rust + if [[ "${has_rust}" -eq 1 ]]; then + cat <<'EOF' +# ============================================================================ +# RUST +# ============================================================================ + +*.rs text eol=lf diff=rust +Cargo.lock text eol=lf + +EOF + fi + + # Common binary assets (always added) + cat <<'EOF' +# ============================================================================ +# COMMON BINARY ASSETS +# ============================================================================ + +# Images +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.bmp binary +*.tga binary +*.tiff binary +*.webp binary +*.ico binary +*.psd binary + +# HDR / EXR (common in TD/GLSL projects) +*.exr binary +*.hdr binary + +# Audio +*.wav binary +*.mp3 binary +*.ogg binary +*.flac binary +*.aiff binary + +# Video +*.mp4 binary +*.mov binary +*.avi binary +*.mkv binary + +# Fonts +*.ttf binary +*.otf binary +*.woff binary +*.woff2 binary + +# 3D / scene files +*.fbx binary +*.obj text eol=lf +*.mtl text eol=lf +*.blend binary +*.glb binary + +# Archives +*.zip binary +*.tar binary +*.gz binary +*.7z binary + +# PDF +*.pdf binary + +# ============================================================================ +# EXPORT ARCHIVE EXCLUSIONS +# ============================================================================ + +# Never include in git archive exports (e.g. GitHub source tarballs) +.gitignore export-ignore +.gitattributes export-ignore +.github/ export-ignore +tests/ export-ignore +*.log export-ignore +logs/ export-ignore + +EOF +} + +main() { + local dry_run=0 + local force=0 + + while [[ $# -gt 0 ]]; do + case "$1" in + --help | -h) + echo "Usage: ./scripts/git/setup_attributes.sh [OPTIONS]" + echo "" + echo "Generate .gitattributes for the project, configuring:" + echo " - Line ending normalization (text files → LF in repo)" + echo " - Binary file handling (no diff, no merge conflicts)" + echo " - Export-ignore for build artifacts and CI files" + echo "" + echo "Auto-detects: Python, TouchDesigner (.toe/.tox), GLSL shaders," + echo " JavaScript/TypeScript, Go, Rust" + echo "" + echo "Options:" + echo " --dry-run Show output without writing to .gitattributes" + echo " --force Overwrite existing .gitattributes without prompting" + echo " -h, --help Show this help" + exit 0 + ;; + --dry-run) dry_run=1 ;; + --force) force=1 ;; + *) + echo "[ERROR] Unknown flag: $1" >&2 + exit 1 + ;; + esac + shift + done + + cd "${PROJECT_ROOT}" || { + err "Cannot find project root" + exit 1 + } + + echo "=== Setup .gitattributes ===" + echo "" + + # Detect project types + local has_python has_touchdesigner has_glsl has_rust has_js has_go + detect_project_types + + echo "Detected project types:" + [[ "${has_python}" -eq 1 ]] && echo " ✓ Python" + [[ "${has_touchdesigner}" -eq 1 ]] && echo " ✓ TouchDesigner" + [[ "${has_glsl}" -eq 1 ]] && echo " ✓ GLSL/shaders" + [[ "${has_js}" -eq 1 ]] && echo " ✓ JavaScript/TypeScript" + [[ "${has_go}" -eq 1 ]] && echo " ✓ Go" + [[ "${has_rust}" -eq 1 ]] && echo " ✓ Rust" + echo "" + + local attr_content + attr_content=$(build_attributes) + + if [[ ${dry_run} -eq 1 ]]; then + echo "--- Dry run: would write to .gitattributes ---" + echo "" + echo "${attr_content}" + echo "" + echo "--- End dry run ---" + exit 0 + fi + + # Check existing file + if [[ -f "${PROJECT_ROOT}/.gitattributes" ]] && [[ ${force} -eq 0 ]]; then + echo "⚠ .gitattributes already exists." + echo "" + read -r -p "Overwrite? (yes/no) [no]: " answer + case "${answer,,}" in + y | yes) ;; + *) + echo "Aborted — .gitattributes not modified." + exit 0 + ;; + esac + fi + + echo "${attr_content}" >"${PROJECT_ROOT}/.gitattributes" + echo "✓ Written: ${PROJECT_ROOT}/.gitattributes" + echo "" + echo "Next steps:" + echo " git add .gitattributes" + echo " git commit -m 'chore: configure .gitattributes for binary and text files'" + echo "" + echo "If you have existing binary files already tracked, renormalize them:" + echo " git add --renormalize ." + echo " git commit -m 'chore: renormalize line endings'" +} + +main "$@" diff --git a/scripts/git/stash_work.sh b/scripts/git/stash_work.sh new file mode 100644 index 0000000..0489595 --- /dev/null +++ b/scripts/git/stash_work.sh @@ -0,0 +1,243 @@ +#!/usr/bin/env bash +# stash_work.sh - Safe stash wrapper with logging and untracked file support +# Purpose: Wrapper around git stash that always includes untracked files (-u), +# supports named stashes, and logs operations for traceability. +# Usage: ./scripts/git/stash_work.sh [OPTIONS] +# +# Commands: +# push [message] Stash current work (default command if omitted) +# pop Apply most recent stash and remove it +# apply [ref] Apply stash without removing it +# list Show all stashes with metadata +# drop [ref] Remove a specific stash (interactive if omitted) +# show [ref] Show contents of a stash +# clear Remove ALL stashes (requires confirmation) +# +# Globals: +# SCRIPT_DIR - Directory containing this script +# PROJECT_ROOT - Auto-detected git repo root (set by _config.sh) +# Returns: +# 0 on success, 1 on failure + +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${SCRIPT_DIR}/_common.sh" + +usage() { + echo "Usage: ./scripts/git/stash_work.sh [OPTIONS]" + echo "" + echo "Safe stash wrapper — always includes untracked files (-u)." + echo "" + echo "Commands:" + echo " push [message] Stash current work with optional description" + echo " pop Apply and remove most recent stash" + echo " apply [ref] Apply stash without removing it (default: stash@{0})" + echo " list Show all stashes with branch and date" + echo " drop [ref] Remove a specific stash" + echo " show [ref] Show diff for a stash (default: stash@{0})" + echo " clear Remove ALL stashes (requires confirmation)" + echo "" + echo "Options (for push):" + echo " --include-index Also stash staged changes (git stash push -S)" + echo " --no-untracked Omit untracked files (not recommended)" + echo "" + echo "Examples:" + echo " ./scripts/git/stash_work.sh push 'wip: half-done refactor'" + echo " ./scripts/git/stash_work.sh pop" + echo " ./scripts/git/stash_work.sh list" + echo " ./scripts/git/stash_work.sh apply stash@{2}" +} + +main() { + if [[ $# -eq 0 ]]; then + usage + exit 0 + fi + + local command="$1" + shift + + cd "${PROJECT_ROOT}" || { + err "Cannot find project root" + exit 1 + } + + case "${command}" in + push | save) + local message="" + local include_index=0 + local untracked=1 + + while [[ $# -gt 0 ]]; do + case "$1" in + --include-index) include_index=1 ;; + --no-untracked) untracked=0 ;; + --help | -h) usage; exit 0 ;; + -*) echo "[ERROR] Unknown flag: $1" >&2; exit 1 ;; + *) message="$1" ;; + esac + shift + done + + # Check for changes to stash + if git diff --quiet && git diff --cached --quiet && [[ -z "$(git ls-files --others --exclude-standard)" ]]; then + echo "[OK] Nothing to stash — working tree is clean" + exit 0 + fi + + local stash_args=() + [[ ${untracked} -eq 1 ]] && stash_args+=("-u") + [[ ${include_index} -eq 1 ]] && stash_args+=("-S") + if [[ -n "${message}" ]]; then + stash_args+=("--message" "${message}") + fi + + echo "=== Stash Work ===" + echo "" + echo "Changes being stashed:" + git status --short + echo "" + + if git stash push "${stash_args[@]}"; then + echo "" + echo "✓ Stash created: $(git stash list | head -1)" + echo "" + echo "Working tree is now clean." + echo "Restore with: ./scripts/git/stash_work.sh pop" + else + echo "[ERROR] Stash failed" >&2 + exit 1 + fi + ;; + + pop) + local ref="${1:-}" + echo "=== Pop Stash ===" + echo "" + + if [[ -z "$(git stash list 2>/dev/null)" ]]; then + echo "[OK] No stashes to pop" + exit 0 + fi + + local target="${ref:-stash@{0}}" + echo "Applying: $(git stash list | grep "^${target}" || echo "${target}")" + echo "" + + if git stash pop "${target}"; then + echo "" + echo "✓ Stash applied and removed" + else + echo "[ERROR] Stash pop failed — conflicts may need manual resolution" >&2 + echo " Resolve conflicts, then: git stash drop ${target}" + exit 1 + fi + ;; + + apply) + local ref="${1:-stash@{0}}" + echo "=== Apply Stash (keeping stash) ===" + echo "" + + if [[ -z "$(git stash list 2>/dev/null)" ]]; then + echo "[OK] No stashes to apply" + exit 0 + fi + + echo "Applying: $(git stash list | grep "^${ref}" || echo "${ref}")" + echo "" + + if git stash apply "${ref}"; then + echo "" + echo "✓ Stash applied (stash retained — use 'drop' to remove)" + else + echo "[ERROR] Stash apply failed" >&2 + exit 1 + fi + ;; + + list) + echo "=== Stash List ===" + echo "" + if [[ -z "$(git stash list 2>/dev/null)" ]]; then + echo " (no stashes)" + else + git stash list --format="%C(yellow)%gd%C(reset) %C(green)%cr%C(reset) on %C(cyan)%gs%C(reset)" + fi + ;; + + drop) + local ref="${1:-}" + echo "=== Drop Stash ===" + echo "" + + if [[ -z "$(git stash list 2>/dev/null)" ]]; then + echo "[OK] No stashes to drop" + exit 0 + fi + + if [[ -z "${ref}" ]]; then + echo "Stashes:" + git stash list + echo "" + read -r -p "Which stash to drop? (e.g. stash@{0}): " ref + [[ -z "${ref}" ]] && echo "Cancelled" && exit 0 + fi + + echo "Dropping: $(git stash list | grep "^${ref}" || echo "${ref}")" + read -r -p "Confirm drop? (yes/no): " answer + case "${answer,,}" in + y | yes) + git stash drop "${ref}" && echo "✓ Stash dropped" + ;; + *) + echo "Cancelled" + ;; + esac + ;; + + show) + local ref="${1:-stash@{0}}" + echo "=== Stash Contents: ${ref} ===" + echo "" + git stash show -p "${ref}" + ;; + + clear) + echo "=== Clear All Stashes ===" + echo "" + + if [[ -z "$(git stash list 2>/dev/null)" ]]; then + echo "[OK] No stashes to clear" + exit 0 + fi + + echo "All stashes:" + git stash list + echo "" + echo "⚠ WARNING: This permanently removes ALL stashes listed above." + read -r -p "Type 'CLEAR' to confirm: " confirm + + if [[ "${confirm}" == "CLEAR" ]]; then + git stash clear && echo "✓ All stashes cleared" + else + echo "Cancelled" + fi + ;; + + --help | -h | help) + usage + exit 0 + ;; + + *) + echo "[ERROR] Unknown command: ${command}" >&2 + echo "" + usage + exit 1 + ;; + esac +} + +main "$@" diff --git a/skill/SKILL.md b/skill/SKILL.md index 6e9d3b3..f602468 100644 --- a/skill/SKILL.md +++ b/skill/SKILL.md @@ -169,3 +169,86 @@ Creates a GitHub PR from source → target via `gh` CLI. Requires `gh auth login ./scripts/git/merge_docs.sh ./scripts/git/merge_docs.sh --non-interactive ``` + +**Undoing something:** +```bash +# Undo last commit (keep changes staged, creates backup tag): +./scripts/git/undo_last.sh commit + +# Remove a file from staging: +./scripts/git/undo_last.sh unstage + +# Fix last commit message (local only — before push): +./scripts/git/undo_last.sh amend-message "fix: correct message" + +# Discard working-tree changes (irreversible — interactive only): +./scripts/git/undo_last.sh discard +``` + +**Branch cleanup:** +```bash +# Dry-run preview (safe default — shows what would be deleted): +./scripts/git/branch_cleanup.sh + +# Execute: delete merged branches + prune stale remote-tracking refs: +./scripts/git/branch_cleanup.sh --execute + +# Also clean up old backup tags: +./scripts/git/branch_cleanup.sh --tags --execute +``` + +**Safe rebase:** +```bash +# Rebase current branch onto target: +./scripts/git/rebase_safe.sh --onto main + +# Squash last N commits (opens editor): +./scripts/git/rebase_safe.sh --squash-last 3 + +# Abort in-progress rebase: +./scripts/git/rebase_safe.sh --abort + +# Continue after resolving conflicts: +./scripts/git/rebase_safe.sh --continue +``` + +**Bisecting a bug:** +```bash +# Automated: provide a test command +./scripts/git/bisect_helper.sh --good v1.0.0 --run "bash tests/smoke_test.sh" + +# Manual: mark commits good/bad interactively +./scripts/git/bisect_helper.sh --good v1.0.0 + +# Abort stuck session: +./scripts/git/bisect_helper.sh --abort +``` + +**Generating a changelog:** +```bash +./scripts/git/changelog_generate.sh --from v1.0.0 # since tag → stdout +./scripts/git/changelog_generate.sh --from v1.0.0 --output CHANGELOG.md +``` + +**Stashing work in progress:** +```bash +./scripts/git/stash_work.sh push "wip: description" +./scripts/git/stash_work.sh pop +./scripts/git/stash_work.sh list +``` + +**Creating a release:** +```bash +./scripts/git/create_release.sh v1.2.3 --push # tag + push (triggers GitHub Release) +./scripts/git/create_release.sh v1.2.3 --dry-run +``` + +**Project setup & hygiene:** +```bash +./scripts/git/setup_attributes.sh --dry-run # preview .gitattributes changes +./scripts/git/setup_attributes.sh # write .gitattributes +./scripts/git/clean_build.sh # dry-run artifact cleanup +./scripts/git/clean_build.sh --execute # actually delete artifacts +./scripts/git/repo_health.sh # integrity check + size report +./scripts/git/repo_health.sh --gc # also run garbage collection +``` diff --git a/skill/references/error-recovery.md b/skill/references/error-recovery.md index 7fcc805..ef8235a 100644 --- a/skill/references/error-recovery.md +++ b/skill/references/error-recovery.md @@ -88,6 +88,60 @@ git rebase --continue --- +## Rebase Issues (rebase_safe.sh) + +If `rebase_safe.sh` hits conflicts mid-rebase: +```bash +# Resolve conflicting files, then: +git add +./scripts/git/rebase_safe.sh --continue + +# To abandon the rebase entirely: +./scripts/git/rebase_safe.sh --abort + +# To skip the conflicting commit: +./scripts/git/rebase_safe.sh --skip +``` + +Restore from backup tag if needed: +```bash +git checkout pre-rebase-YYYYMMDD_HHMMSS +``` + +--- + +## Bisect Stuck Session + +If a `bisect_helper.sh` session gets interrupted or abandoned: +```bash +./scripts/git/bisect_helper.sh --abort # resets bisect and returns to original branch +``` + +Restore from backup tag if needed: +```bash +git checkout pre-bisect-YYYYMMDD_HHMMSS +``` + +--- + +## Undo Operations + +Use `undo_last.sh` to recover from common commit mistakes: +```bash +# Undo most recent commit (changes remain staged): +./scripts/git/undo_last.sh commit + +# Remove a file from the staging area: +./scripts/git/undo_last.sh unstage src/file.py + +# Fix a commit message (local only — don't use after pushing): +./scripts/git/undo_last.sh amend-message "fix: correct description" +``` + +Each operation creates a backup tag (`pre-undo-commit-*`) before acting. + +--- + ## No Changes to Commit `commit_enhanced.sh` checks for changes before committing. If no changes exist, it exits cleanly — not an error. Check with: @@ -124,3 +178,6 @@ All scripts write to `logs/` directory (excluded from commits): | `sync_branches.sh` | `logs/sync_branches_YYYYMMDD_HHMMSS.log` | | `create_pr.sh` | `logs/create_pr_YYYYMMDD_HHMMSS.log` | | `install_hooks.sh` | `logs/install_hooks_YYYYMMDD_HHMMSS.log` | +| `bisect_helper.sh` | `logs/bisect_helper_YYYYMMDD_HHMMSS.log` | +| `rebase_safe.sh` | `logs/rebase_safe_YYYYMMDD_HHMMSS.log` | +| `undo_last.sh` | `logs/undo_last_YYYYMMDD_HHMMSS.log` | diff --git a/skill/references/script-reference.md b/skill/references/script-reference.md index 7aa876b..bfc6025 100644 --- a/skill/references/script-reference.md +++ b/skill/references/script-reference.md @@ -15,7 +15,7 @@ All scripts live in `scripts/git/`. Run from project root. Every script supports ```bash ./scripts/git/configure.sh ``` -Scans project, generates `.cgw.conf`, installs pre-commit hook, optionally installs Claude Code skill. +Scans project, generates `.cgw.conf`, installs pre-commit + pre-push hooks, optionally installs Claude Code skill. | Flag | Purpose | |------|---------| @@ -24,11 +24,48 @@ Scans project, generates `.cgw.conf`, installs pre-commit hook, optionally insta | `--skip-hooks` | Don't install git hooks | | `--skip-skill` | Don't install Claude Code skill | -**`install_hooks.sh`** — Install git pre-commit hooks +**`install_hooks.sh`** — Install git hooks (pre-commit + pre-push) ```bash ./scripts/git/install_hooks.sh ``` -Copies `.githooks/pre-commit` (generated by `configure.sh`) to `.git/hooks/pre-commit`. +Copies `.githooks/pre-commit` and `.githooks/pre-push` (generated by `configure.sh`) to `.git/hooks/`. + +**`setup_attributes.sh`** — Generate `.gitattributes` for binary/text handling +```bash +./scripts/git/setup_attributes.sh [--dry-run] [--force] +``` + +| Flag | Purpose | +|------|---------| +| `--dry-run` | Preview changes without writing `.gitattributes` | +| `--force` | Overwrite existing `.gitattributes` without prompting | + +Auto-detects project type (Python, TouchDesigner, GLSL, images/assets). Safe to re-run. + +**`repo_health.sh`** — Repository integrity and size report +```bash +./scripts/git/repo_health.sh [--gc] [--full] [--large ] +``` + +| Flag | Purpose | +|------|---------| +| `--gc` | Run `git gc` (garbage collection) after health check | +| `--full` | Run `git fsck --full` (thorough object integrity check) | +| `--large ` | Report tracked files larger than N MB (default: 10) | + +**`clean_build.sh`** — Remove generated build artifacts +```bash +./scripts/git/clean_build.sh [--execute] [--python] [--td] [--glsl] [--all] +``` + +| Flag | Purpose | +|------|---------| +| *(no flags)* | Dry-run preview of what would be deleted (safe default) | +| `--execute` | Actually delete detected artifacts | +| `--python` | Limit to Python artifacts (`__pycache__`, `.pyc`, `.egg-info`) | +| `--td` | Limit to TouchDesigner artifacts (`.bak`, temp files) | +| `--glsl` | Limit to GLSL/shader artifacts (`.spv`, compiled shaders) | +| `--all` | All project types | --- @@ -141,6 +178,149 @@ Requires `gh` CLI authenticated (`gh auth login`). Checks ahead/behind status, t --- +## Advanced Operations + +**`rebase_safe.sh`** — Safe rebase with backup tag +```bash +./scripts/git/rebase_safe.sh --onto # rebase onto branch +./scripts/git/rebase_safe.sh --squash-last # interactive squash of last N commits +./scripts/git/rebase_safe.sh --abort # abort in-progress rebase +./scripts/git/rebase_safe.sh --continue # continue after resolving conflicts +./scripts/git/rebase_safe.sh --skip # skip conflicting commit +``` + +| Flag | Purpose | When to Use | +|------|---------|-------------| +| `--onto ` | Rebase current branch onto this ref | Sync feature branch with main | +| `--squash-last ` | Squash last N commits (opens editor) | Clean up commit history before PR | +| `--autosquash` | Apply `fixup!`/`squash!` prefixes automatically | With `--squash-last` for automated squash | +| `--autostash` | Auto-stash dirty working tree before rebase | Rebase with uncommitted changes | +| `--abort` | Cancel in-progress rebase | Bail out of conflicted rebase | +| `--continue` | Resume after resolving conflicts | Mid-rebase conflict resolution | +| `--skip` | Skip the current conflicting commit | Drop a commit during rebase | +| `--non-interactive` | Skip confirmation prompts (requires `--autosquash` with `--squash-last`) | Automation | +| `--dry-run` | Show plan without rebasing | Preview | + +Creates `pre-rebase-TIMESTAMP` backup tag before any rebase. Warns if commits already pushed. + +**`bisect_helper.sh`** — Guided git bisect for bug hunting +```bash +./scripts/git/bisect_helper.sh --good v1.0.0 --run "bash tests/smoke.sh" # automated +./scripts/git/bisect_helper.sh --good v1.0.0 # manual +./scripts/git/bisect_helper.sh --abort # stop session +./scripts/git/bisect_helper.sh --continue # show status +``` + +| Flag | Purpose | When to Use | +|------|---------|-------------| +| `--good ` | Known-good commit/tag | Auto-detected from latest semver tag if omitted | +| `--bad ` | Known-bad commit/tag (default: HEAD) | Specify if bug isn't in HEAD | +| `--run ` | Shell command for automated bisect (exit 0=good, non-0=bad, 125=skip) | Repeatable test exists | +| `--abort` | Stop in-progress bisect | Clean up after bisect | +| `--continue` | Show current bisect status | Mid-session status check | +| `--non-interactive` | Skip prompts (requires `--run`) | Automation | +| `--dry-run` | Show plan without starting | Preview | + +Creates `pre-bisect-TIMESTAMP` backup tag before starting. + +**`undo_last.sh`** — Safe undo for common operations +```bash +./scripts/git/undo_last.sh commit # undo last commit, keep staged +./scripts/git/undo_last.sh unstage src/file.py # remove from staging area +./scripts/git/undo_last.sh discard src/file.py # discard working-tree changes +./scripts/git/undo_last.sh amend-message "fix: new message" # rewrite last commit message +``` + +| Subcommand | Purpose | Notes | +|------------|---------|-------| +| `commit` | `git reset --soft HEAD~1` — keeps changes staged | Creates `pre-undo-commit-*` backup tag | +| `unstage ...` | Remove file(s) from staging area | Validates files are staged first | +| `discard ...` | Discard working-tree changes (irreversible) | Refused in `--non-interactive` mode | +| `amend-message ` | Rewrite last commit message | Warns if commit already pushed | + +Global flags: `--non-interactive`, `--dry-run`, `--help` + +**`branch_cleanup.sh`** — Prune stale branches and tags +```bash +./scripts/git/branch_cleanup.sh # dry-run preview (safe default) +./scripts/git/branch_cleanup.sh --execute # delete merged branches + prune remote refs +./scripts/git/branch_cleanup.sh --tags --execute # also remove old backup tags +./scripts/git/branch_cleanup.sh --older-than 30 --execute +``` + +| Flag | Purpose | +|------|---------| +| *(no flags)* | Dry-run — shows what would be deleted (safe default) | +| `--execute` | Actually perform deletions | +| `--remote` | Prune stale remote-tracking refs (`git remote prune`) | +| `--tags` | Remove old `pre-merge-backup-*`, `pre-rebase-*`, `pre-bisect-*` backup tags | +| `--older-than ` | Only target branches/tags older than N days | +| `--non-interactive` | Skip confirmation prompts | + +Protects `CGW_SOURCE_BRANCH`, `CGW_TARGET_BRANCH`, and `CGW_PROTECTED_BRANCHES` from deletion. + +--- + +## Release & History + +**`create_release.sh`** — Create annotated version tag to trigger GitHub Releases +```bash +./scripts/git/create_release.sh v1.2.3 # create tag (no push) +./scripts/git/create_release.sh v1.2.3 --push # create tag + push (triggers release.yml) +./scripts/git/create_release.sh v1.2.3 --dry-run # preview +``` + +| Flag | Purpose | +|------|---------| +| `` | Semver string, e.g. `v1.2.3` (required) | +| `--message ` | Custom tag annotation message | +| `--push` | Push tag to origin immediately | +| `--non-interactive` | Skip confirmation prompts | +| `--dry-run` | Show what would be tagged without creating | + +**`changelog_generate.sh`** — Generate changelog from conventional commits +```bash +./scripts/git/changelog_generate.sh # since latest semver tag → stdout +./scripts/git/changelog_generate.sh --from v1.0.0 # since specific ref +./scripts/git/changelog_generate.sh --from v1.0.0 --output CHANGELOG.md +./scripts/git/changelog_generate.sh --from v1.0.0 --to v1.1.0 --format text +``` + +| Flag | Purpose | +|------|---------| +| `--from ` | Start ref, exclusive (default: latest semver tag or root commit) | +| `--to ` | End ref, inclusive (default: HEAD) | +| `--format ` | Output format: `md` (default) or `text` | +| `--output ` | Write to file instead of stdout | +| `--include-merges` | Include merge commits (excluded by default) | + +Categories recognized: `feat`, `fix`, `docs`, `perf`, `refactor`, `style`, `test`, `chore`, `other`. + +**`stash_work.sh`** — Safe stash wrapper with logging +```bash +./scripts/git/stash_work.sh push "wip: half-done feature" # stash with message +./scripts/git/stash_work.sh list # list stashes +./scripts/git/stash_work.sh pop # pop most recent stash +./scripts/git/stash_work.sh apply stash@{1} # apply without removing +./scripts/git/stash_work.sh drop stash@{1} # delete a stash +./scripts/git/stash_work.sh show stash@{1} # show stash contents +./scripts/git/stash_work.sh clear # remove all stashes +``` + +| Subcommand | Purpose | +|------------|---------| +| `push [message]` | Stash changes (includes untracked files by default) | +| `pop` | Apply and remove most recent stash | +| `apply [ref]` | Apply stash without removing it | +| `list` | List all stashes with timestamps | +| `drop [ref]` | Delete a specific stash | +| `show [ref]` | Show diff of a stash | +| `clear` | Remove all stashes (with confirmation) | + +`push` flags: `--include-index` (also stage index), `--no-untracked` (skip untracked files). + +--- + ## Push & Sync **`push_validated.sh`** — Validated push to remote From 515fee03e7011ee7c7790a29b582bbeaffaf62d1 Mon Sep 17 00:00:00 2001 From: forkni Date: Thu, 9 Apr 2026 12:27:39 -0400 Subject: [PATCH 4/8] fix: resolve CGW_ALL_PREFIXES unbound variable in configure.sh pre-push hook install --- scripts/git/configure.sh | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/scripts/git/configure.sh b/scripts/git/configure.sh index 52b0e77..0dc8b94 100644 --- a/scripts/git/configure.sh +++ b/scripts/git/configure.sh @@ -322,8 +322,20 @@ _install_hook() { # Also install pre-push hook if template exists alongside pre-commit local pre_push_template="${hooks_template_dir}/pre-push" if [[ -f "${pre_push_template}" ]]; then - # Build CGW_ALL_PREFIXES for substitution into pre-push template - local all_prefixes_escaped="${CGW_ALL_PREFIXES//|/\\|}" + # Build CGW_ALL_PREFIXES for substitution into pre-push template. + # Can't source _config.sh here (see top-of-file comment), so compute locally + # by reading CGW_EXTRA_PREFIXES from the just-written .cgw.conf. + local _base_prefixes="feat|fix|docs|chore|test|refactor|style|perf" + local _extra_prefixes + _extra_prefixes=$(grep -m1 '^CGW_EXTRA_PREFIXES=' "${PROJECT_ROOT}/.cgw.conf" \ + | sed 's/CGW_EXTRA_PREFIXES=//;s/"//g' || true) + local _all_prefixes + if [[ -n "${_extra_prefixes}" ]]; then + _all_prefixes="${_base_prefixes}|${_extra_prefixes}" + else + _all_prefixes="${_base_prefixes}" + fi + local all_prefixes_escaped="${_all_prefixes//|/\\|}" sed -e "s|__CGW_LOCAL_FILES_PATTERN__|${sed_files_pattern}|g" \ -e "s|__CGW_ALL_PREFIXES__|${all_prefixes_escaped}|g" \ "${pre_push_template}" >"${PROJECT_ROOT}/.githooks/pre-push" From 8433bf3d7b5f3c2b58aa4d8b823805219d1c1b2a Mon Sep 17 00:00:00 2001 From: forkni Date: Thu, 9 Apr 2026 14:26:01 -0400 Subject: [PATCH 5/8] test: add integration tests for 10 previously uncovered scripts; fix branch_cleanup exit code --- scripts/git/branch_cleanup.sh | 4 +- tests/integration/bisect_helper.bats | 83 +++++++++++ tests/integration/branch_cleanup.bats | 96 +++++++++++++ tests/integration/changelog_generate.bats | 94 ++++++++++++ tests/integration/cherry_pick.bats | 71 +++++++++ tests/integration/configure.bats | 21 +++ tests/integration/create_release.bats | 85 +++++++++++ tests/integration/help_flags.bats | 114 +++++++++++++++ tests/integration/rebase_safe.bats | 109 ++++++++++++++ tests/integration/rollback_merge.bats | 89 ++++++++++++ tests/integration/stash_work.bats | 95 +++++++++++++ tests/integration/undo_last.bats | 166 ++++++++++++++++++++++ 12 files changed, 1026 insertions(+), 1 deletion(-) create mode 100644 tests/integration/bisect_helper.bats create mode 100644 tests/integration/branch_cleanup.bats create mode 100644 tests/integration/changelog_generate.bats create mode 100644 tests/integration/cherry_pick.bats create mode 100644 tests/integration/create_release.bats create mode 100644 tests/integration/rebase_safe.bats create mode 100644 tests/integration/rollback_merge.bats create mode 100644 tests/integration/stash_work.bats create mode 100644 tests/integration/undo_last.bats diff --git a/scripts/git/branch_cleanup.sh b/scripts/git/branch_cleanup.sh index a5f4bb9..50f58e3 100644 --- a/scripts/git/branch_cleanup.sh +++ b/scripts/git/branch_cleanup.sh @@ -226,7 +226,9 @@ main() { fi echo "=== Done ===" - [[ ${execute} -eq 0 ]] && echo "Run with --execute to apply changes." + if [[ ${execute} -eq 0 ]]; then + echo "Run with --execute to apply changes." + fi } _delete_local_branches() { diff --git a/tests/integration/bisect_helper.bats b/tests/integration/bisect_helper.bats new file mode 100644 index 0000000..c20ea26 --- /dev/null +++ b/tests/integration/bisect_helper.bats @@ -0,0 +1,83 @@ +#!/usr/bin/env bats +# tests/integration/bisect_helper.bats - Integration tests for bisect_helper.sh +# Runs: bats tests/integration/bisect_helper.bats + +bats_require_minimum_version 1.5.0 +load '../helpers/setup' +load '../helpers/mocks' + +setup() { + create_test_repo + setup_mock_bin + install_mock_lint + git -C "${TEST_REPO_DIR}" checkout --quiet development + # Add several commits to make a meaningful bisect range + for i in 1 2 3 4 5; do + echo "v${i}" > "${TEST_REPO_DIR}/v${i}.txt" + git -C "${TEST_REPO_DIR}" add "v${i}.txt" + git -C "${TEST_REPO_DIR}" commit --quiet -m "chore: commit ${i}" + done + # Tag the first commit in the range as a known good baseline + git -C "${TEST_REPO_DIR}" tag "v0.1.0" HEAD~5 +} + +teardown() { + cleanup_test_repo +} + +# ── validation ──────────────────────────────────────────────────────────────── + +@test "--non-interactive without --run exits 1" { + run run_script bisect_helper.sh --good HEAD~3 --non-interactive + [ "${status}" -eq 1 ] + [[ "${output}" == *"--run"* ]] || [[ "${output}" == *"requires"* ]] +} + +@test "invalid --good ref exits 1" { + run run_script bisect_helper.sh --good nonexistent-ref-xyz --non-interactive --run "true" + [ "${status}" -eq 1 ] + [[ "${output}" == *"Invalid"* ]] || [[ "${output}" == *"invalid"* ]] +} + +# ── --abort ─────────────────────────────────────────────────────────────────── + +@test "--abort when no bisect in progress exits 0" { + run run_script bisect_helper.sh --abort + [ "${status}" -eq 0 ] +} + +# ── --dry-run ───────────────────────────────────────────────────────────────── + +@test "--dry-run shows plan without starting bisect" { + run run_script bisect_helper.sh --good v0.1.0 --dry-run --non-interactive --run "true" + [ "${status}" -eq 0 ] + [[ "${output}" == *"ry run"* ]] || [[ "${output}" == *"Would run"* ]] + # No bisect session started + ! git -C "${TEST_REPO_DIR}" bisect log >/dev/null 2>&1 +} + +# ── auto-detect good ref ────────────────────────────────────────────────────── + +@test "auto-detects semver tag as good ref when --good omitted" { + run run_script bisect_helper.sh --dry-run --non-interactive --run "true" + [ "${status}" -eq 0 ] + # The auto-detected ref (v0.1.0) should appear in output + [[ "${output}" == *"v0.1.0"* ]] || [[ "${output}" == *"Auto-detected"* ]] +} + +# ── backup tag ──────────────────────────────────────────────────────────────── + +@test "automated bisect with --run creates backup tag" { + # Use 'true' as run command so bisect immediately finds no "bad" commit + # and terminates (all commits "pass" the test) + run run_script bisect_helper.sh --good v0.1.0 --non-interactive --run "true" + # The script may exit 0 or non-zero depending on bisect result, but backup tag must exist + git -C "${TEST_REPO_DIR}" tag | grep -q "^pre-bisect-" +} + +# ── --continue ──────────────────────────────────────────────────────────────── + +@test "--continue when no bisect in progress shows status and exits 0" { + run run_script bisect_helper.sh --continue + [ "${status}" -eq 0 ] +} diff --git a/tests/integration/branch_cleanup.bats b/tests/integration/branch_cleanup.bats new file mode 100644 index 0000000..8c2f284 --- /dev/null +++ b/tests/integration/branch_cleanup.bats @@ -0,0 +1,96 @@ +#!/usr/bin/env bats +# tests/integration/branch_cleanup.bats - Integration tests for branch_cleanup.sh +# Runs: bats tests/integration/branch_cleanup.bats + +bats_require_minimum_version 1.5.0 +load '../helpers/setup' +load '../helpers/mocks' + +setup() { + create_test_repo_with_remote + setup_mock_bin + install_mock_lint +} + +teardown() { + cleanup_test_repo +} + +# ── default dry-run ─────────────────────────────────────────────────────────── + +@test "default mode is dry-run and exits 0" { + run run_script branch_cleanup.sh + [ "${status}" -eq 0 ] + [[ "${output}" == *"DRY RUN"* ]] || [[ "${output}" == *"dry run"* ]] +} + +@test "dry-run does not delete any branches" { + # Create a merged feature branch + git -C "${TEST_REPO_DIR}" checkout --quiet main + git -C "${TEST_REPO_DIR}" checkout --quiet -b feature/merged + echo "x" > "${TEST_REPO_DIR}/feat.txt" + git -C "${TEST_REPO_DIR}" add feat.txt + git -C "${TEST_REPO_DIR}" commit --quiet -m "feat: feature work" + git -C "${TEST_REPO_DIR}" checkout --quiet main + git -C "${TEST_REPO_DIR}" merge --quiet --no-ff feature/merged -m "Merge feature/merged" + + run run_script branch_cleanup.sh + [ "${status}" -eq 0 ] + # Branch still exists + git -C "${TEST_REPO_DIR}" branch | grep -q "feature/merged" +} + +# ── --execute mode ──────────────────────────────────────────────────────────── + +@test "--execute --non-interactive deletes merged branches" { + # Create and merge a feature branch + git -C "${TEST_REPO_DIR}" checkout --quiet main + git -C "${TEST_REPO_DIR}" checkout --quiet -b feature/to-delete + echo "x" > "${TEST_REPO_DIR}/feat2.txt" + git -C "${TEST_REPO_DIR}" add feat2.txt + git -C "${TEST_REPO_DIR}" commit --quiet -m "feat: to delete" + git -C "${TEST_REPO_DIR}" checkout --quiet main + git -C "${TEST_REPO_DIR}" merge --quiet --no-ff feature/to-delete -m "Merge feature/to-delete" + + run run_script branch_cleanup.sh --execute --non-interactive + [ "${status}" -eq 0 ] + # Branch is gone + ! git -C "${TEST_REPO_DIR}" branch | grep -q "feature/to-delete" +} + +# ── protected branches ──────────────────────────────────────────────────────── + +@test "protected branch 'main' is never deleted" { + run run_script branch_cleanup.sh --execute --non-interactive + [ "${status}" -eq 0 ] + git -C "${TEST_REPO_DIR}" branch | grep -q "main" +} + +@test "protected branch 'development' is never deleted" { + run run_script branch_cleanup.sh --execute --non-interactive + [ "${status}" -eq 0 ] + git -C "${TEST_REPO_DIR}" branch | grep -q "development" +} + +# ── backup tag cleanup ──────────────────────────────────────────────────────── + +@test "--tags dry-run shows old backup tags without deleting" { + # Create a fake backup tag + git -C "${TEST_REPO_DIR}" tag "pre-merge-backup-20200101_000000" HEAD + + run run_script branch_cleanup.sh --tags + [ "${status}" -eq 0 ] + [[ "${output}" == *"DRY RUN"* ]] || [[ "${output}" == *"dry run"* ]] + # Tag still exists + git -C "${TEST_REPO_DIR}" tag | grep -q "pre-merge-backup-20200101_000000" +} + +@test "--tags --execute --older-than 0 deletes old backup tags" { + # Create a fake backup tag (will be treated as very old) + git -C "${TEST_REPO_DIR}" tag "pre-merge-backup-20200101_000000" HEAD + + run run_script branch_cleanup.sh --tags --execute --older-than 0 --non-interactive + [ "${status}" -eq 0 ] + # Tag is deleted + ! git -C "${TEST_REPO_DIR}" tag | grep -q "pre-merge-backup-20200101_000000" +} diff --git a/tests/integration/changelog_generate.bats b/tests/integration/changelog_generate.bats new file mode 100644 index 0000000..c542614 --- /dev/null +++ b/tests/integration/changelog_generate.bats @@ -0,0 +1,94 @@ +#!/usr/bin/env bats +# tests/integration/changelog_generate.bats - Integration tests for changelog_generate.sh +# Runs: bats tests/integration/changelog_generate.bats + +bats_require_minimum_version 1.5.0 +load '../helpers/setup' +load '../helpers/mocks' + +setup() { + create_test_repo + setup_mock_bin + install_mock_lint + # Add conventional commits to development branch for changelog generation + git -C "${TEST_REPO_DIR}" checkout --quiet development + echo "a" > "${TEST_REPO_DIR}/a.txt" && git -C "${TEST_REPO_DIR}" add a.txt + git -C "${TEST_REPO_DIR}" commit --quiet -m "feat: add feature A" + echo "b" > "${TEST_REPO_DIR}/b.txt" && git -C "${TEST_REPO_DIR}" add b.txt + git -C "${TEST_REPO_DIR}" commit --quiet -m "fix: fix bug B" + echo "c" > "${TEST_REPO_DIR}/c.txt" && git -C "${TEST_REPO_DIR}" add c.txt + git -C "${TEST_REPO_DIR}" commit --quiet -m "docs: update readme" + # Tag first commit as a base release + git -C "${TEST_REPO_DIR}" tag "v0.1.0" HEAD~3 +} + +teardown() { + cleanup_test_repo +} + +# ── basic generation ────────────────────────────────────────────────────────── + +@test "--from generates changelog output" { + run run_script changelog_generate.sh --from v0.1.0 + [ "${status}" -eq 0 ] + [[ "${output}" == *"feat"* ]] || [[ "${output}" == *"fix"* ]] +} + +@test "auto-detects latest semver tag as from-ref" { + run run_script changelog_generate.sh + [ "${status}" -eq 0 ] +} + +@test "categorizes feat commits under Features section" { + run run_script changelog_generate.sh --from v0.1.0 + [ "${status}" -eq 0 ] + [[ "${output}" == *"add feature A"* ]] +} + +@test "categorizes fix commits under Bug Fixes section" { + run run_script changelog_generate.sh --from v0.1.0 + [ "${status}" -eq 0 ] + [[ "${output}" == *"fix bug B"* ]] +} + +# ── --format ────────────────────────────────────────────────────────────────── + +@test "--format md produces markdown headers" { + run run_script changelog_generate.sh --from v0.1.0 --format md + [ "${status}" -eq 0 ] + [[ "${output}" == *"##"* ]] || [[ "${output}" == *"**"* ]] +} + +@test "--format text produces plain text output without markdown" { + run run_script changelog_generate.sh --from v0.1.0 --format text + [ "${status}" -eq 0 ] + [[ "${output}" != *"## "* ]] +} + +@test "invalid --format exits 1" { + run run_script changelog_generate.sh --format html + [ "${status}" -eq 1 ] +} + +# ── --output ────────────────────────────────────────────────────────────────── + +@test "--output writes changelog to file" { + run run_script changelog_generate.sh --from v0.1.0 --output "${TEST_REPO_DIR}/CHANGELOG.md" + [ "${status}" -eq 0 ] + [ -f "${TEST_REPO_DIR}/CHANGELOG.md" ] + grep -q "feat\|fix" "${TEST_REPO_DIR}/CHANGELOG.md" +} + +# ── edge cases ──────────────────────────────────────────────────────────────── + +@test "no commits in range exits 0 with informational message" { + # Tag HEAD so from==to and there are no commits between them + git -C "${TEST_REPO_DIR}" tag "v0.2.0" HEAD + run run_script changelog_generate.sh --from v0.2.0 --to v0.2.0 + [ "${status}" -eq 0 ] +} + +@test "invalid --from ref exits 1" { + run run_script changelog_generate.sh --from nonexistent-ref-xyz + [ "${status}" -eq 1 ] +} diff --git a/tests/integration/cherry_pick.bats b/tests/integration/cherry_pick.bats new file mode 100644 index 0000000..b7c6834 --- /dev/null +++ b/tests/integration/cherry_pick.bats @@ -0,0 +1,71 @@ +#!/usr/bin/env bats +# tests/integration/cherry_pick.bats - Integration tests for cherry_pick_commits.sh +# Runs: bats tests/integration/cherry_pick.bats + +bats_require_minimum_version 1.5.0 +load '../helpers/setup' +load '../helpers/mocks' + +setup() { + create_test_repo_with_remote + setup_mock_bin + install_mock_lint + # Start on development — which has one unique commit ("feat: dev commit") over main + git -C "${TEST_REPO_DIR}" checkout --quiet development +} + +teardown() { + cleanup_test_repo +} + +# ── --non-interactive requires --commit ─────────────────────────────────────── + +@test "--non-interactive without --commit exits 1" { + run run_script cherry_pick_commits.sh --non-interactive + [ "${status}" -eq 1 ] + [[ "${output}" == *"--commit"* ]] || [[ "${output}" == *"required"* ]] +} + +# ── --dry-run ───────────────────────────────────────────────────────────────── + +@test "--dry-run shows commit details without cherry-picking" { + local dev_commit + dev_commit=$(git -C "${TEST_REPO_DIR}" log development --oneline -1 | awk '{print $1}') + + run run_script cherry_pick_commits.sh --commit "${dev_commit}" --dry-run --non-interactive + [ "${status}" -eq 0 ] + [[ "${output}" == *"ry run"* ]] || [[ "${output}" == *"DRY"* ]] || [[ "${output}" == *"Dry"* ]] + # We should still be on development (or main — no actual cherry-pick) +} + +# ── successful cherry-pick ──────────────────────────────────────────────────── + +@test "--commit --non-interactive cherry-picks commit to target branch" { + # Get the unique dev commit hash + local dev_commit + dev_commit=$(git -C "${TEST_REPO_DIR}" log development --oneline --no-merges \ + | grep -v "$(git -C "${TEST_REPO_DIR}" log main --oneline | head -1 | awk '{print $1}')" \ + | head -1 | awk '{print $1}') + + run run_script cherry_pick_commits.sh --commit "${dev_commit}" --non-interactive + [ "${status}" -eq 0 ] + + # Commit should now be on main + git -C "${TEST_REPO_DIR}" log main --oneline | grep -q "dev commit" +} + +@test "cherry-pick creates a backup tag" { + local dev_commit + dev_commit=$(git -C "${TEST_REPO_DIR}" log development --oneline -1 | awk '{print $1}') + + run_script cherry_pick_commits.sh --commit "${dev_commit}" --non-interactive || true + + git -C "${TEST_REPO_DIR}" tag | grep -q "^pre-cherry-pick-" +} + +# ── invalid commit hash ─────────────────────────────────────────────────────── + +@test "invalid commit hash exits 1" { + run run_script cherry_pick_commits.sh --commit "deadbeef1234567890" --non-interactive + [ "${status}" -eq 1 ] +} diff --git a/tests/integration/configure.bats b/tests/integration/configure.bats index 4407a79..2d194a6 100644 --- a/tests/integration/configure.bats +++ b/tests/integration/configure.bats @@ -86,6 +86,27 @@ _run_configure() { fi } +# ── Hook installation ───────────────────────────────────────────────────────── + +@test "--non-interactive installs pre-commit hook" { + _run_configure "--non-interactive" + [ -f "${TEST_REPO_DIR}/.githooks/pre-commit" ] +} + +@test "--non-interactive installs pre-push hook" { + # Regression test: configure.sh used CGW_ALL_PREFIXES (unbound var) when + # building the pre-push hook template substitution. Verify the hook is + # written and contains the expected prefixes pattern. + _run_configure "--non-interactive" + [ -f "${TEST_REPO_DIR}/.githooks/pre-push" ] + grep -q "feat" "${TEST_REPO_DIR}/.githooks/pre-push" +} + +@test "--skip-hooks does not install hooks" { + _run_configure "--non-interactive --skip-hooks" + [ ! -f "${TEST_REPO_DIR}/.githooks/pre-commit" ] +} + # ── Exit code ──────────────────────────────────────────────────────────────── @test "configure.sh exits 0 in non-interactive mode" { diff --git a/tests/integration/create_release.bats b/tests/integration/create_release.bats new file mode 100644 index 0000000..bddeaaa --- /dev/null +++ b/tests/integration/create_release.bats @@ -0,0 +1,85 @@ +#!/usr/bin/env bats +# tests/integration/create_release.bats - Integration tests for create_release.sh +# Runs: bats tests/integration/create_release.bats + +bats_require_minimum_version 1.5.0 +load '../helpers/setup' +load '../helpers/mocks' + +setup() { + create_test_repo + setup_mock_bin + install_mock_lint + # Ensure we start on main (the target branch) + git -C "${TEST_REPO_DIR}" checkout --quiet main +} + +teardown() { + cleanup_test_repo +} + +# ── semver validation ───────────────────────────────────────────────────────── + +@test "valid semver v1.2.3 creates annotated tag" { + run run_script create_release.sh v1.2.3 --non-interactive + [ "${status}" -eq 0 ] + git -C "${TEST_REPO_DIR}" tag -l "v1.2.3" | grep -q "v1.2.3" +} + +@test "version without v prefix auto-adds v prefix" { + run run_script create_release.sh 2.0.0 --non-interactive + [ "${status}" -eq 0 ] + git -C "${TEST_REPO_DIR}" tag -l "v2.0.0" | grep -q "v2.0.0" +} + +@test "invalid semver exits 1" { + run run_script create_release.sh 1.0 --non-interactive + [ "${status}" -eq 1 ] + [[ "${output}" == *"semver"* ]] || [[ "${output}" == *"format"* ]] +} + +# ── branch guard ────────────────────────────────────────────────────────────── + +@test "running from non-target branch exits 1" { + git -C "${TEST_REPO_DIR}" checkout --quiet development + run run_script create_release.sh v1.0.0 --non-interactive + [ "${status}" -eq 1 ] + [[ "${output}" == *"target branch"* ]] || [[ "${output}" == *"Must be on"* ]] +} + +# ── existing tag guard ──────────────────────────────────────────────────────── + +@test "existing tag exits 1" { + git -C "${TEST_REPO_DIR}" tag "v1.0.0" + run run_script create_release.sh v1.0.0 --non-interactive + [ "${status}" -eq 1 ] + [[ "${output}" == *"already exists"* ]] +} + +# ── uncommitted changes guard ───────────────────────────────────────────────── + +@test "uncommitted changes exits 1" { + echo "dirty" > "${TEST_REPO_DIR}/dirty.txt" + git -C "${TEST_REPO_DIR}" add dirty.txt + run run_script create_release.sh v1.0.0 --non-interactive + [ "${status}" -eq 1 ] + [[ "${output}" == *"Uncommitted"* ]] || [[ "${output}" == *"uncommitted"* ]] +} + +# ── dry-run ─────────────────────────────────────────────────────────────────── + +@test "--dry-run shows plan without creating tag" { + run run_script create_release.sh v1.0.0 --dry-run --non-interactive + [ "${status}" -eq 0 ] + [[ "${output}" == *"ry run"* ]] || [[ "${output}" == *"would be"* ]] + # Tag not created + ! git -C "${TEST_REPO_DIR}" tag -l "v1.0.0" | grep -q "v1.0.0" +} + +# ── annotated tag ───────────────────────────────────────────────────────────── + +@test "created tag is annotated (has a message)" { + run_script create_release.sh v1.1.0 --non-interactive + # Annotated tags show type "tag"; lightweight show type "commit" + git -C "${TEST_REPO_DIR}" cat-file -t "v1.1.0" | grep -q "tag" +} diff --git a/tests/integration/help_flags.bats b/tests/integration/help_flags.bats index ef5d310..adfebf3 100644 --- a/tests/integration/help_flags.bats +++ b/tests/integration/help_flags.bats @@ -21,6 +21,16 @@ CGW_SCRIPTS=( check_lint.sh push_validated.sh create_pr.sh + create_release.sh + stash_work.sh + clean_build.sh + setup_attributes.sh + repo_health.sh + branch_cleanup.sh + undo_last.sh + changelog_generate.sh + bisect_helper.sh + rebase_safe.sh ) setup() { @@ -199,3 +209,107 @@ teardown() { run run_script create_pr.sh --foobar [[ "${output}" == *"ERROR"* ]] || [[ "${output}" == *"Unknown"* ]] || [[ "${output}" == *"unknown"* ]] } + +# ── Newer scripts: --help exits 0 ───────────────────────────────────────────── + +@test "create_release.sh --help exits 0" { + run run_script create_release.sh --help + [ "${status}" -eq 0 ] +} + +@test "stash_work.sh --help exits 0" { + run run_script stash_work.sh --help + [ "${status}" -eq 0 ] +} + +@test "clean_build.sh --help exits 0" { + run run_script clean_build.sh --help + [ "${status}" -eq 0 ] +} + +@test "setup_attributes.sh --help exits 0" { + run run_script setup_attributes.sh --help + [ "${status}" -eq 0 ] +} + +@test "repo_health.sh --help exits 0" { + run run_script repo_health.sh --help + [ "${status}" -eq 0 ] +} + +@test "branch_cleanup.sh --help exits 0" { + run run_script branch_cleanup.sh --help + [ "${status}" -eq 0 ] +} + +@test "undo_last.sh --help exits 0" { + run run_script undo_last.sh --help + [ "${status}" -eq 0 ] +} + +@test "changelog_generate.sh --help exits 0" { + run run_script changelog_generate.sh --help + [ "${status}" -eq 0 ] +} + +@test "bisect_helper.sh --help exits 0" { + run run_script bisect_helper.sh --help + [ "${status}" -eq 0 ] +} + +@test "rebase_safe.sh --help exits 0" { + run run_script rebase_safe.sh --help + [ "${status}" -eq 0 ] +} + +# ── Newer scripts: unknown flag exits 1 ─────────────────────────────────────── + +@test "create_release.sh unknown flag exits 1" { + run run_script create_release.sh --foobar + [ "${status}" -eq 1 ] +} + +@test "stash_work.sh unknown flag exits 1" { + run run_script stash_work.sh --foobar + [ "${status}" -eq 1 ] +} + +@test "clean_build.sh unknown flag exits 1" { + run run_script clean_build.sh --foobar + [ "${status}" -eq 1 ] +} + +@test "setup_attributes.sh unknown flag exits 1" { + run run_script setup_attributes.sh --foobar + [ "${status}" -eq 1 ] +} + +@test "repo_health.sh unknown flag exits 1" { + run run_script repo_health.sh --foobar + [ "${status}" -eq 1 ] +} + +@test "branch_cleanup.sh unknown flag exits 1" { + run run_script branch_cleanup.sh --foobar + [ "${status}" -eq 1 ] +} + +@test "undo_last.sh unknown flag exits 1" { + run run_script undo_last.sh --foobar + [ "${status}" -eq 1 ] +} + +@test "changelog_generate.sh unknown flag exits 1" { + run run_script changelog_generate.sh --foobar + [ "${status}" -eq 1 ] +} + +@test "bisect_helper.sh unknown flag exits 1" { + run run_script bisect_helper.sh --foobar + [ "${status}" -eq 1 ] +} + +@test "rebase_safe.sh unknown flag exits 1" { + run run_script rebase_safe.sh --foobar + [ "${status}" -eq 1 ] +} diff --git a/tests/integration/rebase_safe.bats b/tests/integration/rebase_safe.bats new file mode 100644 index 0000000..0f21175 --- /dev/null +++ b/tests/integration/rebase_safe.bats @@ -0,0 +1,109 @@ +#!/usr/bin/env bats +# tests/integration/rebase_safe.bats - Integration tests for rebase_safe.sh +# Runs: bats tests/integration/rebase_safe.bats + +bats_require_minimum_version 1.5.0 +load '../helpers/setup' +load '../helpers/mocks' + +setup() { + create_test_repo + setup_mock_bin + install_mock_lint + # Ensure development has commits not on main, and main has no divergence + git -C "${TEST_REPO_DIR}" checkout --quiet main +} + +teardown() { + cleanup_test_repo +} + +# ── validation ──────────────────────────────────────────────────────────────── + +@test "no operation flag exits 1 with helpful message" { + run run_script rebase_safe.sh --non-interactive + [ "${status}" -eq 1 ] + [[ "${output}" == *"--onto"* ]] || [[ "${output}" == *"Specify"* ]] +} + +@test "--onto and --squash-last together exits 1" { + run run_script rebase_safe.sh --onto main --squash-last 2 --non-interactive + [ "${status}" -eq 1 ] + [[ "${output}" == *"not both"* ]] || [[ "${output}" == *"either"* ]] +} + +@test "--abort when no rebase in progress exits 0 with informational message" { + run run_script rebase_safe.sh --abort + [ "${status}" -eq 0 ] + [[ "${output}" == *"No rebase"* ]] || [[ "${output}" == *"not in progress"* ]] || [[ "${output}" == *"in progress"* ]] +} + +# ── --onto ──────────────────────────────────────────────────────────────────── + +@test "--onto main when development is already up to date exits 0" { + # Create a fresh repo where development == main (no extra commits on development) + local already_repo="${TEST_TMPDIR}/aligned" + mkdir -p "${already_repo}" + git -C "${already_repo}" init --quiet + git -C "${already_repo}" config user.email "test@example.com" + git -C "${already_repo}" config user.name "Test User" + git -C "${already_repo}" config core.autocrlf false + echo "x" > "${already_repo}/x.txt" + git -C "${already_repo}" add x.txt + git -C "${already_repo}" commit --quiet -m "chore: init" + git -C "${already_repo}" checkout --quiet -b main 2>/dev/null || \ + git -C "${already_repo}" branch -m main 2>/dev/null || true + git -C "${already_repo}" checkout --quiet -b development + # development has no extra commits beyond main, so rebase is a no-op + run bash -c " + cd '${already_repo}' + export SCRIPT_DIR='${CGW_PROJECT_ROOT}/scripts/git' + export PROJECT_ROOT='${already_repo}' + bash '${CGW_PROJECT_ROOT}/scripts/git/rebase_safe.sh' --onto main --non-interactive + " + [ "${status}" -eq 0 ] + [[ "${output}" == *"up to date"* ]] || [[ "${output}" == *"nothing to rebase"* ]] +} + +@test "--onto main rebases development commits and creates backup tag" { + # development has one extra commit over main (from create_test_repo) + git -C "${TEST_REPO_DIR}" checkout --quiet development + + run run_script rebase_safe.sh --onto main --non-interactive + [ "${status}" -eq 0 ] + + # Backup tag created + git -C "${TEST_REPO_DIR}" tag | grep -q "^pre-rebase-" +} + +@test "--dry-run --onto main shows plan without rebasing" { + git -C "${TEST_REPO_DIR}" checkout --quiet development + local head_before + head_before=$(git -C "${TEST_REPO_DIR}" rev-parse HEAD) + + run run_script rebase_safe.sh --onto main --dry-run --non-interactive + [ "${status}" -eq 0 ] + [[ "${output}" == *"ry run"* ]] || [[ "${output}" == *"Would run"* ]] + + # HEAD unchanged + local head_after + head_after=$(git -C "${TEST_REPO_DIR}" rev-parse HEAD) + [ "${head_before}" = "${head_after}" ] +} + +@test "dirty tree without --autostash exits 1" { + git -C "${TEST_REPO_DIR}" checkout --quiet development + echo "dirty" >> "${TEST_REPO_DIR}/DEV.md" + + run run_script rebase_safe.sh --onto main --non-interactive + [ "${status}" -eq 1 ] + [[ "${output}" == *"dirty"* ]] || [[ "${output}" == *"uncommitted"* ]] || [[ "${output}" == *"stash"* ]] +} + +@test "invalid --onto ref exits 1" { + git -C "${TEST_REPO_DIR}" checkout --quiet development + + run run_script rebase_safe.sh --onto nonexistent-branch-xyz --non-interactive + [ "${status}" -eq 1 ] + [[ "${output}" == *"Invalid"* ]] || [[ "${output}" == *"invalid"* ]] +} diff --git a/tests/integration/rollback_merge.bats b/tests/integration/rollback_merge.bats new file mode 100644 index 0000000..bbb1d94 --- /dev/null +++ b/tests/integration/rollback_merge.bats @@ -0,0 +1,89 @@ +#!/usr/bin/env bats +# tests/integration/rollback_merge.bats - Integration tests for rollback_merge.sh +# Runs: bats tests/integration/rollback_merge.bats + +bats_require_minimum_version 1.5.0 +load '../helpers/setup' +load '../helpers/mocks' + +setup() { + create_test_repo + setup_mock_bin + install_mock_lint + git -C "${TEST_REPO_DIR}" checkout --quiet main +} + +teardown() { + cleanup_test_repo +} + +# ── branch guard ────────────────────────────────────────────────────────────── + +@test "running from non-target branch exits 1" { + git -C "${TEST_REPO_DIR}" checkout --quiet development + run run_script rollback_merge.sh --non-interactive --target HEAD~1 + [ "${status}" -eq 1 ] + [[ "${output}" == *"target branch"* ]] || [[ "${output}" == *"Not on"* ]] +} + +# ── uncommitted changes guard ───────────────────────────────────────────────── + +@test "uncommitted changes in non-interactive mode exits 1" { + echo "dirty" > "${TEST_REPO_DIR}/dirty.txt" + git -C "${TEST_REPO_DIR}" add dirty.txt + run run_script rollback_merge.sh --non-interactive --target HEAD~1 + [ "${status}" -eq 1 ] + [[ "${output}" == *"Aborting"* ]] || [[ "${output}" == *"uncommitted"* ]] || [[ "${output}" == *"Uncommitted"* ]] +} + +# ── --dry-run ───────────────────────────────────────────────────────────────── + +@test "--dry-run shows rollback target without resetting" { + # Need at least two commits for HEAD~1 to be valid + echo "extra" > "${TEST_REPO_DIR}/extra.txt" + git -C "${TEST_REPO_DIR}" add extra.txt + git -C "${TEST_REPO_DIR}" commit --quiet -m "chore: second commit" + + local head_before + head_before=$(git -C "${TEST_REPO_DIR}" rev-parse HEAD) + + run run_script rollback_merge.sh --non-interactive --dry-run --target HEAD~1 + [ "${status}" -eq 0 ] + [[ "${output}" == *"ry run"* ]] || [[ "${output}" == *"DRY"* ]] || [[ "${output}" == *"Dry"* ]] + + local head_after + head_after=$(git -C "${TEST_REPO_DIR}" rev-parse HEAD) + [ "${head_before}" = "${head_after}" ] +} + +# ── --target with explicit ref ───────────────────────────────────────────────── + +@test "--non-interactive with no backup tag falls back to HEAD~1" { + # Create a second commit so HEAD~1 exists + echo "extra" > "${TEST_REPO_DIR}/extra.txt" + git -C "${TEST_REPO_DIR}" add extra.txt + git -C "${TEST_REPO_DIR}" commit --quiet -m "chore: second commit" + + run run_script rollback_merge.sh --non-interactive --target HEAD~1 + [ "${status}" -eq 0 ] +} + +# ── --target with explicit backup tag ───────────────────────────────────────── + +@test "--target with explicit backup tag rolls back to that tag" { + # Create a backup tag at HEAD, then add one commit + local before_sha + before_sha=$(git -C "${TEST_REPO_DIR}" rev-parse HEAD) + git -C "${TEST_REPO_DIR}" tag "pre-merge-backup-20250101_000000" HEAD + echo "after" > "${TEST_REPO_DIR}/after.txt" + git -C "${TEST_REPO_DIR}" add after.txt + git -C "${TEST_REPO_DIR}" commit --quiet -m "chore: after tag commit" + + run run_script rollback_merge.sh --non-interactive --target pre-merge-backup-20250101_000000 + [ "${status}" -eq 0 ] + + # HEAD should be back to before_sha + local current_sha + current_sha=$(git -C "${TEST_REPO_DIR}" rev-parse HEAD) + [ "${current_sha}" = "${before_sha}" ] +} diff --git a/tests/integration/stash_work.bats b/tests/integration/stash_work.bats new file mode 100644 index 0000000..e9014d7 --- /dev/null +++ b/tests/integration/stash_work.bats @@ -0,0 +1,95 @@ +#!/usr/bin/env bats +# tests/integration/stash_work.bats - Integration tests for stash_work.sh +# Runs: bats tests/integration/stash_work.bats + +bats_require_minimum_version 1.5.0 +load '../helpers/setup' +load '../helpers/mocks' + +setup() { + create_test_repo + setup_mock_bin + install_mock_lint + git -C "${TEST_REPO_DIR}" checkout --quiet development +} + +teardown() { + cleanup_test_repo +} + +# ── no command ──────────────────────────────────────────────────────────────── + +@test "no command shows usage and exits 0" { + run run_script stash_work.sh + [ "${status}" -eq 0 ] + [[ "${output}" == *"Usage:"* ]] || [[ "${output}" == *"push"* ]] +} + +@test "unknown command exits 1" { + run run_script stash_work.sh frobnicate + [ "${status}" -eq 1 ] +} + +# ── push ────────────────────────────────────────────────────────────────────── + +@test "push stashes uncommitted changes" { + echo "wip" > "${TEST_REPO_DIR}/wip.txt" + git -C "${TEST_REPO_DIR}" add wip.txt + + run run_script stash_work.sh push "wip: test stash" + [ "${status}" -eq 0 ] + [[ "${output}" == *"Stash created"* ]] || [[ "${output}" == *"created"* ]] + # Working tree is clean + git -C "${TEST_REPO_DIR}" diff --quiet + git -C "${TEST_REPO_DIR}" diff --cached --quiet +} + +@test "push stashes untracked files by default" { + echo "untracked" > "${TEST_REPO_DIR}/untracked.txt" + + run run_script stash_work.sh push "wip: untracked" + [ "${status}" -eq 0 ] + # Untracked file is gone from working tree (stashed) + [ ! -f "${TEST_REPO_DIR}/untracked.txt" ] +} + +@test "push on clean tree exits 0 with informational message" { + run run_script stash_work.sh push + [ "${status}" -eq 0 ] + [[ "${output}" == *"clean"* ]] || [[ "${output}" == *"Nothing to stash"* ]] +} + +# ── pop ─────────────────────────────────────────────────────────────────────── + +@test "pop restores stashed changes" { + echo "popped" > "${TEST_REPO_DIR}/popped.txt" + git -C "${TEST_REPO_DIR}" add popped.txt + run_script stash_work.sh push "wip: to pop" + + run run_script stash_work.sh pop + [ "${status}" -eq 0 ] + [ -f "${TEST_REPO_DIR}/popped.txt" ] +} + +@test "pop with no stashes exits 0 gracefully" { + run run_script stash_work.sh pop + [ "${status}" -eq 0 ] + [[ "${output}" == *"No stashes"* ]] || [[ "${output}" == *"no stash"* ]] +} + +# ── list ────────────────────────────────────────────────────────────────────── + +@test "list shows stashes when present" { + echo "listed" > "${TEST_REPO_DIR}/listed.txt" + git -C "${TEST_REPO_DIR}" add listed.txt + run_script stash_work.sh push "wip: listed" + + run run_script stash_work.sh list + [ "${status}" -eq 0 ] + [[ "${output}" == *"stash"* ]] +} + +@test "list exits 0 when no stashes" { + run run_script stash_work.sh list + [ "${status}" -eq 0 ] +} diff --git a/tests/integration/undo_last.bats b/tests/integration/undo_last.bats new file mode 100644 index 0000000..0e295e5 --- /dev/null +++ b/tests/integration/undo_last.bats @@ -0,0 +1,166 @@ +#!/usr/bin/env bats +# tests/integration/undo_last.bats - Integration tests for undo_last.sh +# Runs: bats tests/integration/undo_last.bats + +bats_require_minimum_version 1.5.0 +load '../helpers/setup' +load '../helpers/mocks' + +setup() { + create_test_repo_with_remote + setup_mock_bin + install_mock_lint +} + +teardown() { + cleanup_test_repo +} + +# ── no subcommand / help ────────────────────────────────────────────────────── + +@test "no subcommand shows help and exits 0" { + run run_script undo_last.sh + [ "${status}" -eq 0 ] + [[ "${output}" == *"Usage:"* ]] || [[ "${output}" == *"subcommand"* ]] +} + +@test "unknown subcommand exits 1" { + run run_script undo_last.sh bogus + [ "${status}" -eq 1 ] + [[ "${output}" == *"Unknown subcommand"* ]] || [[ "${output}" == *"unknown"* ]] +} + +# ── commit subcommand ───────────────────────────────────────────────────────── + +@test "commit: soft-resets HEAD~1 and keeps changes staged" { + git -C "${TEST_REPO_DIR}" checkout --quiet development + # Add a second commit to undo + echo "extra" > "${TEST_REPO_DIR}/extra.txt" + git -C "${TEST_REPO_DIR}" add extra.txt + git -C "${TEST_REPO_DIR}" commit --quiet -m "feat: extra commit" + local commit_before + commit_before=$(git -C "${TEST_REPO_DIR}" rev-parse HEAD) + + run run_script undo_last.sh commit --non-interactive + [ "${status}" -eq 0 ] + + local commit_after + commit_after=$(git -C "${TEST_REPO_DIR}" rev-parse HEAD) + # HEAD moved back + [ "${commit_before}" != "${commit_after}" ] + # File still staged + git -C "${TEST_REPO_DIR}" diff --cached --name-only | grep -q "extra.txt" +} + +@test "commit: creates pre-undo-commit backup tag" { + git -C "${TEST_REPO_DIR}" checkout --quiet development + echo "extra" > "${TEST_REPO_DIR}/extra.txt" + git -C "${TEST_REPO_DIR}" add extra.txt + git -C "${TEST_REPO_DIR}" commit --quiet -m "feat: extra commit" + + run_script undo_last.sh commit --non-interactive || true + + git -C "${TEST_REPO_DIR}" tag | grep -q "^pre-undo-commit-" +} + +@test "commit: refuses to undo initial commit" { + # Create a repo with only one commit + local single_commit_repo="${TEST_TMPDIR}/single" + mkdir -p "${single_commit_repo}" + git -C "${single_commit_repo}" init --quiet + git -C "${single_commit_repo}" config user.email "test@example.com" + git -C "${single_commit_repo}" config user.name "Test User" + echo "init" > "${single_commit_repo}/README.md" + git -C "${single_commit_repo}" add README.md + git -C "${single_commit_repo}" commit --quiet -m "chore: initial" + + ( + cd "${single_commit_repo}" || exit 1 + export SCRIPT_DIR="${CGW_PROJECT_ROOT}/scripts/git" + export PROJECT_ROOT="${single_commit_repo}" + run bash "${CGW_PROJECT_ROOT}/scripts/git/undo_last.sh" commit --non-interactive + [ "${status}" -eq 1 ] + ) +} + +@test "commit: --dry-run shows plan without resetting" { + git -C "${TEST_REPO_DIR}" checkout --quiet development + echo "extra" > "${TEST_REPO_DIR}/extra.txt" + git -C "${TEST_REPO_DIR}" add extra.txt + git -C "${TEST_REPO_DIR}" commit --quiet -m "feat: extra commit" + local commit_before + commit_before=$(git -C "${TEST_REPO_DIR}" rev-parse HEAD) + + run run_script undo_last.sh commit --dry-run --non-interactive + [ "${status}" -eq 0 ] + [[ "${output}" == *"ry run"* ]] || [[ "${output}" == *"Would run"* ]] + + local commit_after + commit_after=$(git -C "${TEST_REPO_DIR}" rev-parse HEAD) + [ "${commit_before}" = "${commit_after}" ] +} + +# ── unstage subcommand ──────────────────────────────────────────────────────── + +@test "unstage: removes a file from staging area" { + git -C "${TEST_REPO_DIR}" checkout --quiet development + echo "staged" > "${TEST_REPO_DIR}/staged.txt" + git -C "${TEST_REPO_DIR}" add staged.txt + + run run_script undo_last.sh unstage staged.txt --non-interactive + [ "${status}" -eq 0 ] + + # File no longer staged + ! git -C "${TEST_REPO_DIR}" diff --cached --name-only | grep -q "staged.txt" +} + +@test "unstage: no files specified exits 1" { + git -C "${TEST_REPO_DIR}" checkout --quiet development + # Stage a file so the "nothing staged → exit 0" early return is not hit + echo "staged2" > "${TEST_REPO_DIR}/staged2.txt" + git -C "${TEST_REPO_DIR}" add staged2.txt + + run run_script undo_last.sh unstage --non-interactive + [ "${status}" -eq 1 ] +} + +@test "unstage: non-staged file is skipped gracefully" { + git -C "${TEST_REPO_DIR}" checkout --quiet development + + run run_script undo_last.sh unstage nonexistent.txt --non-interactive + # Exits 0 with "Nothing to unstage" or similar + [ "${status}" -eq 0 ] +} + +# ── discard subcommand ──────────────────────────────────────────────────────── + +@test "discard: refuses in non-interactive mode" { + git -C "${TEST_REPO_DIR}" checkout --quiet development + echo "modified" >> "${TEST_REPO_DIR}/DEV.md" + + run run_script undo_last.sh discard DEV.md --non-interactive + [ "${status}" -eq 1 ] + [[ "${output}" == *"non-interactive"* ]] || [[ "${output}" == *"Refusing"* ]] +} + +# ── amend-message subcommand ────────────────────────────────────────────────── + +@test "amend-message: updates last commit message" { + git -C "${TEST_REPO_DIR}" checkout --quiet development + # Create a new unpushed commit (development was already pushed in setup) + echo "new" > "${TEST_REPO_DIR}/new.txt" + git -C "${TEST_REPO_DIR}" add new.txt + git -C "${TEST_REPO_DIR}" commit --quiet -m "feat: original message" + + run run_script undo_last.sh amend-message "fix: corrected message" --non-interactive + [ "${status}" -eq 0 ] + + git -C "${TEST_REPO_DIR}" log -1 --format="%s" | grep -q "fix: corrected message" +} + +@test "amend-message: no message argument exits 1" { + git -C "${TEST_REPO_DIR}" checkout --quiet development + + run run_script undo_last.sh amend-message --non-interactive + [ "${status}" -eq 1 ] +} From f767ca348176d2560525e0ec8c9a5d33038b4fe1 Mon Sep 17 00:00:00 2001 From: forkni Date: Thu, 9 Apr 2026 14:39:50 -0400 Subject: [PATCH 6/8] fix: configure.sh no longer modifies .gitignore; preserves branch settings on reconfigure --- scripts/git/configure.sh | 19 ++++++++++++------- tests/integration/configure.bats | 21 +++++++++++++++++++++ 2 files changed, 33 insertions(+), 7 deletions(-) diff --git a/scripts/git/configure.sh b/scripts/git/configure.sh index 0dc8b94..4f2e203 100644 --- a/scripts/git/configure.sh +++ b/scripts/git/configure.sh @@ -499,17 +499,27 @@ main() { # ── Interactive confirmation (only when generating/updating config) ────────── + # When reconfiguring, preserve existing branch settings from .cgw.conf + # rather than overwriting with fresh auto-detection. local target_branch="${detected_target}" local source_branch="${detected_source}" + if [[ -f ".cgw.conf" ]] && [[ ${reconfigure} -eq 1 ]]; then + local existing_target existing_source + existing_target=$(grep -m1 '^CGW_TARGET_BRANCH=' .cgw.conf | sed 's/CGW_TARGET_BRANCH=//;s/"//g' || true) + existing_source=$(grep -m1 '^CGW_SOURCE_BRANCH=' .cgw.conf | sed 's/CGW_SOURCE_BRANCH=//;s/"//g' || true) + [[ -n "${existing_target}" ]] && target_branch="${existing_target}" + [[ -n "${existing_source}" ]] && source_branch="${existing_source}" + fi + local local_files="${detected_local_files:-CLAUDE.md MEMORY.md .claude/ logs/}" if [[ ${non_interactive} -eq 0 ]] && { [[ ! -f ".cgw.conf" ]] || [[ ${reconfigure} -eq 1 ]]; }; then echo "Press Enter to accept [default], or type a different value." echo "" - read -r -p "Target branch [${detected_target}]: " answer + read -r -p "Target branch [${target_branch}]: " answer [[ -n "${answer}" && ! "${answer}" =~ ^[Yy]([Ee][Ss])?$ ]] && target_branch="${answer}" - read -r -p "Source branch [${detected_source}]: " answer + read -r -p "Source branch [${source_branch}]: " answer [[ -n "${answer}" && ! "${answer}" =~ ^[Yy]([Ee][Ss])?$ ]] && source_branch="${answer}" echo "" @@ -594,11 +604,6 @@ main() { fi fi - # ── Update .gitignore ───────────────────────────────────────────────────── - - echo "Updating .gitignore..." - _update_gitignore - # ── Install Claude Code skill ───────────────────────────────────────────── if [[ ${skip_skill} -eq 0 ]]; then diff --git a/tests/integration/configure.bats b/tests/integration/configure.bats index 2d194a6..a8dda46 100644 --- a/tests/integration/configure.bats +++ b/tests/integration/configure.bats @@ -107,6 +107,27 @@ _run_configure() { [ ! -f "${TEST_REPO_DIR}/.githooks/pre-commit" ] } +# ── Branch preservation on reconfigure ─────────────────────────────────────── + +@test "--reconfigure preserves existing branch settings from .cgw.conf" { + # Write a config with custom branch names + cat > "${TEST_REPO_DIR}/.cgw.conf" <<'EOF' +CGW_SOURCE_BRANCH="my-dev" +CGW_TARGET_BRANCH="my-stable" +CGW_LOCAL_FILES=".claude/ logs/" +EOF + _run_configure "--non-interactive --reconfigure" + grep -q 'CGW_SOURCE_BRANCH="my-dev"' "${TEST_REPO_DIR}/.cgw.conf" + grep -q 'CGW_TARGET_BRANCH="my-stable"' "${TEST_REPO_DIR}/.cgw.conf" +} + +@test "--reconfigure does not modify .gitignore" { + echo "# existing" > "${TEST_REPO_DIR}/.gitignore" + _run_configure "--non-interactive --reconfigure" || true + # .gitignore should be unchanged (still only the one line we wrote) + [ "$(wc -l < "${TEST_REPO_DIR}/.gitignore")" -eq 1 ] +} + # ── Exit code ──────────────────────────────────────────────────────────────── @test "configure.sh exits 0 in non-interactive mode" { From a4e114e8380e2c2557617fe0d5c9c09d1f8154d4 Mon Sep 17 00:00:00 2001 From: forkni Date: Thu, 9 Apr 2026 14:53:42 -0400 Subject: [PATCH 7/8] fix: add readline (-e) to free-text read prompts so arrow keys work --- scripts/git/cherry_pick_commits.sh | 2 +- scripts/git/configure.sh | 6 +++--- scripts/git/stash_work.sh | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/scripts/git/cherry_pick_commits.sh b/scripts/git/cherry_pick_commits.sh index ed814ee..52b596f 100644 --- a/scripts/git/cherry_pick_commits.sh +++ b/scripts/git/cherry_pick_commits.sh @@ -164,7 +164,7 @@ main() { else echo "[4/6] Select commit to cherry-pick..." echo "" - read -r -p "Enter commit hash (or 'cancel' to abort): " commit_hash + read -e -r -p "Enter commit hash (or 'cancel' to abort): " commit_hash if [[ "${commit_hash}" == "cancel" ]]; then echo "" diff --git a/scripts/git/configure.sh b/scripts/git/configure.sh index 4f2e203..a42a401 100644 --- a/scripts/git/configure.sh +++ b/scripts/git/configure.sh @@ -516,15 +516,15 @@ main() { if [[ ${non_interactive} -eq 0 ]] && { [[ ! -f ".cgw.conf" ]] || [[ ${reconfigure} -eq 1 ]]; }; then echo "Press Enter to accept [default], or type a different value." echo "" - read -r -p "Target branch [${target_branch}]: " answer + read -e -r -p "Target branch [${target_branch}]: " answer [[ -n "${answer}" && ! "${answer}" =~ ^[Yy]([Ee][Ss])?$ ]] && target_branch="${answer}" - read -r -p "Source branch [${source_branch}]: " answer + read -e -r -p "Source branch [${source_branch}]: " answer [[ -n "${answer}" && ! "${answer}" =~ ^[Yy]([Ee][Ss])?$ ]] && source_branch="${answer}" echo "" echo "Local-only files (never committed): ${local_files}" - read -r -p "Add/change local files? (press Enter to keep, or type new list): " answer + read -e -r -p "Add/change local files? (press Enter to keep, or type new list): " answer [[ -n "${answer}" && ! "${answer}" =~ ^[Yy]([Ee][Ss])?$ ]] && local_files="${answer}" fi diff --git a/scripts/git/stash_work.sh b/scripts/git/stash_work.sh index 0489595..4798b48 100644 --- a/scripts/git/stash_work.sh +++ b/scripts/git/stash_work.sh @@ -181,7 +181,7 @@ main() { echo "Stashes:" git stash list echo "" - read -r -p "Which stash to drop? (e.g. stash@{0}): " ref + read -e -r -p "Which stash to drop? (e.g. stash@{0}): " ref [[ -z "${ref}" ]] && echo "Cancelled" && exit 0 fi From a793b7557083ae0e0c7eb68bba8eb93a440e3a5c Mon Sep 17 00:00:00 2001 From: forkni Date: Thu, 9 Apr 2026 18:01:56 -0400 Subject: [PATCH 8/8] fix: address all 10 Charlie CI review findings --- .githooks/pre-commit | 70 ++++++------ .githooks/pre-push | 120 +++++++++---------- hooks/pre-commit | 70 ++++++------ hooks/pre-push | 112 +++++++++--------- scripts/git/_common.sh | 2 +- scripts/git/bisect_helper.sh | 1 + scripts/git/branch_cleanup.sh | 8 +- scripts/git/changelog_generate.sh | 178 ++++++++++------------------- scripts/git/check_lint.sh | 5 +- scripts/git/cherry_pick_commits.sh | 2 + scripts/git/commit_enhanced.sh | 2 +- scripts/git/configure.sh | 4 +- scripts/git/create_release.sh | 5 +- scripts/git/fix_lint.sh | 5 +- scripts/git/rebase_safe.sh | 1 + scripts/git/rollback_merge.sh | 9 ++ scripts/git/stash_work.sh | 1 + scripts/git/undo_last.sh | 1 + tests/unit/common.bats | 12 +- 19 files changed, 289 insertions(+), 319 deletions(-) diff --git a/.githooks/pre-commit b/.githooks/pre-commit index ae4ecf9..103f0e7 100644 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -1,4 +1,4 @@ -#!/bin/sh +#!/usr/bin/env bash # Pre-commit hook — blocks local-only files from being committed # Generated by claude-git-workflow configure.sh # @@ -8,27 +8,29 @@ # The pattern below is populated by configure.sh from CGW_LOCAL_FILES. # To regenerate after changing .cgw.conf, run: ./scripts/git/configure.sh --skip-skill +set -uo pipefail + echo "Checking for local-only files..." -PROBLEMATIC_FILES=$(git diff --cached --name-status | grep -E "^[AM]\s+(CLAUDE\.md|SESSION_LOG\.md|\.claude|logs)") - -if [ -n "$PROBLEMATIC_FILES" ]; then - echo "ERROR: Attempting to add or modify local-only files!" - echo "" - echo "The following files must remain local only:" - echo "$PROBLEMATIC_FILES" - echo "" - echo "DELETIONS are allowed (removing from git tracking)" - echo "ADDITIONS/MODIFICATIONS are blocked" - echo "" - echo "To fix: git reset HEAD " - echo "" - exit 1 +PROBLEMATIC_FILES=$(git diff --cached --name-status | grep -E "^[AM][[:space:]]+(CLAUDE\.md|SESSION_LOG\.md|\.claude|logs)" || true) + +if [[ -n "${PROBLEMATIC_FILES}" ]]; then + echo "ERROR: Attempting to add or modify local-only files!" + echo "" + echo "The following files must remain local only:" + echo "${PROBLEMATIC_FILES}" + echo "" + echo "DELETIONS are allowed (removing from git tracking)" + echo "ADDITIONS/MODIFICATIONS are blocked" + echo "" + echo "To fix: git reset HEAD " + echo "" + exit 1 fi -DELETED_FILES=$(git diff --cached --name-status | grep -E "^D\s+(CLAUDE\.md|SESSION_LOG\.md|\.claude|logs)") -if [ -n "$DELETED_FILES" ]; then - echo " Local-only files being removed from git tracking (allowed)" +DELETED_FILES=$(git diff --cached --name-status | grep -E "^D[[:space:]]+(CLAUDE\.md|SESSION_LOG\.md|\.claude|logs)" || true) +if [[ -n "${DELETED_FILES}" ]]; then + echo " Local-only files being removed from git tracking (allowed)" fi echo " No local-only files detected" @@ -36,23 +38,23 @@ echo " No local-only files detected" # Optional: lint check for staged files (non-blocking) STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM) -if [ -n "$STAGED_FILES" ]; then - # Python lint (ruff) - if command -v ruff > /dev/null 2>&1; then - PY_FILES=$(echo "$STAGED_FILES" | grep '\.py$' || true) - if [ -n "$PY_FILES" ]; then - echo "" - echo "Checking Python lint (non-blocking)..." - # shellcheck disable=SC2086 - if ! ruff check $PY_FILES > /dev/null 2>&1; then - echo " WARNING: lint issues in staged Python files" - echo " Run 'ruff check --fix .' to auto-fix" - echo " (Commit proceeds — fix lint before pushing)" - else - echo " Lint check passed" - fi - fi +if [[ -n "${STAGED_FILES}" ]]; then + # Python lint (ruff) + if command -v ruff > /dev/null 2>&1; then + PY_FILES=$(echo "${STAGED_FILES}" | grep '\.py$' || true) + if [[ -n "${PY_FILES}" ]]; then + echo "" + echo "Checking Python lint (non-blocking)..." + # shellcheck disable=SC2086 + if ! ruff check ${PY_FILES} > /dev/null 2>&1; then + echo " WARNING: lint issues in staged Python files" + echo " Run 'ruff check --fix .' to auto-fix" + echo " (Commit proceeds — fix lint before pushing)" + else + echo " Lint check passed" + fi fi + fi fi echo "" diff --git a/.githooks/pre-push b/.githooks/pre-push index b83dbaf..e9ab420 100644 --- a/.githooks/pre-push +++ b/.githooks/pre-push @@ -1,4 +1,4 @@ -#!/bin/sh +#!/usr/bin/env bash # Pre-push hook — validates commits before they leave local repo # Generated by claude-git-workflow configure.sh # @@ -13,79 +13,81 @@ # The patterns below are populated by configure.sh from CGW_LOCAL_FILES # and CGW_ALL_PREFIXES. To regenerate, run: ./scripts/git/configure.sh --skip-skill # -# CONVENTIONAL PREFIXES: __CGW_ALL_PREFIXES__ -# LOCAL FILES PATTERN: __CGW_LOCAL_FILES_PATTERN__ +# CONVENTIONAL PREFIXES: feat\|fix\|docs\|chore\|test\|refactor\|style\|perf +# LOCAL FILES PATTERN: CLAUDE\.md|SESSION_LOG\.md|\.claude|logs + +set -uo pipefail # --------------------------------------------------------------------------- # Read stdin: remote , url , # --------------------------------------------------------------------------- -LOCAL_FILES_PATTERN="__CGW_LOCAL_FILES_PATTERN__" -ALL_PREFIXES="__CGW_ALL_PREFIXES__" +LOCAL_FILES_PATTERN="CLAUDE\.md|SESSION_LOG\.md|\.claude|logs" +ALL_PREFIXES="feat\|fix\|docs\|chore\|test\|refactor\|style\|perf" # Collect push info from stdin (git passes it to pre-push) while read -r LOCAL_REF LOCAL_SHA REMOTE_REF REMOTE_SHA; do - # Skip deletions (empty sha = 0000...0) - if [ "${LOCAL_SHA}" = "0000000000000000000000000000000000000000" ]; then - continue - fi + # Skip deletions (empty sha = 0000...0) + if [[ "${LOCAL_SHA}" = "0000000000000000000000000000000000000000" ]]; then + continue + fi - # Determine commit range to check - if [ "${REMOTE_SHA}" = "0000000000000000000000000000000000000000" ]; then - # New branch being pushed — check all commits not in any remote branch - RANGE="${LOCAL_SHA}" - COMMITS=$(git rev-list "${RANGE}" --not --remotes 2>/dev/null || true) - else - RANGE="${REMOTE_SHA}..${LOCAL_SHA}" - COMMITS=$(git rev-list "${RANGE}" 2>/dev/null || true) - fi + # Determine commit range to check + if [[ "${REMOTE_SHA}" = "0000000000000000000000000000000000000000" ]]; then + # New branch being pushed — check all commits not in any remote branch + RANGE="${LOCAL_SHA}" + COMMITS=$(git rev-list "${RANGE}" --not --remotes 2>/dev/null || true) + else + RANGE="${REMOTE_SHA}..${LOCAL_SHA}" + COMMITS=$(git rev-list "${RANGE}" 2>/dev/null || true) + fi - [ -z "${COMMITS}" ] && continue + [[ -z "${COMMITS}" ]] && continue - echo "pre-push: checking $(echo "${COMMITS}" | wc -l | tr -d ' ') commit(s) in ${LOCAL_REF}..." + echo "pre-push: checking $(echo "${COMMITS}" | wc -l | tr -d ' ') commit(s) in ${LOCAL_REF}..." - # ── [1] Local-only file check ────────────────────────────────────────── - if [ -n "${LOCAL_FILES_PATTERN}" ]; then - for COMMIT in ${COMMITS}; do - FILES_IN_COMMIT=$(git diff-tree --no-commit-id -r --name-only "${COMMIT}" 2>/dev/null || true) - PROBLEM=$(echo "${FILES_IN_COMMIT}" | grep -E "${LOCAL_FILES_PATTERN}" || true) - if [ -n "${PROBLEM}" ]; then - echo "" - echo "ERROR [pre-push]: Commit ${COMMIT} contains local-only files:" - echo "${PROBLEM}" | sed 's/^/ /' - echo "" - echo "These files must not be pushed: ${LOCAL_FILES_PATTERN}" - echo "To fix: git rebase -i HEAD~N and drop/edit the offending commit" - echo "" - exit 1 - fi - done - fi + # ── [1] Local-only file check ────────────────────────────────────────── + if [[ -n "${LOCAL_FILES_PATTERN}" ]]; then + for COMMIT in ${COMMITS}; do + FILES_IN_COMMIT=$(git diff-tree --no-commit-id -r --name-only "${COMMIT}" 2>/dev/null || true) + PROBLEM=$(echo "${FILES_IN_COMMIT}" | grep -E "${LOCAL_FILES_PATTERN}" || true) + if [[ -n "${PROBLEM}" ]]; then + echo "" + echo "ERROR [pre-push]: Commit ${COMMIT} contains local-only files:" + echo "${PROBLEM}" | sed 's/^/ /' + echo "" + echo "These files must not be pushed: ${LOCAL_FILES_PATTERN}" + echo "To fix: git rebase -i HEAD~N and drop/edit the offending commit" + echo "" + exit 1 + fi + done + fi - # ── [2] Conventional commit format check ────────────────────────────── - if [ -n "${ALL_PREFIXES}" ]; then - for COMMIT in ${COMMITS}; do - MSG=$(git log -1 --format="%s" "${COMMIT}" 2>/dev/null || true) + # ── [2] Conventional commit format check ────────────────────────────── + if [[ -n "${ALL_PREFIXES}" ]]; then + for COMMIT in ${COMMITS}; do + MSG=$(git log -1 --format="%s" "${COMMIT}" 2>/dev/null || true) - # Skip merge commits (they often have non-conventional messages) - PARENT_COUNT=$(git cat-file -p "${COMMIT}" 2>/dev/null | grep -c "^parent " || true) - [ "${PARENT_COUNT}" -ge 2 ] && continue + # Skip merge commits (they often have non-conventional messages) + PARENT_COUNT=$(git cat-file -p "${COMMIT}" 2>/dev/null | grep -c "^parent " || true) + [[ "${PARENT_COUNT}" -ge 2 ]] && continue - if ! echo "${MSG}" | grep -qE "^(${ALL_PREFIXES}):"; then - echo "" - echo "WARNING [pre-push]: Commit ${COMMIT} has non-conventional message:" - echo " ${MSG}" - echo "" - echo "Expected format: : " - echo "Types: ${ALL_PREFIXES}" - echo "" - echo "Push blocked. Fix with: ./scripts/git/undo_last.sh amend-message ': '" - echo "Or bypass (not recommended): git push --no-verify" - echo "" - exit 1 - fi - done - fi + if ! echo "${MSG}" | grep -qE "^(${ALL_PREFIXES}):"; then + echo "" + echo "WARNING [pre-push]: Commit ${COMMIT} has non-conventional message:" + echo " ${MSG}" + echo "" + echo "Expected format: : " + echo "Types: ${ALL_PREFIXES}" + echo "" + echo "Push blocked. Fix with: ./scripts/git/undo_last.sh amend-message ': '" + echo "Or bypass (not recommended): git push --no-verify" + echo "" + exit 1 + fi + done + fi done diff --git a/hooks/pre-commit b/hooks/pre-commit index 0a7d3aa..4a7920f 100644 --- a/hooks/pre-commit +++ b/hooks/pre-commit @@ -1,4 +1,4 @@ -#!/bin/sh +#!/usr/bin/env bash # Pre-commit hook — blocks local-only files from being committed # Generated by claude-git-workflow configure.sh # @@ -8,27 +8,29 @@ # The pattern below is populated by configure.sh from CGW_LOCAL_FILES. # To regenerate after changing .cgw.conf, run: ./scripts/git/configure.sh --skip-skill +set -uo pipefail + echo "Checking for local-only files..." -PROBLEMATIC_FILES=$(git diff --cached --name-status | grep -E "^[AM]\s+(__CGW_LOCAL_FILES_PATTERN__)") - -if [ -n "$PROBLEMATIC_FILES" ]; then - echo "ERROR: Attempting to add or modify local-only files!" - echo "" - echo "The following files must remain local only:" - echo "$PROBLEMATIC_FILES" - echo "" - echo "DELETIONS are allowed (removing from git tracking)" - echo "ADDITIONS/MODIFICATIONS are blocked" - echo "" - echo "To fix: git reset HEAD " - echo "" - exit 1 +PROBLEMATIC_FILES=$(git diff --cached --name-status | grep -E "^[AM][[:space:]]+(__CGW_LOCAL_FILES_PATTERN__)" || true) + +if [[ -n "${PROBLEMATIC_FILES}" ]]; then + echo "ERROR: Attempting to add or modify local-only files!" + echo "" + echo "The following files must remain local only:" + echo "${PROBLEMATIC_FILES}" + echo "" + echo "DELETIONS are allowed (removing from git tracking)" + echo "ADDITIONS/MODIFICATIONS are blocked" + echo "" + echo "To fix: git reset HEAD " + echo "" + exit 1 fi -DELETED_FILES=$(git diff --cached --name-status | grep -E "^D\s+(__CGW_LOCAL_FILES_PATTERN__)") -if [ -n "$DELETED_FILES" ]; then - echo " Local-only files being removed from git tracking (allowed)" +DELETED_FILES=$(git diff --cached --name-status | grep -E "^D[[:space:]]+(__CGW_LOCAL_FILES_PATTERN__)" || true) +if [[ -n "${DELETED_FILES}" ]]; then + echo " Local-only files being removed from git tracking (allowed)" fi echo " No local-only files detected" @@ -36,23 +38,23 @@ echo " No local-only files detected" # Optional: lint check for staged files (non-blocking) STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM) -if [ -n "$STAGED_FILES" ]; then - # Python lint (ruff) - if command -v ruff > /dev/null 2>&1; then - PY_FILES=$(echo "$STAGED_FILES" | grep '\.py$' || true) - if [ -n "$PY_FILES" ]; then - echo "" - echo "Checking Python lint (non-blocking)..." - # shellcheck disable=SC2086 - if ! ruff check $PY_FILES > /dev/null 2>&1; then - echo " WARNING: lint issues in staged Python files" - echo " Run 'ruff check --fix .' to auto-fix" - echo " (Commit proceeds — fix lint before pushing)" - else - echo " Lint check passed" - fi - fi +if [[ -n "${STAGED_FILES}" ]]; then + # Python lint (ruff) + if command -v ruff > /dev/null 2>&1; then + PY_FILES=$(echo "${STAGED_FILES}" | grep '\.py$' || true) + if [[ -n "${PY_FILES}" ]]; then + echo "" + echo "Checking Python lint (non-blocking)..." + # shellcheck disable=SC2086 + if ! ruff check ${PY_FILES} > /dev/null 2>&1; then + echo " WARNING: lint issues in staged Python files" + echo " Run 'ruff check --fix .' to auto-fix" + echo " (Commit proceeds — fix lint before pushing)" + else + echo " Lint check passed" + fi fi + fi fi echo "" diff --git a/hooks/pre-push b/hooks/pre-push index b83dbaf..f1b5ce9 100644 --- a/hooks/pre-push +++ b/hooks/pre-push @@ -1,4 +1,4 @@ -#!/bin/sh +#!/usr/bin/env bash # Pre-push hook — validates commits before they leave local repo # Generated by claude-git-workflow configure.sh # @@ -16,6 +16,8 @@ # CONVENTIONAL PREFIXES: __CGW_ALL_PREFIXES__ # LOCAL FILES PATTERN: __CGW_LOCAL_FILES_PATTERN__ +set -uo pipefail + # --------------------------------------------------------------------------- # Read stdin: remote , url , # --------------------------------------------------------------------------- @@ -25,67 +27,67 @@ ALL_PREFIXES="__CGW_ALL_PREFIXES__" # Collect push info from stdin (git passes it to pre-push) while read -r LOCAL_REF LOCAL_SHA REMOTE_REF REMOTE_SHA; do - # Skip deletions (empty sha = 0000...0) - if [ "${LOCAL_SHA}" = "0000000000000000000000000000000000000000" ]; then - continue - fi + # Skip deletions (empty sha = 0000...0) + if [[ "${LOCAL_SHA}" = "0000000000000000000000000000000000000000" ]]; then + continue + fi - # Determine commit range to check - if [ "${REMOTE_SHA}" = "0000000000000000000000000000000000000000" ]; then - # New branch being pushed — check all commits not in any remote branch - RANGE="${LOCAL_SHA}" - COMMITS=$(git rev-list "${RANGE}" --not --remotes 2>/dev/null || true) - else - RANGE="${REMOTE_SHA}..${LOCAL_SHA}" - COMMITS=$(git rev-list "${RANGE}" 2>/dev/null || true) - fi + # Determine commit range to check + if [[ "${REMOTE_SHA}" = "0000000000000000000000000000000000000000" ]]; then + # New branch being pushed — check all commits not in any remote branch + RANGE="${LOCAL_SHA}" + COMMITS=$(git rev-list "${RANGE}" --not --remotes 2>/dev/null || true) + else + RANGE="${REMOTE_SHA}..${LOCAL_SHA}" + COMMITS=$(git rev-list "${RANGE}" 2>/dev/null || true) + fi - [ -z "${COMMITS}" ] && continue + [[ -z "${COMMITS}" ]] && continue - echo "pre-push: checking $(echo "${COMMITS}" | wc -l | tr -d ' ') commit(s) in ${LOCAL_REF}..." + echo "pre-push: checking $(echo "${COMMITS}" | wc -l | tr -d ' ') commit(s) in ${LOCAL_REF}..." - # ── [1] Local-only file check ────────────────────────────────────────── - if [ -n "${LOCAL_FILES_PATTERN}" ]; then - for COMMIT in ${COMMITS}; do - FILES_IN_COMMIT=$(git diff-tree --no-commit-id -r --name-only "${COMMIT}" 2>/dev/null || true) - PROBLEM=$(echo "${FILES_IN_COMMIT}" | grep -E "${LOCAL_FILES_PATTERN}" || true) - if [ -n "${PROBLEM}" ]; then - echo "" - echo "ERROR [pre-push]: Commit ${COMMIT} contains local-only files:" - echo "${PROBLEM}" | sed 's/^/ /' - echo "" - echo "These files must not be pushed: ${LOCAL_FILES_PATTERN}" - echo "To fix: git rebase -i HEAD~N and drop/edit the offending commit" - echo "" - exit 1 - fi - done - fi + # ── [1] Local-only file check ────────────────────────────────────────── + if [[ -n "${LOCAL_FILES_PATTERN}" ]]; then + for COMMIT in ${COMMITS}; do + FILES_IN_COMMIT=$(git diff-tree --no-commit-id -r --name-only "${COMMIT}" 2>/dev/null || true) + PROBLEM=$(echo "${FILES_IN_COMMIT}" | grep -E "${LOCAL_FILES_PATTERN}" || true) + if [[ -n "${PROBLEM}" ]]; then + echo "" + echo "ERROR [pre-push]: Commit ${COMMIT} contains local-only files:" + echo "${PROBLEM}" | sed 's/^/ /' + echo "" + echo "These files must not be pushed: ${LOCAL_FILES_PATTERN}" + echo "To fix: git rebase -i HEAD~N and drop/edit the offending commit" + echo "" + exit 1 + fi + done + fi - # ── [2] Conventional commit format check ────────────────────────────── - if [ -n "${ALL_PREFIXES}" ]; then - for COMMIT in ${COMMITS}; do - MSG=$(git log -1 --format="%s" "${COMMIT}" 2>/dev/null || true) + # ── [2] Conventional commit format check ────────────────────────────── + if [[ -n "${ALL_PREFIXES}" ]]; then + for COMMIT in ${COMMITS}; do + MSG=$(git log -1 --format="%s" "${COMMIT}" 2>/dev/null || true) - # Skip merge commits (they often have non-conventional messages) - PARENT_COUNT=$(git cat-file -p "${COMMIT}" 2>/dev/null | grep -c "^parent " || true) - [ "${PARENT_COUNT}" -ge 2 ] && continue + # Skip merge commits (they often have non-conventional messages) + PARENT_COUNT=$(git cat-file -p "${COMMIT}" 2>/dev/null | grep -c "^parent " || true) + [[ "${PARENT_COUNT}" -ge 2 ]] && continue - if ! echo "${MSG}" | grep -qE "^(${ALL_PREFIXES}):"; then - echo "" - echo "WARNING [pre-push]: Commit ${COMMIT} has non-conventional message:" - echo " ${MSG}" - echo "" - echo "Expected format: : " - echo "Types: ${ALL_PREFIXES}" - echo "" - echo "Push blocked. Fix with: ./scripts/git/undo_last.sh amend-message ': '" - echo "Or bypass (not recommended): git push --no-verify" - echo "" - exit 1 - fi - done - fi + if ! echo "${MSG}" | grep -qE "^(${ALL_PREFIXES}):"; then + echo "" + echo "WARNING [pre-push]: Commit ${COMMIT} has non-conventional message:" + echo " ${MSG}" + echo "" + echo "Expected format: : " + echo "Types: ${ALL_PREFIXES}" + echo "" + echo "Push blocked. Fix with: ./scripts/git/undo_last.sh amend-message ': '" + echo "Or bypass (not recommended): git push --no-verify" + echo "" + exit 1 + fi + done + fi done diff --git a/scripts/git/_common.sh b/scripts/git/_common.sh index 5e6f579..96d39d5 100644 --- a/scripts/git/_common.sh +++ b/scripts/git/_common.sh @@ -51,7 +51,7 @@ init_logging() { # Use PROJECT_ROOT for an absolute path so logs land in the right place # even when the script is invoked from a subdirectory. PROJECT_ROOT is set # by _config.sh before any script calls init_logging. - local log_dir="${PROJECT_ROOT}/logs" + local log_dir="${PROJECT_ROOT:+${PROJECT_ROOT}/}logs" if [[ ! -d "${log_dir}" ]]; then mkdir -p "${log_dir}" diff --git a/scripts/git/bisect_helper.sh b/scripts/git/bisect_helper.sh index d3749a0..5bfff52 100644 --- a/scripts/git/bisect_helper.sh +++ b/scripts/git/bisect_helper.sh @@ -24,6 +24,7 @@ set -uo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=scripts/git/_common.sh source "${SCRIPT_DIR}/_common.sh" init_logging "bisect_helper" diff --git a/scripts/git/branch_cleanup.sh b/scripts/git/branch_cleanup.sh index 50f58e3..ccf78d9 100644 --- a/scripts/git/branch_cleanup.sh +++ b/scripts/git/branch_cleanup.sh @@ -25,6 +25,7 @@ set -uo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=scripts/git/_common.sh source "${SCRIPT_DIR}/_common.sh" main() { @@ -105,9 +106,6 @@ main() { local -a merged_branches=() while IFS= read -r branch; do - branch="${branch# }" # strip leading spaces from git branch output - branch="${branch#* }" # strip leading asterisk+space for current branch - # Skip empty [[ -z "${branch}" ]] && continue @@ -122,7 +120,7 @@ main() { [[ "${branch}" == "${current_branch}" ]] && continue merged_branches+=("${branch}") - done < <(git branch --merged "${CGW_TARGET_BRANCH}" 2>/dev/null) + done < <(git for-each-ref --format='%(refname:short)' refs/heads --merged="${CGW_TARGET_BRANCH}" 2>/dev/null) if [[ ${#merged_branches[@]} -eq 0 ]]; then echo " ✓ No merged local branches to clean up" @@ -188,7 +186,7 @@ main() { while IFS= read -r tag; do local tag_epoch tag_epoch=$(git log -1 --format="%ct" "${tag}" 2>/dev/null || echo "0") - if [[ ${tag_epoch} -lt ${cutoff_epoch} ]]; then + if [[ ${tag_epoch} -le ${cutoff_epoch} ]]; then old_tags+=("${tag}") fi done < <(git tag -l "pre-merge-backup-*" "pre-cherry-pick-*" 2>/dev/null | sort) diff --git a/scripts/git/changelog_generate.sh b/scripts/git/changelog_generate.sh index 2f6764f..c87cf35 100644 --- a/scripts/git/changelog_generate.sh +++ b/scripts/git/changelog_generate.sh @@ -24,6 +24,7 @@ set -uo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=scripts/git/_common.sh source "${SCRIPT_DIR}/_common.sh" main() { @@ -170,141 +171,82 @@ main() { esac done <<< "${commits}" - # Build output - local output="" - output=$(_build_changelog \ - "${output_format}" "${to_desc}" "${to_date}" "${from_ref}" \ - "${cat_feat[@]+"${cat_feat[@]}"}" \ - "BREAK_FEAT" \ - "${cat_fix[@]+"${cat_fix[@]}"}" \ - "BREAK_FIX" \ - "${cat_docs[@]+"${cat_docs[@]}"}" \ - "BREAK_DOCS" \ - "${cat_perf[@]+"${cat_perf[@]}"}" \ - "BREAK_PERF" \ - "${cat_refactor[@]+"${cat_refactor[@]}"}" \ - "BREAK_REFACTOR" \ - "${cat_style[@]+"${cat_style[@]}"}" \ - "BREAK_STYLE" \ - "${cat_test[@]+"${cat_test[@]}"}" \ - "BREAK_TEST" \ - "${cat_chore[@]+"${cat_chore[@]}"}" \ - "BREAK_CHORE" \ - "${cat_other[@]+"${cat_other[@]}"}" \ - 2>/dev/null || true) - - # Write output - if [[ -n "${output_file}" ]]; then - echo "${output}" >"${output_file}" - echo "✓ Changelog written to: ${output_file}" >&2 - else - echo "${output}" - fi -} - -_build_changelog() { - local fmt="$1" version="$2" date_str="$3" from_ref="$4" - shift 4 - - # Parse the positional args back into categories using BREAK_ sentinel tokens - local current_cat="feat" + # Build output directly from the already-categorized arrays declare -A cats cats[feat]="" cats[fix]="" cats[docs]="" cats[perf]="" cats[refactor]="" cats[style]="" cats[test]="" cats[chore]="" cats[other]="" - # Re-collect — simpler: re-run git log with format per category - # (The sentinel approach doesn't work cleanly with arrays passed through positional args) - # Instead, recollect directly here. - local merge_flag="--no-merges" - - local log_range - if [[ -n "${from_ref}" ]]; then - log_range="${from_ref}..HEAD" - else - log_range="HEAD" - fi - - # Read commits again (simpler than passing arrays through function args) - while IFS='|' read -r hash subject; do - [[ -z "${hash}" ]] && continue - local prefix rest short_hash - prefix=$(echo "${subject}" | grep -oE "^[a-zA-Z]+" || echo "other") - rest=$(echo "${subject}" | sed 's/^[^:]*: *//') - short_hash=$(git log -1 --format="%h" "${hash}" 2>/dev/null || echo "${hash:0:7}") - local entry=" - ${rest} (${short_hash})" - case "${prefix}" in - feat) cats[feat]+="${entry}"$'\n' ;; - fix) cats[fix]+="${entry}"$'\n' ;; - docs) cats[docs]+="${entry}"$'\n' ;; - perf) cats[perf]+="${entry}"$'\n' ;; - refactor) cats[refactor]+="${entry}"$'\n' ;; - style) cats[style]+="${entry}"$'\n' ;; - test) cats[test]+="${entry}"$'\n' ;; - chore) cats[chore]+="${entry}"$'\n' ;; - *) cats[other]+="${entry}"$'\n' ;; - esac - done < <(git log --no-merges --format="%H|%s" "${log_range}" 2>/dev/null || true) + for item in "${cat_feat[@]+"${cat_feat[@]}"}"; do cats[feat]+=" - ${item}"$'\n'; done + for item in "${cat_fix[@]+"${cat_fix[@]}"}"; do cats[fix]+=" - ${item}"$'\n'; done + for item in "${cat_docs[@]+"${cat_docs[@]}"}"; do cats[docs]+=" - ${item}"$'\n'; done + for item in "${cat_perf[@]+"${cat_perf[@]}"}"; do cats[perf]+=" - ${item}"$'\n'; done + for item in "${cat_refactor[@]+"${cat_refactor[@]}"}"; do cats[refactor]+=" - ${item}"$'\n'; done + for item in "${cat_style[@]+"${cat_style[@]}"}"; do cats[style]+=" - ${item}"$'\n'; done + for item in "${cat_test[@]+"${cat_test[@]}"}"; do cats[test]+=" - ${item}"$'\n'; done + for item in "${cat_chore[@]+"${cat_chore[@]}"}"; do cats[chore]+=" - ${item}"$'\n'; done + for item in "${cat_other[@]+"${cat_other[@]}"}"; do cats[other]+=" - ${item}"$'\n'; done + + local section_map_md=( + "feat:New Features" + "fix:Bug Fixes" + "perf:Performance Improvements" + "docs:Documentation" + "refactor:Refactoring" + "test:Tests" + "style:Code Style" + "chore:Maintenance" + "other:Other Changes" + ) + local section_map_text=( + "feat:New Features" + "fix:Bug Fixes" + "perf:Performance" + "docs:Documentation" + "refactor:Refactoring" + "test:Tests" + "style:Style" + "chore:Maintenance" + "other:Other" + ) - local from_label - from_label="${from_ref:-root}" - - if [[ "${fmt}" == "md" ]]; then - echo "## ${version} (${date_str})" - echo "" - [[ -n "${from_ref}" ]] && echo "> Changes since \`${from_ref}\`" && echo "" - - local section_map=( - "feat:New Features" - "fix:Bug Fixes" - "perf:Performance Improvements" - "docs:Documentation" - "refactor:Refactoring" - "test:Tests" - "style:Code Style" - "chore:Maintenance" - "other:Other Changes" - ) + local output="" + if [[ "${output_format}" == "md" ]]; then + output="## ${to_desc} (${to_date})"$'\n\n' + [[ -n "${from_ref}" ]] && output+="> Changes since \`${from_ref}\`"$'\n\n' local has_any=0 - for entry in "${section_map[@]}"; do - local key="${entry%%:*}" - local title="${entry#*:}" + for sec in "${section_map_md[@]}"; do + local key="${sec%%:*}" + local title="${sec#*:}" if [[ -n "${cats[${key}]}" ]]; then - echo "### ${title}" - echo "" - echo "${cats[${key}]}" + output+="### ${title}"$'\n\n' + output+="${cats[${key}]}"$'\n' has_any=1 fi done - - [[ ${has_any} -eq 0 ]] && echo "_No categorized commits found in this range._" + [[ ${has_any} -eq 0 ]] && output+="_No categorized commits found in this range._"$'\n' else - # Plain text - echo "${version} (${date_str})" - echo "$(printf '=%.0s' {1..40})" - [[ -n "${from_ref}" ]] && echo "Changes since ${from_ref}" && echo "" + output="${to_desc} (${to_date})"$'\n' + output+="$(printf '=%.0s' {1..40})"$'\n' + [[ -n "${from_ref}" ]] && output+="Changes since ${from_ref}"$'\n\n' - local section_map=( - "feat:New Features" - "fix:Bug Fixes" - "perf:Performance" - "docs:Documentation" - "refactor:Refactoring" - "test:Tests" - "style:Style" - "chore:Maintenance" - "other:Other" - ) - - for entry in "${section_map[@]}"; do - local key="${entry%%:*}" - local title="${entry#*:}" + for sec in "${section_map_text[@]}"; do + local key="${sec%%:*}" + local title="${sec#*:}" if [[ -n "${cats[${key}]}" ]]; then - echo "${title}:" - echo "${cats[${key}]}" + output+="${title}:"$'\n' + output+="${cats[${key}]}"$'\n' fi done fi + + # Write output + if [[ -n "${output_file}" ]]; then + echo "${output}" >"${output_file}" + echo "✓ Changelog written to: ${output_file}" >&2 + else + echo "${output}" + fi } main "$@" diff --git a/scripts/git/check_lint.sh b/scripts/git/check_lint.sh index 3cd88ad..97ad417 100644 --- a/scripts/git/check_lint.sh +++ b/scripts/git/check_lint.sh @@ -106,8 +106,9 @@ main() { fi local modified_files # CGW_LINT_EXTENSIONS controls which files are considered (default: *.py) - # shellcheck disable=SC2086 - modified_files=$(git diff --name-only --diff-filter=ACMR HEAD -- ${CGW_LINT_EXTENSIONS:-*.py}) + local -a lint_exts + read -r -a lint_exts <<< "${CGW_LINT_EXTENSIONS:-*.py}" + modified_files=$(git diff --name-only --diff-filter=ACMR HEAD -- "${lint_exts[@]}") if [[ -z "$modified_files" ]]; then echo "[OK] No modified files to check" exit 0 diff --git a/scripts/git/cherry_pick_commits.sh b/scripts/git/cherry_pick_commits.sh index 52b596f..b38f357 100644 --- a/scripts/git/cherry_pick_commits.sh +++ b/scripts/git/cherry_pick_commits.sh @@ -21,6 +21,7 @@ set -uo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=scripts/git/_common.sh source "${SCRIPT_DIR}/_common.sh" init_logging "cherry_pick_commits" @@ -262,6 +263,7 @@ main() { log_section_start "GIT CHERRY-PICK" "$logfile" if run_git_with_logging "GIT CHERRY-PICK COMMIT" "$logfile" cherry-pick "${commit_hash}"; then + trap - EXIT INT TERM log_section_end "GIT CHERRY-PICK" "$logfile" "0" echo "" | tee -a "$logfile" { diff --git a/scripts/git/commit_enhanced.sh b/scripts/git/commit_enhanced.sh index caae5a3..63a7aa9 100644 --- a/scripts/git/commit_enhanced.sh +++ b/scripts/git/commit_enhanced.sh @@ -220,7 +220,7 @@ main() { unstage_local_only_files echo "[OK] Changes staged" else - read -rp "Stage all changes? (yes/no): " stage_all + read -rp "Stage all tracked changes? (yes/no): " stage_all if [[ "$stage_all" == "yes" ]]; then git add -u unstage_local_only_files diff --git a/scripts/git/configure.sh b/scripts/git/configure.sh index a42a401..b8bf770 100644 --- a/scripts/git/configure.sh +++ b/scripts/git/configure.sh @@ -335,7 +335,9 @@ _install_hook() { else _all_prefixes="${_base_prefixes}" fi - local all_prefixes_escaped="${_all_prefixes//|/\\|}" + local all_prefixes_escaped="${_all_prefixes//\\/\\\\}" + all_prefixes_escaped="${all_prefixes_escaped//&/\\&}" + all_prefixes_escaped="${all_prefixes_escaped//|/\\|}" sed -e "s|__CGW_LOCAL_FILES_PATTERN__|${sed_files_pattern}|g" \ -e "s|__CGW_ALL_PREFIXES__|${all_prefixes_escaped}|g" \ "${pre_push_template}" >"${PROJECT_ROOT}/.githooks/pre-push" diff --git a/scripts/git/create_release.sh b/scripts/git/create_release.sh index 9f2209b..c3bc6f4 100644 --- a/scripts/git/create_release.sh +++ b/scripts/git/create_release.sh @@ -22,6 +22,7 @@ set -uo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=scripts/git/_common.sh source "${SCRIPT_DIR}/_common.sh" # validate_semver - Check that version matches vX.Y.Z or vX.Y.Z-suffix format. @@ -149,7 +150,9 @@ main() { echo " Branch: ${current_branch}" echo " Commit: $(git log -1 --format='%h %s')" echo " Message: ${tag_message}" - echo " Push: $([ ${push_tag} -eq 1 ] && echo 'yes (after creation)' || echo 'no (manual push required)')" + local push_label="no (manual push required)" + [[ ${push_tag} -eq 1 ]] && push_label="yes (after creation)" + echo " Push: ${push_label}" echo "" if [[ ${dry_run} -eq 1 ]]; then diff --git a/scripts/git/fix_lint.sh b/scripts/git/fix_lint.sh index 8501d24..8b3680b 100644 --- a/scripts/git/fix_lint.sh +++ b/scripts/git/fix_lint.sh @@ -86,8 +86,9 @@ main() { if [[ "${modified_only}" -eq 1 ]]; then local modified_files # CGW_LINT_EXTENSIONS controls which files are considered (default: *.py) - # shellcheck disable=SC2086 - modified_files=$(git diff --name-only --diff-filter=ACMR HEAD -- ${CGW_LINT_EXTENSIONS:-*.py}) + local -a lint_exts + read -r -a lint_exts <<< "${CGW_LINT_EXTENSIONS:-*.py}" + modified_files=$(git diff --name-only --diff-filter=ACMR HEAD -- "${lint_exts[@]}") if [[ -z "$modified_files" ]]; then echo "[OK] No modified files to fix" exit 0 diff --git a/scripts/git/rebase_safe.sh b/scripts/git/rebase_safe.sh index f287d32..fdec45b 100644 --- a/scripts/git/rebase_safe.sh +++ b/scripts/git/rebase_safe.sh @@ -29,6 +29,7 @@ set -uo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=scripts/git/_common.sh source "${SCRIPT_DIR}/_common.sh" init_logging "rebase_safe" diff --git a/scripts/git/rollback_merge.sh b/scripts/git/rollback_merge.sh index a751842..a7494c2 100644 --- a/scripts/git/rollback_merge.sh +++ b/scripts/git/rollback_merge.sh @@ -19,11 +19,15 @@ set -uo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=scripts/git/_common.sh source "${SCRIPT_DIR}/_common.sh" init_logging "rollback_merge" +_rollback_done=0 + _cleanup_rollback() { + [[ ${_rollback_done} -eq 1 ]] && return 0 echo "" >&2 echo "⚠ Rollback interrupted. Verify repository state before proceeding:" >&2 echo " git log --oneline -5" >&2 @@ -231,6 +235,7 @@ main() { 4) echo "" | tee -a "$logfile" echo "Rollback cancelled" | tee -a "$logfile" + _rollback_done=1 exit 0 ;; *) @@ -251,6 +256,7 @@ main() { if [[ ${dry_run} -eq 1 ]]; then echo "=== DRY RUN — no changes made ===" | tee -a "$logfile" echo "Would reset ${CGW_TARGET_BRANCH} to: ${rollback_target}" | tee -a "$logfile" + _rollback_done=1 exit 0 fi @@ -264,6 +270,7 @@ main() { if [[ "${confirm}" != "ROLLBACK" ]]; then echo "" | tee -a "$logfile" echo "Rollback cancelled" | tee -a "$logfile" + _rollback_done=1 exit 0 fi @@ -301,6 +308,7 @@ main() { echo "End Time: $(date)" } | tee -a "$logfile" echo "" | tee -a "$logfile" + _rollback_done=1 echo "Full log: $logfile" else log_section_end "GIT REVERT" "$logfile" "1" @@ -336,6 +344,7 @@ main() { echo "End Time: $(date)" } | tee -a "$logfile" echo "" | tee -a "$logfile" + _rollback_done=1 echo "Full log: $logfile" else log_section_end "GIT RESET" "$logfile" "1" diff --git a/scripts/git/stash_work.sh b/scripts/git/stash_work.sh index 4798b48..f329752 100644 --- a/scripts/git/stash_work.sh +++ b/scripts/git/stash_work.sh @@ -22,6 +22,7 @@ set -uo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=scripts/git/_common.sh source "${SCRIPT_DIR}/_common.sh" usage() { diff --git a/scripts/git/undo_last.sh b/scripts/git/undo_last.sh index f25e4db..5a0fd1e 100644 --- a/scripts/git/undo_last.sh +++ b/scripts/git/undo_last.sh @@ -25,6 +25,7 @@ set -uo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=scripts/git/_common.sh source "${SCRIPT_DIR}/_common.sh" _show_help() { diff --git a/tests/unit/common.bats b/tests/unit/common.bats index 4682aea..7a334e6 100644 --- a/tests/unit/common.bats +++ b/tests/unit/common.bats @@ -68,22 +68,22 @@ teardown() { @test "init_logging() creates logs/ directory" { cd "${TEST_REPO_DIR}" - init_logging "test_script" - [ -d "logs" ] + PROJECT_ROOT="${TEST_REPO_DIR}" init_logging "test_script" + [ -d "${TEST_REPO_DIR}/logs" ] } @test "init_logging() sets \$logfile path" { cd "${TEST_REPO_DIR}" - init_logging "test_script" + PROJECT_ROOT="${TEST_REPO_DIR}" init_logging "test_script" [ -n "${logfile}" ] - [[ "${logfile}" == logs/test_script_*.log ]] + [[ "${logfile}" == "${TEST_REPO_DIR}/logs/test_script_"*.log ]] } @test "init_logging() sets \$reportfile path" { cd "${TEST_REPO_DIR}" - init_logging "test_script" + PROJECT_ROOT="${TEST_REPO_DIR}" init_logging "test_script" [ -n "${reportfile}" ] - [[ "${reportfile}" == logs/test_script_analysis_*.log ]] + [[ "${reportfile}" == "${TEST_REPO_DIR}/logs/test_script_analysis_"*.log ]] } @test "init_logging() logfile path includes timestamp" {