diff --git a/.gitattributes b/.gitattributes index eb83ced..511be94 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,5 +1,7 @@ # Force LF line endings for shell scripts (required for Linux/macOS compatibility) *.sh text eol=lf +*.bats text eol=lf +*.bash text eol=lf hooks/pre-commit text eol=lf # Text files — normalize to LF diff --git a/.github/workflows/branch-protection.yml b/.github/workflows/branch-protection.yml index 3d4fbd0..a53c2ca 100644 --- a/.github/workflows/branch-protection.yml +++ b/.github/workflows/branch-protection.yml @@ -75,3 +75,30 @@ jobs: - name: Check shell formatting (shfmt) run: shfmt -d -i 2 -ci scripts/ continue-on-error: true + + test: + name: Run Test Suite + runs-on: ubuntu-latest + needs: lint + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install bats-core and helpers + run: | + git clone --depth 1 --branch v1.13.0 https://github.com/bats-core/bats-core.git /tmp/bats + sudo /tmp/bats/install.sh /usr/local + git clone --depth 1 --branch v0.3.0 https://github.com/bats-core/bats-support.git /tmp/bats-support + git clone --depth 1 --branch v2.2.4 https://github.com/bats-core/bats-assert.git /tmp/bats-assert + + - name: Configure git identity for test repos + run: | + git config --global user.email "ci@example.com" + git config --global user.name "CI Test" + + - name: Run unit tests + run: bats tests/unit/ + + - name: Run integration tests + run: bats tests/integration/ diff --git a/.gitignore b/.gitignore index 232d51b..eac7479 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,7 @@ logs/ *.log .cgw.conf +CLAUDE.md +SESSION_LOG.md +.claude/commands/ +.claude/skills/ diff --git a/.shellcheckrc b/.shellcheckrc new file mode 100644 index 0000000..2c84c5e --- /dev/null +++ b/.shellcheckrc @@ -0,0 +1,5 @@ +# shellcheck configuration — matches CI invocation in branch-protection.yml +# Enables source-following (-x) and sets source path so local runs +# produce the same results as: shellcheck -x --source-path=scripts/git +external-sources=true +source-path=scripts/git diff --git a/README.md b/README.md index bc8d2c9..e30a757 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ No manual config editing required for common setups. `configure.sh` auto-detects | `validate_branches.sh` | Check branch state before operations | | `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 | --- @@ -106,6 +107,21 @@ Add project-specific prefixes via `CGW_EXTRA_PREFIXES="cuda|tensorrt"` in `.cgw. --- +## Branch Setup (one-time) + +CGW uses a two-branch model. Create `development` before starting work: + +```bash +git checkout -b development +git push -u origin development +``` + +> **Note:** `git push -u origin development` above is a one-time bootstrap exception — CGW isn't configured yet at this point so the wrapper scripts aren't available. All subsequent pushes should use `./scripts/git/push_validated.sh`. + +Keep `main` as the GitHub default branch. Charlie reads its config from the default branch. + +--- + ## Common Operations ### Commit with lint validation @@ -121,13 +137,25 @@ Add project-specific prefixes via `CGW_EXTRA_PREFIXES="cuda|tensorrt"` in `.cgw. ./scripts/git/commit_enhanced.sh --non-interactive "feat: add feature" ``` -### Merge to target branch +### Merge to target branch (direct) ```bash -./scripts/git/merge_with_validation.sh --dry-run # preview +./scripts/git/merge_with_validation.sh --dry-run # preview ./scripts/git/merge_with_validation.sh --non-interactive # execute ``` +### Create PR (triggers Charlie CI + GitHub Actions) + +```bash +./scripts/git/create_pr.sh --dry-run # preview title + commits +./scripts/git/create_pr.sh # interactive — confirm title +./scripts/git/create_pr.sh --non-interactive # accept auto-generated title +./scripts/git/create_pr.sh --draft # open as draft (skip auto-review) +``` + +Requires: `gh` CLI installed and authenticated (`gh auth login`). +Set `CGW_MERGE_MODE="pr"` in `.cgw.conf` to use PRs by default. + ### Push ```bash @@ -248,11 +276,50 @@ Legacy `CLAUDE_GIT_*` variables are still supported: - git 2.0+ - For lint: ruff / eslint / golangci-lint (or none — set `CGW_LINT_CMD=""`) - For Claude Code integration: Claude Code CLI +- For PR creation (`create_pr.sh`): [gh CLI](https://cli.github.com/) + `gh auth login` Compatible with: Linux, macOS, Windows (Git Bash / WSL) --- +## CI & Code Quality + +### GitHub Actions + +| Workflow | Trigger | Checks | +|----------|---------|--------| +| Branch Protection | Push/PR to `development`, `main` | Local-only file detection, `.gitattributes` presence, ShellCheck, shfmt format | +| Docs Validation | Changes to `*.md` files | Markdown linting, broken links, spelling | + +### Charlie CI Agent + +This project uses [Charlie](https://charlielabs.ai) for AI-assisted code review on pull requests. + +```yaml +# .charlie/config.yml +checkCommands: + fix: shfmt -w -i 2 -ci scripts/ # auto-format after edits + lint: shellcheck scripts/git/*.sh # static analysis +``` + +**Setup** (repository admin): Install the `charliecreates` GitHub App and invite `@CharlieHelps` as a repository collaborator (Triage role minimum). + +### Local Tool Installation + +```bash +# macOS +brew install shellcheck shfmt + +# Ubuntu/Debian +sudo apt-get install shellcheck +# shfmt: https://github.com/mvdan/sh/releases + +# Windows (scoop) +scoop install shellcheck shfmt +``` + +--- + ## License MIT — see [LICENSE](LICENSE) diff --git a/cgw.conf.example b/cgw.conf.example index 4be712b..40c0371 100644 --- a/cgw.conf.example +++ b/cgw.conf.example @@ -123,3 +123,20 @@ CGW_CLEANUP_TESTS="0" # for force-push. Defaults to CGW_TARGET_BRANCH if not set. # CGW_PROTECTED_BRANCHES="main staging" + +# ============================================================================ +# MERGE MODE +# ============================================================================ +# Controls how changes are promoted from source to target branch. +# +# "direct" (default): merge locally via merge_with_validation.sh. +# Fast, no PR required. +# +# "pr": create a GitHub PR via create_pr.sh. +# Triggers Charlie CI auto-review + GitHub Actions. +# Requires: gh CLI installed + authenticated. +# +# Use "pr" to enable CI agent review before merging to main. +# +CGW_MERGE_MODE="direct" +# CGW_MERGE_MODE="pr" diff --git a/command/auto-git-workflow.md b/command/auto-git-workflow.md index d06f236..9f2bf0c 100644 --- a/command/auto-git-workflow.md +++ b/command/auto-git-workflow.md @@ -128,9 +128,23 @@ git log -1 --format="%h %s" --- -### Phase 4: Merge to Target Branch +### Phase 4: Merge or PR -**Step 4.1: Run merge with validation** +Check `CGW_MERGE_MODE` (or ask user preference): + +```bash +echo "${CGW_MERGE_MODE:-direct}" +``` + +**If `CGW_MERGE_MODE=direct` (default):** → Follow Phase 4A (direct merge) + +**If `CGW_MERGE_MODE=pr`:** → Follow Phase 4B (create PR, stop — CI takes over) + +--- + +#### Phase 4A: Direct Merge to Target Branch + +**Step 4A.1: Run merge with validation** ```bash ./scripts/git/merge_with_validation.sh --non-interactive @@ -147,7 +161,34 @@ git log -1 --format="%h %s" --- -### Phase 5: Push Target Branch +#### Phase 4B: Create PR (triggers Charlie CI + GitHub Actions) + +**Step 4B.1: Create pull request** + +```bash +./scripts/git/create_pr.sh --non-interactive +``` + +- If exit code 0: PR created — workflow ends here (CI + Charlie review the PR) +- If exit code ≠ 0: Run without `--non-interactive` to see error + +**Step 4B.2: Return to source branch** + +```bash +git checkout "${CGW_SOURCE_BRANCH:-development}" >/dev/null 2>&1 +``` + +**Final Report (PR mode):** +``` +Workflow complete (PR mode) + +Source branch: [hash] "[message]" pushed +PR: [url] — awaiting Charlie CI review +``` + +--- + +### Phase 5: Push Target Branch (direct mode only) **Step 5.1: Push target branch (suppress output)** @@ -166,7 +207,7 @@ git checkout "${CGW_SOURCE_BRANCH:-development}" >/dev/null 2>&1 --- -### Final Report +### Final Report (direct mode) ```bash git log "${CGW_SOURCE_BRANCH:-development}" -1 --format="%h %s" diff --git a/scripts/git/_common.sh b/scripts/git/_common.sh index 6e27eb5..80cdc39 100644 --- a/scripts/git/_common.sh +++ b/scripts/git/_common.sh @@ -22,7 +22,7 @@ # SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # source "${SCRIPT_DIR}/_common.sh" if [[ -z "${SCRIPT_DIR:-}" ]]; then - SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" fi # Source config (sets PROJECT_ROOT + all CGW_* variables) @@ -35,7 +35,7 @@ source "${SCRIPT_DIR}/_config.sh" # Error output helper — always goes to STDERR per style guide err() { - echo "[ERROR] $*" >&2 + echo "[ERROR] $*" >&2 } # Section timer storage — associative array avoids global clobbering when @@ -43,181 +43,182 @@ err() { declare -A _SECTION_START_TIMES=() 2>/dev/null || true get_timestamp() { - timestamp=$(date +%Y%m%d_%H%M%S) + timestamp=$(date +%Y%m%d_%H%M%S) } init_logging() { - local script_name="$1" + local script_name="$1" - if [[ ! -d "logs" ]]; then - mkdir -p "logs" - fi + if [[ ! -d "logs" ]]; then + mkdir -p "logs" + fi - get_timestamp + get_timestamp - # shellcheck disable=SC2034 - logfile="logs/${script_name}_${timestamp}.log" - # shellcheck disable=SC2034 - reportfile="logs/${script_name}_analysis_${timestamp}.log" + # shellcheck disable=SC2034 + logfile="logs/${script_name}_${timestamp}.log" + # shellcheck disable=SC2034 + reportfile="logs/${script_name}_analysis_${timestamp}.log" } get_lint_exclusions() { - # Build ruff exclusion flags from CGW config variables. - # Used by check_lint.sh, fix_lint.sh, commit_enhanced.sh. - # shellcheck disable=SC2034 - RUFF_CHECK_EXCLUDE="${CGW_LINT_EXCLUDES}" - # shellcheck disable=SC2034 - RUFF_FORMAT_EXCLUDE="${CGW_FORMAT_EXCLUDES}" + # Build ruff exclusion flags from CGW config variables. + # Used by check_lint.sh, fix_lint.sh, commit_enhanced.sh. + # shellcheck disable=SC2034 + RUFF_CHECK_EXCLUDE="${CGW_LINT_EXCLUDES}" + # shellcheck disable=SC2034 + RUFF_FORMAT_EXCLUDE="${CGW_FORMAT_EXCLUDES}" } get_python_path() { - # CGW_NO_VENV=1 or SKIP_VENV=1: skip venv detection, use system ruff directly - if [[ "${CGW_NO_VENV:-0}" == "1" ]] || [[ "${SKIP_VENV:-0}" == "1" ]]; then - # shellcheck disable=SC2034 - PYTHON_BIN="" - # shellcheck disable=SC2034 - PYTHON_EXT="" - return 0 - fi - - if [[ -d ".venv/Scripts" ]]; then - # Windows (Git Bash, MSYS) - # shellcheck disable=SC2034 - PYTHON_BIN=".venv/Scripts" - # shellcheck disable=SC2034 - PYTHON_EXT=".exe" - elif [[ -d ".venv/bin" ]]; then - # Linux, macOS - # shellcheck disable=SC2034 - PYTHON_BIN=".venv/bin" - # shellcheck disable=SC2034 - PYTHON_EXT="" - else - # Fallback to system ruff - if command -v ruff &>/dev/null; then - # shellcheck disable=SC2034 - PYTHON_BIN="" - # shellcheck disable=SC2034 - PYTHON_EXT="" - return 0 - fi - echo "[ERROR] Virtual environment not found (.venv/Scripts or .venv/bin) and ruff not in PATH" >&2 - return 1 - fi - return 0 + # CGW_NO_VENV=1 or SKIP_VENV=1: skip venv detection, use system ruff directly + if [[ "${CGW_NO_VENV:-0}" == "1" ]] || [[ "${SKIP_VENV:-0}" == "1" ]]; then + # shellcheck disable=SC2034 + PYTHON_BIN="" + # shellcheck disable=SC2034 + PYTHON_EXT="" + return 0 + fi + + if [[ -d ".venv/Scripts" ]]; then + # Windows (Git Bash, MSYS) + # shellcheck disable=SC2034 + PYTHON_BIN=".venv/Scripts" + # shellcheck disable=SC2034 + PYTHON_EXT=".exe" + elif [[ -d ".venv/bin" ]]; then + # Linux, macOS + # shellcheck disable=SC2034 + PYTHON_BIN=".venv/bin" + # shellcheck disable=SC2034 + PYTHON_EXT="" + else + # Fallback to system ruff + if command -v ruff &>/dev/null; then + # shellcheck disable=SC2034 + PYTHON_BIN="" + # shellcheck disable=SC2034 + PYTHON_EXT="" + return 0 + fi + echo "[ERROR] Virtual environment not found (.venv/Scripts or .venv/bin) and ruff not in PATH" >&2 + return 1 + fi + return 0 } log_message() { - local msg="$1" - local log_path="$2" + local msg="$1" + local log_path="$2" - echo "$msg" - echo "$msg" >> "$log_path" + echo "$msg" + echo "$msg" >>"$log_path" } log_section_start() { - # Globals: _SECTION_START_TIMES (associative array, keyed by section name) - # Arguments: section_name, log_path - local section_name="$1" - local log_path="$2" - local time_str - time_str=$(date +%H:%M:%S) - _SECTION_START_TIMES["${section_name}"]=$(date +%s) - - { - echo "" - echo "========================================" - echo "[${section_name}] Started: ${time_str}" - echo "========================================" - } | tee -a "${log_path}" + # Globals: _SECTION_START_TIMES (associative array, keyed by section name) + # Arguments: section_name, log_path + local section_name="$1" + local log_path="$2" + local time_str + time_str=$(date +%H:%M:%S) + _SECTION_START_TIMES["${section_name}"]=$(date +%s) + + { + echo "" + echo "========================================" + echo "[${section_name}] Started: ${time_str}" + echo "========================================" + } | tee -a "${log_path}" } log_section_end() { - # Globals: _SECTION_START_TIMES (associative array, keyed by section name) - # Arguments: section_name, log_path, exit_code, [error_count] - local section_name="$1" - local log_path="$2" - local exit_code="$3" - local error_count="${4:-0}" - - local time_str duration status - time_str=$(date +%H:%M:%S) - local end_time start_time - end_time=$(date +%s) - start_time="${_SECTION_START_TIMES[${section_name}]:-${end_time}}" - duration=$((end_time - start_time)) - - if [[ ${exit_code} -eq 0 ]]; then - status="PASSED" - else - status="FAILED" - fi - - echo "[${section_name}] Ended: ${time_str} (${duration}s) - ${status}" | tee -a "${log_path}" + # Globals: _SECTION_START_TIMES (associative array, keyed by section name) + # Arguments: section_name, log_path, exit_code, [error_count] + local section_name="$1" + local log_path="$2" + local exit_code="$3" + # shellcheck disable=SC2034 # Reserved parameter for future error-count display; not yet used in output + local error_count="${4:-0}" + + local time_str duration status + time_str=$(date +%H:%M:%S) + local end_time start_time + end_time=$(date +%s) + start_time="${_SECTION_START_TIMES[${section_name}]:-${end_time}}" + duration=$((end_time - start_time)) + + if [[ ${exit_code} -eq 0 ]]; then + status="PASSED" + else + status="FAILED" + fi + + echo "[${section_name}] Ended: ${time_str} (${duration}s) - ${status}" | tee -a "${log_path}" } run_tool_with_logging() { - local tool_name="$1" - local log_path="$2" - shift 2 + local tool_name="$1" + local log_path="$2" + shift 2 - log_section_start "$tool_name" "$log_path" + log_section_start "$tool_name" "$log_path" - TOOL_OUTPUT=$("$@" 2>&1) - local exit_code=$? + TOOL_OUTPUT=$("$@" 2>&1) + local exit_code=$? - TOOL_ERROR_COUNT=$(echo "$TOOL_OUTPUT" | grep -cE "^[^:]+:[0-9]+:[0-9]+:" || echo "0") + TOOL_ERROR_COUNT=$(echo "$TOOL_OUTPUT" | grep -cE "^[^:]+:[0-9]+:[0-9]+:" || true) - if [[ -n "$TOOL_OUTPUT" ]]; then - echo "$TOOL_OUTPUT" | tee -a "$log_path" - fi + if [[ -n "$TOOL_OUTPUT" ]]; then + echo "$TOOL_OUTPUT" | tee -a "$log_path" + fi - log_section_end "$tool_name" "$log_path" "$exit_code" "$TOOL_ERROR_COUNT" + log_section_end "$tool_name" "$log_path" "$exit_code" "$TOOL_ERROR_COUNT" - return $exit_code + return $exit_code } log_summary_table() { - local log_path="$1" - shift - - { - echo "" - echo "========================================" - echo "[ERROR SUMMARY]" - echo "========================================" - printf "%-14s %-8s %-8s %s\n" "Tool" "Status" "Errors" "Duration" - printf "%-14s %-8s %-8s %s\n" "----" "------" "------" "--------" - - local total_errors=0 - for result in "$@"; do - IFS=':' read -r name status errors duration <<< "$result" - printf "%-14s %-8s %-8s %s\n" "$name" "$status" "$errors" "${duration}s" - (( total_errors += errors )) - done - - echo "" - echo "Total: $total_errors errors" - } | tee -a "$log_path" + local log_path="$1" + shift + + { + echo "" + echo "========================================" + echo "[ERROR SUMMARY]" + echo "========================================" + printf "%-14s %-8s %-8s %s\n" "Tool" "Status" "Errors" "Duration" + printf "%-14s %-8s %-8s %s\n" "----" "------" "------" "--------" + + local total_errors=0 + for result in "$@"; do + IFS=':' read -r name status errors duration <<<"$result" + printf "%-14s %-8s %-8s %s\n" "$name" "$status" "$errors" "${duration}s" + ((total_errors += errors)) + done + + echo "" + echo "Total: $total_errors errors" + } | tee -a "$log_path" } run_git_with_logging() { - local section_name="$1" - local log_path="$2" - shift 2 + local section_name="$1" + local log_path="$2" + shift 2 - log_section_start "$section_name" "$log_path" + log_section_start "$section_name" "$log_path" - echo "Command: git $*" | tee -a "$log_path" + echo "Command: git $*" | tee -a "$log_path" - GIT_OUTPUT=$(git "$@" 2>&1) - GIT_EXIT_CODE=$? + GIT_OUTPUT=$(git "$@" 2>&1) + GIT_EXIT_CODE=$? - if [[ -n "$GIT_OUTPUT" ]]; then - echo "$GIT_OUTPUT" | tee -a "$log_path" - fi + if [[ -n "$GIT_OUTPUT" ]]; then + echo "$GIT_OUTPUT" | tee -a "$log_path" + fi - log_section_end "$section_name" "$log_path" "$GIT_EXIT_CODE" + log_section_end "$section_name" "$log_path" "$GIT_EXIT_CODE" - return $GIT_EXIT_CODE + return $GIT_EXIT_CODE } diff --git a/scripts/git/_config.sh b/scripts/git/_config.sh index 29dcb80..0118e02 100644 --- a/scripts/git/_config.sh +++ b/scripts/git/_config.sh @@ -17,24 +17,27 @@ # Works regardless of where scripts/ lives in the project tree. _detect_project_root() { - local dir - dir="$(cd "${SCRIPT_DIR}" && pwd)" - while [[ "${dir}" != "/" ]] && [[ -n "${dir}" ]]; do - if [[ -d "${dir}/.git" ]]; then - echo "${dir}" - return 0 - fi - dir="$(dirname "${dir}")" - done - # Fallback: ask git directly - if git rev-parse --show-toplevel 2>/dev/null; then - return 0 - fi - echo "[ERROR] Cannot find git repository root from ${SCRIPT_DIR}" >&2 - return 1 + local dir + dir="$(cd "${SCRIPT_DIR}" && pwd)" + while [[ "${dir}" != "/" ]] && [[ -n "${dir}" ]]; do + if [[ -d "${dir}/.git" ]]; then + echo "${dir}" + return 0 + fi + dir="$(dirname "${dir}")" + done + # Fallback: ask git directly + if git rev-parse --show-toplevel 2>/dev/null; then + return 0 + fi + echo "[ERROR] Cannot find git repository root from ${SCRIPT_DIR}" >&2 + return 1 } -PROJECT_ROOT="$(_detect_project_root)" || exit 1 +# Allow tests (and CI overrides) to pin PROJECT_ROOT via env var, skipping auto-detection. +if [[ -z "${PROJECT_ROOT:-}" ]]; then + PROJECT_ROOT="$(_detect_project_root)" || exit 1 +fi # ============================================================================ # CONFIG FILE LOADING @@ -47,8 +50,26 @@ PROJECT_ROOT="$(_detect_project_root)" || exit 1 _CGW_CONF="${PROJECT_ROOT}/.cgw.conf" if [[ -f "${_CGW_CONF}" ]]; then - # shellcheck source=/dev/null - source "${_CGW_CONF}" + # Read .cgw.conf line-by-line, only applying variables not already set in the environment. + # This ensures env vars take priority AND derived values (e.g. CGW_PROTECTED_BRANCHES + # referencing CGW_TARGET_BRANCH) stay consistent with the values actually used. + while IFS= read -r _line; do + [[ "${_line}" =~ ^[[:space:]]*# ]] && continue # skip comments + [[ "${_line}" =~ ^[[:space:]]*$ ]] && continue # skip blank lines + # Only accept CGW_* assignment lines (optionally prefixed with export). + # Reject anything else to prevent eval of arbitrary shell statements. + if [[ "${_line}" =~ ^[[:space:]]*(export[[:space:]]+)?(CGW_[A-Z0-9_]+)= ]]; then + _cgw_var="${BASH_REMATCH[2]}" + # Only set if not already in environment (preserves env var priority) + if [[ -z "${!_cgw_var+x}" ]]; then + # shellcheck disable=SC2163 # eval required: _line contains "KEY=VALUE" or "export KEY=VALUE" + eval "${_line}" + fi + else + printf '%s\n' "[WARN] Ignoring unsupported line in .cgw.conf: ${_line}" >&2 + fi + done <"${_CGW_CONF}" + unset _line _cgw_var fi # ============================================================================ @@ -69,25 +90,33 @@ _CGW_BASE_PREFIXES="feat|fix|docs|chore|test|refactor|style|perf" # Project-specific extras (pipe-separated, e.g. "cuda|tensorrt"): CGW_EXTRA_PREFIXES="${CGW_EXTRA_PREFIXES:-}" if [[ -n "${CGW_EXTRA_PREFIXES}" ]]; then - CGW_ALL_PREFIXES="${_CGW_BASE_PREFIXES}|${CGW_EXTRA_PREFIXES}" + CGW_ALL_PREFIXES="${_CGW_BASE_PREFIXES}|${CGW_EXTRA_PREFIXES}" else - CGW_ALL_PREFIXES="${_CGW_BASE_PREFIXES}" + CGW_ALL_PREFIXES="${_CGW_BASE_PREFIXES}" fi +export CGW_ALL_PREFIXES # consumed by commit_enhanced.sh (cross-file, not detectable by shellcheck) # --- Lint configuration --- # Set CGW_LINT_CMD="" to disable lint checks entirely (e.g. non-Python projects # that haven't configured a linter yet). -CGW_LINT_CMD="${CGW_LINT_CMD:-ruff}" +# Use +x (not :-) to distinguish "unset" from "explicitly set to empty string". +[[ -z "${CGW_LINT_CMD+x}" ]] && CGW_LINT_CMD="ruff" CGW_LINT_CHECK_ARGS="${CGW_LINT_CHECK_ARGS:-check .}" CGW_LINT_FIX_ARGS="${CGW_LINT_FIX_ARGS:-check --fix .}" CGW_LINT_EXCLUDES="${CGW_LINT_EXCLUDES:---extend-exclude logs --extend-exclude .venv}" # Set CGW_FORMAT_CMD="" to disable formatting checks. -CGW_FORMAT_CMD="${CGW_FORMAT_CMD:-ruff}" +# Use +x (not :-) to distinguish "unset" from "explicitly set to empty string". +[[ -z "${CGW_FORMAT_CMD+x}" ]] && CGW_FORMAT_CMD="ruff" CGW_FORMAT_CHECK_ARGS="${CGW_FORMAT_CHECK_ARGS:-format --check .}" CGW_FORMAT_FIX_ARGS="${CGW_FORMAT_FIX_ARGS:-format .}" CGW_FORMAT_EXCLUDES="${CGW_FORMAT_EXCLUDES:---exclude logs --exclude .venv}" +# Set CGW_MARKDOWNLINT_CMD to enable a dedicated markdown lint step. +# Empty (default) = markdown lint step skipped. Example: "markdownlint-cli2" +CGW_MARKDOWNLINT_CMD="${CGW_MARKDOWNLINT_CMD:-}" +CGW_MARKDOWNLINT_ARGS="${CGW_MARKDOWNLINT_ARGS:-**/*.md !CLAUDE.md !MEMORY.md}" + # --- Docs CI validation (merge_with_validation.sh) --- # Extended regex for allowed doc filenames. Empty = skip validation entirely. # Example: "^(README\.md|.*_GUIDE\.md|.*_REFERENCE\.md)$" @@ -105,14 +134,19 @@ CGW_CLEANUP_TESTS="${CGW_CLEANUP_TESTS:-0}" # Branches requiring explicit --force flag + confirmation for force-push. CGW_PROTECTED_BRANCHES="${CGW_PROTECTED_BRANCHES:-${CGW_TARGET_BRANCH}}" +# --- Merge mode --- +# "direct": merge locally via merge_with_validation.sh (default, no PR required) +# "pr": create a GitHub PR via create_pr.sh (triggers Charlie CI + GitHub Actions) +CGW_MERGE_MODE="${CGW_MERGE_MODE:-direct}" + # ============================================================================ # BACKWARD COMPATIBILITY # ============================================================================ # Legacy CLAUDE_GIT_* env vars are mapped to CGW_* equivalents. [[ "${CLAUDE_GIT_NON_INTERACTIVE:-0}" == "1" ]] && CGW_NON_INTERACTIVE=1 -[[ "${CLAUDE_GIT_NO_VENV:-0}" == "1" ]] && CGW_NO_VENV=1 -[[ "${CLAUDE_GIT_STAGED_ONLY:-0}" == "1" ]] && CGW_STAGED_ONLY=1 +[[ "${CLAUDE_GIT_NO_VENV:-0}" == "1" ]] && CGW_NO_VENV=1 +[[ "${CLAUDE_GIT_STAGED_ONLY:-0}" == "1" ]] && CGW_STAGED_ONLY=1 CGW_NON_INTERACTIVE="${CGW_NON_INTERACTIVE:-0}" CGW_NO_VENV="${CGW_NO_VENV:-0}" diff --git a/scripts/git/check_lint.sh b/scripts/git/check_lint.sh index 3050609..efb2e83 100644 --- a/scripts/git/check_lint.sh +++ b/scripts/git/check_lint.sh @@ -14,152 +14,216 @@ set -uo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=scripts/git/_common.sh source "${SCRIPT_DIR}/_common.sh" main() { - local modified_only=0 - - while [[ $# -gt 0 ]]; do - case "$1" in - --help|-h) - echo "Usage: ./scripts/git/check_lint.sh [OPTIONS]" - echo "" - echo "Run lint and format checks (read-only, no modifications)." - echo "" - echo "Options:" - echo " --modified-only Only check files modified vs HEAD" - echo " --no-venv Use system lint tool instead of .venv" - echo " -h, --help Show this help" - echo "" - echo "Environment:" - echo " CGW_NO_VENV=1 Same as --no-venv" - echo " CGW_LINT_CMD= Override lint tool (default: ruff)" - echo " (Also: CLAUDE_GIT_NO_VENV)" - exit 0 - ;; - --no-venv) CGW_NO_VENV=1; SKIP_VENV=1; shift ;; - --modified-only) modified_only=1; shift ;; - *) echo "[ERROR] Unknown flag: $1" >&2; exit 1 ;; - esac - done - - if [[ -z "${CGW_LINT_CMD}" ]]; then - echo "[OK] Lint check skipped (CGW_LINT_CMD not set — configure in .cgw.conf)" - exit 0 - fi - - cd "${PROJECT_ROOT}" || { - err "Cannot find project root" - exit 1 - } - - get_lint_exclusions - - # Determine lint binary (venv or PATH) - local lint_cmd="${CGW_LINT_CMD}" - if [[ "${CGW_LINT_CMD}" == "ruff" ]]; then - get_python_path 2>/dev/null || true - if [[ -n "${PYTHON_BIN:-}" ]] && [[ -f "${PYTHON_BIN}/ruff${PYTHON_EXT:-}" ]]; then - lint_cmd="${PYTHON_BIN}/ruff${PYTHON_EXT:-}" - fi - fi - - # Handle --modified-only mode - if [[ "${modified_only}" -eq 1 ]]; then - local modified_files - modified_files=$(git diff --name-only --diff-filter=ACMR HEAD -- '*.py') - if [[ -z "$modified_files" ]]; then - echo "[OK] No modified files to check" - exit 0 - fi - - echo "=== Modified-Only Lint Check ===" - echo "Files: $modified_files" - echo "" - - local EXIT_CODE=0 - - echo "[LINT CHECK]" - # shellcheck disable=SC2086 - "${lint_cmd}" check $modified_files || EXIT_CODE=1 - - if [[ -n "${CGW_FORMAT_CMD}" ]]; then - echo "" - echo "[FORMAT CHECK]" - # shellcheck disable=SC2086 - "${CGW_FORMAT_CMD}" format --check $modified_files || EXIT_CODE=1 - fi - - exit $EXIT_CODE - fi - - # Full lint check with logging - init_logging "check_lint" - - local script_start - script_start=$(date +%s) - - { - echo "=========================================" - echo "Lint Validation Log" - echo "=========================================" - echo "Start Time: $(date)" - echo "Working Directory: ${PROJECT_ROOT}" - echo "Lint tool: ${CGW_LINT_CMD}" - } > "$logfile" - - local -a results=() - local lint_status=0 format_status=0 - - # LINT CHECK - local lint_start lint_end lint_duration - lint_start=$(date +%s) - # shellcheck disable=SC2086 - if ! run_tool_with_logging "LINT CHECK" "$logfile" \ - "${lint_cmd}" ${CGW_LINT_CHECK_ARGS} ${CGW_LINT_EXCLUDES}; then - lint_status=1 - fi - lint_end=$(date +%s) - lint_duration=$((lint_end - lint_start)) - results+=("Lint:$([ $lint_status -eq 0 ] && echo PASSED || echo FAILED):${TOOL_ERROR_COUNT}:${lint_duration}") - - # FORMAT CHECK - if [[ -n "${CGW_FORMAT_CMD}" ]]; then - local format_start format_end format_duration - format_start=$(date +%s) - # shellcheck disable=SC2086 - if ! run_tool_with_logging "FORMAT CHECK" "$logfile" \ - "${CGW_FORMAT_CMD}" ${CGW_FORMAT_CHECK_ARGS} ${CGW_FORMAT_EXCLUDES}; then - format_status=1 - fi - format_end=$(date +%s) - format_duration=$((format_end - format_start)) - results+=("Format:$([ $format_status -eq 0 ] && echo PASSED || echo FAILED):${TOOL_ERROR_COUNT}:${format_duration}") - fi - - log_summary_table "$logfile" "${results[@]}" - - local script_end total_duration overall_status - script_end=$(date +%s) - total_duration=$((script_end - script_start)) - - if [[ $lint_status -eq 0 ]] && [[ $format_status -eq 0 ]]; then - overall_status="PASSED" - else - overall_status="FAILED" - fi - - { - echo "" - echo "End Time: $(date)" - echo "Total Duration: ${total_duration}s" - echo "STATUS: $overall_status" - } | tee -a "$logfile" - - echo "" - echo "Full log: $logfile" - - [[ "$overall_status" == "PASSED" ]] && exit 0 || exit 1 + local modified_only=0 + local skip_lint=0 + local skip_md_lint=0 + + [[ "${CGW_SKIP_LINT:-0}" == "1" ]] && skip_lint=1 && skip_md_lint=1 + [[ "${CGW_SKIP_MD_LINT:-0}" == "1" ]] && skip_md_lint=1 + + while [[ $# -gt 0 ]]; do + case "$1" in + --help | -h) + echo "Usage: ./scripts/git/check_lint.sh [OPTIONS]" + echo "" + echo "Run lint and format checks (read-only, no modifications)." + echo "" + echo "Options:" + echo " --modified-only Only check files modified vs HEAD" + echo " --no-venv Use system lint tool instead of .venv" + echo " --skip-lint Skip all lint checks" + echo " --skip-md-lint Skip markdown lint only (CGW_MARKDOWNLINT_CMD step)" + echo " -h, --help Show this help" + echo "" + echo "Environment:" + echo " CGW_NO_VENV=1 Same as --no-venv" + echo " CGW_SKIP_LINT=1 Same as --skip-lint" + echo " CGW_SKIP_MD_LINT=1 Same as --skip-md-lint" + echo " CGW_LINT_CMD= Override lint tool (default: ruff)" + echo " (Also: CLAUDE_GIT_NO_VENV)" + exit 0 + ;; + --no-venv) + CGW_NO_VENV=1 + SKIP_VENV=1 + shift + ;; + --modified-only) + modified_only=1 + shift + ;; + --skip-lint) + skip_lint=1 + skip_md_lint=1 + shift + ;; + --skip-md-lint) + skip_md_lint=1 + shift + ;; + *) + echo "[ERROR] Unknown flag: $1" >&2 + exit 1 + ;; + esac + done + + if [[ ${skip_lint} -eq 1 ]]; then + echo "[OK] All lint checks skipped (--skip-lint)" + exit 0 + fi + + if [[ -z "${CGW_LINT_CMD}" ]] && [[ -z "${CGW_FORMAT_CMD}" ]] && [[ -z "${CGW_MARKDOWNLINT_CMD}" ]]; then + echo "[OK] All lint checks skipped (CGW_LINT_CMD, CGW_FORMAT_CMD, and CGW_MARKDOWNLINT_CMD not set)" + exit 0 + fi + + cd "${PROJECT_ROOT}" || { + err "Cannot find project root" + exit 1 + } + + get_lint_exclusions + + # Determine lint binary (venv or PATH) + local lint_cmd="${CGW_LINT_CMD}" + if [[ "${CGW_LINT_CMD}" == "ruff" ]]; then + get_python_path 2>/dev/null || true + if [[ -n "${PYTHON_BIN:-}" ]] && [[ -f "${PYTHON_BIN}/ruff${PYTHON_EXT:-}" ]]; then + lint_cmd="${PYTHON_BIN}/ruff${PYTHON_EXT:-}" + fi + fi + + # Handle --modified-only mode (code lint only, requires CGW_LINT_CMD) + if [[ "${modified_only}" -eq 1 ]]; then + if [[ -z "${CGW_LINT_CMD}" ]]; then + echo "[OK] No code lint tool configured for --modified-only (CGW_LINT_CMD not set)" + exit 0 + fi + local modified_files + modified_files=$(git diff --name-only --diff-filter=ACMR HEAD -- '*.py') + if [[ -z "$modified_files" ]]; then + echo "[OK] No modified files to check" + exit 0 + fi + + echo "=== Modified-Only Lint Check ===" + echo "Files: $modified_files" + echo "" + + local EXIT_CODE=0 + + echo "[LINT CHECK]" + # shellcheck disable=SC2086 + "${lint_cmd}" check $modified_files || EXIT_CODE=1 + + if [[ -n "${CGW_FORMAT_CMD}" ]]; then + echo "" + echo "[FORMAT CHECK]" + # shellcheck disable=SC2086 + "${CGW_FORMAT_CMD}" format --check $modified_files || EXIT_CODE=1 + fi + + exit $EXIT_CODE + fi + + # Full lint check with logging + init_logging "check_lint" + + local script_start + script_start=$(date +%s) + + { + echo "=========================================" + echo "Lint Validation Log" + echo "=========================================" + echo "Start Time: $(date)" + echo "Working Directory: ${PROJECT_ROOT}" + echo "Lint tool: ${CGW_LINT_CMD}" + } >"$logfile" + + local -a results=() + local lint_status=0 format_status=0 md_lint_status=0 + + # LINT CHECK (skipped when CGW_LINT_CMD is not set) + if [[ -n "${CGW_LINT_CMD}" ]]; then + local lint_start lint_end lint_duration lint_status_str + lint_start=$(date +%s) + # shellcheck disable=SC2086 # Word splitting intentional: CGW_LINT_CHECK_ARGS contains multiple flags + if ! run_tool_with_logging "LINT CHECK" "$logfile" \ + "${lint_cmd}" ${CGW_LINT_CHECK_ARGS} ${CGW_LINT_EXCLUDES}; then + lint_status=1 + fi + lint_end=$(date +%s) + lint_duration=$((lint_end - lint_start)) + lint_status_str="PASSED" + [[ ${lint_status} -ne 0 ]] && lint_status_str="FAILED" + results+=("Lint:${lint_status_str}:${TOOL_ERROR_COUNT}:${lint_duration}") + else + echo " (code lint skipped — CGW_LINT_CMD not set)" | tee -a "$logfile" + fi + + # FORMAT CHECK (independent of lint check — runs even when CGW_LINT_CMD is unset) + if [[ -n "${CGW_FORMAT_CMD}" ]]; then + local format_start format_end format_duration format_status_str + format_start=$(date +%s) + # shellcheck disable=SC2086 # Word splitting intentional: CGW_FORMAT_CHECK_ARGS contains multiple flags + if ! run_tool_with_logging "FORMAT CHECK" "$logfile" \ + "${CGW_FORMAT_CMD}" ${CGW_FORMAT_CHECK_ARGS} ${CGW_FORMAT_EXCLUDES}; then + format_status=1 + fi + format_end=$(date +%s) + format_duration=$((format_end - format_start)) + format_status_str="PASSED" + [[ ${format_status} -ne 0 ]] && format_status_str="FAILED" + results+=("Format:${format_status_str}:${TOOL_ERROR_COUNT}:${format_duration}") + fi + + # MARKDOWN LINT + if [[ ${skip_md_lint} -eq 0 ]] && [[ -n "${CGW_MARKDOWNLINT_CMD}" ]]; then + local md_start md_end md_duration md_status_str + md_start=$(date +%s) + # shellcheck disable=SC2086 # Word splitting intentional: CGW_MARKDOWNLINT_ARGS contains multiple flags/patterns + if ! run_tool_with_logging "MARKDOWN LINT" "$logfile" \ + "${CGW_MARKDOWNLINT_CMD}" ${CGW_MARKDOWNLINT_ARGS}; then + md_lint_status=1 + fi + md_end=$(date +%s) + md_duration=$((md_end - md_start)) + md_status_str="PASSED" + [[ ${md_lint_status} -ne 0 ]] && md_status_str="FAILED" + results+=("Markdown:${md_status_str}:${TOOL_ERROR_COUNT}:${md_duration}") + elif [[ ${skip_md_lint} -eq 1 ]]; then + echo " (markdown lint skipped — --skip-md-lint)" | tee -a "$logfile" + fi + + log_summary_table "$logfile" "${results[@]}" + + local script_end total_duration overall_status + script_end=$(date +%s) + total_duration=$((script_end - script_start)) + + if [[ $lint_status -eq 0 ]] && [[ $format_status -eq 0 ]] && [[ $md_lint_status -eq 0 ]]; then + overall_status="PASSED" + else + overall_status="FAILED" + fi + + { + echo "" + echo "End Time: $(date)" + echo "Total Duration: ${total_duration}s" + echo "STATUS: $overall_status" + } | tee -a "$logfile" + + echo "" + echo "Full log: $logfile" + + [[ "$overall_status" == "PASSED" ]] && exit 0 || exit 1 } main "$@" diff --git a/scripts/git/cherry_pick_commits.sh b/scripts/git/cherry_pick_commits.sh index bb18fe2..e1b4c2c 100644 --- a/scripts/git/cherry_pick_commits.sh +++ b/scripts/git/cherry_pick_commits.sh @@ -29,275 +29,281 @@ _cp_original_branch="" _cp_did_checkout_target=0 _cleanup_cherry_pick() { - local current - current=$(git branch --show-current 2>/dev/null || true) - if [[ ${_cp_did_checkout_target} -eq 1 ]] && [[ -n "${_cp_original_branch}" ]] && \ - [[ "${current}" != "${_cp_original_branch}" ]]; then - echo "" >&2 - echo "⚠ Interrupted — you are on branch: ${current}" >&2 - echo " Returning to: ${_cp_original_branch}" >&2 - if git rev-parse -q --verify CHERRY_PICK_HEAD >/dev/null 2>&1; then - git cherry-pick --abort 2>/dev/null || true - fi - git checkout "${_cp_original_branch}" 2>/dev/null || true - fi + local current + current=$(git branch --show-current 2>/dev/null || true) + if [[ ${_cp_did_checkout_target} -eq 1 ]] && [[ -n "${_cp_original_branch}" ]] && + [[ "${current}" != "${_cp_original_branch}" ]]; then + echo "" >&2 + echo "⚠ Interrupted — you are on branch: ${current}" >&2 + echo " Returning to: ${_cp_original_branch}" >&2 + if git rev-parse -q --verify CHERRY_PICK_HEAD >/dev/null 2>&1; then + git cherry-pick --abort 2>/dev/null || true + fi + git checkout "${_cp_original_branch}" 2>/dev/null || true + fi } trap _cleanup_cherry_pick INT TERM main() { - local non_interactive=0 - local dry_run=0 - local commit_hash_flag="" - - while [[ $# -gt 0 ]]; do - case "${1}" in - --help|-h) - echo "Usage: ./scripts/git/cherry_pick_commits.sh [OPTIONS]" - echo "" - echo "Cherry-pick a commit from source branch to target branch with validation." - echo "" - echo "Options:" - echo " --non-interactive Skip prompts; requires --commit" - echo " --commit Commit hash to cherry-pick (skips interactive selection)" - echo " --dry-run Show commit details without cherry-picking" - echo " -h, --help Show this help" - echo "" - echo "Configuration:" - echo " CGW_SOURCE_BRANCH Branch commits come from (default: development)" - echo " CGW_TARGET_BRANCH Branch commits go to (default: main)" - echo " CGW_DEV_ONLY_FILES Dev-only paths to warn about (default: empty)" - echo "" - echo "Environment:" - echo " CGW_NON_INTERACTIVE=1 Same as --non-interactive" - exit 0 - ;; - --non-interactive) non_interactive=1 ;; - --dry-run) dry_run=1 ;; - --commit) commit_hash_flag="${2:-}"; shift ;; - *) echo "[ERROR] Unknown flag: $1" >&2; exit 1 ;; - esac - shift - done - - [[ "${CGW_NON_INTERACTIVE:-0}" == "1" ]] && non_interactive=1 - - { - echo "=========================================" - echo "Cherry-Pick Commits Log" - echo "=========================================" - echo "Start Time: $(date)" - echo "Working Directory: ${PROJECT_ROOT}" - } > "$logfile" - - echo "=== Cherry-Pick Commits: ${CGW_SOURCE_BRANCH} → ${CGW_TARGET_BRANCH} ===" | tee -a "$logfile" - echo "" | tee -a "$logfile" - - cd "${PROJECT_ROOT}" || { - err "Cannot find project root" - exit 1 - } - - # [1/6] Run validation - log_section_start "PRE-CHERRY-PICK VALIDATION" "$logfile" - - if [[ -f "${SCRIPT_DIR}/validate_branches.sh" ]]; then - if ! bash "${SCRIPT_DIR}/validate_branches.sh" >> "$logfile" 2>&1; then - echo "✗ Validation failed - aborting cherry-pick" | tee -a "$logfile" - log_section_end "PRE-CHERRY-PICK VALIDATION" "$logfile" "1" - echo "Please fix validation errors before retrying" - exit 1 - fi - fi - - echo "✓ Pre-cherry-pick validation passed" | tee -a "$logfile" - log_section_end "PRE-CHERRY-PICK VALIDATION" "$logfile" "0" - echo "" | tee -a "$logfile" - - # [2/6] Store current branch and checkout target - log_section_start "GIT CHECKOUT TARGET" "$logfile" - - local original_branch - original_branch=$(git branch --show-current) - _cp_original_branch="${original_branch}" - - if [[ -z "${original_branch}" ]]; then - echo "✗ Failed to determine current branch" | tee -a "$logfile" - log_section_end "GIT CHECKOUT TARGET" "$logfile" "1" - exit 1 - fi - - echo "Current branch: ${original_branch}" | tee -a "$logfile" - - if ! run_git_with_logging "GIT CHECKOUT" "$logfile" checkout "${CGW_TARGET_BRANCH}"; then - echo "✗ Failed to checkout ${CGW_TARGET_BRANCH} branch" | tee -a "$logfile" - exit 1 - fi - _cp_did_checkout_target=1 - - log_section_end "GIT CHECKOUT TARGET" "$logfile" "0" - echo "" | tee -a "$logfile" - - # [3/6] Show recent source branch commits - if [[ -z "${commit_hash_flag}" ]]; then - echo "[3/6] Recent commits on ${CGW_SOURCE_BRANCH} branch:" - echo "====================================" - git log "${CGW_SOURCE_BRANCH}" --oneline -20 --no-merges - echo "====================================" - echo "" - fi - - # [4/6] Get commit hash - local commit_hash - if [[ -n "${commit_hash_flag}" ]]; then - commit_hash="${commit_hash_flag}" - echo "[4/6] Using --commit: ${commit_hash}" | tee -a "$logfile" - elif [[ ${non_interactive} -eq 1 ]]; then - echo "✗ [Non-interactive] --commit is required" >&2 - git checkout "${original_branch}" - exit 1 - else - echo "[4/6] Select commit to cherry-pick..." - echo "" - read -r -p "Enter commit hash (or 'cancel' to abort): " commit_hash - - if [[ "${commit_hash}" == "cancel" ]]; then - echo "" - log_message "Cherry-pick cancelled" "${logfile}" - git checkout "${original_branch}" - exit 0 - fi - fi - - if ! git rev-parse "${commit_hash}" >/dev/null 2>&1; then - log_message "✗ ERROR: Invalid commit hash: ${commit_hash}" "${logfile}" - git checkout "${original_branch}" - exit 1 - fi - - # Validate commit is on source branch - if ! git merge-base --is-ancestor "${commit_hash}" "${CGW_SOURCE_BRANCH}" 2>/dev/null; then - echo "⚠ WARNING: ${commit_hash} is not an ancestor of ${CGW_SOURCE_BRANCH}" | tee -a "$logfile" - if [[ ${non_interactive} -eq 1 ]]; then - echo "✗ [Non-interactive] Aborting — commit not on ${CGW_SOURCE_BRANCH} branch" | tee -a "$logfile" - git checkout "${original_branch}" - exit 1 - fi - read -r -p "Continue anyway? (yes/no): " branch_check_choice - if [[ "${branch_check_choice}" != "yes" ]]; then - log_message "Cherry-pick cancelled" "${logfile}" - git checkout "${original_branch}" - exit 0 - fi - fi - - echo "" - echo "Selected commit:" - git log "${commit_hash}" --oneline -1 - echo "" - echo "Commit details:" - git show "${commit_hash}" --stat - echo "" - - if [[ ${dry_run} -eq 1 ]]; then - echo "=== DRY RUN — no changes made ===" | tee -a "$logfile" - echo "Would cherry-pick: ${commit_hash}" | tee -a "$logfile" - git checkout "${original_branch}" - exit 0 - fi - - # Check if commit modifies dev-only files (configurable warning) - if [[ -n "${CGW_DEV_ONLY_FILES}" ]]; then - local has_excluded_files=0 - for dev_file in ${CGW_DEV_ONLY_FILES}; do - if git show "${commit_hash}" --name-only --format="" | grep -q "^${dev_file}"; then - has_excluded_files=1 - break - fi - done - - if [[ ${has_excluded_files} -eq 1 ]]; then - echo "⚠ WARNING: This commit modifies configured dev-only files" - echo "Dev-only files (CGW_DEV_ONLY_FILES):" - for dev_file in ${CGW_DEV_ONLY_FILES}; do - git show "${commit_hash}" --name-only --format="" | grep "^${dev_file}" || true - done - echo "" - if [[ ${non_interactive} -eq 1 ]]; then - echo "✗ [Non-interactive] Aborting — commit touches dev-only files" | tee -a "$logfile" - git checkout "${original_branch}" - exit 1 - fi - read -r -p "Continue anyway? (yes/no): " continue_choice - if [[ "${continue_choice}" != "yes" ]]; then - echo "" - log_message "Cherry-pick cancelled" "${logfile}" - git checkout "${original_branch}" - exit 0 - fi - fi - fi - - # [5/6] Create backup tag - log_section_start "CREATE BACKUP TAG" "$logfile" - - if [[ -z "${timestamp:-}" ]]; then get_timestamp; fi - local backup_tag="pre-cherry-pick-${timestamp}-$$" - - if git tag "${backup_tag}" >> "$logfile" 2>&1; then - echo "✓ Created backup tag: ${backup_tag}" | tee -a "$logfile" - log_section_end "CREATE BACKUP TAG" "$logfile" "0" - else - echo "⚠ Warning: Could not create backup tag" | tee -a "$logfile" - log_section_end "CREATE BACKUP TAG" "$logfile" "1" - fi - echo "" | tee -a "$logfile" - - # [6/6] Cherry-pick - log_section_start "GIT CHERRY-PICK" "$logfile" - - if run_git_with_logging "GIT CHERRY-PICK COMMIT" "$logfile" cherry-pick "${commit_hash}"; then - log_section_end "GIT CHERRY-PICK" "$logfile" "0" - echo "" | tee -a "$logfile" - { - echo "========================================" - echo "[CHERRY-PICK SUMMARY]" - echo "========================================" - } | tee -a "$logfile" - echo "✓ CHERRY-PICK SUCCESSFUL" | tee -a "$logfile" - echo "" | tee -a "$logfile" - git log -1 --oneline | while read -r line; do echo " Cherry-picked: $line" | tee -a "$logfile"; done - echo " Original commit: ${commit_hash}" | tee -a "$logfile" - echo " Backup tag: ${backup_tag}" | tee -a "$logfile" - echo "" | tee -a "$logfile" - echo "Next steps:" | tee -a "$logfile" - echo " 1. Review: git show HEAD" | tee -a "$logfile" - echo " 2. Push: ./scripts/git/push_validated.sh" | tee -a "$logfile" - echo " Rollback: git reset --hard ${backup_tag}" | tee -a "$logfile" - { - echo "" - echo "End Time: $(date)" - } | tee -a "$logfile" - echo "" | tee -a "$logfile" - echo "Full log: $logfile" - else - log_section_end "GIT CHERRY-PICK" "$logfile" "1" - echo "" | tee -a "$logfile" - echo "⚠ Cherry-pick conflicts detected" | tee -a "$logfile" - echo "" | tee -a "$logfile" - git status | tee -a "$logfile" - echo "" - echo "Please resolve conflicts manually:" - echo " 1. Edit conflicted files" - echo " 2. git add " - echo " 3. git cherry-pick --continue" - echo "" - echo "Or abort:" - echo " git cherry-pick --abort" - echo " git checkout ${original_branch}" - echo "" - echo "Backup available: git reset --hard ${backup_tag}" - exit 1 - fi + local non_interactive=0 + local dry_run=0 + local commit_hash_flag="" + + while [[ $# -gt 0 ]]; do + case "${1}" in + --help | -h) + echo "Usage: ./scripts/git/cherry_pick_commits.sh [OPTIONS]" + echo "" + echo "Cherry-pick a commit from source branch to target branch with validation." + echo "" + echo "Options:" + echo " --non-interactive Skip prompts; requires --commit" + echo " --commit Commit hash to cherry-pick (skips interactive selection)" + echo " --dry-run Show commit details without cherry-picking" + echo " -h, --help Show this help" + echo "" + echo "Configuration:" + echo " CGW_SOURCE_BRANCH Branch commits come from (default: development)" + echo " CGW_TARGET_BRANCH Branch commits go to (default: main)" + echo " CGW_DEV_ONLY_FILES Dev-only paths to warn about (default: empty)" + echo "" + echo "Environment:" + echo " CGW_NON_INTERACTIVE=1 Same as --non-interactive" + exit 0 + ;; + --non-interactive) non_interactive=1 ;; + --dry-run) dry_run=1 ;; + --commit) + commit_hash_flag="${2:-}" + shift + ;; + *) + echo "[ERROR] Unknown flag: $1" >&2 + exit 1 + ;; + esac + shift + done + + [[ "${CGW_NON_INTERACTIVE:-0}" == "1" ]] && non_interactive=1 + + { + echo "=========================================" + echo "Cherry-Pick Commits Log" + echo "=========================================" + echo "Start Time: $(date)" + echo "Working Directory: ${PROJECT_ROOT}" + } >"$logfile" + + echo "=== Cherry-Pick Commits: ${CGW_SOURCE_BRANCH} → ${CGW_TARGET_BRANCH} ===" | tee -a "$logfile" + echo "" | tee -a "$logfile" + + cd "${PROJECT_ROOT}" || { + err "Cannot find project root" + exit 1 + } + + # [1/6] Run validation + log_section_start "PRE-CHERRY-PICK VALIDATION" "$logfile" + + if [[ -f "${SCRIPT_DIR}/validate_branches.sh" ]]; then + if ! bash "${SCRIPT_DIR}/validate_branches.sh" >>"$logfile" 2>&1; then + echo "✗ Validation failed - aborting cherry-pick" | tee -a "$logfile" + log_section_end "PRE-CHERRY-PICK VALIDATION" "$logfile" "1" + echo "Please fix validation errors before retrying" + exit 1 + fi + fi + + echo "✓ Pre-cherry-pick validation passed" | tee -a "$logfile" + log_section_end "PRE-CHERRY-PICK VALIDATION" "$logfile" "0" + echo "" | tee -a "$logfile" + + # [2/6] Store current branch and checkout target + log_section_start "GIT CHECKOUT TARGET" "$logfile" + + local original_branch + original_branch=$(git branch --show-current) + _cp_original_branch="${original_branch}" + + if [[ -z "${original_branch}" ]]; then + echo "✗ Failed to determine current branch" | tee -a "$logfile" + log_section_end "GIT CHECKOUT TARGET" "$logfile" "1" + exit 1 + fi + + echo "Current branch: ${original_branch}" | tee -a "$logfile" + + if ! run_git_with_logging "GIT CHECKOUT" "$logfile" checkout "${CGW_TARGET_BRANCH}"; then + echo "✗ Failed to checkout ${CGW_TARGET_BRANCH} branch" | tee -a "$logfile" + exit 1 + fi + _cp_did_checkout_target=1 + + log_section_end "GIT CHECKOUT TARGET" "$logfile" "0" + echo "" | tee -a "$logfile" + + # [3/6] Show recent source branch commits + if [[ -z "${commit_hash_flag}" ]]; then + echo "[3/6] Recent commits on ${CGW_SOURCE_BRANCH} branch:" + echo "====================================" + git log "${CGW_SOURCE_BRANCH}" --oneline -20 --no-merges + echo "====================================" + echo "" + fi + + # [4/6] Get commit hash + local commit_hash + if [[ -n "${commit_hash_flag}" ]]; then + commit_hash="${commit_hash_flag}" + echo "[4/6] Using --commit: ${commit_hash}" | tee -a "$logfile" + elif [[ ${non_interactive} -eq 1 ]]; then + echo "✗ [Non-interactive] --commit is required" >&2 + git checkout "${original_branch}" + exit 1 + else + echo "[4/6] Select commit to cherry-pick..." + echo "" + read -r -p "Enter commit hash (or 'cancel' to abort): " commit_hash + + if [[ "${commit_hash}" == "cancel" ]]; then + echo "" + log_message "Cherry-pick cancelled" "${logfile}" + git checkout "${original_branch}" + exit 0 + fi + fi + + if ! git rev-parse "${commit_hash}" >/dev/null 2>&1; then + log_message "✗ ERROR: Invalid commit hash: ${commit_hash}" "${logfile}" + git checkout "${original_branch}" + exit 1 + fi + + # Validate commit is on source branch + if ! git merge-base --is-ancestor "${commit_hash}" "${CGW_SOURCE_BRANCH}" 2>/dev/null; then + echo "⚠ WARNING: ${commit_hash} is not an ancestor of ${CGW_SOURCE_BRANCH}" | tee -a "$logfile" + if [[ ${non_interactive} -eq 1 ]]; then + echo "✗ [Non-interactive] Aborting — commit not on ${CGW_SOURCE_BRANCH} branch" | tee -a "$logfile" + git checkout "${original_branch}" + exit 1 + fi + read -r -p "Continue anyway? (yes/no): " branch_check_choice + if [[ "${branch_check_choice}" != "yes" ]]; then + log_message "Cherry-pick cancelled" "${logfile}" + git checkout "${original_branch}" + exit 0 + fi + fi + + echo "" + echo "Selected commit:" + git log "${commit_hash}" --oneline -1 + echo "" + echo "Commit details:" + git show "${commit_hash}" --stat + echo "" + + if [[ ${dry_run} -eq 1 ]]; then + echo "=== DRY RUN — no changes made ===" | tee -a "$logfile" + echo "Would cherry-pick: ${commit_hash}" | tee -a "$logfile" + git checkout "${original_branch}" + exit 0 + fi + + # Check if commit modifies dev-only files (configurable warning) + if [[ -n "${CGW_DEV_ONLY_FILES}" ]]; then + local has_excluded_files=0 + for dev_file in ${CGW_DEV_ONLY_FILES}; do + if git show "${commit_hash}" --name-only --format="" | grep -q "^${dev_file}"; then + has_excluded_files=1 + break + fi + done + + if [[ ${has_excluded_files} -eq 1 ]]; then + echo "⚠ WARNING: This commit modifies configured dev-only files" + echo "Dev-only files (CGW_DEV_ONLY_FILES):" + for dev_file in ${CGW_DEV_ONLY_FILES}; do + git show "${commit_hash}" --name-only --format="" | grep "^${dev_file}" || true + done + echo "" + if [[ ${non_interactive} -eq 1 ]]; then + echo "✗ [Non-interactive] Aborting — commit touches dev-only files" | tee -a "$logfile" + git checkout "${original_branch}" + exit 1 + fi + read -r -p "Continue anyway? (yes/no): " continue_choice + if [[ "${continue_choice}" != "yes" ]]; then + echo "" + log_message "Cherry-pick cancelled" "${logfile}" + git checkout "${original_branch}" + exit 0 + fi + fi + fi + + # [5/6] Create backup tag + log_section_start "CREATE BACKUP TAG" "$logfile" + + if [[ -z "${timestamp:-}" ]]; then get_timestamp; fi + local backup_tag="pre-cherry-pick-${timestamp}-$$" + + if git tag "${backup_tag}" >>"$logfile" 2>&1; then + echo "✓ Created backup tag: ${backup_tag}" | tee -a "$logfile" + log_section_end "CREATE BACKUP TAG" "$logfile" "0" + else + echo "⚠ Warning: Could not create backup tag" | tee -a "$logfile" + log_section_end "CREATE BACKUP TAG" "$logfile" "1" + fi + echo "" | tee -a "$logfile" + + # [6/6] Cherry-pick + log_section_start "GIT CHERRY-PICK" "$logfile" + + if run_git_with_logging "GIT CHERRY-PICK COMMIT" "$logfile" cherry-pick "${commit_hash}"; then + log_section_end "GIT CHERRY-PICK" "$logfile" "0" + echo "" | tee -a "$logfile" + { + echo "========================================" + echo "[CHERRY-PICK SUMMARY]" + echo "========================================" + } | tee -a "$logfile" + echo "✓ CHERRY-PICK SUCCESSFUL" | tee -a "$logfile" + echo "" | tee -a "$logfile" + git log -1 --oneline | while read -r line; do echo " Cherry-picked: $line" | tee -a "$logfile"; done + echo " Original commit: ${commit_hash}" | tee -a "$logfile" + echo " Backup tag: ${backup_tag}" | tee -a "$logfile" + echo "" | tee -a "$logfile" + echo "Next steps:" | tee -a "$logfile" + echo " 1. Review: git show HEAD" | tee -a "$logfile" + echo " 2. Push: ./scripts/git/push_validated.sh" | tee -a "$logfile" + echo " Rollback: git reset --hard ${backup_tag}" | tee -a "$logfile" + { + echo "" + echo "End Time: $(date)" + } | tee -a "$logfile" + echo "" | tee -a "$logfile" + echo "Full log: $logfile" + else + log_section_end "GIT CHERRY-PICK" "$logfile" "1" + echo "" | tee -a "$logfile" + echo "⚠ Cherry-pick conflicts detected" | tee -a "$logfile" + echo "" | tee -a "$logfile" + git status | tee -a "$logfile" + echo "" + echo "Please resolve conflicts manually:" + echo " 1. Edit conflicted files" + echo " 2. git add " + echo " 3. git cherry-pick --continue" + echo "" + echo "Or abort:" + echo " git cherry-pick --abort" + echo " git checkout ${original_branch}" + echo "" + echo "Backup available: git reset --hard ${backup_tag}" + exit 1 + fi } main "$@" diff --git a/scripts/git/commit_enhanced.sh b/scripts/git/commit_enhanced.sh index e8249d4..aa1a54c 100644 --- a/scripts/git/commit_enhanced.sh +++ b/scripts/git/commit_enhanced.sh @@ -26,7 +26,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "${SCRIPT_DIR}/_common.sh" generate_analysis_report() { - cat > "$reportfile" << EOF + cat >"$reportfile" <> "$logfile" + echo "End Time: $(date)" >>"$logfile" } unstage_local_only_files() { - # Unstage files listed in CGW_LOCAL_FILES (space-separated). - # Entries ending with / are treated as directory prefixes. - local file - for file in ${CGW_LOCAL_FILES}; do - if [[ "${file}" == */ ]]; then - # Directory prefix: unstage all matching staged files - while read -r f; do - git reset HEAD "$f" 2>/dev/null || true - done < <(git diff --cached --name-only | grep "^${file}" || true) - else - git reset HEAD "${file}" 2>/dev/null || true - fi - done + # Unstage files listed in CGW_LOCAL_FILES (space-separated). + # Entries ending with / are treated as directory prefixes. + local file + for file in ${CGW_LOCAL_FILES}; do + if [[ "${file}" == */ ]]; then + # Directory prefix: unstage all matching staged files + while read -r f; do + git reset HEAD "$f" 2>/dev/null || true + done < <(git diff --cached --name-only | grep "^${file}" || true) + else + git reset HEAD "${file}" 2>/dev/null || true + fi + done } main() { - local non_interactive=0 - local skip_md_lint=1 # markdown lint always skipped (no-op flag kept for compat) - local staged_only=0 - local commit_msg_param="" - - # Auto-detect non-interactive mode when no TTY - if [[ ! -t 0 ]]; then - non_interactive=1 - fi - - # CGW_* environment variable overrides - [[ "${CGW_NON_INTERACTIVE:-0}" == "1" ]] && non_interactive=1 - [[ "${CGW_STAGED_ONLY:-0}" == "1" ]] && staged_only=1 - [[ "${CGW_NO_VENV:-0}" == "1" ]] && SKIP_VENV=1 - - while [[ $# -gt 0 ]]; do - case "$1" in - --help|-h) - echo "Usage: ./scripts/git/commit_enhanced.sh [OPTIONS] \"commit message\"" - echo "" - echo "Enhanced commit workflow with lint validation and local-only file protection." - echo "" - echo "Options:" - echo " --non-interactive Skip all prompts (auto-stage, auto-fix lint)" - echo " --interactive Force interactive mode even without TTY" - echo " --staged-only Use pre-staged files only, skip auto-staging" - echo " --no-venv Use system ruff instead of .venv ruff" - echo " --skip-md-lint (no-op, markdown lint always skipped)" - echo " -h, --help Show this help" - echo "" - echo "Commit message format: : " - echo " Standard types: feat fix docs chore test refactor style perf" - echo " Configure extras via CGW_EXTRA_PREFIXES in .cgw.conf" - echo "" - echo "Environment:" - echo " CGW_NON_INTERACTIVE=1 Same as --non-interactive" - echo " CGW_STAGED_ONLY=1 Same as --staged-only" - echo " CGW_NO_VENV=1 Same as --no-venv" - echo " (Also: CLAUDE_GIT_NON_INTERACTIVE, CLAUDE_GIT_STAGED_ONLY, CLAUDE_GIT_NO_VENV)" - echo "" - echo "Protected files (never committed): configured via CGW_LOCAL_FILES in .cgw.conf" - echo " Default: CLAUDE.md MEMORY.md .claude/ logs/" - exit 0 - ;; - --non-interactive) non_interactive=1; shift ;; - --skip-md-lint) skip_md_lint=1; shift ;; - --interactive) non_interactive=0; shift ;; - --staged-only) staged_only=1; shift ;; - --no-venv) SKIP_VENV=1; CGW_NO_VENV=1; shift ;; - *) commit_msg_param="$1"; shift ;; - esac - done - - init_logging "commit_enhanced" - - if [[ -z "$logfile" ]] || [[ -z "$reportfile" ]]; then - err "Failed to initialize logging" - exit 1 - fi - - { - echo "=========================================" - echo "Enhanced Commit Workflow Log" - echo "=========================================" - echo "Start Time: $(date)" - echo "Branch: $(git branch --show-current)" - echo "" - } > "$logfile" - - log_message "=== Enhanced Commit Workflow ===" "$logfile" - log_message "" "$logfile" - - cd "${PROJECT_ROOT}" || { - err "Cannot find project root" - exit 1 - } - - # Get Python path (best-effort, ruff may be in PATH) - get_python_path 2>/dev/null || true - - local current_branch - current_branch=$(git branch --show-current) - echo "Current branch: $current_branch" - echo "" - - # [1] Check for uncommitted changes - echo "[1/6] Checking for changes..." - - git diff --quiet - local has_unstaged=$? - git diff --cached --quiet - local has_staged=$? - - if [[ ${has_unstaged} -eq 0 ]] && [[ ${has_staged} -eq 0 ]]; then - echo "[!] No changes to commit" - exit 0 - fi - - if [[ ${has_unstaged} -ne 0 ]]; then - echo "Unstaged changes detected:" - git diff --name-status - echo "" - - 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 . - 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 . - unstage_local_only_files - echo "[OK] Changes staged" - else - echo "Please stage changes manually: git add " - exit 1 - fi - fi - fi - echo "" - - # [2] Validate staged files — unstage and verify local-only files - echo "[2/6] Validating staged files..." - unstage_local_only_files - - # Post-unstage check: verify nothing slipped through - local found_local_files=0 - local staged_files - staged_files=$(git diff --cached --name-only) - - local file - for file in ${CGW_LOCAL_FILES}; do - local check_file="${file%/}" # strip trailing slash - if echo "${staged_files}" | grep -q "^${check_file}"; then - echo "[X] ERROR: '${check_file}' is staged (local-only file — should not be committed)" >&2 - found_local_files=1 - fi - done - - if [[ ${found_local_files} -eq 1 ]]; then - echo "Remove these files from staging: git reset HEAD " >&2 - exit 1 - fi - - echo "[OK] Staged files validated" - echo "" - - # [3] Code quality check - echo "[3/6] Checking code quality..." - - get_lint_exclusions - - local python_lint_error=0 - - if [[ -z "${CGW_LINT_CMD}" ]]; then - echo " (lint check skipped — CGW_LINT_CMD not set)" - else - log_section_start "LINT CHECK" "$logfile" - - # Determine lint binary (venv or PATH) - local lint_cmd="${CGW_LINT_CMD}" - if [[ "${CGW_LINT_CMD}" == "ruff" ]]; then - get_python_path 2>/dev/null || true - if [[ -n "${PYTHON_BIN:-}" ]] && [[ -f "${PYTHON_BIN}/ruff${PYTHON_EXT:-}" ]]; then - lint_cmd="${PYTHON_BIN}/ruff${PYTHON_EXT:-}" - fi - fi - - local lint_output format_output - - # shellcheck disable=SC2086 - lint_output=$("${lint_cmd}" ${CGW_LINT_CHECK_ARGS} ${CGW_LINT_EXCLUDES} 2>&1) || python_lint_error=1 - if [[ -n "$lint_output" ]] && [[ "$lint_output" != *"All checks passed"* ]]; then - echo "[LINT ERRORS]" | tee -a "$logfile" - echo "$lint_output" | tee -a "$logfile" - fi - - if [[ -n "${CGW_FORMAT_CMD}" ]]; then - # shellcheck disable=SC2086 - format_output=$("${CGW_FORMAT_CMD}" ${CGW_FORMAT_CHECK_ARGS} ${CGW_FORMAT_EXCLUDES} 2>&1) || python_lint_error=1 - if [[ -n "$format_output" ]] && [[ "$format_output" == *"would reformat"* ]]; then - echo "[FORMAT ERRORS]" | tee -a "$logfile" - echo "$format_output" | tee -a "$logfile" - fi - fi - - log_section_end "LINT CHECK" "$logfile" "$python_lint_error" - - if [[ ${python_lint_error} -eq 1 ]]; then - echo "[!] Lint errors detected" - if [[ ${non_interactive} -eq 1 ]]; then - echo "[Non-interactive] Auto-fixing lint issues..." - # shellcheck disable=SC2086 - "${lint_cmd}" ${CGW_LINT_FIX_ARGS} ${CGW_LINT_EXCLUDES} 2>&1 | tee -a "$logfile" - if [[ -n "${CGW_FORMAT_CMD}" ]]; then - # shellcheck disable=SC2086 - "${CGW_FORMAT_CMD}" ${CGW_FORMAT_FIX_ARGS} ${CGW_FORMAT_EXCLUDES} 2>&1 | tee -a "$logfile" - fi - - if [[ ${staged_only} -eq 0 ]]; then - git add . - unstage_local_only_files - fi - - # Re-check - python_lint_error=0 - # shellcheck disable=SC2086 - "${lint_cmd}" ${CGW_LINT_CHECK_ARGS} ${CGW_LINT_EXCLUDES} 2>&1 | tee -a "$logfile" || python_lint_error=1 - - if [[ ${python_lint_error} -eq 1 ]]; then - err "Lint errors remain after auto-fix" - exit 1 - fi - else - read -rp "Auto-fix lint issues? (yes/no/skip): " fix_lint - case "$fix_lint" in - yes|y) - # shellcheck disable=SC2086 - "${lint_cmd}" ${CGW_LINT_FIX_ARGS} ${CGW_LINT_EXCLUDES} - if [[ -n "${CGW_FORMAT_CMD}" ]]; then - # shellcheck disable=SC2086 - "${CGW_FORMAT_CMD}" ${CGW_FORMAT_FIX_ARGS} ${CGW_FORMAT_EXCLUDES} - fi - git add . - unstage_local_only_files - ;; - skip|s) - echo "[!] Proceeding with lint warnings (CI may flag these)" ;; - *) - echo "Commit cancelled — fix lint errors first" - exit 1 ;; - esac - fi - else - echo "[OK] Code quality checks passed" - fi - fi - echo "" - - # [4] Show staged changes - echo "[4/6] Staged changes:" - echo "====================================" - git diff --cached --name-status - echo "====================================" - echo "" - - local staged_count - staged_count=$(git diff --cached --name-only | wc -l) - echo "Files to commit: $staged_count" - echo "" - - # [5] Get commit message - echo "[5/6] Commit message..." - - if [[ -z "$commit_msg_param" ]]; then - err "Commit message required" - echo "Usage: ./scripts/git/commit_enhanced.sh \"feat: Your message\"" >&2 - echo "Types: feat fix docs chore test refactor style perf (+ extras in .cgw.conf)" >&2 - exit 1 - fi - - local commit_msg="$commit_msg_param" - - 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 - read -rp "Continue anyway? (yes/no): " continue_commit - if [[ "$continue_commit" != "yes" ]]; then - echo "Commit cancelled" - exit 0 - fi - fi - fi - - echo "Commit message: $commit_msg" - echo "" - - # [6] Create commit - echo "[6/6] Creating commit..." - - if [[ ${non_interactive} -eq 1 ]]; then - echo "[Non-interactive] Branch: $current_branch — Proceeding..." - else - echo "[!] Branch verification: you are committing to: $current_branch" - read -rp "Is this the correct branch? (yes/no): " correct_branch - if [[ "$correct_branch" != "yes" ]]; then - echo "Switch to correct branch first: git checkout " - exit 0 - fi - read -rp "Proceed with commit? (yes/no): " confirm_commit - if [[ "$confirm_commit" != "yes" ]]; then - echo "Commit cancelled" - exit 0 - fi - fi - - if git commit -m "$commit_msg"; then - echo "" - echo "====================================" - echo "[OK] COMMIT SUCCESSFUL" - echo "====================================" - echo "" - echo "Commit: $(git log -1 --oneline)" - echo "Branch: $current_branch" - echo "Files: $staged_count" - echo "" - echo "Next steps:" - if [[ "$current_branch" == "${CGW_SOURCE_BRANCH}" ]]; then - echo " - Continue development" - echo " - When ready: ./scripts/git/merge_with_validation.sh --dry-run" - else - echo " - Push: ./scripts/git/push_validated.sh" - fi - - generate_analysis_report - else - err "Commit failed — check output above" - exit 1 - fi - - exit 0 + local non_interactive=0 + local skip_lint=0 + local skip_md_lint=0 + local staged_only=0 + local commit_msg_param="" + + # Auto-detect non-interactive mode when no TTY + if [[ ! -t 0 ]]; then + non_interactive=1 + fi + + # CGW_* environment variable overrides + [[ "${CGW_NON_INTERACTIVE:-0}" == "1" ]] && non_interactive=1 + [[ "${CGW_STAGED_ONLY:-0}" == "1" ]] && staged_only=1 + [[ "${CGW_NO_VENV:-0}" == "1" ]] && SKIP_VENV=1 + [[ "${CGW_SKIP_LINT:-0}" == "1" ]] && skip_lint=1 && skip_md_lint=1 + [[ "${CGW_SKIP_MD_LINT:-0}" == "1" ]] && skip_md_lint=1 + + while [[ $# -gt 0 ]]; do + case "$1" in + --help | -h) + echo "Usage: ./scripts/git/commit_enhanced.sh [OPTIONS] \"commit message\"" + echo "" + echo "Enhanced commit workflow with lint validation and local-only file protection." + echo "" + echo "Options:" + echo " --non-interactive Skip all prompts (auto-stage, auto-fix lint)" + echo " --interactive Force interactive mode even without TTY" + echo " --staged-only Use pre-staged files only, skip auto-staging" + echo " --no-venv Use system ruff instead of .venv ruff" + echo " --skip-lint Skip all lint checks (code + markdown)" + echo " --skip-md-lint Skip markdown lint only (CGW_MARKDOWNLINT_CMD step)" + echo " -h, --help Show this help" + echo "" + echo "Commit message format: : " + echo " Standard types: feat fix docs chore test refactor style perf" + echo " Configure extras via CGW_EXTRA_PREFIXES in .cgw.conf" + echo "" + echo "Environment:" + echo " CGW_NON_INTERACTIVE=1 Same as --non-interactive" + echo " CGW_STAGED_ONLY=1 Same as --staged-only" + echo " CGW_NO_VENV=1 Same as --no-venv" + echo " CGW_SKIP_LINT=1 Same as --skip-lint" + echo " CGW_SKIP_MD_LINT=1 Same as --skip-md-lint" + echo " (Also: CLAUDE_GIT_NON_INTERACTIVE, CLAUDE_GIT_STAGED_ONLY, CLAUDE_GIT_NO_VENV)" + echo "" + echo "Protected files (never committed): configured via CGW_LOCAL_FILES in .cgw.conf" + echo " Default: CLAUDE.md MEMORY.md .claude/ logs/" + exit 0 + ;; + --non-interactive) + non_interactive=1 + shift + ;; + --skip-lint) + skip_lint=1 + skip_md_lint=1 + shift + ;; + --skip-md-lint) + skip_md_lint=1 + shift + ;; + --interactive) + non_interactive=0 + shift + ;; + --staged-only) + staged_only=1 + shift + ;; + --no-venv) + SKIP_VENV=1 + CGW_NO_VENV=1 + shift + ;; + --*) + echo "[ERROR] Unknown flag: $1" >&2 + exit 1 + ;; + *) + commit_msg_param="$1" + shift + ;; + esac + done + + init_logging "commit_enhanced" + + if [[ -z "$logfile" ]] || [[ -z "$reportfile" ]]; then + err "Failed to initialize logging" + exit 1 + fi + + { + echo "=========================================" + echo "Enhanced Commit Workflow Log" + echo "=========================================" + echo "Start Time: $(date)" + echo "Branch: $(git branch --show-current)" + echo "" + } >"$logfile" + + log_message "=== Enhanced Commit Workflow ===" "$logfile" + log_message "" "$logfile" + + cd "${PROJECT_ROOT}" || { + err "Cannot find project root" + exit 1 + } + + # Get Python path (best-effort, ruff may be in PATH) + get_python_path 2>/dev/null || true + + local current_branch + current_branch=$(git branch --show-current) + echo "Current branch: $current_branch" + echo "" + + # [1] Check for uncommitted changes + echo "[1/6] Checking for changes..." + + git diff --quiet + local has_unstaged=$? + git diff --cached --quiet + local has_staged=$? + + if [[ ${has_unstaged} -eq 0 ]] && [[ ${has_staged} -eq 0 ]]; then + echo "[!] No changes to commit" + exit 0 + fi + + if [[ ${has_unstaged} -ne 0 ]]; then + echo "Unstaged changes detected:" + git diff --name-status + echo "" + + 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 . + 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 . + unstage_local_only_files + echo "[OK] Changes staged" + else + echo "Please stage changes manually: git add " + exit 1 + fi + fi + fi + echo "" + + # [2] Validate staged files — unstage and verify local-only files + echo "[2/6] Validating staged files..." + unstage_local_only_files + + # Post-unstage check: verify nothing slipped through + local found_local_files=0 + local staged_files + staged_files=$(git diff --cached --name-only) + + local file + for file in ${CGW_LOCAL_FILES}; do + local check_file="${file%/}" # strip trailing slash + if echo "${staged_files}" | grep -q "^${check_file}"; then + echo "[X] ERROR: '${check_file}' is staged (local-only file — should not be committed)" >&2 + found_local_files=1 + fi + done + + if [[ ${found_local_files} -eq 1 ]]; then + echo "Remove these files from staging: git reset HEAD " >&2 + exit 1 + fi + + echo "[OK] Staged files validated" + echo "" + + # [3] Code quality check + echo "[3/6] Checking code quality..." + + if [[ ${skip_lint} -eq 1 ]]; then + echo " (all lint checks skipped — --skip-lint)" + else + get_lint_exclusions + + # Resolve lint and format binaries independently (each uses venv ruff if available) + local lint_cmd="${CGW_LINT_CMD}" + local format_cmd="${CGW_FORMAT_CMD}" + if [[ -n "${CGW_LINT_CMD}" ]] || [[ -n "${CGW_FORMAT_CMD}" ]]; then + get_python_path 2>/dev/null || true + fi + if [[ -n "${CGW_LINT_CMD}" && "${CGW_LINT_CMD}" == "ruff" ]]; then + if [[ -n "${PYTHON_BIN:-}" ]] && [[ -f "${PYTHON_BIN}/ruff${PYTHON_EXT:-}" ]]; then + lint_cmd="${PYTHON_BIN}/ruff${PYTHON_EXT:-}" + fi + fi + if [[ -n "${CGW_FORMAT_CMD}" && "${CGW_FORMAT_CMD}" == "ruff" ]]; then + if [[ -n "${PYTHON_BIN:-}" ]] && [[ -f "${PYTHON_BIN}/ruff${PYTHON_EXT:-}" ]]; then + format_cmd="${PYTHON_BIN}/ruff${PYTHON_EXT:-}" + fi + fi + + local lint_error=0 format_error=0 lint_output format_output + + # -- Code lint (skipped when CGW_LINT_CMD not set) ------------------------- + if [[ -n "${CGW_LINT_CMD}" ]]; then + log_section_start "LINT CHECK" "$logfile" + # shellcheck disable=SC2086 # Word splitting intentional: CGW_LINT_CHECK_ARGS/CGW_LINT_EXCLUDES contain multiple flags + lint_output=$("${lint_cmd}" ${CGW_LINT_CHECK_ARGS} ${CGW_LINT_EXCLUDES} 2>&1) || lint_error=1 + if [[ -n "$lint_output" ]] && [[ "$lint_output" != *"All checks passed"* ]]; then + echo "[LINT ERRORS]" | tee -a "$logfile" + echo "$lint_output" | tee -a "$logfile" + fi + log_section_end "LINT CHECK" "$logfile" "$lint_error" + else + echo " (lint check skipped — CGW_LINT_CMD not set)" + fi + + # -- Format check (skipped when CGW_FORMAT_CMD not set) -------------------- + if [[ -n "${CGW_FORMAT_CMD}" ]]; then + log_section_start "FORMAT CHECK" "$logfile" + # shellcheck disable=SC2086 # Word splitting intentional: CGW_FORMAT_CHECK_ARGS/CGW_FORMAT_EXCLUDES contain multiple flags + format_output=$("${format_cmd}" ${CGW_FORMAT_CHECK_ARGS} ${CGW_FORMAT_EXCLUDES} 2>&1) || format_error=1 + if [[ -n "$format_output" ]] && [[ "$format_output" == *"would reformat"* ]]; then + echo "[FORMAT ERRORS]" | tee -a "$logfile" + echo "$format_output" | tee -a "$logfile" + fi + log_section_end "FORMAT CHECK" "$logfile" "$format_error" + fi + + # -- Combined error handling ----------------------------------------------- + local python_lint_error=$((lint_error | format_error)) + + if [[ ${python_lint_error} -eq 1 ]]; then + echo "[!] Code quality errors detected" + if [[ ${non_interactive} -eq 1 ]]; then + echo "[Non-interactive] Auto-fixing code quality issues..." + if [[ -n "${CGW_LINT_CMD}" ]]; then + # shellcheck disable=SC2086 # Word splitting intentional: CGW_LINT_FIX_ARGS/CGW_LINT_EXCLUDES contain multiple flags + "${lint_cmd}" ${CGW_LINT_FIX_ARGS} ${CGW_LINT_EXCLUDES} 2>&1 | tee -a "$logfile" + fi + if [[ -n "${CGW_FORMAT_CMD}" ]]; then + # 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} 2>&1 | tee -a "$logfile" + fi + + if [[ ${staged_only} -eq 0 ]]; then + git add . + unstage_local_only_files + fi + + # Re-check + python_lint_error=0 + if [[ -n "${CGW_LINT_CMD}" ]]; then + # shellcheck disable=SC2086 # Word splitting intentional: CGW_LINT_CHECK_ARGS/CGW_LINT_EXCLUDES contain multiple flags + "${lint_cmd}" ${CGW_LINT_CHECK_ARGS} ${CGW_LINT_EXCLUDES} 2>&1 | tee -a "$logfile" || python_lint_error=1 + fi + if [[ -n "${CGW_FORMAT_CMD}" ]]; then + # shellcheck disable=SC2086 # Word splitting intentional: CGW_FORMAT_CHECK_ARGS/CGW_FORMAT_EXCLUDES contain multiple flags + "${format_cmd}" ${CGW_FORMAT_CHECK_ARGS} ${CGW_FORMAT_EXCLUDES} 2>&1 | tee -a "$logfile" || python_lint_error=1 + fi + + if [[ ${python_lint_error} -eq 1 ]]; then + err "Code quality errors remain after auto-fix" + exit 1 + fi + else + read -rp "Auto-fix code quality issues? (yes/no/skip): " fix_lint + case "$fix_lint" in + yes | y) + if [[ -n "${CGW_LINT_CMD}" ]]; then + # shellcheck disable=SC2086 # Word splitting intentional: CGW_LINT_FIX_ARGS/CGW_LINT_EXCLUDES contain multiple flags + "${lint_cmd}" ${CGW_LINT_FIX_ARGS} ${CGW_LINT_EXCLUDES} + fi + if [[ -n "${CGW_FORMAT_CMD}" ]]; then + # 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 . + unstage_local_only_files + ;; + skip | s) + echo "[!] Proceeding with code quality warnings (CI may flag these)" + ;; + *) + echo "Commit cancelled — fix code quality errors first" + exit 1 + ;; + esac + fi + else + echo "[OK] Code quality checks passed" + fi + + # Markdown lint step (skipped if --skip-md-lint or CGW_MARKDOWNLINT_CMD not set) + if [[ ${skip_md_lint} -eq 0 ]] && [[ -n "${CGW_MARKDOWNLINT_CMD}" ]]; then + log_section_start "MARKDOWN LINT" "$logfile" + local md_lint_error=0 + # shellcheck disable=SC2086 # Word splitting intentional: CGW_MARKDOWNLINT_ARGS contains multiple flags/patterns + if ! "${CGW_MARKDOWNLINT_CMD}" ${CGW_MARKDOWNLINT_ARGS} 2>&1 | tee -a "$logfile"; then + md_lint_error=1 + fi + log_section_end "MARKDOWN LINT" "$logfile" "$md_lint_error" + if [[ ${md_lint_error} -eq 1 ]]; then + echo "[!] Markdown lint errors detected" + if [[ ${non_interactive} -eq 1 ]]; then + err "Markdown lint failed — fix errors or use --skip-md-lint to bypass" + exit 1 + fi + read -rp "Proceed despite markdown lint errors? (yes/no): " md_choice + [[ "${md_choice}" == "yes" ]] || exit 1 + fi + elif [[ ${skip_md_lint} -eq 1 ]]; then + echo " (markdown lint skipped — --skip-md-lint)" + fi + fi + echo "" + + # [4] Show staged changes + echo "[4/6] Staged changes:" + echo "====================================" + git diff --cached --name-status + echo "====================================" + echo "" + + local staged_count + staged_count=$(git diff --cached --name-only | wc -l) + echo "Files to commit: $staged_count" + echo "" + + # [5] Get commit message + echo "[5/6] Commit message..." + + if [[ -z "$commit_msg_param" ]]; then + err "Commit message required" + echo "Usage: ./scripts/git/commit_enhanced.sh \"feat: Your message\"" >&2 + echo "Types: feat fix docs chore test refactor style perf (+ extras in .cgw.conf)" >&2 + exit 1 + fi + + local commit_msg="$commit_msg_param" + + 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 + read -rp "Continue anyway? (yes/no): " continue_commit + if [[ "$continue_commit" != "yes" ]]; then + echo "Commit cancelled" + exit 0 + fi + fi + fi + + echo "Commit message: $commit_msg" + echo "" + + # [6] Create commit + echo "[6/6] Creating commit..." + + if [[ ${non_interactive} -eq 1 ]]; then + echo "[Non-interactive] Branch: $current_branch — Proceeding..." + else + echo "[!] Branch verification: you are committing to: $current_branch" + read -rp "Is this the correct branch? (yes/no): " correct_branch + if [[ "$correct_branch" != "yes" ]]; then + echo "Switch to correct branch first: git checkout " + exit 0 + fi + read -rp "Proceed with commit? (yes/no): " confirm_commit + if [[ "$confirm_commit" != "yes" ]]; then + echo "Commit cancelled" + exit 0 + fi + fi + + if git commit -m "$commit_msg"; then + echo "" + echo "====================================" + echo "[OK] COMMIT SUCCESSFUL" + echo "====================================" + echo "" + echo "Commit: $(git log -1 --oneline)" + echo "Branch: $current_branch" + echo "Files: $staged_count" + echo "" + echo "Next steps:" + if [[ "$current_branch" == "${CGW_SOURCE_BRANCH}" ]]; then + echo " - Continue development" + echo " - When ready: ./scripts/git/merge_with_validation.sh --dry-run" + else + echo " - Push: ./scripts/git/push_validated.sh" + fi + + generate_analysis_report + else + err "Commit failed — check output above" + exit 1 + fi + + exit 0 } main "$@" diff --git a/scripts/git/configure.sh b/scripts/git/configure.sh index a7beb6a..dd128fd 100644 --- a/scripts/git/configure.sh +++ b/scripts/git/configure.sh @@ -23,276 +23,314 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # For configure.sh, we detect PROJECT_ROOT ourselves (can't source _common.sh yet # because _config.sh requires PROJECT_ROOT to already exist for .cgw.conf loading) _find_project_root() { - local dir - dir="$(cd "${SCRIPT_DIR}" && pwd)" - while [[ "${dir}" != "/" ]] && [[ -n "${dir}" ]]; do - if [[ -d "${dir}/.git" ]]; then - echo "${dir}" - return 0 - fi - dir="$(dirname "${dir}")" - done - git rev-parse --show-toplevel 2>/dev/null && return 0 - return 1 + local dir + dir="$(cd "${SCRIPT_DIR}" && pwd)" + while [[ "${dir}" != "/" ]] && [[ -n "${dir}" ]]; do + if [[ -d "${dir}/.git" ]]; then + echo "${dir}" + return 0 + fi + dir="$(dirname "${dir}")" + done + git rev-parse --show-toplevel 2>/dev/null && return 0 + return 1 } -PROJECT_ROOT="$(_find_project_root)" || { - echo "[ERROR] Cannot find git repository root. Are you inside a git repo?" >&2 - exit 1 -} +if [[ -z "${PROJECT_ROOT:-}" ]]; then + PROJECT_ROOT="$(_find_project_root)" || { + echo "[ERROR] Cannot find git repository root. Are you inside a git repo?" >&2 + exit 1 + } +fi # ============================================================================ # AUTO-DETECTION FUNCTIONS # ============================================================================ _detect_target_branch() { - # Check git remote HEAD pointer - local remote_head - remote_head=$(git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's|refs/remotes/origin/||') - if [[ -n "${remote_head}" ]]; then echo "${remote_head}"; return 0; fi - # Check common names - if git show-ref --verify --quiet refs/heads/main 2>/dev/null; then echo "main"; return 0; fi - if git show-ref --verify --quiet refs/heads/master 2>/dev/null; then echo "master"; return 0; fi - echo "main" + # Check git remote HEAD pointer + local remote_head + remote_head=$(git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's|refs/remotes/origin/||') + if [[ -n "${remote_head}" ]]; then + echo "${remote_head}" + return 0 + fi + # Check common names + if git show-ref --verify --quiet refs/heads/main 2>/dev/null; then + echo "main" + return 0 + fi + if git show-ref --verify --quiet refs/heads/master 2>/dev/null; then + echo "master" + return 0 + fi + echo "main" } _detect_source_branch() { - local target="$1" - # Check common source branch names - for name in development develop dev staging; do - if git show-ref --verify --quiet "refs/heads/${name}" 2>/dev/null; then - echo "${name}"; return 0 - fi - done - # Most recently committed branch that isn't target - local recent - recent=$(git for-each-ref --sort=-committerdate --format='%(refname:short)' refs/heads/ 2>/dev/null \ - | grep -v "^${target}$" | head -1) - if [[ -n "${recent}" ]]; then echo "${recent}"; return 0; fi - echo "${target}" # fallback: same branch (will warn later) + local target="$1" + # Check common source branch names + for name in development develop dev staging; do + if git show-ref --verify --quiet "refs/heads/${name}" 2>/dev/null; then + echo "${name}" + return 0 + fi + done + # Most recently committed branch that isn't target + local recent + recent=$(git for-each-ref --sort=-committerdate --format='%(refname:short)' refs/heads/ 2>/dev/null | + grep -v "^${target}$" | head -1) + if [[ -n "${recent}" ]]; then + echo "${recent}" + return 0 + fi + echo "${target}" # fallback: same branch (will warn later) } _detect_lint_tool() { - # Python project detection - if [[ -f "pyproject.toml" ]] || [[ -f "setup.py" ]] || [[ -f "setup.cfg" ]] || [[ -f "requirements.txt" ]]; then - if command -v ruff &>/dev/null; then echo "ruff"; return 0; fi - if command -v flake8 &>/dev/null; then echo "flake8"; return 0; fi - if command -v pylint &>/dev/null; then echo "pylint"; return 0; fi - fi - # JavaScript/TypeScript project detection - if [[ -f "package.json" ]]; then - if command -v eslint &>/dev/null; then echo "eslint"; return 0; fi - fi - # Go project detection - if [[ -f "go.mod" ]]; then - if command -v golangci-lint &>/dev/null; then echo "golangci-lint"; return 0; fi - fi - # Rust project detection - if [[ -f "Cargo.toml" ]]; then - if command -v cargo &>/dev/null; then echo "cargo"; return 0; fi - fi - echo "" # no lint tool detected + # Python project detection + if [[ -f "pyproject.toml" ]] || [[ -f "setup.py" ]] || [[ -f "setup.cfg" ]] || [[ -f "requirements.txt" ]]; then + if command -v ruff &>/dev/null; then + echo "ruff" + return 0 + fi + if command -v flake8 &>/dev/null; then + echo "flake8" + return 0 + fi + if command -v pylint &>/dev/null; then + echo "pylint" + return 0 + fi + fi + # JavaScript/TypeScript project detection + if [[ -f "package.json" ]]; then + if command -v eslint &>/dev/null; then + echo "eslint" + return 0 + fi + fi + # Go project detection + if [[ -f "go.mod" ]]; then + if command -v golangci-lint &>/dev/null; then + echo "golangci-lint" + return 0 + fi + fi + # Rust project detection + if [[ -f "Cargo.toml" ]]; then + if command -v cargo &>/dev/null; then + echo "cargo" + return 0 + fi + fi + echo "" # no lint tool detected } _detect_format_tool() { - local lint_tool="$1" - case "${lint_tool}" in - ruff) echo "ruff" ;; - eslint) - if command -v prettier &>/dev/null; then echo "prettier"; else echo ""; fi ;; - *) echo "" ;; - esac + local lint_tool="$1" + case "${lint_tool}" in + ruff) echo "ruff" ;; + eslint) + if command -v prettier &>/dev/null; then echo "prettier"; else echo ""; fi + ;; + *) echo "" ;; + esac } _detect_local_files() { - # Scan for files that exist on disk but are not tracked by git - local files=() - local check_files=(CLAUDE.md MEMORY.md SESSION_LOG.md GEMINI.md .env .env.local .env.development .env.production) - local check_dirs=(.claude/ logs/) - - for f in "${check_files[@]}"; do - if [[ -f "${PROJECT_ROOT}/${f}" ]] && ! git -C "${PROJECT_ROOT}" ls-files --error-unmatch "${f}" &>/dev/null 2>&1; then - files+=("${f}") - fi - done - - for d in "${check_dirs[@]}"; do - local dir_path="${PROJECT_ROOT}/${d%/}" - if [[ -d "${dir_path}" ]] && ! git -C "${PROJECT_ROOT}" ls-files --error-unmatch "${d}" &>/dev/null 2>&1; then - files+=("${d}") - fi - done - - echo "${files[*]:-}" + # Scan for files that exist on disk but are not tracked by git + local files=() + local check_files=(CLAUDE.md MEMORY.md SESSION_LOG.md GEMINI.md .env .env.local .env.development .env.production) + local check_dirs=(.claude/ logs/) + + for f in "${check_files[@]}"; do + if [[ -f "${PROJECT_ROOT}/${f}" ]] && ! git -C "${PROJECT_ROOT}" ls-files --error-unmatch "${f}" &>/dev/null 2>&1; then + files+=("${f}") + fi + done + + for d in "${check_dirs[@]}"; do + local dir_path="${PROJECT_ROOT}/${d%/}" + if [[ -d "${dir_path}" ]] && ! git -C "${PROJECT_ROOT}" ls-files --error-unmatch "${d}" &>/dev/null 2>&1; then + files+=("${d}") + fi + done + + echo "${files[*]:-}" } _detect_venv() { - local venv_dirs=(".venv" "venv" "env" ".env") - for d in "${venv_dirs[@]}"; do - if [[ -d "${PROJECT_ROOT}/${d}" ]]; then - echo "${d}" - return 0 - fi - done - echo "" + local venv_dirs=(".venv" "venv" "env" ".env") + for d in "${venv_dirs[@]}"; do + if [[ -d "${PROJECT_ROOT}/${d}" ]]; then + echo "${d}" + return 0 + fi + done + echo "" } _build_lint_config() { - local lint_tool="$1" - local venv_dir="$2" - - case "${lint_tool}" in - ruff) - local excludes="--extend-exclude logs" - if [[ -n "${venv_dir}" ]]; then - excludes="${excludes} --extend-exclude ${venv_dir}" - fi - echo "CGW_LINT_CMD=\"ruff\"" - echo "CGW_LINT_CHECK_ARGS=\"check .\"" - echo "CGW_LINT_FIX_ARGS=\"check --fix .\"" - echo "CGW_LINT_EXCLUDES=\"${excludes}\"" - echo "CGW_FORMAT_CMD=\"ruff\"" - echo "CGW_FORMAT_CHECK_ARGS=\"format --check .\"" - echo "CGW_FORMAT_FIX_ARGS=\"format .\"" - local fmt_excludes="--exclude logs" - if [[ -n "${venv_dir}" ]]; then fmt_excludes="${fmt_excludes} --exclude ${venv_dir}"; fi - echo "CGW_FORMAT_EXCLUDES=\"${fmt_excludes}\"" - ;; - flake8) - echo "CGW_LINT_CMD=\"flake8\"" - echo "CGW_LINT_CHECK_ARGS=\".\"" - echo "CGW_LINT_FIX_ARGS=\".\" # flake8 has no auto-fix; use autopep8 manually" - echo "CGW_LINT_EXCLUDES=\"--exclude logs,.venv\"" - echo "CGW_FORMAT_CMD=\"\" # set to 'black' or 'autopep8' if available" - echo "CGW_FORMAT_CHECK_ARGS=\"\"" - echo "CGW_FORMAT_FIX_ARGS=\"\"" - echo "CGW_FORMAT_EXCLUDES=\"\"" - ;; - eslint) - echo "CGW_LINT_CMD=\"eslint\"" - echo "CGW_LINT_CHECK_ARGS=\".\"" - echo "CGW_LINT_FIX_ARGS=\". --fix\"" - echo "CGW_LINT_EXCLUDES=\"\"" - echo "CGW_FORMAT_CMD=\"prettier\"" - echo "CGW_FORMAT_CHECK_ARGS=\"--check .\"" - echo "CGW_FORMAT_FIX_ARGS=\"--write .\"" - echo "CGW_FORMAT_EXCLUDES=\"\"" - ;; - golangci-lint) - echo "CGW_LINT_CMD=\"golangci-lint\"" - echo "CGW_LINT_CHECK_ARGS=\"run\"" - echo "CGW_LINT_FIX_ARGS=\"run --fix\"" - echo "CGW_LINT_EXCLUDES=\"\"" - echo "CGW_FORMAT_CMD=\"gofmt\"" - echo "CGW_FORMAT_CHECK_ARGS=\"-l .\"" - echo "CGW_FORMAT_FIX_ARGS=\"-w .\"" - echo "CGW_FORMAT_EXCLUDES=\"\"" - ;; - "") - echo "CGW_LINT_CMD=\"\" # no lint tool detected; set to enable" - echo "CGW_LINT_CHECK_ARGS=\"\"" - echo "CGW_LINT_FIX_ARGS=\"\"" - echo "CGW_LINT_EXCLUDES=\"\"" - echo "CGW_FORMAT_CMD=\"\"" - echo "CGW_FORMAT_CHECK_ARGS=\"\"" - echo "CGW_FORMAT_FIX_ARGS=\"\"" - echo "CGW_FORMAT_EXCLUDES=\"\"" - ;; - *) - echo "CGW_LINT_CMD=\"${lint_tool}\"" - echo "CGW_LINT_CHECK_ARGS=\".\" # adjust for your tool" - echo "CGW_LINT_FIX_ARGS=\".\" # adjust for your tool" - echo "CGW_LINT_EXCLUDES=\"\"" - echo "CGW_FORMAT_CMD=\"\"" - echo "CGW_FORMAT_CHECK_ARGS=\"\"" - echo "CGW_FORMAT_FIX_ARGS=\"\"" - echo "CGW_FORMAT_EXCLUDES=\"\"" - ;; - esac + local lint_tool="$1" + local venv_dir="$2" + + case "${lint_tool}" in + ruff) + local excludes="--extend-exclude logs" + if [[ -n "${venv_dir}" ]]; then + excludes="${excludes} --extend-exclude ${venv_dir}" + fi + echo "CGW_LINT_CMD=\"ruff\"" + echo "CGW_LINT_CHECK_ARGS=\"check .\"" + echo "CGW_LINT_FIX_ARGS=\"check --fix .\"" + echo "CGW_LINT_EXCLUDES=\"${excludes}\"" + echo "CGW_FORMAT_CMD=\"ruff\"" + echo "CGW_FORMAT_CHECK_ARGS=\"format --check .\"" + echo "CGW_FORMAT_FIX_ARGS=\"format .\"" + local fmt_excludes="--exclude logs" + if [[ -n "${venv_dir}" ]]; then fmt_excludes="${fmt_excludes} --exclude ${venv_dir}"; fi + echo "CGW_FORMAT_EXCLUDES=\"${fmt_excludes}\"" + ;; + flake8) + echo "CGW_LINT_CMD=\"flake8\"" + echo "CGW_LINT_CHECK_ARGS=\".\"" + echo "CGW_LINT_FIX_ARGS=\".\" # flake8 has no auto-fix; use autopep8 manually" + echo "CGW_LINT_EXCLUDES=\"--exclude logs,.venv\"" + echo "CGW_FORMAT_CMD=\"\" # set to 'black' or 'autopep8' if available" + echo "CGW_FORMAT_CHECK_ARGS=\"\"" + echo "CGW_FORMAT_FIX_ARGS=\"\"" + echo "CGW_FORMAT_EXCLUDES=\"\"" + ;; + eslint) + echo "CGW_LINT_CMD=\"eslint\"" + echo "CGW_LINT_CHECK_ARGS=\".\"" + echo "CGW_LINT_FIX_ARGS=\". --fix\"" + echo "CGW_LINT_EXCLUDES=\"\"" + echo "CGW_FORMAT_CMD=\"prettier\"" + echo "CGW_FORMAT_CHECK_ARGS=\"--check .\"" + echo "CGW_FORMAT_FIX_ARGS=\"--write .\"" + echo "CGW_FORMAT_EXCLUDES=\"\"" + ;; + golangci-lint) + echo "CGW_LINT_CMD=\"golangci-lint\"" + echo "CGW_LINT_CHECK_ARGS=\"run\"" + echo "CGW_LINT_FIX_ARGS=\"run --fix\"" + echo "CGW_LINT_EXCLUDES=\"\"" + echo "CGW_FORMAT_CMD=\"gofmt\"" + echo "CGW_FORMAT_CHECK_ARGS=\"-l .\"" + echo "CGW_FORMAT_FIX_ARGS=\"-w .\"" + echo "CGW_FORMAT_EXCLUDES=\"\"" + ;; + "") + echo "CGW_LINT_CMD=\"\" # no lint tool detected; set to enable" + echo "CGW_LINT_CHECK_ARGS=\"\"" + echo "CGW_LINT_FIX_ARGS=\"\"" + echo "CGW_LINT_EXCLUDES=\"\"" + echo "CGW_FORMAT_CMD=\"\"" + echo "CGW_FORMAT_CHECK_ARGS=\"\"" + echo "CGW_FORMAT_FIX_ARGS=\"\"" + echo "CGW_FORMAT_EXCLUDES=\"\"" + ;; + *) + echo "CGW_LINT_CMD=\"${lint_tool}\"" + echo "CGW_LINT_CHECK_ARGS=\".\" # adjust for your tool" + echo "CGW_LINT_FIX_ARGS=\".\" # adjust for your tool" + echo "CGW_LINT_EXCLUDES=\"\"" + echo "CGW_FORMAT_CMD=\"\"" + echo "CGW_FORMAT_CHECK_ARGS=\"\"" + echo "CGW_FORMAT_FIX_ARGS=\"\"" + echo "CGW_FORMAT_EXCLUDES=\"\"" + ;; + esac } _install_hook() { - local local_files="$1" - local hooks_template_dir="${SCRIPT_DIR}/../../hooks" - - # Normalize: resolve relative path - hooks_template_dir="$(cd "${SCRIPT_DIR}" && cd "../../hooks" 2>/dev/null && pwd)" || { - # Try relative to project structure (when scripts are in scripts/git/ inside the repo) - hooks_template_dir="${PROJECT_ROOT}/.cgw-hooks-template" - } - - local hook_template="${hooks_template_dir}/pre-commit" - - if [[ ! -f "${hook_template}" ]]; then - echo " ⚠ Hook template not found at: ${hook_template}" - echo " Skipping hook installation." - return 1 - fi - - # Build regex pattern from local files list - local files_pattern="" - for f in ${local_files}; do - local escaped="${f%/}" # strip trailing slash - escaped="${escaped//./\\.}" # escape dots - [[ -n "${files_pattern}" ]] && files_pattern="${files_pattern}|" - files_pattern="${files_pattern}${escaped}" - done - - # Create .githooks/ and write patched hook - mkdir -p "${PROJECT_ROOT}/.githooks" - sed "s|__CGW_LOCAL_FILES_PATTERN__|${files_pattern}|g" \ - "${hook_template}" > "${PROJECT_ROOT}/.githooks/pre-commit" - chmod +x "${PROJECT_ROOT}/.githooks/pre-commit" - - # 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" - else - echo " ⚠ Hook installed to .githooks/ but failed to copy to .git/hooks/" - echo " Run: ./scripts/git/install_hooks.sh" - fi + local local_files="$1" + local hooks_template_dir="${SCRIPT_DIR}/../../hooks" + + # Normalize: resolve relative path + hooks_template_dir="$(cd "${SCRIPT_DIR}" && cd "../../hooks" 2>/dev/null && pwd)" || { + # Try relative to project structure (when scripts are in scripts/git/ inside the repo) + hooks_template_dir="${PROJECT_ROOT}/.cgw-hooks-template" + } + + local hook_template="${hooks_template_dir}/pre-commit" + + if [[ ! -f "${hook_template}" ]]; then + echo " ⚠ Hook template not found at: ${hook_template}" + echo " Skipping hook installation." + return 1 + fi + + # Build regex pattern from local files list + local files_pattern="" + for f in ${local_files}; do + local escaped="${f%/}" # strip trailing slash + escaped="${escaped//./\\.}" # escape dots + [[ -n "${files_pattern}" ]] && files_pattern="${files_pattern}|" + files_pattern="${files_pattern}${escaped}" + done + + # Create .githooks/ and write patched hook + # Escape backslashes first, then & (sed replacement special char), then | (sed delimiter) + local sed_files_pattern="${files_pattern//\\/\\\\}" + sed_files_pattern="${sed_files_pattern//&/\\&}" + sed_files_pattern="${sed_files_pattern//|/\\|}" + mkdir -p "${PROJECT_ROOT}/.githooks" + sed "s|__CGW_LOCAL_FILES_PATTERN__|${sed_files_pattern}|g" \ + "${hook_template}" >"${PROJECT_ROOT}/.githooks/pre-commit" + chmod +x "${PROJECT_ROOT}/.githooks/pre-commit" + + # 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" + else + echo " ⚠ Hook installed to .githooks/ but failed to copy to .git/hooks/" + echo " Run: ./scripts/git/install_hooks.sh" + fi } _install_skill() { - local skill_src="${SCRIPT_DIR}/../../skill" - skill_src="$(cd "${SCRIPT_DIR}" && cd "../../skill" 2>/dev/null && pwd)" || { - echo " ⚠ Skill template not found — skipping" - return 1 - } - - local skill_dst="${PROJECT_ROOT}/.claude/skills/auto-git-workflow" - mkdir -p "${skill_dst}/references" - - cp "${skill_src}/SKILL.md" "${skill_dst}/SKILL.md" 2>/dev/null || true - cp "${skill_src}/references/"*.md "${skill_dst}/references/" 2>/dev/null || true - - local cmd_src="${SCRIPT_DIR}/../../command/auto-git-workflow.md" - if [[ -f "${cmd_src}" ]]; then - mkdir -p "${PROJECT_ROOT}/.claude/commands" - cp "${cmd_src}" "${PROJECT_ROOT}/.claude/commands/auto-git-workflow.md" 2>/dev/null || true - echo " ✓ Claude Code skill + slash command installed" - else - echo " ✓ Claude Code skill installed (command template not found)" - fi + local skill_src="${SCRIPT_DIR}/../../skill" + skill_src="$(cd "${SCRIPT_DIR}" && cd "../../skill" 2>/dev/null && pwd)" || { + echo " ⚠ Skill template not found — skipping" + return 1 + } + + local skill_dst="${PROJECT_ROOT}/.claude/skills/auto-git-workflow" + mkdir -p "${skill_dst}/references" + + cp "${skill_src}/SKILL.md" "${skill_dst}/SKILL.md" 2>/dev/null || true + cp "${skill_src}/references/"*.md "${skill_dst}/references/" 2>/dev/null || true + + local cmd_src="${SCRIPT_DIR}/../../command/auto-git-workflow.md" + if [[ -f "${cmd_src}" ]]; then + mkdir -p "${PROJECT_ROOT}/.claude/commands" + cp "${cmd_src}" "${PROJECT_ROOT}/.claude/commands/auto-git-workflow.md" 2>/dev/null || true + echo " ✓ Claude Code skill + slash command installed" + else + echo " ✓ Claude Code skill installed (command template not found)" + fi } _update_gitignore() { - local gitignore="${PROJECT_ROOT}/.gitignore" - local entries=("logs/" ".cgw.conf") - local added=() - - for entry in "${entries[@]}"; do - if [[ ! -f "${gitignore}" ]] || ! grep -qxF "${entry}" "${gitignore}" 2>/dev/null; then - echo "${entry}" >> "${gitignore}" - added+=("${entry}") - fi - done - - if [[ ${#added[@]} -gt 0 ]]; then - echo " ✓ Added to .gitignore: ${added[*]}" - else - echo " ✓ .gitignore already up to date" - fi + local gitignore="${PROJECT_ROOT}/.gitignore" + local entries=("logs/" ".cgw.conf") + local added=() + + for entry in "${entries[@]}"; do + if [[ ! -f "${gitignore}" ]] || ! grep -qxF "${entry}" "${gitignore}" 2>/dev/null; then + echo "${entry}" >>"${gitignore}" + added+=("${entry}") + fi + done + + if [[ ${#added[@]} -gt 0 ]]; then + echo " ✓ Added to .gitignore: ${added[*]}" + else + echo " ✓ .gitignore already up to date" + fi } # ============================================================================ @@ -300,211 +338,214 @@ _update_gitignore() { # ============================================================================ main() { - local non_interactive=0 - local reconfigure=0 - local skip_hooks=0 - local skip_skill=0 - - while [[ $# -gt 0 ]]; do - case "${1}" in - --help|-h) - echo "Usage: ./scripts/git/configure.sh [OPTIONS]" - echo "" - echo "Auto-configure claude-git-workflow for this project." - echo "Scans the project and generates .cgw.conf, installs hooks," - echo "and optionally installs the Claude Code skill." - echo "" - echo "Options:" - echo " --non-interactive Accept all auto-detected defaults" - echo " --reconfigure Overwrite existing .cgw.conf" - echo " --skip-hooks Don't install git pre-commit hook" - echo " --skip-skill Don't install Claude Code skill" - echo " -h, --help Show this help" - echo "" - echo "After running, edit .cgw.conf to customize any detected values." - exit 0 - ;; - --non-interactive) non_interactive=1 ;; - --reconfigure) reconfigure=1 ;; - --skip-hooks) skip_hooks=1 ;; - --skip-skill) skip_skill=1 ;; - *) echo "[ERROR] Unknown flag: $1" >&2; exit 1 ;; - esac - shift - done - - cd "${PROJECT_ROOT}" || { - echo "[ERROR] Cannot change to project root: ${PROJECT_ROOT}" >&2 - exit 1 - } - - echo "" - echo "=== claude-git-workflow: Auto-Configuration ===" - echo "" - echo "Project root: ${PROJECT_ROOT}" - echo "" - - # Check if .cgw.conf already exists - if [[ -f ".cgw.conf" ]] && [[ ${reconfigure} -eq 0 ]]; then - echo "✓ .cgw.conf already exists." - if [[ ${non_interactive} -eq 0 ]]; then - read -r -p " Reconfigure? (yes/no) [no]: " answer - if [[ "${answer}" == "yes" ]]; then - reconfigure=1 - else - echo "" - echo "Using existing configuration. Use --reconfigure to overwrite." - echo "" - # Still run hook + skill install - fi - else - echo " Use --reconfigure to overwrite." - fi - fi - - # ── Detection phase ────────────────────────────────────────────────────── - - echo "Scanning project..." - echo "" - - local detected_target - detected_target="$(_detect_target_branch)" - - local detected_source - detected_source="$(_detect_source_branch "${detected_target}")" - - local detected_lint - detected_lint="$(_detect_lint_tool)" - - local detected_venv - detected_venv="$(_detect_venv)" - - local detected_local_files - detected_local_files="$(_detect_local_files)" - - echo " Target branch (stable): ${detected_target}" - echo " Source branch (dev): ${detected_source}" - echo " Lint tool: ${detected_lint:-none detected}" - echo " Venv directory: ${detected_venv:-none found}" - echo " Local-only files: ${detected_local_files:-none found}" - echo "" - - # ── Interactive confirmation ────────────────────────────────────────────── - - local target_branch="${detected_target}" - local source_branch="${detected_source}" - local local_files="${detected_local_files:-CLAUDE.md MEMORY.md .claude/ logs/}" - - if [[ ${non_interactive} -eq 0 ]]; then - read -r -p "Target branch [${detected_target}]: " answer - [[ -n "${answer}" ]] && target_branch="${answer}" - - read -r -p "Source branch [${detected_source}]: " answer - [[ -n "${answer}" ]] && source_branch="${answer}" - - echo "" - echo "Local-only files (never committed): ${local_files}" - read -r -p "Add/change local files? (press Enter to keep): " answer - [[ -n "${answer}" ]] && local_files="${answer}" - fi - - # ── Generate .cgw.conf ──────────────────────────────────────────────────── - - if [[ ! -f ".cgw.conf" ]] || [[ ${reconfigure} -eq 1 ]]; then - echo "Generating .cgw.conf..." - - { - echo "# .cgw.conf — Auto-generated by configure.sh on $(date)" - echo "# Edit as needed. See cgw.conf.example for all options." - echo "# This file is git-ignored (.cgw.conf in .gitignore)." - echo "" - echo "# Branch configuration" - echo "CGW_SOURCE_BRANCH=\"${source_branch}\"" - echo "CGW_TARGET_BRANCH=\"${target_branch}\"" - echo "" - echo "# Local-only files (space-separated; never committed)" - echo "CGW_LOCAL_FILES=\"${local_files}\"" - echo "" - echo "# Lint configuration (auto-detected)" - _build_lint_config "${detected_lint}" "${detected_venv}" - echo "" - echo "# Commit message prefix extras (pipe-separated, e.g. \"cuda|tensorrt\")" - echo "CGW_EXTRA_PREFIXES=\"\"" - echo "" - echo "# Docs CI pattern (empty = skip; set to enable doc filename validation)" - echo "# Example: CGW_DOCS_PATTERN=\"^(README\\.md|.*_GUIDE\\.md|.*_REFERENCE\\.md)$\"" - echo "CGW_DOCS_PATTERN=\"\"" - echo "" - echo "# Dev-only files warning for cherry-pick (space-separated; empty = skip)" - echo "# Example: CGW_DEV_ONLY_FILES=\"tests/ pytest.ini\"" - echo "CGW_DEV_ONLY_FILES=\"\"" - echo "" - echo "# Remove tests/ from target branch if gitignored (0=disabled, 1=enabled)" - echo "CGW_CLEANUP_TESTS=\"0\"" - } > ".cgw.conf" - - echo " ✓ .cgw.conf generated" - fi - - # ── Install pre-commit hook ─────────────────────────────────────────────── - - if [[ ${skip_hooks} -eq 0 ]]; then - local install_hook="yes" - if [[ ${non_interactive} -eq 0 ]]; then - read -r -p "Install pre-commit hook? (yes/no) [yes]: " answer - [[ -n "${answer}" ]] && install_hook="${answer}" - fi - - if [[ "${install_hook}" == "yes" ]]; then - echo "Installing pre-commit hook..." - _install_hook "${local_files}" - fi - fi - - # ── Update .gitignore ───────────────────────────────────────────────────── - - echo "Updating .gitignore..." - _update_gitignore - - # ── Install Claude Code skill ───────────────────────────────────────────── - - if [[ ${skip_skill} -eq 0 ]]; then - local install_skill="no" - # Default to yes if .claude/ directory already exists - if [[ -d ".claude" ]]; then - install_skill="yes" - fi - - if [[ ${non_interactive} -eq 0 ]]; then - read -r -p "Install Claude Code skill? (yes/no) [${install_skill}]: " answer - [[ -n "${answer}" ]] && install_skill="${answer}" - fi - - if [[ "${install_skill}" == "yes" ]]; then - echo "Installing Claude Code skill..." - _install_skill - fi - fi - - # ── Summary ────────────────────────────────────────────────────────────── - - echo "" - echo "=== Configuration Complete ===" - echo "" - echo " Config file: ${PROJECT_ROOT}/.cgw.conf" - echo " Source branch: ${source_branch}" - echo " Target branch: ${target_branch}" - if [[ -n "${detected_lint}" ]]; then - echo " Lint tool: ${detected_lint}" - fi - echo "" - echo "Quick start:" - echo " ./scripts/git/commit_enhanced.sh \"feat: your feature\"" - echo " ./scripts/git/merge_with_validation.sh --dry-run" - echo " ./scripts/git/push_validated.sh" - echo "" - echo "Edit .cgw.conf to customize any settings." - echo "" + local non_interactive=0 + local reconfigure=0 + local skip_hooks=0 + local skip_skill=0 + + while [[ $# -gt 0 ]]; do + case "${1}" in + --help | -h) + echo "Usage: ./scripts/git/configure.sh [OPTIONS]" + echo "" + echo "Auto-configure claude-git-workflow for this project." + echo "Scans the project and generates .cgw.conf, installs hooks," + echo "and optionally installs the Claude Code skill." + echo "" + echo "Options:" + echo " --non-interactive Accept all auto-detected defaults" + echo " --reconfigure Overwrite existing .cgw.conf" + echo " --skip-hooks Don't install git pre-commit hook" + echo " --skip-skill Don't install Claude Code skill" + echo " -h, --help Show this help" + echo "" + echo "After running, edit .cgw.conf to customize any detected values." + exit 0 + ;; + --non-interactive) non_interactive=1 ;; + --reconfigure) reconfigure=1 ;; + --skip-hooks) skip_hooks=1 ;; + --skip-skill) skip_skill=1 ;; + *) + echo "[ERROR] Unknown flag: $1" >&2 + exit 1 + ;; + esac + shift + done + + cd "${PROJECT_ROOT}" || { + echo "[ERROR] Cannot change to project root: ${PROJECT_ROOT}" >&2 + exit 1 + } + + echo "" + echo "=== claude-git-workflow: Auto-Configuration ===" + echo "" + echo "Project root: ${PROJECT_ROOT}" + echo "" + + # Check if .cgw.conf already exists + if [[ -f ".cgw.conf" ]] && [[ ${reconfigure} -eq 0 ]]; then + echo "✓ .cgw.conf already exists." + if [[ ${non_interactive} -eq 0 ]]; then + read -r -p " Reconfigure? (yes/no) [no]: " answer + if [[ "${answer}" == "yes" ]]; then + reconfigure=1 + else + echo "" + echo "Using existing configuration. Use --reconfigure to overwrite." + echo "" + # Still run hook + skill install + fi + else + echo " Use --reconfigure to overwrite." + fi + fi + + # ── Detection phase ────────────────────────────────────────────────────── + + echo "Scanning project..." + echo "" + + local detected_target + detected_target="$(_detect_target_branch)" + + local detected_source + detected_source="$(_detect_source_branch "${detected_target}")" + + local detected_lint + detected_lint="$(_detect_lint_tool)" + + local detected_venv + detected_venv="$(_detect_venv)" + + local detected_local_files + detected_local_files="$(_detect_local_files)" + + echo " Target branch (stable): ${detected_target}" + echo " Source branch (dev): ${detected_source}" + echo " Lint tool: ${detected_lint:-none detected}" + echo " Venv directory: ${detected_venv:-none found}" + echo " Local-only files: ${detected_local_files:-none found}" + echo "" + + # ── Interactive confirmation ────────────────────────────────────────────── + + local target_branch="${detected_target}" + local source_branch="${detected_source}" + local local_files="${detected_local_files:-CLAUDE.md MEMORY.md .claude/ logs/}" + + if [[ ${non_interactive} -eq 0 ]]; then + read -r -p "Target branch [${detected_target}]: " answer + [[ -n "${answer}" ]] && target_branch="${answer}" + + read -r -p "Source branch [${detected_source}]: " answer + [[ -n "${answer}" ]] && source_branch="${answer}" + + echo "" + echo "Local-only files (never committed): ${local_files}" + read -r -p "Add/change local files? (press Enter to keep): " answer + [[ -n "${answer}" ]] && local_files="${answer}" + fi + + # ── Generate .cgw.conf ──────────────────────────────────────────────────── + + if [[ ! -f ".cgw.conf" ]] || [[ ${reconfigure} -eq 1 ]]; then + echo "Generating .cgw.conf..." + + { + echo "# .cgw.conf — Auto-generated by configure.sh on $(date)" + echo "# Edit as needed. See cgw.conf.example for all options." + echo "# This file is git-ignored (.cgw.conf in .gitignore)." + echo "" + echo "# Branch configuration" + echo "CGW_SOURCE_BRANCH=\"${source_branch}\"" + echo "CGW_TARGET_BRANCH=\"${target_branch}\"" + echo "" + echo "# Local-only files (space-separated; never committed)" + echo "CGW_LOCAL_FILES=\"${local_files}\"" + echo "" + echo "# Lint configuration (auto-detected)" + _build_lint_config "${detected_lint}" "${detected_venv}" + echo "" + echo "# Commit message prefix extras (pipe-separated, e.g. \"cuda|tensorrt\")" + echo "CGW_EXTRA_PREFIXES=\"\"" + echo "" + echo "# Docs CI pattern (empty = skip; set to enable doc filename validation)" + echo "# Example: CGW_DOCS_PATTERN=\"^(README\\.md|.*_GUIDE\\.md|.*_REFERENCE\\.md)$\"" + echo "CGW_DOCS_PATTERN=\"\"" + echo "" + echo "# Dev-only files warning for cherry-pick (space-separated; empty = skip)" + echo "# Example: CGW_DEV_ONLY_FILES=\"tests/ pytest.ini\"" + echo "CGW_DEV_ONLY_FILES=\"\"" + echo "" + echo "# Remove tests/ from target branch if gitignored (0=disabled, 1=enabled)" + echo "CGW_CLEANUP_TESTS=\"0\"" + } >".cgw.conf" + + echo " ✓ .cgw.conf generated" + fi + + # ── Install pre-commit hook ─────────────────────────────────────────────── + + if [[ ${skip_hooks} -eq 0 ]]; then + local install_hook="yes" + if [[ ${non_interactive} -eq 0 ]]; then + read -r -p "Install pre-commit hook? (yes/no) [yes]: " answer + [[ -n "${answer}" ]] && install_hook="${answer}" + fi + + if [[ "${install_hook}" == "yes" ]]; then + echo "Installing pre-commit hook..." + _install_hook "${local_files}" + fi + fi + + # ── Update .gitignore ───────────────────────────────────────────────────── + + echo "Updating .gitignore..." + _update_gitignore + + # ── Install Claude Code skill ───────────────────────────────────────────── + + if [[ ${skip_skill} -eq 0 ]]; then + local install_skill="no" + # Default to yes if .claude/ directory already exists + if [[ -d ".claude" ]]; then + install_skill="yes" + fi + + if [[ ${non_interactive} -eq 0 ]]; then + read -r -p "Install Claude Code skill? (yes/no) [${install_skill}]: " answer + [[ -n "${answer}" ]] && install_skill="${answer}" + fi + + if [[ "${install_skill}" == "yes" ]]; then + echo "Installing Claude Code skill..." + _install_skill + fi + fi + + # ── Summary ────────────────────────────────────────────────────────────── + + echo "" + echo "=== Configuration Complete ===" + echo "" + echo " Config file: ${PROJECT_ROOT}/.cgw.conf" + echo " Source branch: ${source_branch}" + echo " Target branch: ${target_branch}" + if [[ -n "${detected_lint}" ]]; then + echo " Lint tool: ${detected_lint}" + fi + echo "" + echo "Quick start:" + echo " ./scripts/git/commit_enhanced.sh \"feat: your feature\"" + echo " ./scripts/git/merge_with_validation.sh --dry-run" + echo " ./scripts/git/push_validated.sh" + echo "" + echo "Edit .cgw.conf to customize any settings." + echo "" } main "$@" diff --git a/scripts/git/create_pr.sh b/scripts/git/create_pr.sh new file mode 100644 index 0000000..6ba5248 --- /dev/null +++ b/scripts/git/create_pr.sh @@ -0,0 +1,293 @@ +#!/usr/bin/env bash +# create_pr.sh - Create a GitHub pull request from source to target branch +# Purpose: Open a PR to trigger Charlie CI review and GitHub Actions +# Usage: ./scripts/git/create_pr.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_SOURCE_BRANCH - Head branch for the PR (default: development) +# CGW_TARGET_BRANCH - Base branch for the PR (default: main) +# Arguments: +# --title Override auto-generated PR title +# --draft Create PR as draft (not ready for review) +# --non-interactive Accept all defaults, no prompts +# --dry-run Preview PR details without creating +# -h, --help Show help +# Returns: +# 0 on successful PR creation, 1 on failure + +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=scripts/git/_common.sh +source "${SCRIPT_DIR}/_common.sh" + +main() { + local pr_title="" + local draft=0 + local non_interactive=0 + local dry_run=0 + + # Auto-detect non-interactive mode when no TTY + if [[ ! -t 0 ]]; then + non_interactive=1 + fi + + [[ "${CGW_NON_INTERACTIVE:-0}" == "1" ]] && non_interactive=1 + + while [[ $# -gt 0 ]]; do + case "$1" in + --help | -h) + echo "Usage: ./scripts/git/create_pr.sh [OPTIONS]" + echo "" + echo "Create a GitHub PR from source to target branch." + echo "Triggers Charlie CI auto-review and GitHub Actions workflows." + echo "" + echo "Options:" + echo " --title <title> Override auto-generated PR title" + echo " --draft Create as draft PR (not ready for review)" + echo " --non-interactive Accept all defaults, no prompts" + echo " --dry-run Preview PR details without creating" + echo " -h, --help Show this help" + echo "" + echo "Branches:" + echo " Head (from): ${CGW_SOURCE_BRANCH}" + echo " Base (into): ${CGW_TARGET_BRANCH}" + echo "" + echo "Environment:" + echo " CGW_NON_INTERACTIVE=1 Same as --non-interactive" + echo " CGW_SOURCE_BRANCH Override source branch" + echo " CGW_TARGET_BRANCH Override target branch" + echo "" + echo "Prerequisites:" + echo " gh CLI installed and authenticated (gh auth login)" + exit 0 + ;; + --title) + if [[ $# -lt 2 ]] || [[ -z "${2:-}" ]]; then + err "--title requires a non-empty value" + exit 1 + fi + pr_title="${2}" + shift 2 + ;; + --draft) + draft=1 + shift + ;; + --non-interactive) + non_interactive=1 + shift + ;; + --dry-run) + dry_run=1 + shift + ;; + *) + echo "[ERROR] Unknown flag: $1" >&2 + exit 1 + ;; + esac + done + + init_logging "create_pr" + + { + echo "=========================================" + echo "Create PR Log" + echo "=========================================" + echo "Start Time: $(date)" + echo "Source: ${CGW_SOURCE_BRANCH} → Target: ${CGW_TARGET_BRANCH}" + echo "" + } >"$logfile" + + cd "${PROJECT_ROOT}" || { + err "Cannot find project root" + exit 1 + } + + echo "=== Create Pull Request ===" | tee -a "$logfile" + echo "" | tee -a "$logfile" + + # [1/4] Validate prerequisites + log_section_start "PREREQUISITES" "$logfile" + + if ! command -v gh >/dev/null 2>&1; then + err "gh CLI not found. Install from https://cli.github.com/" + log_section_end "PREREQUISITES" "$logfile" "1" + exit 1 + fi + + if ! gh auth status >/dev/null 2>&1; then + err "gh CLI not authenticated. Run: gh auth login" + log_section_end "PREREQUISITES" "$logfile" "1" + exit 1 + fi + + echo "✓ gh CLI installed and authenticated" | tee -a "$logfile" + log_section_end "PREREQUISITES" "$logfile" "0" + echo "" | tee -a "$logfile" + + # [2/4] Validate branch state + log_section_start "BRANCH VALIDATION" "$logfile" + + local current_branch + current_branch=$(git branch --show-current) + echo "Current branch: ${current_branch}" | tee -a "$logfile" + echo "PR: ${CGW_SOURCE_BRANCH} → ${CGW_TARGET_BRANCH}" | tee -a "$logfile" + + if [[ "${CGW_SOURCE_BRANCH}" == "${CGW_TARGET_BRANCH}" ]]; then + err "Source and target branch are the same: ${CGW_SOURCE_BRANCH}" + log_section_end "BRANCH VALIDATION" "$logfile" "1" + exit 1 + fi + + # Verify source branch exists locally and remotely + if ! git show-ref --verify "refs/heads/${CGW_SOURCE_BRANCH}" >/dev/null 2>&1; then + err "Source branch '${CGW_SOURCE_BRANCH}' does not exist locally" + echo " Create it with: git checkout -b ${CGW_SOURCE_BRANCH}" >&2 + log_section_end "BRANCH VALIDATION" "$logfile" "1" + exit 1 + fi + + if ! git ls-remote --exit-code origin "refs/heads/${CGW_SOURCE_BRANCH}" >/dev/null 2>&1; then + err "Source branch '${CGW_SOURCE_BRANCH}' not pushed to origin" + echo " Push it with: ./scripts/git/push_validated.sh" >&2 + log_section_end "BRANCH VALIDATION" "$logfile" "1" + exit 1 + fi + + # Verify target branch exists on remote + if ! git ls-remote --exit-code origin "refs/heads/${CGW_TARGET_BRANCH}" >/dev/null 2>&1; then + err "Target branch '${CGW_TARGET_BRANCH}' does not exist on origin" + echo " Create it with: ./scripts/git/push_validated.sh --branch ${CGW_TARGET_BRANCH}" >&2 + log_section_end "BRANCH VALIDATION" "$logfile" "1" + exit 1 + fi + + # Fetch latest remote refs so comparisons below use current state + if ! git fetch origin "${CGW_SOURCE_BRANCH}" "${CGW_TARGET_BRANCH}" 2>/dev/null; then + echo "⚠ WARNING: git fetch failed — comparisons may use stale refs" | tee -a "$logfile" + fi + + # Warn if local source branch has commits not yet pushed to remote + local local_sha remote_sha + local_sha=$(git rev-parse "${CGW_SOURCE_BRANCH}" 2>/dev/null || true) + remote_sha=$(git rev-parse "origin/${CGW_SOURCE_BRANCH}" 2>/dev/null || true) + if [[ -n "${local_sha}" ]] && [[ "${local_sha}" != "${remote_sha}" ]]; then + echo "⚠ WARNING: Local ${CGW_SOURCE_BRANCH} differs from origin/${CGW_SOURCE_BRANCH}" | tee -a "$logfile" + echo " Local commits may not appear in the PR. Push first with: ./scripts/git/push_validated.sh" | tee -a "$logfile" + fi + + # Check for commits ahead of target + local commits_ahead + if ! commits_ahead=$(git rev-list --count "origin/${CGW_TARGET_BRANCH}..origin/${CGW_SOURCE_BRANCH}" 2>/dev/null); then + err "Cannot determine commit distance between origin/${CGW_TARGET_BRANCH} and origin/${CGW_SOURCE_BRANCH}" + log_section_end "BRANCH VALIDATION" "$logfile" "1" + exit 1 + fi + + if [[ "${commits_ahead}" == "0" ]]; then + echo "[!] No commits ahead of ${CGW_TARGET_BRANCH} — nothing to PR" | tee -a "$logfile" + log_section_end "BRANCH VALIDATION" "$logfile" "1" + exit 1 + fi + + echo "✓ ${commits_ahead} commit(s) ahead of ${CGW_TARGET_BRANCH}" | tee -a "$logfile" + log_section_end "BRANCH VALIDATION" "$logfile" "0" + echo "" | tee -a "$logfile" + + # [3/4] Generate PR title and body + log_section_start "PR CONTENT" "$logfile" + + local commit_log + commit_log=$(git log --oneline "origin/${CGW_TARGET_BRANCH}..origin/${CGW_SOURCE_BRANCH}" 2>/dev/null) + + # Auto-generate title if not provided + if [[ -z "${pr_title}" ]]; then + if [[ "${commits_ahead}" == "1" ]]; then + # Single commit: use its subject line + pr_title=$(git log -1 --format="%s" "origin/${CGW_SOURCE_BRANCH}" 2>/dev/null) + else + # Multiple commits: generic merge title + pr_title="merge: ${CGW_SOURCE_BRANCH} → ${CGW_TARGET_BRANCH}" + fi + fi + + # Build PR body from commit log + local pr_body + local formatted_log + # shellcheck disable=SC2001 # sed required: prepend '- ' to every line; no bash equivalent + formatted_log=$(echo "${commit_log}" | sed 's/^/- /') + + pr_body="## Changes + +${formatted_log} + +## Branch + +\`${CGW_SOURCE_BRANCH}\` → \`${CGW_TARGET_BRANCH}\`" + + echo "Title: ${pr_title}" | tee -a "$logfile" + echo "" | tee -a "$logfile" + echo "Commits:" | tee -a "$logfile" + echo "${commit_log}" | tee -a "$logfile" + + # In interactive mode, allow title override + if [[ ${non_interactive} -eq 0 ]] && [[ ${dry_run} -eq 0 ]]; then + echo "" + read -rp "PR title [${pr_title}]: " title_input + if [[ -n "${title_input}" ]]; then + pr_title="${title_input}" + fi + fi + + log_section_end "PR CONTENT" "$logfile" "0" + echo "" | tee -a "$logfile" + + # [4/4] Create PR + if [[ ${dry_run} -eq 1 ]]; then + echo "=== DRY RUN — PR not created ===" | tee -a "$logfile" + echo "" | tee -a "$logfile" + echo "Would create:" | tee -a "$logfile" + echo " Title: ${pr_title}" | tee -a "$logfile" + echo " Head: ${CGW_SOURCE_BRANCH}" | tee -a "$logfile" + echo " Base: ${CGW_TARGET_BRANCH}" | tee -a "$logfile" + echo " Draft: $([[ ${draft} -eq 1 ]] && echo yes || echo no)" | tee -a "$logfile" + echo "" | tee -a "$logfile" + echo "Charlie CI will auto-review when PR is opened (non-draft)" | tee -a "$logfile" + exit 0 + fi + + log_section_start "CREATE PR" "$logfile" + + local gh_flags=() + gh_flags+=(--base "${CGW_TARGET_BRANCH}") + gh_flags+=(--head "${CGW_SOURCE_BRANCH}") + gh_flags+=(--title "${pr_title}") + gh_flags+=(--body "${pr_body}") + [[ ${draft} -eq 1 ]] && gh_flags+=(--draft) + + local pr_url gh_output + if gh_output=$(gh pr create "${gh_flags[@]}" 2>&1 | tee -a "$logfile"); then + pr_url=$(echo "${gh_output}" | grep -oE 'https://github\.com/[^ ]+' | head -1) + log_section_end "CREATE PR" "$logfile" "0" + echo "" | tee -a "$logfile" + echo "✓ PR created: ${pr_url:-${gh_output}}" | tee -a "$logfile" + echo "" | tee -a "$logfile" + if [[ ${draft} -eq 0 ]]; then + echo "Charlie CI will auto-review this PR." | tee -a "$logfile" + else + echo "Draft PR created. Mark as Ready for Review to trigger Charlie CI." | tee -a "$logfile" + fi + echo "Full log: $logfile" + else + log_section_end "CREATE PR" "$logfile" "1" + err "PR creation failed — check log: ${logfile}" + exit 1 + fi +} + +main "$@" diff --git a/scripts/git/fix_lint.sh b/scripts/git/fix_lint.sh index 1691afd..6ad6b3b 100644 --- a/scripts/git/fix_lint.sh +++ b/scripts/git/fix_lint.sh @@ -17,158 +17,171 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "${SCRIPT_DIR}/_common.sh" main() { - local non_interactive=0 - local modified_only=0 - - while [[ $# -gt 0 ]]; do - case "$1" in - --help|-h) - echo "Usage: ./scripts/git/fix_lint.sh [OPTIONS]" - echo "" - echo "Auto-fix lint issues using configured lint tool." - echo "" - echo "Options:" - echo " --modified-only Only fix files modified vs HEAD" - echo " --non-interactive Skip prompts" - echo " --no-venv Use system lint tool instead of .venv" - echo " -h, --help Show this help" - echo "" - echo "Environment:" - echo " CGW_NON_INTERACTIVE=1 Same as --non-interactive" - echo " CGW_NO_VENV=1 Same as --no-venv" - echo " (Also: CLAUDE_GIT_NON_INTERACTIVE, CLAUDE_GIT_NO_VENV)" - exit 0 - ;; - --non-interactive) non_interactive=1; shift ;; - --no-venv) CGW_NO_VENV=1; SKIP_VENV=1; shift ;; - --modified-only) modified_only=1; shift ;; - *) echo "[ERROR] Unknown flag: $1" >&2; exit 1 ;; - esac - done - - [[ "${CGW_NON_INTERACTIVE:-0}" == "1" ]] && non_interactive=1 - - if [[ -z "${CGW_LINT_CMD}" ]]; then - echo "[OK] Lint fix skipped (CGW_LINT_CMD not set — configure in .cgw.conf)" - exit 0 - fi - - cd "${PROJECT_ROOT}" || { - err "Cannot find project root" - exit 1 - } - - get_lint_exclusions - - # Determine lint binary (venv or PATH) - local lint_cmd="${CGW_LINT_CMD}" - if [[ "${CGW_LINT_CMD}" == "ruff" ]]; then - get_python_path 2>/dev/null || true - if [[ -n "${PYTHON_BIN:-}" ]] && [[ -f "${PYTHON_BIN}/ruff${PYTHON_EXT:-}" ]]; then - lint_cmd="${PYTHON_BIN}/ruff${PYTHON_EXT:-}" - fi - fi - - # Handle --modified-only mode - if [[ "${modified_only}" -eq 1 ]]; then - local modified_files - modified_files=$(git diff --name-only --diff-filter=ACMR HEAD -- '*.py') - if [[ -z "$modified_files" ]]; then - echo "[OK] No modified files to fix" - exit 0 - fi - - echo "=== Modified-Only Lint Fix ===" - echo "Files: $modified_files" - echo "" - - local EXIT_CODE=0 - - echo "[LINT FIX]" - # shellcheck disable=SC2086 - "${lint_cmd}" ${CGW_LINT_FIX_ARGS%% *} $modified_files || EXIT_CODE=1 - - if [[ -n "${CGW_FORMAT_CMD}" ]]; then - echo "" - echo "[FORMAT FIX]" - # shellcheck disable=SC2086 - "${CGW_FORMAT_CMD}" format $modified_files || EXIT_CODE=1 - fi - - exit $EXIT_CODE - fi - - # Full fix with logging - init_logging "fix_lint" - - local script_start - script_start=$(date +%s) - - { - echo "=========================================" - echo "Lint Auto-Fix Log" - echo "=========================================" - echo "Start Time: $(date)" - echo "Working Directory: ${PROJECT_ROOT}" - echo "Lint tool: ${CGW_LINT_CMD}" - echo "Mode: $([ $non_interactive -eq 1 ] && echo 'Non-interactive' || echo 'Interactive')" - } > "$logfile" - - local fix_failed=0 - - # LINT FIX - # shellcheck disable=SC2086 - if ! run_tool_with_logging "LINT AUTO-FIX" "$logfile" \ - "${lint_cmd}" ${CGW_LINT_FIX_ARGS} ${CGW_LINT_EXCLUDES}; then - echo "[!] Lint tool: some issues may not be auto-fixable" | tee -a "$logfile" - fix_failed=1 - fi - - # FORMAT FIX - if [[ -n "${CGW_FORMAT_CMD}" ]]; then - # shellcheck disable=SC2086 - if ! run_tool_with_logging "FORMAT FIX" "$logfile" \ - "${CGW_FORMAT_CMD}" ${CGW_FORMAT_FIX_ARGS} ${CGW_FORMAT_EXCLUDES}; then - err "Formatting failed" - fix_failed=1 - fi - fi - - { - echo "" - echo "========================================" - echo "[FIX SUMMARY]" - echo "========================================" - } | tee -a "$logfile" - - if (( fix_failed == 0 )); then - echo "[OK] All lint fixes applied successfully!" | tee -a "$logfile" - else - echo "[!] Some issues remain — check output above" | tee -a "$logfile" - fi - - # Run final verification - echo "" | tee -a "$logfile" - echo "Running final verification..." | tee -a "$logfile" - - if "${SCRIPT_DIR}/check_lint.sh" 2>&1 | tee -a "$logfile"; then - echo "[OK] All lint checks pass!" | tee -a "$logfile" - else - echo "[!] Some issues remain — manual fixes may be required" | tee -a "$logfile" - fi - - local script_end total_duration - script_end=$(date +%s) - total_duration=$((script_end - script_start)) - - { - echo "" - echo "End Time: $(date)" - echo "Total Duration: ${total_duration}s" - } | tee -a "$logfile" - - echo "" - echo "Full log: $logfile" + local non_interactive=0 + local modified_only=0 + + while [[ $# -gt 0 ]]; do + case "$1" in + --help | -h) + echo "Usage: ./scripts/git/fix_lint.sh [OPTIONS]" + echo "" + echo "Auto-fix lint issues using configured lint tool." + echo "" + echo "Options:" + echo " --modified-only Only fix files modified vs HEAD" + echo " --non-interactive Skip prompts" + echo " --no-venv Use system lint tool instead of .venv" + echo " -h, --help Show this help" + echo "" + echo "Environment:" + echo " CGW_NON_INTERACTIVE=1 Same as --non-interactive" + echo " CGW_NO_VENV=1 Same as --no-venv" + echo " (Also: CLAUDE_GIT_NON_INTERACTIVE, CLAUDE_GIT_NO_VENV)" + exit 0 + ;; + --non-interactive) + non_interactive=1 + shift + ;; + --no-venv) + CGW_NO_VENV=1 + SKIP_VENV=1 + shift + ;; + --modified-only) + modified_only=1 + shift + ;; + *) + echo "[ERROR] Unknown flag: $1" >&2 + exit 1 + ;; + esac + done + + [[ "${CGW_NON_INTERACTIVE:-0}" == "1" ]] && non_interactive=1 + + if [[ -z "${CGW_LINT_CMD}" ]]; then + echo "[OK] Lint fix skipped (CGW_LINT_CMD not set — configure in .cgw.conf)" + exit 0 + fi + + cd "${PROJECT_ROOT}" || { + err "Cannot find project root" + exit 1 + } + + get_lint_exclusions + + # Determine lint binary (venv or PATH) + local lint_cmd="${CGW_LINT_CMD}" + if [[ "${CGW_LINT_CMD}" == "ruff" ]]; then + get_python_path 2>/dev/null || true + if [[ -n "${PYTHON_BIN:-}" ]] && [[ -f "${PYTHON_BIN}/ruff${PYTHON_EXT:-}" ]]; then + lint_cmd="${PYTHON_BIN}/ruff${PYTHON_EXT:-}" + fi + fi + + # Handle --modified-only mode + if [[ "${modified_only}" -eq 1 ]]; then + local modified_files + modified_files=$(git diff --name-only --diff-filter=ACMR HEAD -- '*.py') + if [[ -z "$modified_files" ]]; then + echo "[OK] No modified files to fix" + exit 0 + fi + + echo "=== Modified-Only Lint Fix ===" + echo "Files: $modified_files" + echo "" + + local EXIT_CODE=0 + + echo "[LINT FIX]" + # shellcheck disable=SC2086 + "${lint_cmd}" ${CGW_LINT_FIX_ARGS%% *} $modified_files || EXIT_CODE=1 + + if [[ -n "${CGW_FORMAT_CMD}" ]]; then + echo "" + echo "[FORMAT FIX]" + # shellcheck disable=SC2086 + "${CGW_FORMAT_CMD}" format $modified_files || EXIT_CODE=1 + fi + + exit $EXIT_CODE + fi + + # Full fix with logging + init_logging "fix_lint" + + local script_start + script_start=$(date +%s) + + { + echo "=========================================" + echo "Lint Auto-Fix Log" + echo "=========================================" + echo "Start Time: $(date)" + echo "Working Directory: ${PROJECT_ROOT}" + echo "Lint tool: ${CGW_LINT_CMD}" + echo "Mode: $([ $non_interactive -eq 1 ] && echo 'Non-interactive' || echo 'Interactive')" + } >"$logfile" + + local fix_failed=0 + + # LINT FIX + # shellcheck disable=SC2086 + if ! run_tool_with_logging "LINT AUTO-FIX" "$logfile" \ + "${lint_cmd}" ${CGW_LINT_FIX_ARGS} ${CGW_LINT_EXCLUDES}; then + echo "[!] Lint tool: some issues may not be auto-fixable" | tee -a "$logfile" + fix_failed=1 + fi + + # FORMAT FIX + if [[ -n "${CGW_FORMAT_CMD}" ]]; then + # shellcheck disable=SC2086 + if ! run_tool_with_logging "FORMAT FIX" "$logfile" \ + "${CGW_FORMAT_CMD}" ${CGW_FORMAT_FIX_ARGS} ${CGW_FORMAT_EXCLUDES}; then + err "Formatting failed" + fix_failed=1 + fi + fi + + { + echo "" + echo "========================================" + echo "[FIX SUMMARY]" + echo "========================================" + } | tee -a "$logfile" + + if ((fix_failed == 0)); then + echo "[OK] All lint fixes applied successfully!" | tee -a "$logfile" + else + echo "[!] Some issues remain — check output above" | tee -a "$logfile" + fi + + # Run final verification + echo "" | tee -a "$logfile" + echo "Running final verification..." | tee -a "$logfile" + + if "${SCRIPT_DIR}/check_lint.sh" 2>&1 | tee -a "$logfile"; then + echo "[OK] All lint checks pass!" | tee -a "$logfile" + else + echo "[!] Some issues remain — manual fixes may be required" | tee -a "$logfile" + fi + + local script_end total_duration + script_end=$(date +%s) + total_duration=$((script_end - script_start)) + + { + echo "" + echo "End Time: $(date)" + echo "Total Duration: ${total_duration}s" + } | tee -a "$logfile" + + echo "" + echo "Full log: $logfile" } main "$@" diff --git a/scripts/git/install_hooks.sh b/scripts/git/install_hooks.sh index 662eb70..7ee1248 100644 --- a/scripts/git/install_hooks.sh +++ b/scripts/git/install_hooks.sh @@ -18,97 +18,97 @@ source "${SCRIPT_DIR}/_common.sh" init_logging "install_hooks" main() { - if [[ "${1:-}" == "--help" ]] || [[ "${1:-}" == "-h" ]]; then - echo "Usage: ./scripts/git/install_hooks.sh" - echo "" - echo "Install git hooks from .githooks/ to .git/hooks/." - echo "Installs: pre-commit (blocks local-only files, optional lint check)" - echo "" - echo "The hook file must exist at: \$PROJECT_ROOT/.githooks/pre-commit" - echo "Run configure.sh first to generate this file from the template." - echo "" - echo "Options:" - echo " -h, --help Show this help" - echo "" - echo "To uninstall: rm .git/hooks/pre-commit" - echo "To bypass temporarily (not recommended): git commit --no-verify" - exit 0 - fi - - { - echo "=========================================" - echo "Install Hooks Log" - echo "=========================================" - echo "Start Time: $(date)" - echo "Working Directory: ${PROJECT_ROOT}" - } > "$logfile" - - echo "=== Git Hooks Installer ===" | tee -a "$logfile" - echo "" | tee -a "$logfile" - - cd "${PROJECT_ROOT}" || { - err "Cannot find project root" - exit 1 - } - - if [[ ! -d ".git" ]]; then - err ".git directory not found. Run from repository root." - exit 1 - fi - - if [[ ! -d ".githooks" ]]; then - err ".githooks directory not found." - echo "Run configure.sh first to generate hook files, or create .githooks/ manually." >&2 - exit 1 - fi - - log_section_start "INSTALL HOOKS" "$logfile" - - 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" - else - echo " Failed to install pre-commit hook" | tee -a "$logfile" - log_section_end "INSTALL HOOKS" "$logfile" "1" - exit 1 - fi - else - err "pre-commit template not found at .githooks/pre-commit" - echo "Run configure.sh to generate the hook from your CGW_LOCAL_FILES config." >&2 - log_section_end "INSTALL HOOKS" "$logfile" "1" - exit 1 - fi - - echo "" | tee -a "$logfile" - { - echo "========================================" - echo "[INSTALL SUMMARY]" - echo "========================================" - } | tee -a "$logfile" - echo "HOOKS INSTALLED SUCCESSFULLY" | tee -a "$logfile" - echo "" | tee -a "$logfile" - - echo "Active hooks:" - echo " - pre-commit: Blocks local-only files" - echo " Optional lint check (non-blocking)" - echo "" - echo "To bypass temporarily (not recommended):" - echo " git commit --no-verify" - echo "" - echo "To uninstall:" - echo " rm .git/hooks/pre-commit" - echo "" - - { - echo "" - echo "End Time: $(date)" - } | tee -a "$logfile" - - echo "Full log: $logfile" + if [[ "${1:-}" == "--help" ]] || [[ "${1:-}" == "-h" ]]; then + echo "Usage: ./scripts/git/install_hooks.sh" + echo "" + echo "Install git hooks from .githooks/ to .git/hooks/." + echo "Installs: pre-commit (blocks local-only files, optional lint check)" + echo "" + echo "The hook file must exist at: \$PROJECT_ROOT/.githooks/pre-commit" + echo "Run configure.sh first to generate this file from the template." + echo "" + echo "Options:" + echo " -h, --help Show this help" + echo "" + echo "To uninstall: rm .git/hooks/pre-commit" + echo "To bypass temporarily (not recommended): git commit --no-verify" + exit 0 + fi + + { + echo "=========================================" + echo "Install Hooks Log" + echo "=========================================" + echo "Start Time: $(date)" + echo "Working Directory: ${PROJECT_ROOT}" + } >"$logfile" + + echo "=== Git Hooks Installer ===" | tee -a "$logfile" + echo "" | tee -a "$logfile" + + cd "${PROJECT_ROOT}" || { + err "Cannot find project root" + exit 1 + } + + if [[ ! -d ".git" ]]; then + err ".git directory not found. Run from repository root." + exit 1 + fi + + if [[ ! -d ".githooks" ]]; then + err ".githooks directory not found." + echo "Run configure.sh first to generate hook files, or create .githooks/ manually." >&2 + exit 1 + fi + + log_section_start "INSTALL HOOKS" "$logfile" + + 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" + else + echo " Failed to install pre-commit hook" | tee -a "$logfile" + log_section_end "INSTALL HOOKS" "$logfile" "1" + exit 1 + fi + else + err "pre-commit template not found at .githooks/pre-commit" + echo "Run configure.sh to generate the hook from your CGW_LOCAL_FILES config." >&2 + log_section_end "INSTALL HOOKS" "$logfile" "1" + exit 1 + fi + + echo "" | tee -a "$logfile" + { + echo "========================================" + echo "[INSTALL SUMMARY]" + echo "========================================" + } | tee -a "$logfile" + echo "HOOKS INSTALLED SUCCESSFULLY" | tee -a "$logfile" + echo "" | tee -a "$logfile" + + echo "Active hooks:" + echo " - pre-commit: Blocks local-only files" + echo " Optional lint check (non-blocking)" + echo "" + echo "To bypass temporarily (not recommended):" + echo " git commit --no-verify" + echo "" + echo "To uninstall:" + echo " rm .git/hooks/pre-commit" + echo "" + + { + echo "" + echo "End Time: $(date)" + } | tee -a "$logfile" + + echo "Full log: $logfile" } main "$@" diff --git a/scripts/git/merge_docs.sh b/scripts/git/merge_docs.sh index fbf9817..5739103 100644 --- a/scripts/git/merge_docs.sh +++ b/scripts/git/merge_docs.sh @@ -26,233 +26,236 @@ original_branch="" did_mutate_worktree=0 cleanup() { - if [[ ${did_mutate_worktree} -eq 1 ]]; then - echo "" | tee -a "$logfile" - echo "⚠ Interrupted - restoring original state..." | tee -a "$logfile" - git restore --staged --worktree -- . 2>/dev/null || git reset --hard HEAD 2>/dev/null || true - fi - local current_branch - current_branch=$(git branch --show-current 2>/dev/null) - if [[ -n "$original_branch" ]] && [[ "$original_branch" != "$current_branch" ]]; then - git checkout "$original_branch" 2>/dev/null || true - fi + if [[ ${did_mutate_worktree} -eq 1 ]]; then + echo "" | tee -a "$logfile" + echo "⚠ Interrupted - restoring original state..." | tee -a "$logfile" + git restore --staged --worktree -- . 2>/dev/null || git reset --hard HEAD 2>/dev/null || true + fi + local current_branch + current_branch=$(git branch --show-current 2>/dev/null) + if [[ -n "$original_branch" ]] && [[ "$original_branch" != "$current_branch" ]]; then + git checkout "$original_branch" 2>/dev/null || true + fi } trap cleanup EXIT INT TERM main() { - local non_interactive=0 - - while [[ $# -gt 0 ]]; do - case "${1}" in - --help|-h) - echo "Usage: ./scripts/git/merge_docs.sh [OPTIONS]" - echo "" - echo "Merge only docs/ directory from source branch into target branch." - echo "Code changes are NOT included — only files under docs/." - echo "" - echo "Options:" - echo " --non-interactive Skip prompts" - echo " -h, --help Show this help" - echo "" - echo "Configuration:" - echo " CGW_SOURCE_BRANCH Source branch (default: development)" - echo " CGW_TARGET_BRANCH Target branch (default: main)" - echo "" - echo "Environment:" - echo " CGW_NON_INTERACTIVE=1 Same as --non-interactive" - echo "" - echo "WARNING: Replaces entire docs/ on target with docs/ from source." - echo " Any docs-only changes on target not in source will be lost." - exit 0 - ;; - --non-interactive) non_interactive=1 ;; - *) echo "[ERROR] Unknown flag: $1" >&2; exit 1 ;; - esac - shift - done - - [[ "${CGW_NON_INTERACTIVE:-0}" == "1" ]] && non_interactive=1 - - { - echo "=========================================" - echo "Documentation Merge Log" - echo "=========================================" - echo "Start Time: $(date)" - echo "Working Directory: ${PROJECT_ROOT}" - } > "$logfile" - - echo "=== Documentation Merge: ${CGW_SOURCE_BRANCH} → ${CGW_TARGET_BRANCH} ===" | tee -a "$logfile" - echo "" | tee -a "$logfile" - - cd "${PROJECT_ROOT}" || { - err "Cannot find project root" - exit 1 - } - - # [1/7] Run validation - log_section_start "PRE-MERGE VALIDATION" "$logfile" - - if [[ -f "${SCRIPT_DIR}/validate_branches.sh" ]]; then - if ! bash "${SCRIPT_DIR}/validate_branches.sh" >> "$logfile" 2>&1; then - echo "✗ Validation failed - aborting documentation merge" | tee -a "$logfile" - log_section_end "PRE-MERGE VALIDATION" "$logfile" "1" - echo "Please fix validation errors before retrying" - exit 1 - fi - fi - - echo "✓ Pre-merge validation passed" | tee -a "$logfile" - log_section_end "PRE-MERGE VALIDATION" "$logfile" "0" - echo "" | tee -a "$logfile" - - # [2/7] Store current branch and checkout target - log_section_start "GIT CHECKOUT TARGET" "$logfile" - - original_branch=$(git branch --show-current) - echo "Current branch: ${original_branch}" | tee -a "$logfile" - - if ! run_git_with_logging "GIT CHECKOUT" "$logfile" checkout "${CGW_TARGET_BRANCH}"; then - echo "✗ Failed to checkout ${CGW_TARGET_BRANCH} branch" | tee -a "$logfile" - exit 1 - fi - - log_section_end "GIT CHECKOUT TARGET" "$logfile" "0" - echo "" | tee -a "$logfile" - - # [3/7] Check for documentation changes - echo "[3/7] Checking for documentation changes..." - - if [[ -z "$(git diff --name-only "${CGW_TARGET_BRANCH}" "${CGW_SOURCE_BRANCH}" -- docs/)" ]]; then - log_message "⚠ No documentation changes found between ${CGW_TARGET_BRANCH} and ${CGW_SOURCE_BRANCH}" "${logfile}" - if [[ ${non_interactive} -eq 1 ]]; then - log_message "Documentation merge cancelled (nothing to do)" "${logfile}" - git checkout "${original_branch}" - exit 0 - fi - echo "" - read -r -p "Continue anyway? (yes/no): " continue_choice - if [[ "${continue_choice}" != "yes" ]]; then - echo "" - log_message "Documentation merge cancelled" "${logfile}" - git checkout "${original_branch}" - exit 0 - fi - fi - - echo "Documentation files to be merged:" - git diff --name-only "${CGW_TARGET_BRANCH}" "${CGW_SOURCE_BRANCH}" -- docs/ - echo "" - - # [4/7] Check for non-documentation changes - echo "[4/7] Checking for code changes..." - local non_docs_count - non_docs_count=$(git diff --name-only "${CGW_TARGET_BRANCH}" "${CGW_SOURCE_BRANCH}" | grep -v "^docs/" | wc -l) - - if [[ "${non_docs_count}" -gt 0 ]]; then - echo "⚠ WARNING: Non-documentation changes detected" - echo "" - echo "This merge will ONLY include docs/ changes." - echo "Other changes will remain on ${CGW_SOURCE_BRANCH} branch." - echo "" - git diff --name-only "${CGW_TARGET_BRANCH}" "${CGW_SOURCE_BRANCH}" | grep -v "^docs/" - echo "" - if [[ ${non_interactive} -eq 1 ]]; then - echo "[Non-interactive] Proceeding with docs-only merge despite non-docs changes" | tee -a "$logfile" - else - read -r -p "Proceed with docs-only merge? (yes/no): " confirm - if [[ "${confirm}" != "yes" ]]; then - echo "" - log_message "Documentation merge cancelled" "${logfile}" - git checkout "${original_branch}" - exit 0 - fi - fi - else - log_message "✓ No code changes detected (docs-only merge)" "${logfile}" - fi - echo "" - - # [5/7] Create pre-merge backup tag - log_section_start "CREATE BACKUP TAG" "$logfile" - - if [[ -z "${timestamp:-}" ]]; then get_timestamp; fi - local backup_tag="pre-docs-merge-${timestamp}" - - if git tag "${backup_tag}" >> "$logfile" 2>&1; then - echo "✓ Created backup tag: ${backup_tag}" | tee -a "$logfile" - log_section_end "CREATE BACKUP TAG" "$logfile" "0" - else - echo "⚠ Warning: Could not create backup tag" | tee -a "$logfile" - log_section_end "CREATE BACKUP TAG" "$logfile" "1" - fi - echo "" | tee -a "$logfile" - - # [6/7] Merge documentation changes - log_section_start "MERGE DOCUMENTATION" "$logfile" - - did_mutate_worktree=1 - - if ! run_git_with_logging "GIT CHECKOUT DOCS" "$logfile" checkout "${CGW_SOURCE_BRANCH}" -- docs/; then - echo "✗ Failed to checkout documentation from ${CGW_SOURCE_BRANCH}" | tee -a "$logfile" - git checkout "${original_branch}" >> "$logfile" 2>&1 - exit 1 - fi - - if git diff --cached --quiet; then - echo "" | tee -a "$logfile" - echo "⚠ No documentation changes to merge (docs already in sync)" | tee -a "$logfile" - log_section_end "MERGE DOCUMENTATION" "$logfile" "0" - git checkout "${original_branch}" >> "$logfile" 2>&1 - exit 0 - fi - - echo "✓ Documentation changes staged" | tee -a "$logfile" - echo "Staged files:" | tee -a "$logfile" - git diff --cached --name-only | tee -a "$logfile" - log_section_end "MERGE DOCUMENTATION" "$logfile" "0" - echo "" | tee -a "$logfile" - - # [7/7] Commit the merge - log_section_start "GIT COMMIT" "$logfile" - - if run_git_with_logging "GIT COMMIT DOCS" "$logfile" commit \ - -m "docs: Sync documentation from ${CGW_SOURCE_BRANCH}" \ - -m "- Updated docs/ directory from ${CGW_SOURCE_BRANCH} branch" \ - -m "- Docs-only update (no code changes)" \ - -m "- Backup tag: ${backup_tag}"; then - log_section_end "GIT COMMIT" "$logfile" "0" - echo "" | tee -a "$logfile" - { - echo "========================================" - echo "[DOCS MERGE SUMMARY]" - echo "========================================" - } | tee -a "$logfile" - echo "✓ DOCUMENTATION MERGE SUCCESSFUL" | tee -a "$logfile" - echo "" | tee -a "$logfile" - git log -1 --oneline | while read -r line; do echo " Latest commit: $line" | tee -a "$logfile"; done - echo " Backup tag: ${backup_tag}" | tee -a "$logfile" - echo "" | tee -a "$logfile" - echo "Merged files:" | tee -a "$logfile" - git diff --name-only HEAD~1 HEAD | tee -a "$logfile" - echo "" | tee -a "$logfile" - echo "Next steps:" | tee -a "$logfile" - echo " 1. Review: git show HEAD" | tee -a "$logfile" - echo " 2. Push: ./scripts/git/push_validated.sh" | tee -a "$logfile" - echo " Rollback: git reset --hard ${backup_tag}" | tee -a "$logfile" - { - echo "" - echo "End Time: $(date)" - } | tee -a "$logfile" - echo "" | tee -a "$logfile" - echo "Full log: $logfile" - else - log_section_end "GIT COMMIT" "$logfile" "1" - echo "" | tee -a "$logfile" - echo "✗ Failed to commit documentation merge" | tee -a "$logfile" - echo "" - echo "To abort: git reset --hard HEAD" - exit 1 - fi + local non_interactive=0 + + while [[ $# -gt 0 ]]; do + case "${1}" in + --help | -h) + echo "Usage: ./scripts/git/merge_docs.sh [OPTIONS]" + echo "" + echo "Merge only docs/ directory from source branch into target branch." + echo "Code changes are NOT included — only files under docs/." + echo "" + echo "Options:" + echo " --non-interactive Skip prompts" + echo " -h, --help Show this help" + echo "" + echo "Configuration:" + echo " CGW_SOURCE_BRANCH Source branch (default: development)" + echo " CGW_TARGET_BRANCH Target branch (default: main)" + echo "" + echo "Environment:" + echo " CGW_NON_INTERACTIVE=1 Same as --non-interactive" + echo "" + echo "WARNING: Replaces entire docs/ on target with docs/ from source." + echo " Any docs-only changes on target not in source will be lost." + exit 0 + ;; + --non-interactive) non_interactive=1 ;; + *) + echo "[ERROR] Unknown flag: $1" >&2 + exit 1 + ;; + esac + shift + done + + [[ "${CGW_NON_INTERACTIVE:-0}" == "1" ]] && non_interactive=1 + + { + echo "=========================================" + echo "Documentation Merge Log" + echo "=========================================" + echo "Start Time: $(date)" + echo "Working Directory: ${PROJECT_ROOT}" + } >"$logfile" + + echo "=== Documentation Merge: ${CGW_SOURCE_BRANCH} → ${CGW_TARGET_BRANCH} ===" | tee -a "$logfile" + echo "" | tee -a "$logfile" + + cd "${PROJECT_ROOT}" || { + err "Cannot find project root" + exit 1 + } + + # [1/7] Run validation + log_section_start "PRE-MERGE VALIDATION" "$logfile" + + if [[ -f "${SCRIPT_DIR}/validate_branches.sh" ]]; then + if ! bash "${SCRIPT_DIR}/validate_branches.sh" >>"$logfile" 2>&1; then + echo "✗ Validation failed - aborting documentation merge" | tee -a "$logfile" + log_section_end "PRE-MERGE VALIDATION" "$logfile" "1" + echo "Please fix validation errors before retrying" + exit 1 + fi + fi + + echo "✓ Pre-merge validation passed" | tee -a "$logfile" + log_section_end "PRE-MERGE VALIDATION" "$logfile" "0" + echo "" | tee -a "$logfile" + + # [2/7] Store current branch and checkout target + log_section_start "GIT CHECKOUT TARGET" "$logfile" + + original_branch=$(git branch --show-current) + echo "Current branch: ${original_branch}" | tee -a "$logfile" + + if ! run_git_with_logging "GIT CHECKOUT" "$logfile" checkout "${CGW_TARGET_BRANCH}"; then + echo "✗ Failed to checkout ${CGW_TARGET_BRANCH} branch" | tee -a "$logfile" + exit 1 + fi + + log_section_end "GIT CHECKOUT TARGET" "$logfile" "0" + echo "" | tee -a "$logfile" + + # [3/7] Check for documentation changes + echo "[3/7] Checking for documentation changes..." + + if [[ -z "$(git diff --name-only "${CGW_TARGET_BRANCH}" "${CGW_SOURCE_BRANCH}" -- docs/)" ]]; then + log_message "⚠ No documentation changes found between ${CGW_TARGET_BRANCH} and ${CGW_SOURCE_BRANCH}" "${logfile}" + if [[ ${non_interactive} -eq 1 ]]; then + log_message "Documentation merge cancelled (nothing to do)" "${logfile}" + git checkout "${original_branch}" + exit 0 + fi + echo "" + read -r -p "Continue anyway? (yes/no): " continue_choice + if [[ "${continue_choice}" != "yes" ]]; then + echo "" + log_message "Documentation merge cancelled" "${logfile}" + git checkout "${original_branch}" + exit 0 + fi + fi + + echo "Documentation files to be merged:" + git diff --name-only "${CGW_TARGET_BRANCH}" "${CGW_SOURCE_BRANCH}" -- docs/ + echo "" + + # [4/7] Check for non-documentation changes + echo "[4/7] Checking for code changes..." + local non_docs_count + non_docs_count=$(git diff --name-only "${CGW_TARGET_BRANCH}" "${CGW_SOURCE_BRANCH}" | grep -vc "^docs/") + + if [[ "${non_docs_count}" -gt 0 ]]; then + echo "⚠ WARNING: Non-documentation changes detected" + echo "" + echo "This merge will ONLY include docs/ changes." + echo "Other changes will remain on ${CGW_SOURCE_BRANCH} branch." + echo "" + git diff --name-only "${CGW_TARGET_BRANCH}" "${CGW_SOURCE_BRANCH}" | grep -v "^docs/" + echo "" + if [[ ${non_interactive} -eq 1 ]]; then + echo "[Non-interactive] Proceeding with docs-only merge despite non-docs changes" | tee -a "$logfile" + else + read -r -p "Proceed with docs-only merge? (yes/no): " confirm + if [[ "${confirm}" != "yes" ]]; then + echo "" + log_message "Documentation merge cancelled" "${logfile}" + git checkout "${original_branch}" + exit 0 + fi + fi + else + log_message "✓ No code changes detected (docs-only merge)" "${logfile}" + fi + echo "" + + # [5/7] Create pre-merge backup tag + log_section_start "CREATE BACKUP TAG" "$logfile" + + if [[ -z "${timestamp:-}" ]]; then get_timestamp; fi + local backup_tag="pre-docs-merge-${timestamp}" + + if git tag "${backup_tag}" >>"$logfile" 2>&1; then + echo "✓ Created backup tag: ${backup_tag}" | tee -a "$logfile" + log_section_end "CREATE BACKUP TAG" "$logfile" "0" + else + echo "⚠ Warning: Could not create backup tag" | tee -a "$logfile" + log_section_end "CREATE BACKUP TAG" "$logfile" "1" + fi + echo "" | tee -a "$logfile" + + # [6/7] Merge documentation changes + log_section_start "MERGE DOCUMENTATION" "$logfile" + + did_mutate_worktree=1 + + if ! run_git_with_logging "GIT CHECKOUT DOCS" "$logfile" checkout "${CGW_SOURCE_BRANCH}" -- docs/; then + echo "✗ Failed to checkout documentation from ${CGW_SOURCE_BRANCH}" | tee -a "$logfile" + git checkout "${original_branch}" >>"$logfile" 2>&1 + exit 1 + fi + + if git diff --cached --quiet; then + echo "" | tee -a "$logfile" + echo "⚠ No documentation changes to merge (docs already in sync)" | tee -a "$logfile" + log_section_end "MERGE DOCUMENTATION" "$logfile" "0" + git checkout "${original_branch}" >>"$logfile" 2>&1 + exit 0 + fi + + echo "✓ Documentation changes staged" | tee -a "$logfile" + echo "Staged files:" | tee -a "$logfile" + git diff --cached --name-only | tee -a "$logfile" + log_section_end "MERGE DOCUMENTATION" "$logfile" "0" + echo "" | tee -a "$logfile" + + # [7/7] Commit the merge + log_section_start "GIT COMMIT" "$logfile" + + if run_git_with_logging "GIT COMMIT DOCS" "$logfile" commit \ + -m "docs: Sync documentation from ${CGW_SOURCE_BRANCH}" \ + -m "- Updated docs/ directory from ${CGW_SOURCE_BRANCH} branch" \ + -m "- Docs-only update (no code changes)" \ + -m "- Backup tag: ${backup_tag}"; then + log_section_end "GIT COMMIT" "$logfile" "0" + echo "" | tee -a "$logfile" + { + echo "========================================" + echo "[DOCS MERGE SUMMARY]" + echo "========================================" + } | tee -a "$logfile" + echo "✓ DOCUMENTATION MERGE SUCCESSFUL" | tee -a "$logfile" + echo "" | tee -a "$logfile" + git log -1 --oneline | while read -r line; do echo " Latest commit: $line" | tee -a "$logfile"; done + echo " Backup tag: ${backup_tag}" | tee -a "$logfile" + echo "" | tee -a "$logfile" + echo "Merged files:" | tee -a "$logfile" + git diff --name-only HEAD~1 HEAD | tee -a "$logfile" + echo "" | tee -a "$logfile" + echo "Next steps:" | tee -a "$logfile" + echo " 1. Review: git show HEAD" | tee -a "$logfile" + echo " 2. Push: ./scripts/git/push_validated.sh" | tee -a "$logfile" + echo " Rollback: git reset --hard ${backup_tag}" | tee -a "$logfile" + { + echo "" + echo "End Time: $(date)" + } | tee -a "$logfile" + echo "" | tee -a "$logfile" + echo "Full log: $logfile" + else + log_section_end "GIT COMMIT" "$logfile" "1" + echo "" | tee -a "$logfile" + echo "✗ Failed to commit documentation merge" | tee -a "$logfile" + echo "" + echo "To abort: git reset --hard HEAD" + exit 1 + fi } main "$@" diff --git a/scripts/git/merge_with_validation.sh b/scripts/git/merge_with_validation.sh index b273abd..ed34621 100644 --- a/scripts/git/merge_with_validation.sh +++ b/scripts/git/merge_with_validation.sh @@ -33,15 +33,15 @@ _merge_original_branch="" _merge_did_checkout_target=0 _cleanup_merge() { - local current - current=$(git branch --show-current 2>/dev/null || true) - if [[ ${_merge_did_checkout_target} -eq 1 ]] && [[ -n "${_merge_original_branch}" ]] && \ - [[ "${current}" != "${_merge_original_branch}" ]]; then - echo "" >&2 - echo "⚠ Interrupted — you are on branch: ${current}" >&2 - echo " Returning to: ${_merge_original_branch}" >&2 - git checkout "${_merge_original_branch}" 2>/dev/null || true - fi + local current + current=$(git branch --show-current 2>/dev/null || true) + if [[ ${_merge_did_checkout_target} -eq 1 ]] && [[ -n "${_merge_original_branch}" ]] && + [[ "${current}" != "${_merge_original_branch}" ]]; then + echo "" >&2 + echo "⚠ Interrupted — you are on branch: ${current}" >&2 + echo " Returning to: ${_merge_original_branch}" >&2 + git checkout "${_merge_original_branch}" 2>/dev/null || true + fi } trap _cleanup_merge INT TERM @@ -55,61 +55,61 @@ trap _cleanup_merge INT TERM # $1 - "committed" or "staged" # $2 - original_branch (for rollback on failure) validate_docs_ci_policy() { - local check_mode="$1" - local original_branch="$2" - - # Skip if no pattern configured - if [[ -z "${CGW_DOCS_PATTERN}" ]]; then - echo " (docs CI validation skipped — CGW_DOCS_PATTERN not set in .cgw.conf)" | tee -a "$logfile" - return 0 - fi - - echo "" - echo "[6/7] Validating documentation files against CI policy..." - - local docs_validation_failed=0 - local doc_files - - if [[ "${check_mode}" == "committed" ]]; then - doc_files=$(git diff --name-only HEAD~1 HEAD | grep "^docs/" || true) - for doc_file in ${doc_files}; do - local doc_name - doc_name=$(basename "${doc_file}") - if ! [[ "${doc_name}" =~ ${CGW_DOCS_PATTERN} ]]; then - if git diff --diff-filter=A --name-only HEAD~1 HEAD -- "${doc_file}" | grep -q .; then - echo "✗ ERROR: Unauthorized doc file: ${doc_file}" | tee -a "$logfile" - echo " Not in CGW_DOCS_PATTERN allowlist" | tee -a "$logfile" - docs_validation_failed=1 - fi - fi - done - else - doc_files=$(git diff --cached --name-only --diff-filter=A | grep "^docs/" || true) - for doc_file in ${doc_files}; do - local doc_name - doc_name=$(basename "${doc_file}") - if ! [[ "${doc_name}" =~ ${CGW_DOCS_PATTERN} ]]; then - echo "✗ ERROR: Unauthorized doc file: ${doc_file}" | tee -a "$logfile" - echo " Not in CGW_DOCS_PATTERN allowlist" | tee -a "$logfile" - docs_validation_failed=1 - fi - done - fi - - if [[ ${docs_validation_failed} -eq 1 ]]; then - echo "" | tee -a "$logfile" - echo "✗ CI POLICY VIOLATION: Unauthorized documentation detected" | tee -a "$logfile" - if [[ "${check_mode}" == "committed" ]]; then - echo "Rolling back merge..." | tee -a "$logfile" - git reset --hard HEAD~1 >> "$logfile" 2>&1 - else - echo "Aborting merge..." | tee -a "$logfile" - git merge --abort >> "$logfile" 2>&1 - fi - git checkout "${original_branch}" >> "$logfile" 2>&1 - exit 1 - fi - echo "✓ Documentation validation passed" | tee -a "$logfile" + local check_mode="$1" + local original_branch="$2" + + # Skip if no pattern configured + if [[ -z "${CGW_DOCS_PATTERN}" ]]; then + echo " (docs CI validation skipped — CGW_DOCS_PATTERN not set in .cgw.conf)" | tee -a "$logfile" + return 0 + fi + + echo "" + echo "[6/7] Validating documentation files against CI policy..." + + local docs_validation_failed=0 + local doc_files + + if [[ "${check_mode}" == "committed" ]]; then + doc_files=$(git diff --name-only HEAD~1 HEAD | grep "^docs/" || true) + for doc_file in ${doc_files}; do + local doc_name + doc_name=$(basename "${doc_file}") + if ! [[ "${doc_name}" =~ ${CGW_DOCS_PATTERN} ]]; then + if git diff --diff-filter=A --name-only HEAD~1 HEAD -- "${doc_file}" | grep -q .; then + echo "✗ ERROR: Unauthorized doc file: ${doc_file}" | tee -a "$logfile" + echo " Not in CGW_DOCS_PATTERN allowlist" | tee -a "$logfile" + docs_validation_failed=1 + fi + fi + done + else + doc_files=$(git diff --cached --name-only --diff-filter=A | grep "^docs/" || true) + for doc_file in ${doc_files}; do + local doc_name + doc_name=$(basename "${doc_file}") + if ! [[ "${doc_name}" =~ ${CGW_DOCS_PATTERN} ]]; then + echo "✗ ERROR: Unauthorized doc file: ${doc_file}" | tee -a "$logfile" + echo " Not in CGW_DOCS_PATTERN allowlist" | tee -a "$logfile" + docs_validation_failed=1 + fi + done + fi + + if [[ ${docs_validation_failed} -eq 1 ]]; then + echo "" | tee -a "$logfile" + echo "✗ CI POLICY VIOLATION: Unauthorized documentation detected" | tee -a "$logfile" + if [[ "${check_mode}" == "committed" ]]; then + echo "Rolling back merge..." | tee -a "$logfile" + git reset --hard HEAD~1 >>"$logfile" 2>&1 + else + echo "Aborting merge..." | tee -a "$logfile" + git merge --abort >>"$logfile" 2>&1 + fi + git checkout "${original_branch}" >>"$logfile" 2>&1 + exit 1 + fi + echo "✓ Documentation validation passed" | tee -a "$logfile" } # cleanup_tests_dir - Remove tests/ from target branch if gitignored. @@ -117,300 +117,303 @@ validate_docs_ci_policy() { # Arguments: # $1 - "amend" or "stage" cleanup_tests_dir() { - local commit_mode="$1" - - # Skip unless explicitly enabled - if [[ "${CGW_CLEANUP_TESTS}" != "1" ]]; then - return 0 - fi - - echo "" - echo "[6.5/7] Checking tests/ directory policy..." - - if grep -q "^tests/\$" .gitignore 2>/dev/null; then - if [[ -d "tests" ]]; then - echo "⚠ Removing tests/ directory from ${CGW_TARGET_BRANCH} branch (per .gitignore policy)" | tee -a "$logfile" - if git rm -r tests >> "$logfile" 2>&1; then - echo "✓ Removed tests/ directory" | tee -a "$logfile" - if [[ "${commit_mode}" == "amend" ]]; then - git commit --amend --no-edit >> "$logfile" 2>&1 - else - git add -u >> "$logfile" 2>&1 - fi - else - echo "✗ ERROR: Failed to remove tests/ directory" | tee -a "$logfile" - fi - else - echo "✓ No tests/ directory found" | tee -a "$logfile" - fi - else - echo "✓ tests/ is tracked in git — keeping on ${CGW_TARGET_BRANCH}" | tee -a "$logfile" - fi + local commit_mode="$1" + + # Skip unless explicitly enabled + if [[ "${CGW_CLEANUP_TESTS}" != "1" ]]; then + return 0 + fi + + echo "" + echo "[6.5/7] Checking tests/ directory policy..." + + if grep -q "^tests/\$" .gitignore 2>/dev/null; then + if [[ -d "tests" ]]; then + echo "⚠ Removing tests/ directory from ${CGW_TARGET_BRANCH} branch (per .gitignore policy)" | tee -a "$logfile" + if git rm -r tests >>"$logfile" 2>&1; then + echo "✓ Removed tests/ directory" | tee -a "$logfile" + if [[ "${commit_mode}" == "amend" ]]; then + git commit --amend --no-edit >>"$logfile" 2>&1 + else + git add -u >>"$logfile" 2>&1 + fi + else + echo "✗ ERROR: Failed to remove tests/ directory" | tee -a "$logfile" + fi + else + echo "✓ No tests/ directory found" | tee -a "$logfile" + fi + else + echo "✓ tests/ is tracked in git — keeping on ${CGW_TARGET_BRANCH}" | tee -a "$logfile" + fi } main() { - { - echo "=========================================" - echo "Merge With Validation Log" - echo "=========================================" - echo "Start Time: $(date)" - echo "Working Directory: ${PROJECT_ROOT}" - } > "$logfile" - - echo "=== Safe Merge: ${CGW_SOURCE_BRANCH} → ${CGW_TARGET_BRANCH} ===" | tee -a "$logfile" - echo "" | tee -a "$logfile" - echo "Workflow Log: ${logfile}" | tee -a "$logfile" - echo "" | tee -a "$logfile" - - cd "${PROJECT_ROOT}" || { - err "Cannot find project root" - exit 1 - } - - local non_interactive=0 - local dry_run=0 - - while [[ $# -gt 0 ]]; do - case "${1}" in - --help|-h) - echo "Usage: ./scripts/git/merge_with_validation.sh [OPTIONS]" - echo "" - echo "Safely merge source branch into target with conflict resolution." - echo "" - echo "Options:" - echo " --non-interactive Skip all prompts (aborts on unexpected state)" - echo " --dry-run Show commits/files that would be merged" - echo " -h, --help Show this help" - echo "" - echo "Configuration:" - echo " CGW_SOURCE_BRANCH Source branch (default: development)" - echo " CGW_TARGET_BRANCH Target branch (default: main)" - echo " CGW_DOCS_PATTERN Regex for allowed doc filenames (default: empty = skip)" - echo " CGW_CLEANUP_TESTS Remove tests/ from target if gitignored (default: 0)" - echo "" - echo "Environment:" - echo " CGW_NON_INTERACTIVE=1 Same as --non-interactive" - echo " CGW_DOCS_PATTERN=<regex> Override docs allowlist pattern" - exit 0 - ;; - --non-interactive) non_interactive=1 ;; - --dry-run) dry_run=1 ;; - *) echo "[ERROR] Unknown flag: $1" >&2; exit 1 ;; - esac - shift - done - - [[ "${CGW_NON_INTERACTIVE:-0}" == "1" ]] && non_interactive=1 - - if [[ ${dry_run} -eq 1 ]]; then - echo "=== DRY RUN MODE — no changes will be made ===" | tee -a "$logfile" - echo "Would merge: ${CGW_SOURCE_BRANCH} → ${CGW_TARGET_BRANCH}" | tee -a "$logfile" - echo "Commits to merge:" | tee -a "$logfile" - git log "${CGW_TARGET_BRANCH}..${CGW_SOURCE_BRANCH}" --oneline | tee -a "$logfile" - echo "" - echo "Files that would change:" | tee -a "$logfile" - git diff --name-status "${CGW_TARGET_BRANCH}..${CGW_SOURCE_BRANCH}" | tee -a "$logfile" - exit 0 - fi - - # [1/7] Run validation - log_section_start "PRE-MERGE VALIDATION" "$logfile" - - if [[ -f "${SCRIPT_DIR}/validate_branches.sh" ]]; then - if ! bash "${SCRIPT_DIR}/validate_branches.sh" >> "$logfile" 2>&1; then - echo "✗ Validation failed - aborting merge" | tee -a "$logfile" - log_section_end "PRE-MERGE VALIDATION" "$logfile" "1" - echo "Please fix validation errors before retrying" - exit 1 - fi - fi - - echo "✓ Pre-merge validation passed" | tee -a "$logfile" - log_section_end "PRE-MERGE VALIDATION" "$logfile" "0" - echo "" | tee -a "$logfile" - - # [2/7] Store current branch and checkout target - log_section_start "GIT CHECKOUT TARGET" "$logfile" - - local original_branch - original_branch=$(git branch --show-current) - _merge_original_branch="${original_branch}" - echo "Current branch: ${original_branch}" | tee -a "$logfile" - - if [[ "${original_branch}" == "${CGW_TARGET_BRANCH}" ]]; then - echo "✗ ERROR: Already on ${CGW_TARGET_BRANCH} branch" | tee -a "$logfile" - echo " Run this script from ${CGW_SOURCE_BRANCH} branch" | tee -a "$logfile" - exit 1 - elif [[ "${original_branch}" != "${CGW_SOURCE_BRANCH}" ]]; then - echo "⚠ WARNING: Not on ${CGW_SOURCE_BRANCH} branch" | tee -a "$logfile" - echo " Current: ${original_branch}" | tee -a "$logfile" - - if [[ ${non_interactive} -eq 0 ]]; then - read -p " Continue anyway? [y/N] " -n 1 -r - echo - if [[ ! $REPLY =~ ^[Yy]$ ]]; then - echo " Aborted" | tee -a "$logfile" - exit 1 - fi - else - echo " Non-interactive: aborting for safety" | tee -a "$logfile" - exit 1 - fi - fi - - if ! run_git_with_logging "GIT CHECKOUT" "$logfile" checkout "${CGW_TARGET_BRANCH}"; then - echo "✗ Failed to checkout ${CGW_TARGET_BRANCH} branch" | tee -a "$logfile" - exit 1 - fi - _merge_did_checkout_target=1 - - log_section_end "GIT CHECKOUT TARGET" "$logfile" "0" - echo "" | tee -a "$logfile" - - # [3/7] Create pre-merge backup tag - log_section_start "CREATE BACKUP TAG" "$logfile" - - if [[ -z "${timestamp:-}" ]]; then get_timestamp; fi - local backup_tag="pre-merge-backup-${timestamp}" - - if git tag "${backup_tag}" >> "$logfile" 2>&1; then - echo "✓ Created backup tag: ${backup_tag}" | tee -a "$logfile" - log_section_end "CREATE BACKUP TAG" "$logfile" "0" - else - echo "⚠ Warning: Could not create backup tag" | tee -a "$logfile" - log_section_end "CREATE BACKUP TAG" "$logfile" "1" - fi - echo "" | tee -a "$logfile" - - # [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 - echo "✓ Merge completed without conflicts" | tee -a "$logfile" - log_section_end "GIT MERGE" "$logfile" "0" - - validate_docs_ci_policy "committed" "${original_branch}" - cleanup_tests_dir "amend" - - else - local merge_exit_code=$? - echo "" | tee -a "$logfile" - echo "⚠ Merge conflicts detected - analyzing..." | tee -a "$logfile" - log_section_end "GIT MERGE" "$logfile" "${merge_exit_code}" - - # Auto-resolve DU (modify/delete) conflicts - if git status --short | grep -q "^DU "; then - echo " Found modify/delete conflicts — auto-resolving..." - echo "" - - local resolution_failed=0 - - while read -r conflict_file; do - echo " Resolving: ${conflict_file}" - if git rm "${conflict_file}" >/dev/null 2>&1; then - echo " ✓ Removed: ${conflict_file}" - else - echo " ✗ ERROR: Failed to remove ${conflict_file}" - resolution_failed=1 - fi - done < <(git status --short | grep "^DU " | cut -c 4-) - - if [[ ${resolution_failed} -eq 1 ]]; then - echo "" | tee -a "$logfile" - echo "✗ Auto-resolution failed for some files" | tee -a "$logfile" - git status --short | tee -a "$logfile" - exit 1 - fi - - echo "" | tee -a "$logfile" - echo "✓ Auto-resolved modify/delete conflicts" | tee -a "$logfile" - fi - - # AU/AA conflicts require manual resolution - if git status --short | grep -qE "^(AU|AA) "; then - echo "" | tee -a "$logfile" - echo "✗ Add/add or add/unmerged conflicts require manual resolution:" | tee -a "$logfile" - git status --short | grep -E "^(AU|AA) " | tee -a "$logfile" - echo "" - echo "Please resolve manually:" - echo " 1. Edit conflicted files" - echo " 2. git add <resolved files>" - echo " 3. git commit" - echo "" - echo "Or abort: git merge --abort && git checkout ${original_branch}" - exit 1 - fi - - # DD (both deleted): auto-resolve by accepting deletion - if git status --short | grep -q "^DD "; then - echo " Found both-deleted conflicts — auto-resolving..." | tee -a "$logfile" - while read -r conflict_file; do - git rm "${conflict_file}" >/dev/null 2>&1 || true - echo " ✓ Removed (both deleted): ${conflict_file}" | tee -a "$logfile" - done < <(git status --short | grep "^DD " | cut -c 4-) - fi - - # UU (both modified): requires manual resolution - if git status --short | grep -q "^UU "; then - echo "" | tee -a "$logfile" - echo "✗ Content conflicts require manual resolution:" | tee -a "$logfile" - git status --short | grep "^UU " - echo "" - echo "Please resolve manually:" - echo " 1. Edit conflicted files" - echo " 2. git add <resolved files>" - echo " 3. git commit" - echo "" - echo "Or abort: git merge --abort && git checkout ${original_branch}" - exit 1 - fi - - validate_docs_ci_policy "staged" "${original_branch}" - cleanup_tests_dir "stage" - echo "" | tee -a "$logfile" - - # [7/7] Complete the merge - log_section_start "GIT COMMIT" "$logfile" - if git rev-parse -q --verify MERGE_HEAD >/dev/null 2>&1; then - if run_git_with_logging "GIT COMMIT MERGE" "$logfile" commit --no-edit; then - echo "✓ Merge commit completed" | tee -a "$logfile" - else - echo "✗ Failed to complete merge commit" | tee -a "$logfile" - log_section_end "GIT COMMIT" "$logfile" "1" - echo "To abort: git merge --abort" - exit 1 - fi - fi - log_section_end "GIT COMMIT" "$logfile" "0" - fi - - # Success summary - echo "" | tee -a "$logfile" - { - echo "========================================" - echo "[MERGE SUMMARY]" - echo "========================================" - } | tee -a "$logfile" - - echo "✓ MERGE SUCCESSFUL" | tee -a "$logfile" - echo "" | tee -a "$logfile" - git log -1 --oneline | while read -r line; do echo " Latest commit: $line" | tee -a "$logfile"; done - echo " Backup tag: ${backup_tag}" | tee -a "$logfile" - echo "" | tee -a "$logfile" - echo "Next steps:" | tee -a "$logfile" - echo " 1. Review: git log --oneline -5" | tee -a "$logfile" - echo " 2. Test your build" | tee -a "$logfile" - echo " 3. Push: ./scripts/git/push_validated.sh" | tee -a "$logfile" - echo " Rollback: ./scripts/git/rollback_merge.sh" | tee -a "$logfile" - echo "" | tee -a "$logfile" - - { - echo "" - echo "End Time: $(date)" - } | tee -a "$logfile" - - echo "" | tee -a "$logfile" - echo "Full log: $logfile" + { + echo "=========================================" + echo "Merge With Validation Log" + echo "=========================================" + echo "Start Time: $(date)" + echo "Working Directory: ${PROJECT_ROOT}" + } >"$logfile" + + echo "=== Safe Merge: ${CGW_SOURCE_BRANCH} → ${CGW_TARGET_BRANCH} ===" | tee -a "$logfile" + echo "" | tee -a "$logfile" + echo "Workflow Log: ${logfile}" | tee -a "$logfile" + echo "" | tee -a "$logfile" + + cd "${PROJECT_ROOT}" || { + err "Cannot find project root" + exit 1 + } + + local non_interactive=0 + local dry_run=0 + + while [[ $# -gt 0 ]]; do + case "${1}" in + --help | -h) + echo "Usage: ./scripts/git/merge_with_validation.sh [OPTIONS]" + echo "" + echo "Safely merge source branch into target with conflict resolution." + echo "" + echo "Options:" + echo " --non-interactive Skip all prompts (aborts on unexpected state)" + echo " --dry-run Show commits/files that would be merged" + echo " -h, --help Show this help" + echo "" + echo "Configuration:" + echo " CGW_SOURCE_BRANCH Source branch (default: development)" + echo " CGW_TARGET_BRANCH Target branch (default: main)" + echo " CGW_DOCS_PATTERN Regex for allowed doc filenames (default: empty = skip)" + echo " CGW_CLEANUP_TESTS Remove tests/ from target if gitignored (default: 0)" + echo "" + echo "Environment:" + echo " CGW_NON_INTERACTIVE=1 Same as --non-interactive" + echo " CGW_DOCS_PATTERN=<regex> Override docs allowlist pattern" + exit 0 + ;; + --non-interactive) non_interactive=1 ;; + --dry-run) dry_run=1 ;; + *) + echo "[ERROR] Unknown flag: $1" >&2 + exit 1 + ;; + esac + shift + done + + [[ "${CGW_NON_INTERACTIVE:-0}" == "1" ]] && non_interactive=1 + + if [[ ${dry_run} -eq 1 ]]; then + echo "=== DRY RUN MODE — no changes will be made ===" | tee -a "$logfile" + echo "Would merge: ${CGW_SOURCE_BRANCH} → ${CGW_TARGET_BRANCH}" | tee -a "$logfile" + echo "Commits to merge:" | tee -a "$logfile" + git log "${CGW_TARGET_BRANCH}..${CGW_SOURCE_BRANCH}" --oneline | tee -a "$logfile" + echo "" + echo "Files that would change:" | tee -a "$logfile" + git diff --name-status "${CGW_TARGET_BRANCH}..${CGW_SOURCE_BRANCH}" | tee -a "$logfile" + exit 0 + fi + + # [1/7] Run validation + log_section_start "PRE-MERGE VALIDATION" "$logfile" + + if [[ -f "${SCRIPT_DIR}/validate_branches.sh" ]]; then + if ! bash "${SCRIPT_DIR}/validate_branches.sh" >>"$logfile" 2>&1; then + echo "✗ Validation failed - aborting merge" | tee -a "$logfile" + log_section_end "PRE-MERGE VALIDATION" "$logfile" "1" + echo "Please fix validation errors before retrying" + exit 1 + fi + fi + + echo "✓ Pre-merge validation passed" | tee -a "$logfile" + log_section_end "PRE-MERGE VALIDATION" "$logfile" "0" + echo "" | tee -a "$logfile" + + # [2/7] Store current branch and checkout target + log_section_start "GIT CHECKOUT TARGET" "$logfile" + + local original_branch + original_branch=$(git branch --show-current) + _merge_original_branch="${original_branch}" + echo "Current branch: ${original_branch}" | tee -a "$logfile" + + if [[ "${original_branch}" == "${CGW_TARGET_BRANCH}" ]]; then + echo "✗ ERROR: Already on ${CGW_TARGET_BRANCH} branch" | tee -a "$logfile" + echo " Run this script from ${CGW_SOURCE_BRANCH} branch" | tee -a "$logfile" + exit 1 + elif [[ "${original_branch}" != "${CGW_SOURCE_BRANCH}" ]]; then + echo "⚠ WARNING: Not on ${CGW_SOURCE_BRANCH} branch" | tee -a "$logfile" + echo " Current: ${original_branch}" | tee -a "$logfile" + + if [[ ${non_interactive} -eq 0 ]]; then + read -p " Continue anyway? [y/N] " -n 1 -r + echo + if [[ ! $REPLY =~ ^[Yy]$ ]]; then + echo " Aborted" | tee -a "$logfile" + exit 1 + fi + else + echo " Non-interactive: aborting for safety" | tee -a "$logfile" + exit 1 + fi + fi + + if ! run_git_with_logging "GIT CHECKOUT" "$logfile" checkout "${CGW_TARGET_BRANCH}"; then + echo "✗ Failed to checkout ${CGW_TARGET_BRANCH} branch" | tee -a "$logfile" + exit 1 + fi + _merge_did_checkout_target=1 + + log_section_end "GIT CHECKOUT TARGET" "$logfile" "0" + echo "" | tee -a "$logfile" + + # [3/7] Create pre-merge backup tag + log_section_start "CREATE BACKUP TAG" "$logfile" + + if [[ -z "${timestamp:-}" ]]; then get_timestamp; fi + local backup_tag="pre-merge-backup-${timestamp}" + + if git tag "${backup_tag}" >>"$logfile" 2>&1; then + echo "✓ Created backup tag: ${backup_tag}" | tee -a "$logfile" + log_section_end "CREATE BACKUP TAG" "$logfile" "0" + else + echo "⚠ Warning: Could not create backup tag" | tee -a "$logfile" + log_section_end "CREATE BACKUP TAG" "$logfile" "1" + fi + echo "" | tee -a "$logfile" + + # [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 + echo "✓ Merge completed without conflicts" | tee -a "$logfile" + log_section_end "GIT MERGE" "$logfile" "0" + + validate_docs_ci_policy "committed" "${original_branch}" + cleanup_tests_dir "amend" + + else + local merge_exit_code=$? + echo "" | tee -a "$logfile" + echo "⚠ Merge conflicts detected - analyzing..." | tee -a "$logfile" + log_section_end "GIT MERGE" "$logfile" "${merge_exit_code}" + + # Auto-resolve DU (modify/delete) conflicts + if git status --short | grep -q "^DU "; then + echo " Found modify/delete conflicts — auto-resolving..." + echo "" + + local resolution_failed=0 + + while read -r conflict_file; do + echo " Resolving: ${conflict_file}" + if git rm "${conflict_file}" >/dev/null 2>&1; then + echo " ✓ Removed: ${conflict_file}" + else + echo " ✗ ERROR: Failed to remove ${conflict_file}" + resolution_failed=1 + fi + done < <(git status --short | grep "^DU " | cut -c 4-) + + if [[ ${resolution_failed} -eq 1 ]]; then + echo "" | tee -a "$logfile" + echo "✗ Auto-resolution failed for some files" | tee -a "$logfile" + git status --short | tee -a "$logfile" + exit 1 + fi + + echo "" | tee -a "$logfile" + echo "✓ Auto-resolved modify/delete conflicts" | tee -a "$logfile" + fi + + # AU/AA conflicts require manual resolution + if git status --short | grep -qE "^(AU|AA) "; then + echo "" | tee -a "$logfile" + echo "✗ Add/add or add/unmerged conflicts require manual resolution:" | tee -a "$logfile" + git status --short | grep -E "^(AU|AA) " | tee -a "$logfile" + echo "" + echo "Please resolve manually:" + echo " 1. Edit conflicted files" + echo " 2. git add <resolved files>" + echo " 3. git commit" + echo "" + echo "Or abort: git merge --abort && git checkout ${original_branch}" + exit 1 + fi + + # DD (both deleted): auto-resolve by accepting deletion + if git status --short | grep -q "^DD "; then + echo " Found both-deleted conflicts — auto-resolving..." | tee -a "$logfile" + while read -r conflict_file; do + git rm "${conflict_file}" >/dev/null 2>&1 || true + echo " ✓ Removed (both deleted): ${conflict_file}" | tee -a "$logfile" + done < <(git status --short | grep "^DD " | cut -c 4-) + fi + + # UU (both modified): requires manual resolution + if git status --short | grep -q "^UU "; then + echo "" | tee -a "$logfile" + echo "✗ Content conflicts require manual resolution:" | tee -a "$logfile" + git status --short | grep "^UU " + echo "" + echo "Please resolve manually:" + echo " 1. Edit conflicted files" + echo " 2. git add <resolved files>" + echo " 3. git commit" + echo "" + echo "Or abort: git merge --abort && git checkout ${original_branch}" + exit 1 + fi + + validate_docs_ci_policy "staged" "${original_branch}" + cleanup_tests_dir "stage" + echo "" | tee -a "$logfile" + + # [7/7] Complete the merge + log_section_start "GIT COMMIT" "$logfile" + if git rev-parse -q --verify MERGE_HEAD >/dev/null 2>&1; then + if run_git_with_logging "GIT COMMIT MERGE" "$logfile" commit --no-edit; then + echo "✓ Merge commit completed" | tee -a "$logfile" + else + echo "✗ Failed to complete merge commit" | tee -a "$logfile" + log_section_end "GIT COMMIT" "$logfile" "1" + echo "To abort: git merge --abort" + exit 1 + fi + fi + log_section_end "GIT COMMIT" "$logfile" "0" + fi + + # Success summary + echo "" | tee -a "$logfile" + { + echo "========================================" + echo "[MERGE SUMMARY]" + echo "========================================" + } | tee -a "$logfile" + + echo "✓ MERGE SUCCESSFUL" | tee -a "$logfile" + echo "" | tee -a "$logfile" + git log -1 --oneline | while read -r line; do echo " Latest commit: $line" | tee -a "$logfile"; done + echo " Backup tag: ${backup_tag}" | tee -a "$logfile" + echo "" | tee -a "$logfile" + echo "Next steps:" | tee -a "$logfile" + echo " 1. Review: git log --oneline -5" | tee -a "$logfile" + echo " 2. Test your build" | tee -a "$logfile" + echo " 3. Push: ./scripts/git/push_validated.sh" | tee -a "$logfile" + echo " Rollback: ./scripts/git/rollback_merge.sh" | tee -a "$logfile" + echo "" | tee -a "$logfile" + + { + echo "" + echo "End Time: $(date)" + } | tee -a "$logfile" + + echo "" | tee -a "$logfile" + echo "Full log: $logfile" } main "$@" diff --git a/scripts/git/push_validated.sh b/scripts/git/push_validated.sh index b9db017..7a29fb1 100644 --- a/scripts/git/push_validated.sh +++ b/scripts/git/push_validated.sh @@ -21,240 +21,254 @@ set -uo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=scripts/git/_common.sh source "${SCRIPT_DIR}/_common.sh" init_logging "push_validated" main() { - local non_interactive=0 - local dry_run=0 - local skip_lint=0 - local force_push=0 - local target_branch="" + local non_interactive=0 + local dry_run=0 + local skip_lint=0 + local skip_md_lint=0 + local force_push=0 + local target_branch="" - while [[ $# -gt 0 ]]; do - case "${1}" in - --help|-h) - echo "Usage: ./scripts/git/push_validated.sh [OPTIONS]" - echo "" - echo "Push the current branch to origin with safety checks." - echo "" - echo "Options:" - echo " --non-interactive Skip all prompts" - echo " --dry-run Show what would be pushed without pushing" - echo " --skip-lint Skip pre-push lint check" - echo " --force Allow force-push (uses --force-with-lease)" - echo " --branch <name> Override push target branch (default: current branch)" - echo " -h, --help Show this help" - echo "" - echo "Safety checks performed:" - echo " - Verifies remote 'origin' is reachable" - echo " - Blocks force-push to protected branches without explicit --force" - echo " - Warns if local branch is behind remote (may overwrite remote work)" - echo " - Optional pre-push lint check" - echo "" - echo "Environment:" - echo " CGW_NON_INTERACTIVE=1 Same as --non-interactive" - echo " CGW_PROTECTED_BRANCHES=<list> Space-separated protected branch names" - echo " (Also: CLAUDE_GIT_NON_INTERACTIVE, CLAUDE_GIT_NO_VENV)" - exit 0 - ;; - --non-interactive) non_interactive=1 ;; - --dry-run) dry_run=1 ;; - --skip-lint) skip_lint=1 ;; - --force) force_push=1 ;; - --branch) target_branch="${2:-}"; shift ;; - *) echo "[ERROR] Unknown flag: $1" >&2; exit 1 ;; - esac - shift - done + while [[ $# -gt 0 ]]; do + case "${1}" in + --help | -h) + echo "Usage: ./scripts/git/push_validated.sh [OPTIONS]" + echo "" + echo "Push the current branch to origin with safety checks." + echo "" + echo "Options:" + echo " --non-interactive Skip all prompts" + echo " --dry-run Show what would be pushed without pushing" + echo " --skip-lint Skip pre-push lint check (all lint)" + echo " --skip-md-lint Skip markdown lint only in pre-push check" + echo " --force Allow force-push (uses --force-with-lease)" + echo " --branch <name> Override push target branch (default: current branch)" + echo " -h, --help Show this help" + echo "" + echo "Safety checks performed:" + echo " - Verifies remote 'origin' is reachable" + echo " - Blocks force-push to protected branches without explicit --force" + echo " - Warns if local branch is behind remote (may overwrite remote work)" + echo " - Optional pre-push lint check" + echo "" + echo "Environment:" + echo " CGW_NON_INTERACTIVE=1 Same as --non-interactive" + echo " CGW_PROTECTED_BRANCHES=<list> Space-separated protected branch names" + echo " (Also: CLAUDE_GIT_NON_INTERACTIVE, CLAUDE_GIT_NO_VENV)" + exit 0 + ;; + --non-interactive) non_interactive=1 ;; + --dry-run) dry_run=1 ;; + --skip-lint) skip_lint=1 ;; + --skip-md-lint) skip_md_lint=1 ;; + --force) force_push=1 ;; + --branch) + target_branch="${2:-}" + shift + ;; + *) + echo "[ERROR] Unknown flag: $1" >&2 + exit 1 + ;; + esac + shift + done - [[ "${CGW_NON_INTERACTIVE:-0}" == "1" ]] && non_interactive=1 + [[ "${CGW_NON_INTERACTIVE:-0}" == "1" ]] && non_interactive=1 + [[ "${CGW_SKIP_LINT:-0}" == "1" ]] && skip_lint=1 + [[ "${CGW_SKIP_MD_LINT:-0}" == "1" ]] && skip_md_lint=1 - { - echo "=========================================" - echo "Push Validated Log" - echo "=========================================" - echo "Start Time: $(date)" - echo "Working Directory: ${PROJECT_ROOT}" - } > "$logfile" + { + echo "=========================================" + echo "Push Validated Log" + echo "=========================================" + echo "Start Time: $(date)" + echo "Working Directory: ${PROJECT_ROOT}" + } >"$logfile" - echo "=== Validated Push ===" | tee -a "$logfile" - echo "" | tee -a "$logfile" - echo "Workflow Log: ${logfile}" | tee -a "$logfile" - echo "" | tee -a "$logfile" + echo "=== Validated Push ===" | tee -a "$logfile" + echo "" | tee -a "$logfile" + echo "Workflow Log: ${logfile}" | tee -a "$logfile" + echo "" | tee -a "$logfile" - cd "${PROJECT_ROOT}" || { - err "Cannot find project root" - exit 1 - } + cd "${PROJECT_ROOT}" || { + err "Cannot find project root" + exit 1 + } - # [1/5] Determine push branch - log_section_start "BRANCH CHECK" "$logfile" + # [1/5] Determine push branch + log_section_start "BRANCH CHECK" "$logfile" - local current_branch - current_branch=$(git branch --show-current) + local current_branch + current_branch=$(git branch --show-current) - if [[ -z "${target_branch}" ]]; then - target_branch="${current_branch}" - fi + if [[ -z "${target_branch}" ]]; then + target_branch="${current_branch}" + fi - if [[ -z "${target_branch}" ]]; then - err "Cannot determine current branch (detached HEAD?)" - log_section_end "BRANCH CHECK" "$logfile" "1" - exit 1 - fi + if [[ -z "${target_branch}" ]]; then + err "Cannot determine current branch (detached HEAD?)" + log_section_end "BRANCH CHECK" "$logfile" "1" + exit 1 + fi - echo "Branch to push: ${target_branch}" | tee -a "$logfile" - echo "Remote: origin" | tee -a "$logfile" + echo "Branch to push: ${target_branch}" | tee -a "$logfile" + echo "Remote: origin" | tee -a "$logfile" - # Check force-push protection against configured protected branches - local is_protected=0 - for protected in ${CGW_PROTECTED_BRANCHES}; do - if [[ "${target_branch}" == "${protected}" ]]; then - is_protected=1 - break - fi - done + # Check force-push protection against configured protected branches + local is_protected=0 + for protected in ${CGW_PROTECTED_BRANCHES}; do + if [[ "${target_branch}" == "${protected}" ]]; then + is_protected=1 + break + fi + done - if [[ ${is_protected} -eq 1 ]] && [[ ${force_push} -eq 1 ]]; then - echo "⚠ WARNING: Force-push to protected branch '${target_branch}' requested!" | tee -a "$logfile" - echo " This rewrites remote history and affects all collaborators." | tee -a "$logfile" - if [[ ${non_interactive} -eq 0 ]]; then - read -r -p " Type 'FORCE' to confirm force-push to ${target_branch}: " force_confirm - if [[ "${force_confirm}" != "FORCE" ]]; then - echo " Aborted" | tee -a "$logfile" - log_section_end "BRANCH CHECK" "$logfile" "1" - exit 1 - fi - else - echo " [Non-interactive] Aborting — force-push to protected branch requires manual confirmation" | tee -a "$logfile" - log_section_end "BRANCH CHECK" "$logfile" "1" - exit 1 - fi - elif [[ ${is_protected} -eq 1 ]] && [[ ${force_push} -eq 0 ]]; then - echo "✓ Pushing to ${target_branch} (normal push)" | tee -a "$logfile" - fi + if [[ ${is_protected} -eq 1 ]] && [[ ${force_push} -eq 1 ]]; then + echo "⚠ WARNING: Force-push to protected branch '${target_branch}' requested!" | tee -a "$logfile" + echo " This rewrites remote history and affects all collaborators." | tee -a "$logfile" + if [[ ${non_interactive} -eq 0 ]]; then + read -r -p " Type 'FORCE' to confirm force-push to ${target_branch}: " force_confirm + if [[ "${force_confirm}" != "FORCE" ]]; then + echo " Aborted" | tee -a "$logfile" + log_section_end "BRANCH CHECK" "$logfile" "1" + exit 1 + fi + else + echo " [Non-interactive] Aborting — force-push to protected branch requires manual confirmation" | tee -a "$logfile" + log_section_end "BRANCH CHECK" "$logfile" "1" + exit 1 + fi + elif [[ ${is_protected} -eq 1 ]] && [[ ${force_push} -eq 0 ]]; then + echo "✓ Pushing to ${target_branch} (normal push)" | tee -a "$logfile" + fi - log_section_end "BRANCH CHECK" "$logfile" "0" - echo "" | tee -a "$logfile" + log_section_end "BRANCH CHECK" "$logfile" "0" + echo "" | tee -a "$logfile" - # [2/5] Check remote reachability - log_section_start "REMOTE CHECK" "$logfile" + # [2/5] Check remote reachability + log_section_start "REMOTE CHECK" "$logfile" - echo "Checking remote origin..." | tee -a "$logfile" - if ! git ls-remote --exit-code origin HEAD >/dev/null 2>&1; then - err "Remote 'origin' is not reachable. Check network/auth." - log_section_end "REMOTE CHECK" "$logfile" "1" - exit 1 - fi - echo "✓ Remote 'origin' is reachable" | tee -a "$logfile" + echo "Checking remote origin..." | tee -a "$logfile" + if ! git ls-remote --exit-code origin HEAD >/dev/null 2>&1; then + err "Remote 'origin' is not reachable. Check network/auth." + log_section_end "REMOTE CHECK" "$logfile" "1" + exit 1 + fi + echo "✓ Remote 'origin' is reachable" | tee -a "$logfile" - # Check if local is behind remote - git fetch origin "${target_branch}" >> "$logfile" 2>&1 || true - local behind - behind=$(git rev-list --count "HEAD..origin/${target_branch}" 2>/dev/null || echo "0") - if [[ "${behind}" -gt 0 ]]; then - echo "⚠ WARNING: Local branch is ${behind} commit(s) behind origin/${target_branch}" | tee -a "$logfile" - echo " A normal push may fail or overwrite remote changes." | tee -a "$logfile" - echo " Consider: ./scripts/git/sync_branches.sh" | tee -a "$logfile" - if [[ ${non_interactive} -eq 0 ]] && [[ ${force_push} -eq 0 ]]; then - read -r -p " Continue push anyway? (yes/no): " behind_choice - if [[ "${behind_choice}" != "yes" ]]; then - echo " Aborted" | tee -a "$logfile" - log_section_end "REMOTE CHECK" "$logfile" "1" - exit 1 - fi - fi - fi + # Check if local is behind remote + git fetch origin "${target_branch}" >>"$logfile" 2>&1 || true + local behind + behind=$(git rev-list --count "HEAD..origin/${target_branch}" 2>/dev/null || echo "0") + if [[ "${behind}" -gt 0 ]]; then + echo "⚠ WARNING: Local branch is ${behind} commit(s) behind origin/${target_branch}" | tee -a "$logfile" + echo " A normal push may fail or overwrite remote changes." | tee -a "$logfile" + echo " Consider: ./scripts/git/sync_branches.sh" | tee -a "$logfile" + if [[ ${non_interactive} -eq 0 ]] && [[ ${force_push} -eq 0 ]]; then + read -r -p " Continue push anyway? (yes/no): " behind_choice + if [[ "${behind_choice}" != "yes" ]]; then + echo " Aborted" | tee -a "$logfile" + log_section_end "REMOTE CHECK" "$logfile" "1" + exit 1 + fi + fi + fi - log_section_end "REMOTE CHECK" "$logfile" "0" - echo "" | tee -a "$logfile" + log_section_end "REMOTE CHECK" "$logfile" "0" + echo "" | tee -a "$logfile" - # [3/5] Optional pre-push lint check - if [[ ${skip_lint} -eq 0 ]] && [[ -n "${CGW_LINT_CMD}" ]]; then - log_section_start "PRE-PUSH LINT CHECK" "$logfile" - echo "Running pre-push lint check..." | tee -a "$logfile" - if "${SCRIPT_DIR}/check_lint.sh" >> "$logfile" 2>&1; then - echo "✓ Lint check passed" | tee -a "$logfile" - log_section_end "PRE-PUSH LINT CHECK" "$logfile" "0" - else - echo "⚠ Lint check failed" | tee -a "$logfile" - log_section_end "PRE-PUSH LINT CHECK" "$logfile" "1" - echo " Run ./scripts/git/fix_lint.sh to fix issues, or use --skip-lint to bypass" | tee -a "$logfile" - if [[ ${non_interactive} -eq 0 ]]; then - read -r -p " Push anyway despite lint errors? (yes/no): " lint_choice - if [[ "${lint_choice}" != "yes" ]]; then - exit 1 - fi - else - echo " [Non-interactive] Aborting due to lint errors" | tee -a "$logfile" - exit 1 - fi - fi - echo "" | tee -a "$logfile" - fi + # [3/5] Optional pre-push lint check + if [[ ${skip_lint} -eq 0 ]] && [[ -n "${CGW_LINT_CMD}${CGW_FORMAT_CMD}${CGW_MARKDOWNLINT_CMD}" ]]; then + log_section_start "PRE-PUSH LINT CHECK" "$logfile" + echo "Running pre-push lint check..." | tee -a "$logfile" + local lint_args=() + [[ ${skip_md_lint} -eq 1 ]] && lint_args+=("--skip-md-lint") + if "${SCRIPT_DIR}/check_lint.sh" "${lint_args[@]}" >>"$logfile" 2>&1; then + echo "✓ Lint check passed" | tee -a "$logfile" + log_section_end "PRE-PUSH LINT CHECK" "$logfile" "0" + else + echo "⚠ Lint check failed" | tee -a "$logfile" + log_section_end "PRE-PUSH LINT CHECK" "$logfile" "1" + echo " Run ./scripts/git/fix_lint.sh to fix issues, or use --skip-lint to bypass" | tee -a "$logfile" + if [[ ${non_interactive} -eq 0 ]]; then + read -r -p " Push anyway despite lint errors? (yes/no): " lint_choice + if [[ "${lint_choice}" != "yes" ]]; then + exit 1 + fi + else + echo " [Non-interactive] Aborting due to lint errors" | tee -a "$logfile" + exit 1 + fi + fi + echo "" | tee -a "$logfile" + fi - # [4/5] Show what will be pushed - echo "[4/5] Commits to be pushed:" | tee -a "$logfile" - local ahead - ahead=$(git rev-list --count "origin/${target_branch}..HEAD" 2>/dev/null || echo "unknown") - echo " Local ahead of origin/${target_branch}: ${ahead} commit(s)" | tee -a "$logfile" - if [[ "${ahead}" != "0" ]] && [[ "${ahead}" != "unknown" ]]; then - git log "origin/${target_branch}..HEAD" --oneline 2>/dev/null | tee -a "$logfile" || true - fi - echo "" | tee -a "$logfile" + # [4/5] Show what will be pushed + echo "[4/5] Commits to be pushed:" | tee -a "$logfile" + local ahead + ahead=$(git rev-list --count "origin/${target_branch}..HEAD" 2>/dev/null || echo "unknown") + echo " Local ahead of origin/${target_branch}: ${ahead} commit(s)" | tee -a "$logfile" + if [[ "${ahead}" != "0" ]] && [[ "${ahead}" != "unknown" ]]; then + git log "origin/${target_branch}..HEAD" --oneline 2>/dev/null | tee -a "$logfile" || true + fi + echo "" | tee -a "$logfile" - if [[ ${dry_run} -eq 1 ]]; then - echo "=== DRY RUN — no push performed ===" | tee -a "$logfile" - echo "Would push: ${target_branch} → origin/${target_branch}" | tee -a "$logfile" - if [[ ${force_push} -eq 1 ]]; then - echo "Would use: --force-with-lease" | tee -a "$logfile" - fi - exit 0 - fi + if [[ ${dry_run} -eq 1 ]]; then + echo "=== DRY RUN — no push performed ===" | tee -a "$logfile" + echo "Would push: ${target_branch} → origin/${target_branch}" | tee -a "$logfile" + if [[ ${force_push} -eq 1 ]]; then + echo "Would use: --force-with-lease" | tee -a "$logfile" + fi + exit 0 + fi - # [5/5] Execute push - log_section_start "GIT PUSH" "$logfile" + # [5/5] Execute push + log_section_start "GIT PUSH" "$logfile" - local push_flags=() - push_flags+=("origin" "${target_branch}") - if [[ ${force_push} -eq 1 ]]; then - push_flags+=("--force-with-lease") - echo "Using --force-with-lease (safer than --force)" | tee -a "$logfile" - fi + local push_flags=() + push_flags+=("origin" "${target_branch}") + if [[ ${force_push} -eq 1 ]]; then + push_flags+=("--force-with-lease") + echo "Using --force-with-lease (safer than --force)" | tee -a "$logfile" + fi - if run_git_with_logging "GIT PUSH" "$logfile" push "${push_flags[@]}"; then - log_section_end "GIT PUSH" "$logfile" "0" - echo "" | tee -a "$logfile" - { - echo "========================================" - echo "[PUSH SUMMARY]" - echo "========================================" - } | tee -a "$logfile" - echo "✓ PUSH SUCCESSFUL" | tee -a "$logfile" - echo "" | tee -a "$logfile" - echo " Branch: ${target_branch} → origin/${target_branch}" | tee -a "$logfile" - echo " Commits pushed: ${ahead}" | tee -a "$logfile" - echo "" | tee -a "$logfile" - { - echo "" - echo "End Time: $(date)" - } | tee -a "$logfile" - echo "Full log: $logfile" - else - log_section_end "GIT PUSH" "$logfile" "1" - echo "" | tee -a "$logfile" - echo "✗ Push failed" | tee -a "$logfile" - echo "" | tee -a "$logfile" - echo "Common causes:" | tee -a "$logfile" - echo " - Remote has new commits: ./scripts/git/sync_branches.sh" | tee -a "$logfile" - echo " - Auth error: check SSH key or token" | tee -a "$logfile" - echo " - Branch protection: push may require a PR" | tee -a "$logfile" - echo "" | tee -a "$logfile" - echo "Full log: $logfile" - exit 1 - fi + if run_git_with_logging "GIT PUSH" "$logfile" push "${push_flags[@]}"; then + log_section_end "GIT PUSH" "$logfile" "0" + echo "" | tee -a "$logfile" + { + echo "========================================" + echo "[PUSH SUMMARY]" + echo "========================================" + } | tee -a "$logfile" + echo "✓ PUSH SUCCESSFUL" | tee -a "$logfile" + echo "" | tee -a "$logfile" + echo " Branch: ${target_branch} → origin/${target_branch}" | tee -a "$logfile" + echo " Commits pushed: ${ahead}" | tee -a "$logfile" + echo "" | tee -a "$logfile" + { + echo "" + echo "End Time: $(date)" + } | tee -a "$logfile" + echo "Full log: $logfile" + else + log_section_end "GIT PUSH" "$logfile" "1" + echo "" | tee -a "$logfile" + echo "✗ Push failed" | tee -a "$logfile" + echo "" | tee -a "$logfile" + echo "Common causes:" | tee -a "$logfile" + echo " - Remote has new commits: ./scripts/git/sync_branches.sh" | tee -a "$logfile" + echo " - Auth error: check SSH key or token" | tee -a "$logfile" + echo " - Branch protection: push may require a PR" | tee -a "$logfile" + echo "" | tee -a "$logfile" + echo "Full log: $logfile" + exit 1 + fi } main "$@" diff --git a/scripts/git/rollback_merge.sh b/scripts/git/rollback_merge.sh index 3b4bd73..f40a674 100644 --- a/scripts/git/rollback_merge.sh +++ b/scripts/git/rollback_merge.sh @@ -24,272 +24,278 @@ source "${SCRIPT_DIR}/_common.sh" init_logging "rollback_merge" _cleanup_rollback() { - echo "" >&2 - echo "⚠ Rollback interrupted. Verify repository state before proceeding:" >&2 - echo " git log --oneline -5" >&2 - echo " git status" >&2 + echo "" >&2 + echo "⚠ Rollback interrupted. Verify repository state before proceeding:" >&2 + echo " git log --oneline -5" >&2 + echo " git status" >&2 } trap _cleanup_rollback INT TERM main() { - local non_interactive=0 - local dry_run=0 - local rollback_target_flag="" + local non_interactive=0 + local dry_run=0 + local rollback_target_flag="" - while [[ $# -gt 0 ]]; do - case "${1}" in - --help|-h) - echo "Usage: ./scripts/git/rollback_merge.sh [OPTIONS]" - echo "" - echo "Emergency rollback: resets target branch to a pre-merge state." - echo "Must be run from the target branch (default: ${CGW_TARGET_BRANCH})." - echo "" - echo "Options:" - echo " --non-interactive Skip prompts; requires --target" - echo " --target <ref> Commit hash, tag name, or HEAD~1 to roll back to" - echo " --dry-run Show rollback target without resetting" - 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." - exit 0 - ;; - --non-interactive) non_interactive=1 ;; - --dry-run) dry_run=1 ;; - --target) rollback_target_flag="${2:-}"; shift ;; - *) echo "[ERROR] Unknown flag: $1" >&2; exit 1 ;; - esac - shift - done + while [[ $# -gt 0 ]]; do + case "${1}" in + --help | -h) + echo "Usage: ./scripts/git/rollback_merge.sh [OPTIONS]" + echo "" + echo "Emergency rollback: resets target branch to a pre-merge state." + echo "Must be run from the target branch (default: ${CGW_TARGET_BRANCH})." + echo "" + echo "Options:" + echo " --non-interactive Skip prompts; requires --target" + echo " --target <ref> Commit hash, tag name, or HEAD~1 to roll back to" + echo " --dry-run Show rollback target without resetting" + 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." + exit 0 + ;; + --non-interactive) non_interactive=1 ;; + --dry-run) dry_run=1 ;; + --target) + rollback_target_flag="${2:-}" + shift + ;; + *) + echo "[ERROR] Unknown flag: $1" >&2 + exit 1 + ;; + esac + shift + done - [[ "${CGW_NON_INTERACTIVE:-0}" == "1" ]] && non_interactive=1 + [[ "${CGW_NON_INTERACTIVE:-0}" == "1" ]] && non_interactive=1 - { - echo "=========================================" - echo "Rollback Merge Log" - echo "=========================================" - echo "Start Time: $(date)" - echo "Working Directory: ${PROJECT_ROOT}" - } > "$logfile" + { + echo "=========================================" + echo "Rollback Merge Log" + echo "=========================================" + echo "Start Time: $(date)" + echo "Working Directory: ${PROJECT_ROOT}" + } >"$logfile" - echo "=== Emergency Merge Rollback ===" | tee -a "$logfile" - echo "" | tee -a "$logfile" + echo "=== Emergency Merge Rollback ===" | tee -a "$logfile" + echo "" | tee -a "$logfile" - cd "${PROJECT_ROOT}" || { - err "Cannot find project root" - exit 1 - } + cd "${PROJECT_ROOT}" || { + err "Cannot find project root" + exit 1 + } - # [1/5] Verify current branch - log_section_start "BRANCH VERIFICATION" "$logfile" + # [1/5] Verify current branch + log_section_start "BRANCH VERIFICATION" "$logfile" - local current_branch - current_branch=$(git branch --show-current 2>&1) - echo "Current branch: ${current_branch}" | tee -a "$logfile" + local current_branch + current_branch=$(git branch --show-current 2>&1) + echo "Current branch: ${current_branch}" | tee -a "$logfile" - if [[ "${current_branch}" != "${CGW_TARGET_BRANCH}" ]]; then - echo "" | tee -a "$logfile" - echo "✗ ERROR: Not on target branch (${CGW_TARGET_BRANCH})" | tee -a "$logfile" - echo "This script should only be run from the target branch" | tee -a "$logfile" - echo "" | tee -a "$logfile" - echo "Current branch: ${current_branch}" | tee -a "$logfile" - echo "Expected: ${CGW_TARGET_BRANCH}" | tee -a "$logfile" - echo "" | tee -a "$logfile" - echo "Please checkout target branch first: git checkout ${CGW_TARGET_BRANCH}" - log_section_end "BRANCH VERIFICATION" "$logfile" "1" - exit 1 - fi - echo "✓ On target branch (${CGW_TARGET_BRANCH})" | tee -a "$logfile" - log_section_end "BRANCH VERIFICATION" "$logfile" "0" - echo "" | tee -a "$logfile" + if [[ "${current_branch}" != "${CGW_TARGET_BRANCH}" ]]; then + echo "" | tee -a "$logfile" + echo "✗ ERROR: Not on target branch (${CGW_TARGET_BRANCH})" | tee -a "$logfile" + echo "This script should only be run from the target branch" | tee -a "$logfile" + echo "" | tee -a "$logfile" + echo "Current branch: ${current_branch}" | tee -a "$logfile" + echo "Expected: ${CGW_TARGET_BRANCH}" | tee -a "$logfile" + echo "" | tee -a "$logfile" + echo "Please checkout target branch first: git checkout ${CGW_TARGET_BRANCH}" + log_section_end "BRANCH VERIFICATION" "$logfile" "1" + exit 1 + fi + echo "✓ On target branch (${CGW_TARGET_BRANCH})" | tee -a "$logfile" + log_section_end "BRANCH VERIFICATION" "$logfile" "0" + echo "" | tee -a "$logfile" - # [2/5] Check for uncommitted changes - log_section_start "UNCOMMITTED CHANGES CHECK" "$logfile" + # [2/5] Check for uncommitted changes + log_section_start "UNCOMMITTED CHANGES CHECK" "$logfile" - if ! git diff-index --quiet HEAD --; then - echo "⚠ WARNING: Uncommitted changes detected" | tee -a "$logfile" - echo "" | tee -a "$logfile" - git status --short | tee -a "$logfile" - echo "" | tee -a "$logfile" - echo "These changes will be LOST during rollback!" | tee -a "$logfile" - echo "" | tee -a "$logfile" - if [[ ${non_interactive} -eq 1 ]]; then - echo "[Non-interactive] Aborting — commit or stash changes first" | tee -a "$logfile" - log_section_end "UNCOMMITTED CHANGES CHECK" "$logfile" "1" - exit 1 - fi - read -r -p "Continue anyway? (yes/no): " continue_choice - if [[ "${continue_choice}" != "yes" ]]; then - echo "" | tee -a "$logfile" - echo "Rollback cancelled" | tee -a "$logfile" - echo "Please commit or stash changes first" - log_section_end "UNCOMMITTED CHANGES CHECK" "$logfile" "1" - exit 1 - fi - else - echo "✓ No uncommitted changes" | tee -a "$logfile" - fi - log_section_end "UNCOMMITTED CHANGES CHECK" "$logfile" "0" - echo "" | tee -a "$logfile" + if ! git diff-index --quiet HEAD --; then + echo "⚠ WARNING: Uncommitted changes detected" | tee -a "$logfile" + echo "" | tee -a "$logfile" + git status --short | tee -a "$logfile" + echo "" | tee -a "$logfile" + echo "These changes will be LOST during rollback!" | tee -a "$logfile" + echo "" | tee -a "$logfile" + if [[ ${non_interactive} -eq 1 ]]; then + echo "[Non-interactive] Aborting — commit or stash changes first" | tee -a "$logfile" + log_section_end "UNCOMMITTED CHANGES CHECK" "$logfile" "1" + exit 1 + fi + read -r -p "Continue anyway? (yes/no): " continue_choice + if [[ "${continue_choice}" != "yes" ]]; then + echo "" | tee -a "$logfile" + echo "Rollback cancelled" | tee -a "$logfile" + echo "Please commit or stash changes first" + log_section_end "UNCOMMITTED CHANGES CHECK" "$logfile" "1" + exit 1 + fi + else + echo "✓ No uncommitted changes" | tee -a "$logfile" + fi + log_section_end "UNCOMMITTED CHANGES CHECK" "$logfile" "0" + echo "" | tee -a "$logfile" - # [3/5] Find rollback target - log_section_start "FIND ROLLBACK TARGET" "$logfile" + # [3/5] Find rollback target + log_section_start "FIND ROLLBACK TARGET" "$logfile" - local backup_tags - backup_tags=$(git tag -l "pre-merge-backup-*" | sort -r | head -5) - if [[ -n "${backup_tags}" ]]; then - echo "Available backup tags:" | tee -a "$logfile" - echo "${backup_tags}" | tee -a "$logfile" - echo "" | tee -a "$logfile" - else - echo "No backup tags found (pre-merge-backup-*)" | tee -a "$logfile" - echo "" | tee -a "$logfile" - fi + local backup_tags + backup_tags=$(git tag -l "pre-merge-backup-*" | sort -r | head -5) + if [[ -n "${backup_tags}" ]]; then + echo "Available backup tags:" | tee -a "$logfile" + echo "${backup_tags}" | tee -a "$logfile" + echo "" | tee -a "$logfile" + else + echo "No backup tags found (pre-merge-backup-*)" | tee -a "$logfile" + echo "" | tee -a "$logfile" + fi - echo "Recent commits:" | tee -a "$logfile" - git log --oneline -5 | tee -a "$logfile" - echo "" | tee -a "$logfile" + echo "Recent commits:" | tee -a "$logfile" + git log --oneline -5 | tee -a "$logfile" + echo "" | tee -a "$logfile" - local latest_merge - latest_merge=$(git log --merges --oneline -1) - if [[ -n "${latest_merge}" ]]; then - echo "Latest merge commit: ${latest_merge}" | tee -a "$logfile" - echo "" | tee -a "$logfile" - fi + local latest_merge + latest_merge=$(git log --merges --oneline -1) + if [[ -n "${latest_merge}" ]]; then + echo "Latest merge commit: ${latest_merge}" | tee -a "$logfile" + echo "" | tee -a "$logfile" + fi - log_section_end "FIND ROLLBACK TARGET" "$logfile" "0" + log_section_end "FIND ROLLBACK TARGET" "$logfile" "0" - # [4/5] Choose rollback target - local rollback_target="" + # [4/5] Choose rollback target + local rollback_target="" - if [[ -n "${rollback_target_flag}" ]]; then - if ! git rev-parse "${rollback_target_flag}" >/dev/null 2>&1; then - err "Invalid --target ref: ${rollback_target_flag}" - exit 1 - fi - rollback_target="${rollback_target_flag}" - echo "Rollback target (from --target): ${rollback_target}" | tee -a "$logfile" - elif [[ ${non_interactive} -eq 1 ]]; then - local latest_tag - latest_tag=$(git tag -l "pre-merge-backup-*" | sort -r | head -1) - if [[ -n "${latest_tag}" ]]; then - rollback_target="${latest_tag}" - echo "[Non-interactive] Using latest backup tag: ${rollback_target}" | tee -a "$logfile" - else - rollback_target="HEAD~1" - echo "[Non-interactive] No backup tag found — using HEAD~1" | tee -a "$logfile" - fi - else - echo "[4/5] Choose rollback method:" - echo "" - echo "Available options:" - echo " 1. Rollback to latest pre-merge backup tag (recommended)" - echo " 2. Rollback to commit before latest merge (HEAD~1)" - echo " 3. Rollback to specific commit hash" - echo " 4. Cancel rollback" - echo "" + if [[ -n "${rollback_target_flag}" ]]; then + if ! git rev-parse "${rollback_target_flag}" >/dev/null 2>&1; then + err "Invalid --target ref: ${rollback_target_flag}" + exit 1 + fi + rollback_target="${rollback_target_flag}" + echo "Rollback target (from --target): ${rollback_target}" | tee -a "$logfile" + elif [[ ${non_interactive} -eq 1 ]]; then + local latest_tag + latest_tag=$(git tag -l "pre-merge-backup-*" | sort -r | head -1) + if [[ -n "${latest_tag}" ]]; then + rollback_target="${latest_tag}" + echo "[Non-interactive] Using latest backup tag: ${rollback_target}" | tee -a "$logfile" + else + rollback_target="HEAD~1" + echo "[Non-interactive] No backup tag found — using HEAD~1" | tee -a "$logfile" + fi + else + echo "[4/5] Choose rollback method:" + echo "" + echo "Available options:" + echo " 1. Rollback to latest pre-merge backup tag (recommended)" + echo " 2. Rollback to commit before latest merge (HEAD~1)" + echo " 3. Rollback to specific commit hash" + echo " 4. Cancel rollback" + echo "" - read -r -p "Select option (1-4): " rollback_choice + read -r -p "Select option (1-4): " rollback_choice - case "${rollback_choice}" in - 1) - rollback_target=$(git tag -l "pre-merge-backup-*" | sort -r | head -1) - if [[ -z "${rollback_target}" ]]; then - err "No backup tags found" - echo "Please use option 2 or 3" - exit 1 - fi - echo "Rollback target: ${rollback_target}" - ;; - 2) - rollback_target="HEAD~1" - echo "Rollback target: HEAD~1 (previous commit)" - ;; - 3) - echo "" - read -r -p "Enter commit hash: " rollback_target - echo "" - if ! git rev-parse "${rollback_target}" >/dev/null 2>&1; then - err "Invalid commit hash: ${rollback_target}" - exit 1 - fi - echo "Rollback target: ${rollback_target}" - ;; - 4) - echo "" | tee -a "$logfile" - echo "Rollback cancelled" | tee -a "$logfile" - exit 0 - ;; - *) - err "Invalid choice: ${rollback_choice}" - exit 1 - ;; - esac - fi + case "${rollback_choice}" in + 1) + rollback_target=$(git tag -l "pre-merge-backup-*" | sort -r | head -1) + if [[ -z "${rollback_target}" ]]; then + err "No backup tags found" + echo "Please use option 2 or 3" + exit 1 + fi + echo "Rollback target: ${rollback_target}" + ;; + 2) + rollback_target="HEAD~1" + echo "Rollback target: HEAD~1 (previous commit)" + ;; + 3) + echo "" + read -r -p "Enter commit hash: " rollback_target + echo "" + if ! git rev-parse "${rollback_target}" >/dev/null 2>&1; then + err "Invalid commit hash: ${rollback_target}" + exit 1 + fi + echo "Rollback target: ${rollback_target}" + ;; + 4) + echo "" | tee -a "$logfile" + echo "Rollback cancelled" | tee -a "$logfile" + exit 0 + ;; + *) + err "Invalid choice: ${rollback_choice}" + exit 1 + ;; + esac + fi - # [5/5] Execute rollback - echo "" | tee -a "$logfile" - echo "⚠ WARNING: This will permanently reset ${CGW_TARGET_BRANCH} branch to:" | tee -a "$logfile" - git log "${rollback_target}" --oneline -1 | tee -a "$logfile" - echo "" | tee -a "$logfile" - echo "All commits after this point will be lost!" | tee -a "$logfile" - echo "" | tee -a "$logfile" + # [5/5] Execute rollback + echo "" | tee -a "$logfile" + echo "⚠ WARNING: This will permanently reset ${CGW_TARGET_BRANCH} branch to:" | tee -a "$logfile" + git log "${rollback_target}" --oneline -1 | tee -a "$logfile" + echo "" | tee -a "$logfile" + echo "All commits after this point will be lost!" | tee -a "$logfile" + echo "" | tee -a "$logfile" - 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" - exit 0 - fi + 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" + exit 0 + fi - local confirm - if [[ ${non_interactive} -eq 0 ]]; then - read -r -p "Type 'ROLLBACK' to confirm: " confirm - else - confirm="ROLLBACK" - fi + local confirm + if [[ ${non_interactive} -eq 0 ]]; then + read -r -p "Type 'ROLLBACK' to confirm: " confirm + else + confirm="ROLLBACK" + fi - if [[ "${confirm}" != "ROLLBACK" ]]; then - echo "" | tee -a "$logfile" - echo "Rollback cancelled" | tee -a "$logfile" - exit 0 - fi + if [[ "${confirm}" != "ROLLBACK" ]]; then + echo "" | tee -a "$logfile" + echo "Rollback cancelled" | tee -a "$logfile" + exit 0 + fi - log_section_start "GIT RESET" "$logfile" + 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 + 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 } main "$@" diff --git a/scripts/git/sync_branches.sh b/scripts/git/sync_branches.sh index bcfce2f..557692d 100644 --- a/scripts/git/sync_branches.sh +++ b/scripts/git/sync_branches.sh @@ -26,13 +26,13 @@ init_logging "sync_branches" _sync_original_branch="" _cleanup_sync() { - local current - current=$(git branch --show-current 2>/dev/null || true) - if [[ -n "${_sync_original_branch}" ]] && [[ "${current}" != "${_sync_original_branch}" ]]; then - echo "" >&2 - echo "⚠ Interrupted — returning to: ${_sync_original_branch}" >&2 - git checkout "${_sync_original_branch}" 2>/dev/null || true - fi + local current + current=$(git branch --show-current 2>/dev/null || true) + if [[ -n "${_sync_original_branch}" ]] && [[ "${current}" != "${_sync_original_branch}" ]]; then + echo "" >&2 + echo "⚠ Interrupted — returning to: ${_sync_original_branch}" >&2 + git checkout "${_sync_original_branch}" 2>/dev/null || true + fi } trap _cleanup_sync INT TERM @@ -41,190 +41,193 @@ trap _cleanup_sync INT TERM # $1 - branch name # Returns: 0 on success, 1 on failure sync_one_branch() { - local branch="$1" - local current_branch - current_branch=$(git branch --show-current) - - echo "" | tee -a "$logfile" - echo "--- Syncing ${branch} ---" | tee -a "$logfile" - - if ! git show-ref --verify --quiet "refs/heads/${branch}"; then - echo " ⚠ Branch '${branch}' does not exist locally — skipping" | tee -a "$logfile" - return 0 - fi - - if ! git show-ref --verify --quiet "refs/remotes/origin/${branch}"; then - echo " ⚠ No remote tracking branch 'origin/${branch}' — skipping" | tee -a "$logfile" - return 0 - fi - - if [[ "${current_branch}" != "${branch}" ]]; then - if ! git checkout "${branch}" >> "$logfile" 2>&1; then - echo " ✗ Failed to checkout ${branch}" | tee -a "$logfile" - return 1 - fi - echo " Switched to ${branch}" | tee -a "$logfile" - fi - - local behind ahead - behind=$(git rev-list --count "HEAD..origin/${branch}" 2>/dev/null || echo "0") - ahead=$(git rev-list --count "origin/${branch}..HEAD" 2>/dev/null || echo "0") - - echo " Local: ${ahead} ahead, ${behind} behind origin/${branch}" | tee -a "$logfile" - - if [[ "${behind}" -eq 0 ]]; then - echo " ✓ Already up-to-date with origin/${branch}" | tee -a "$logfile" - return 0 - fi - - if [[ "${ahead}" -gt 0 ]]; then - 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 - echo " ✓ ${branch} synced successfully" | tee -a "$logfile" - return 0 - else - echo " ✗ Rebase failed for ${branch}" | tee -a "$logfile" - echo " Aborting rebase..." | tee -a "$logfile" - git rebase --abort 2>/dev/null || true - echo " Manual action needed: git pull --rebase origin ${branch}" | tee -a "$logfile" - return 1 - fi + local branch="$1" + local current_branch + current_branch=$(git branch --show-current) + + echo "" | tee -a "$logfile" + echo "--- Syncing ${branch} ---" | tee -a "$logfile" + + if ! git show-ref --verify --quiet "refs/heads/${branch}"; then + echo " ⚠ Branch '${branch}' does not exist locally — skipping" | tee -a "$logfile" + return 0 + fi + + if ! git show-ref --verify --quiet "refs/remotes/origin/${branch}"; then + echo " ⚠ No remote tracking branch 'origin/${branch}' — skipping" | tee -a "$logfile" + return 0 + fi + + if [[ "${current_branch}" != "${branch}" ]]; then + if ! git checkout "${branch}" >>"$logfile" 2>&1; then + echo " ✗ Failed to checkout ${branch}" | tee -a "$logfile" + return 1 + fi + echo " Switched to ${branch}" | tee -a "$logfile" + fi + + local behind ahead + behind=$(git rev-list --count "HEAD..origin/${branch}" 2>/dev/null || echo "0") + ahead=$(git rev-list --count "origin/${branch}..HEAD" 2>/dev/null || echo "0") + + echo " Local: ${ahead} ahead, ${behind} behind origin/${branch}" | tee -a "$logfile" + + if [[ "${behind}" -eq 0 ]]; then + echo " ✓ Already up-to-date with origin/${branch}" | tee -a "$logfile" + return 0 + fi + + if [[ "${ahead}" -gt 0 ]]; then + 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 + echo " ✓ ${branch} synced successfully" | tee -a "$logfile" + return 0 + else + echo " ✗ Rebase failed for ${branch}" | tee -a "$logfile" + echo " Aborting rebase..." | tee -a "$logfile" + git rebase --abort 2>/dev/null || true + echo " Manual action needed: git pull --rebase origin ${branch}" | tee -a "$logfile" + return 1 + fi } main() { - local sync_all=0 - local non_interactive=0 - - while [[ $# -gt 0 ]]; do - case "${1}" in - --help|-h) - echo "Usage: ./scripts/git/sync_branches.sh [OPTIONS]" - echo "" - echo "Sync local branches with remote origin via fetch + rebase." - echo "" - echo "Options:" - echo " --all Sync both source and target branches (default: current only)" - echo " --non-interactive Abort (instead of prompt) if uncommitted changes found" - echo " -h, --help Show this help" - echo "" - echo "Behavior:" - echo " - Runs git fetch origin first to update remote refs" - echo " - Uses git pull --rebase (preserves clean linear history)" - echo " - With --all: switches between branches, returns to starting branch" - echo " - Warns if local diverges from remote before rebasing" - echo "" - echo "Configuration:" - echo " CGW_SOURCE_BRANCH Source branch (default: development)" - echo " CGW_TARGET_BRANCH Target branch (default: main)" - echo "" - echo "Environment:" - echo " CGW_NON_INTERACTIVE=1 Same as --non-interactive" - exit 0 - ;; - --all) sync_all=1 ;; - --non-interactive) non_interactive=1 ;; - *) echo "[ERROR] Unknown flag: $1" >&2; exit 1 ;; - esac - shift - done - - [[ "${CGW_NON_INTERACTIVE:-0}" == "1" ]] && non_interactive=1 - - { - echo "=========================================" - echo "Sync Branches Log" - echo "=========================================" - echo "Start Time: $(date)" - echo "Working Directory: ${PROJECT_ROOT}" - } > "$logfile" - - echo "=== Branch Sync ===" | tee -a "$logfile" - echo "" | tee -a "$logfile" - echo "Workflow Log: ${logfile}" | tee -a "$logfile" - echo "" | tee -a "$logfile" - - cd "${PROJECT_ROOT}" || { - err "Cannot find project root" - exit 1 - } - - _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" - 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 - fi - fi - - # [1] Fetch all remotes - log_section_start "GIT FETCH" "$logfile" - echo "Fetching from origin..." | tee -a "$logfile" - if git fetch origin >> "$logfile" 2>&1; then - echo "✓ Fetch complete" | tee -a "$logfile" - log_section_end "GIT FETCH" "$logfile" "0" - else - echo "✗ Fetch failed — check network/auth" | tee -a "$logfile" - log_section_end "GIT FETCH" "$logfile" "1" - exit 1 - fi - echo "" | tee -a "$logfile" - - # [2] Sync branches - log_section_start "SYNC BRANCHES" "$logfile" - - local sync_failed=0 - - if [[ ${sync_all} -eq 1 ]]; then - sync_one_branch "${CGW_SOURCE_BRANCH}" || sync_failed=1 - sync_one_branch "${CGW_TARGET_BRANCH}" || sync_failed=1 - else - sync_one_branch "${_sync_original_branch}" || sync_failed=1 - fi - - log_section_end "SYNC BRANCHES" "$logfile" "${sync_failed}" - echo "" | tee -a "$logfile" - - # Return to original branch if we moved - local current_after - current_after=$(git branch --show-current) - if [[ "${current_after}" != "${_sync_original_branch}" ]]; then - git checkout "${_sync_original_branch}" >> "$logfile" 2>&1 - echo "Returned to: ${_sync_original_branch}" | tee -a "$logfile" - echo "" | tee -a "$logfile" - fi - - { - echo "========================================" - echo "[SYNC SUMMARY]" - echo "========================================" - } | tee -a "$logfile" - - if [[ ${sync_failed} -eq 0 ]]; then - echo "✓ SYNC SUCCESSFUL" | tee -a "$logfile" - else - echo "⚠ SYNC COMPLETED WITH ERRORS" | tee -a "$logfile" - echo " Check log for details: ${logfile}" | tee -a "$logfile" - fi - - { - echo "" - echo "End Time: $(date)" - } | tee -a "$logfile" - - echo "Full log: $logfile" - - return ${sync_failed} + local sync_all=0 + local non_interactive=0 + + while [[ $# -gt 0 ]]; do + case "${1}" in + --help | -h) + echo "Usage: ./scripts/git/sync_branches.sh [OPTIONS]" + echo "" + echo "Sync local branches with remote origin via fetch + rebase." + echo "" + echo "Options:" + echo " --all Sync both source and target branches (default: current only)" + echo " --non-interactive Abort (instead of prompt) if uncommitted changes found" + echo " -h, --help Show this help" + echo "" + echo "Behavior:" + echo " - Runs git fetch origin first to update remote refs" + echo " - Uses git pull --rebase (preserves clean linear history)" + echo " - With --all: switches between branches, returns to starting branch" + echo " - Warns if local diverges from remote before rebasing" + echo "" + echo "Configuration:" + echo " CGW_SOURCE_BRANCH Source branch (default: development)" + echo " CGW_TARGET_BRANCH Target branch (default: main)" + echo "" + echo "Environment:" + echo " CGW_NON_INTERACTIVE=1 Same as --non-interactive" + exit 0 + ;; + --all) sync_all=1 ;; + --non-interactive) non_interactive=1 ;; + *) + echo "[ERROR] Unknown flag: $1" >&2 + exit 1 + ;; + esac + shift + done + + [[ "${CGW_NON_INTERACTIVE:-0}" == "1" ]] && non_interactive=1 + + { + echo "=========================================" + echo "Sync Branches Log" + echo "=========================================" + echo "Start Time: $(date)" + echo "Working Directory: ${PROJECT_ROOT}" + } >"$logfile" + + echo "=== Branch Sync ===" | tee -a "$logfile" + echo "" | tee -a "$logfile" + echo "Workflow Log: ${logfile}" | tee -a "$logfile" + echo "" | tee -a "$logfile" + + cd "${PROJECT_ROOT}" || { + err "Cannot find project root" + exit 1 + } + + _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" + 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 + fi + fi + + # [1] Fetch all remotes + log_section_start "GIT FETCH" "$logfile" + echo "Fetching from origin..." | tee -a "$logfile" + if git fetch origin >>"$logfile" 2>&1; then + echo "✓ Fetch complete" | tee -a "$logfile" + log_section_end "GIT FETCH" "$logfile" "0" + else + echo "✗ Fetch failed — check network/auth" | tee -a "$logfile" + log_section_end "GIT FETCH" "$logfile" "1" + exit 1 + fi + echo "" | tee -a "$logfile" + + # [2] Sync branches + log_section_start "SYNC BRANCHES" "$logfile" + + local sync_failed=0 + + if [[ ${sync_all} -eq 1 ]]; then + sync_one_branch "${CGW_SOURCE_BRANCH}" || sync_failed=1 + sync_one_branch "${CGW_TARGET_BRANCH}" || sync_failed=1 + else + sync_one_branch "${_sync_original_branch}" || sync_failed=1 + fi + + log_section_end "SYNC BRANCHES" "$logfile" "${sync_failed}" + echo "" | tee -a "$logfile" + + # Return to original branch if we moved + local current_after + current_after=$(git branch --show-current) + if [[ "${current_after}" != "${_sync_original_branch}" ]]; then + git checkout "${_sync_original_branch}" >>"$logfile" 2>&1 + echo "Returned to: ${_sync_original_branch}" | tee -a "$logfile" + echo "" | tee -a "$logfile" + fi + + { + echo "========================================" + echo "[SYNC SUMMARY]" + echo "========================================" + } | tee -a "$logfile" + + if [[ ${sync_failed} -eq 0 ]]; then + echo "✓ SYNC SUCCESSFUL" | tee -a "$logfile" + else + echo "⚠ SYNC COMPLETED WITH ERRORS" | tee -a "$logfile" + echo " Check log for details: ${logfile}" | tee -a "$logfile" + fi + + { + echo "" + echo "End Time: $(date)" + } | tee -a "$logfile" + + echo "Full log: $logfile" + + return ${sync_failed} } main "$@" diff --git a/scripts/git/validate_branches.sh b/scripts/git/validate_branches.sh index d4503cb..1091037 100644 --- a/scripts/git/validate_branches.sh +++ b/scripts/git/validate_branches.sh @@ -18,143 +18,149 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "${SCRIPT_DIR}/_common.sh" main() { - if [[ "${1:-}" == "--help" ]] || [[ "${1:-}" == "-h" ]]; then - echo "Usage: ./scripts/git/validate_branches.sh" - echo "" - echo "Validate branch state before a merge:" - echo " - Checks current branch (warns if not source/target branch)" - echo " - Fails if uncommitted changes exist" - echo " - Warns about untracked files" - echo " - Reports commits ahead/behind between source and target branches" - echo "" - echo "Options:" - echo " -h, --help Show this help" - echo "" - echo "Configuration (via .cgw.conf or env vars):" - echo " CGW_SOURCE_BRANCH Source branch name (default: development)" - echo " CGW_TARGET_BRANCH Target branch name (default: main)" - exit 0 - fi - - init_logging "validate_branches" - - local script_start - script_start=$(date +%s) - - { - echo "=========================================" - echo "Branch Validation Log" - echo "=========================================" - echo "Start Time: $(date)" - echo "Working Directory: ${PROJECT_ROOT}" - } > "$logfile" - - echo "=== Branch Validation ===" | tee -a "$logfile" - echo "" | tee -a "$logfile" - - cd "${PROJECT_ROOT}" || { - err "Cannot find project root" - exit 1 - } - - local validation_failed=0 - - # [CHECK 1] Current branch - log_section_start "BRANCH CHECK" "$logfile" - - local current_branch - current_branch=$(git rev-parse --abbrev-ref HEAD 2>&1) - local branch_check_exit=$? - - echo "Current branch: $current_branch" | tee -a "$logfile" - - if [[ $branch_check_exit -ne 0 ]]; then - echo " Failed to get current branch" | tee -a "$logfile" - validation_failed=1 - elif [[ "$current_branch" != "${CGW_SOURCE_BRANCH}" && "$current_branch" != "${CGW_TARGET_BRANCH}" ]]; then - echo " WARNING: Not on '${CGW_SOURCE_BRANCH}' or '${CGW_TARGET_BRANCH}' branch" | tee -a "$logfile" - echo " Current: $current_branch" | tee -a "$logfile" - else - echo " On valid branch: $current_branch" | tee -a "$logfile" - fi - - log_section_end "BRANCH CHECK" "$logfile" "$validation_failed" - - # [CHECK 2] Uncommitted changes + untracked files - echo "" | tee -a "$logfile" - log_section_start "UNCOMMITTED CHANGES CHECK" "$logfile" - - local uncommitted_check=0 - if ! git diff-index --quiet HEAD -- 2>/dev/null; then - echo " Uncommitted changes detected:" | tee -a "$logfile" - git status --short | tee -a "$logfile" - uncommitted_check=1 - validation_failed=1 - else - echo " No uncommitted changes" | tee -a "$logfile" - fi - - # Check for untracked files (git diff-index only checks tracked files) - local untracked_files - untracked_files=$(git ls-files --others --exclude-standard) - if [[ -n "${untracked_files}" ]]; then - echo " Untracked files detected (may be missing from commit):" | tee -a "$logfile" - echo "${untracked_files}" | tee -a "$logfile" - echo " Use 'git add <file>' to stage, or add to .gitignore" | tee -a "$logfile" - # Warning only — untracked files don't affect merge safety - fi - - log_section_end "UNCOMMITTED CHANGES CHECK" "$logfile" "$uncommitted_check" - - # [CHECK 3] Branch relationship - echo "" | tee -a "$logfile" - log_section_start "BRANCH RELATIONSHIP CHECK" "$logfile" - - local source_ahead target_ahead - source_ahead=$(git rev-list --count "${CGW_TARGET_BRANCH}..${CGW_SOURCE_BRANCH}" 2>/dev/null || echo "unknown") - target_ahead=$(git rev-list --count "${CGW_SOURCE_BRANCH}..${CGW_TARGET_BRANCH}" 2>/dev/null || echo "unknown") - - echo "${CGW_SOURCE_BRANCH} ahead of ${CGW_TARGET_BRANCH}: $source_ahead commits" | tee -a "$logfile" - echo "${CGW_TARGET_BRANCH} ahead of ${CGW_SOURCE_BRANCH}: $target_ahead commits" | tee -a "$logfile" - - if [[ "$source_ahead" != "unknown" ]] && (( source_ahead == 0 )) && [[ "${current_branch}" == "${CGW_SOURCE_BRANCH}" ]]; then - echo " Warning: ${CGW_SOURCE_BRANCH} has no new commits vs ${CGW_TARGET_BRANCH}" | tee -a "$logfile" - fi - - if [[ "$target_ahead" != "unknown" ]] && (( target_ahead > 0 )) && [[ "${current_branch}" == "${CGW_SOURCE_BRANCH}" ]]; then - echo " Warning: ${CGW_TARGET_BRANCH} is ahead — consider merging ${CGW_TARGET_BRANCH} into ${CGW_SOURCE_BRANCH}" | tee -a "$logfile" - fi - - log_section_end "BRANCH RELATIONSHIP CHECK" "$logfile" "0" - - # Summary - echo "" | tee -a "$logfile" - { - echo "========================================" - echo "[VALIDATION SUMMARY]" - echo "========================================" - } | tee -a "$logfile" - - local script_end total_duration - script_end=$(date +%s) - total_duration=$((script_end - script_start)) - - if (( validation_failed == 0 )); then - echo "Branch validation passed" | tee -a "$logfile" - else - echo "Branch validation failed" | tee -a "$logfile" - fi - - { - echo "" - echo "End Time: $(date)" - echo "Total Duration: ${total_duration}s" - } | tee -a "$logfile" - - echo "" - echo "Full log: $logfile" - - exit $validation_failed + if [[ "${1:-}" == "--help" ]] || [[ "${1:-}" == "-h" ]]; then + echo "Usage: ./scripts/git/validate_branches.sh" + echo "" + echo "Validate branch state before a merge:" + echo " - Checks current branch (warns if not source/target branch)" + echo " - Fails if uncommitted changes exist" + echo " - Warns about untracked files" + echo " - Reports commits ahead/behind between source and target branches" + echo "" + echo "Options:" + echo " -h, --help Show this help" + echo "" + echo "Configuration (via .cgw.conf or env vars):" + echo " CGW_SOURCE_BRANCH Source branch name (default: development)" + echo " CGW_TARGET_BRANCH Target branch name (default: main)" + exit 0 + fi + + init_logging "validate_branches" + + local script_start + script_start=$(date +%s) + + { + echo "=========================================" + echo "Branch Validation Log" + echo "=========================================" + echo "Start Time: $(date)" + echo "Working Directory: ${PROJECT_ROOT}" + } >"$logfile" + + echo "=== Branch Validation ===" | tee -a "$logfile" + echo "" | tee -a "$logfile" + + cd "${PROJECT_ROOT}" || { + err "Cannot find project root" + exit 1 + } + + local validation_failed=0 + + # [CHECK 1] Current branch + log_section_start "BRANCH CHECK" "$logfile" + + local current_branch + current_branch=$(git rev-parse --abbrev-ref HEAD 2>&1) + local branch_check_exit=$? + + echo "Current branch: $current_branch" | tee -a "$logfile" + + # Verify source branch exists in this repository + if ! git rev-parse --verify "${CGW_SOURCE_BRANCH}" >/dev/null 2>&1; then + echo " ERROR: Source branch '${CGW_SOURCE_BRANCH}' does not exist" | tee -a "$logfile" + validation_failed=1 + fi + + if [[ $branch_check_exit -ne 0 ]]; then + echo " Failed to get current branch" | tee -a "$logfile" + validation_failed=1 + elif [[ "$current_branch" != "${CGW_SOURCE_BRANCH}" && "$current_branch" != "${CGW_TARGET_BRANCH}" ]]; then + echo " WARNING: Not on '${CGW_SOURCE_BRANCH}' or '${CGW_TARGET_BRANCH}' branch" | tee -a "$logfile" + echo " Current: $current_branch" | tee -a "$logfile" + else + echo " On valid branch: $current_branch" | tee -a "$logfile" + fi + + log_section_end "BRANCH CHECK" "$logfile" "$validation_failed" + + # [CHECK 2] Uncommitted changes + untracked files + echo "" | tee -a "$logfile" + log_section_start "UNCOMMITTED CHANGES CHECK" "$logfile" + + local uncommitted_check=0 + if ! git diff-index --quiet HEAD -- 2>/dev/null; then + echo " Uncommitted changes detected:" | tee -a "$logfile" + git status --short | tee -a "$logfile" + uncommitted_check=1 + validation_failed=1 + else + echo " No uncommitted changes" | tee -a "$logfile" + fi + + # Check for untracked files (git diff-index only checks tracked files) + local untracked_files + untracked_files=$(git ls-files --others --exclude-standard) + if [[ -n "${untracked_files}" ]]; then + echo " Untracked files detected (may be missing from commit):" | tee -a "$logfile" + echo "${untracked_files}" | tee -a "$logfile" + echo " Use 'git add <file>' to stage, or add to .gitignore" | tee -a "$logfile" + # Warning only — untracked files don't affect merge safety + fi + + log_section_end "UNCOMMITTED CHANGES CHECK" "$logfile" "$uncommitted_check" + + # [CHECK 3] Branch relationship + echo "" | tee -a "$logfile" + log_section_start "BRANCH RELATIONSHIP CHECK" "$logfile" + + local source_ahead target_ahead + source_ahead=$(git rev-list --count "${CGW_TARGET_BRANCH}..${CGW_SOURCE_BRANCH}" 2>/dev/null || echo "unknown") + target_ahead=$(git rev-list --count "${CGW_SOURCE_BRANCH}..${CGW_TARGET_BRANCH}" 2>/dev/null || echo "unknown") + + echo "${CGW_SOURCE_BRANCH} ahead of ${CGW_TARGET_BRANCH}: $source_ahead commits" | tee -a "$logfile" + echo "${CGW_TARGET_BRANCH} ahead of ${CGW_SOURCE_BRANCH}: $target_ahead commits" | tee -a "$logfile" + + if [[ "$source_ahead" != "unknown" ]] && ((source_ahead == 0)) && [[ "${current_branch}" == "${CGW_SOURCE_BRANCH}" ]]; then + echo " Warning: ${CGW_SOURCE_BRANCH} has no new commits vs ${CGW_TARGET_BRANCH}" | tee -a "$logfile" + fi + + if [[ "$target_ahead" != "unknown" ]] && ((target_ahead > 0)) && [[ "${current_branch}" == "${CGW_SOURCE_BRANCH}" ]]; then + echo " Warning: ${CGW_TARGET_BRANCH} is ahead — consider merging ${CGW_TARGET_BRANCH} into ${CGW_SOURCE_BRANCH}" | tee -a "$logfile" + fi + + log_section_end "BRANCH RELATIONSHIP CHECK" "$logfile" "0" + + # Summary + echo "" | tee -a "$logfile" + { + echo "========================================" + echo "[VALIDATION SUMMARY]" + echo "========================================" + } | tee -a "$logfile" + + local script_end total_duration + script_end=$(date +%s) + total_duration=$((script_end - script_start)) + + if ((validation_failed == 0)); then + echo "Branch validation passed" | tee -a "$logfile" + else + echo "Branch validation failed" | tee -a "$logfile" + fi + + { + echo "" + echo "End Time: $(date)" + echo "Total Duration: ${total_duration}s" + } | tee -a "$logfile" + + echo "" + echo "Full log: $logfile" + + exit $validation_failed } main "$@" diff --git a/tests/fixtures/sample.cgw.conf b/tests/fixtures/sample.cgw.conf new file mode 100644 index 0000000..e3b8279 --- /dev/null +++ b/tests/fixtures/sample.cgw.conf @@ -0,0 +1,8 @@ +# sample.cgw.conf - Test fixture: minimal CGW configuration +# Used by unit/config.bats to verify .cgw.conf loading + +CGW_SOURCE_BRANCH="feature" +CGW_TARGET_BRANCH="stable" +CGW_LINT_CMD="eslint" +CGW_EXTRA_PREFIXES="cuda|tensorrt" +CGW_MERGE_MODE="pr" diff --git a/tests/helpers/mocks.bash b/tests/helpers/mocks.bash new file mode 100644 index 0000000..93127b3 --- /dev/null +++ b/tests/helpers/mocks.bash @@ -0,0 +1,108 @@ +#!/usr/bin/env bash +# tests/helpers/mocks.bash - Mock external tools for CGW integration tests +# Usage: load '../helpers/mocks' (from within a .bats file) + +# Each install_mock_* function: +# 1. Creates a fake executable in $MOCK_BIN_DIR +# 2. Prepends $MOCK_BIN_DIR to $PATH + +# ── Shared bin dir ───────────────────────────────────────────────────────────── + +setup_mock_bin() { + MOCK_BIN_DIR="${TEST_TMPDIR}/mock-bin" + export MOCK_BIN_DIR + mkdir -p "${MOCK_BIN_DIR}" + export PATH="${MOCK_BIN_DIR}:${PATH}" +} + +# ── Lint mock ────────────────────────────────────────────────────────────────── + +# install_mock_lint +# Creates a fake `ruff` that exits with $MOCK_LINT_EXIT (default 0). +# Output is written to $MOCK_BIN_DIR/ruff.log on each call. +install_mock_lint() { + local exit_code="${MOCK_LINT_EXIT:-0}" + cat > "${MOCK_BIN_DIR}/ruff" << EOF +#!/usr/bin/env bash +echo "mock ruff \$*" >> "${MOCK_BIN_DIR}/ruff.log" +exit ${exit_code} +EOF + chmod +x "${MOCK_BIN_DIR}/ruff" +} + +# install_mock_lint_with_errors +# Creates a fake `ruff` that exits 1 and prints lint-style diagnostic lines. +install_mock_lint_with_errors() { + cat > "${MOCK_BIN_DIR}/ruff" << 'EOF' +#!/usr/bin/env bash +echo "src/foo.py:10:5: E501 line too long" +echo "src/foo.py:22:1: F401 unused import" +exit 1 +EOF + chmod +x "${MOCK_BIN_DIR}/ruff" +} + +# ── gh CLI mock ──────────────────────────────────────────────────────────────── + +# install_mock_gh +# Creates a fake `gh` that: +# - `gh auth status` → exits $MOCK_GH_AUTH_EXIT (default 0) +# - `gh pr create` → exits $MOCK_GH_PR_EXIT (default 0), prints a fake PR URL +# - All calls are logged to $MOCK_BIN_DIR/gh.log +install_mock_gh() { + local auth_exit="${MOCK_GH_AUTH_EXIT:-0}" + local pr_exit="${MOCK_GH_PR_EXIT:-0}" + cat > "${MOCK_BIN_DIR}/gh" << EOF +#!/usr/bin/env bash +echo "gh \$*" >> "${MOCK_BIN_DIR}/gh.log" +if [[ "\$1" == "auth" && "\$2" == "status" ]]; then + exit ${auth_exit} +fi +if [[ "\$1" == "pr" && "\$2" == "create" ]]; then + echo "https://github.com/owner/repo/pull/42" + exit ${pr_exit} +fi +exit 0 +EOF + chmod +x "${MOCK_BIN_DIR}/gh" +} + +# install_mock_gh_no_auth +# gh is present but `gh auth status` fails. +install_mock_gh_no_auth() { + MOCK_GH_AUTH_EXIT=1 install_mock_gh +} + +# ── Markdownlint mock ────────────────────────────────────────────────────────── + +# install_mock_markdownlint +# Creates a fake markdownlint-cli2 at $MOCK_BIN_DIR. +# Exits with $MOCK_MDLINT_EXIT (default 0). +install_mock_markdownlint() { + local exit_code="${MOCK_MDLINT_EXIT:-0}" + local cmd_name="${1:-markdownlint-cli2}" + cat > "${MOCK_BIN_DIR}/${cmd_name}" << EOF +#!/usr/bin/env bash +echo "mock ${cmd_name} \$*" >> "${MOCK_BIN_DIR}/mdlint.log" +exit ${exit_code} +EOF + chmod +x "${MOCK_BIN_DIR}/${cmd_name}" +} + +# ── gh CLI absence helper ────────────────────────────────────────────────────── + +# hide_gh +# Installs a shim in MOCK_BIN_DIR that exits 1 on any invocation, simulating +# an unusable gh CLI. setup_mock_bin must be called first. +# NOTE: `command -v gh` will still find this shim (it's executable), so tests +# exercise the auth-failure path rather than the command-not-found path. On CI, +# gh lives in /usr/bin alongside rm/bash/etc — we cannot safely remove that +# directory from PATH. +hide_gh() { + cat > "${MOCK_BIN_DIR}/gh" << 'EOF' +#!/usr/bin/env bash +echo "gh: command not found" >&2 +exit 1 +EOF + chmod +x "${MOCK_BIN_DIR}/gh" +} diff --git a/tests/helpers/setup.bash b/tests/helpers/setup.bash new file mode 100644 index 0000000..b080919 --- /dev/null +++ b/tests/helpers/setup.bash @@ -0,0 +1,112 @@ +#!/usr/bin/env bash +# tests/helpers/setup.bash - Shared test repo setup/teardown helpers +# Usage: load '../helpers/setup' (from within a .bats file) + +# Absolute path to the real project scripts (tests run real scripts against fake repos) +export CGW_PROJECT_ROOT +CGW_PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" + +# ── Temp directory management ───────────────────────────────────────────────── + +# create_temp_dir — set BATS_TEST_TMPDIR to an isolated temp dir +create_temp_dir() { + # GNU mktemp needs no template; BSD mktemp requires -t. Try GNU first. + TEST_TMPDIR="$(mktemp -d 2>/dev/null || mktemp -d -t 'cgw.XXXXXX')" + export TEST_TMPDIR +} + +cleanup_temp_dir() { + [[ -n "${TEST_TMPDIR:-}" ]] && rm -rf "${TEST_TMPDIR}" +} + +# ── Bare remote repo helper ─────────────────────────────────────────────────── + +# create_bare_remote <dir> +# Creates a bare git repo at <dir>, used as the `origin` remote. +create_bare_remote() { + local remote_dir="$1" + git init --bare "${remote_dir}" --quiet +} + +# ── Full test repo ───────────────────────────────────────────────────────────── + +# create_test_repo +# Creates a git repo at $TEST_TMPDIR/repo with: +# - Configured user identity +# - Initial commit on `main` +# - `development` branch with one extra commit +# - Sets TEST_REPO_DIR +create_test_repo() { + create_temp_dir + + TEST_REPO_DIR="${TEST_TMPDIR}/repo" + export TEST_REPO_DIR + mkdir -p "${TEST_REPO_DIR}/scripts/git" + + git -C "${TEST_REPO_DIR}" init --quiet + git -C "${TEST_REPO_DIR}" config user.email "test@example.com" + git -C "${TEST_REPO_DIR}" config user.name "Test User" + git -C "${TEST_REPO_DIR}" config core.autocrlf false + + # Initial commit on main + echo "# Test Repo" > "${TEST_REPO_DIR}/README.md" + git -C "${TEST_REPO_DIR}" add README.md + git -C "${TEST_REPO_DIR}" commit --quiet -m "chore: initial commit" + git -C "${TEST_REPO_DIR}" checkout --quiet -b main 2>/dev/null || \ + git -C "${TEST_REPO_DIR}" branch -m main 2>/dev/null || true + + # development branch with extra commit + git -C "${TEST_REPO_DIR}" checkout --quiet -b development + echo "# Dev note" > "${TEST_REPO_DIR}/DEV.md" + git -C "${TEST_REPO_DIR}" add DEV.md + git -C "${TEST_REPO_DIR}" commit --quiet -m "feat: dev commit" + + git -C "${TEST_REPO_DIR}" checkout --quiet main +} + +# create_test_repo_with_remote +# Same as create_test_repo but adds a local bare repo as `origin` +# and pushes both branches, so remote tracking refs exist. +create_test_repo_with_remote() { + create_test_repo + + TEST_REMOTE_DIR="${TEST_TMPDIR}/remote.git" + export TEST_REMOTE_DIR + create_bare_remote "${TEST_REMOTE_DIR}" + + git -C "${TEST_REPO_DIR}" remote add origin "${TEST_REMOTE_DIR}" + git -C "${TEST_REPO_DIR}" push --quiet --all origin + git -C "${TEST_REPO_DIR}" push --quiet --set-upstream origin main + git -C "${TEST_REPO_DIR}" push --quiet --set-upstream origin development +} + +# cleanup_test_repo — remove all temp dirs +cleanup_test_repo() { + cleanup_temp_dir +} + +# ── Script path helpers ──────────────────────────────────────────────────────── + +# script_path <name> — returns absolute path to scripts/git/<name> +script_path() { + echo "${CGW_PROJECT_ROOT}/scripts/git/$1" +} + +# run_script <name> [args...] +# Runs a CGW script with SCRIPT_DIR pointing at the real scripts/git/ directory. +# The current directory is TEST_REPO_DIR (if set), or the temp dir. +run_script() { + local script_name="$1" + shift + local script_file + script_file="$(script_path "${script_name}")" + + local work_dir="${TEST_REPO_DIR:-${TEST_TMPDIR:-$(pwd)}}" + + ( + cd "${work_dir}" || exit 1 + export SCRIPT_DIR="${CGW_PROJECT_ROOT}/scripts/git" + export PROJECT_ROOT="${work_dir}" + bash "${script_file}" "$@" + ) +} diff --git a/tests/integration/check_lint.bats b/tests/integration/check_lint.bats new file mode 100644 index 0000000..745d92f --- /dev/null +++ b/tests/integration/check_lint.bats @@ -0,0 +1,145 @@ +#!/usr/bin/env bats +# tests/integration/check_lint.bats - Integration tests for check_lint.sh +# Runs: bats tests/integration/check_lint.bats + +bats_require_minimum_version 1.5.0 +load '../helpers/setup' +load '../helpers/mocks' + +setup() { + create_test_repo + setup_mock_bin +} + +teardown() { + cleanup_test_repo +} + +# ── --skip-lint ──────────────────────────────────────────────────────────────── + +@test "--skip-lint exits 0" { + run run_script check_lint.sh --skip-lint + [ "${status}" -eq 0 ] +} + +@test "--skip-lint output mentions skip" { + run run_script check_lint.sh --skip-lint + [[ "${output}" == *"skip"* ]] || [[ "${output}" == *"Skip"* ]] || [[ "${output}" == *"SKIP"* ]] +} + +# ── CGW_LINT_CMD="" disables lint ───────────────────────────────────────────── + +@test "CGW_LINT_CMD='' exits 0" { + run bash -c " + cd '${TEST_REPO_DIR}' + export SCRIPT_DIR='${CGW_PROJECT_ROOT}/scripts/git' + export PROJECT_ROOT='${TEST_REPO_DIR}' + export CGW_LINT_CMD='' + export CGW_FORMAT_CMD='' + bash '${CGW_PROJECT_ROOT}/scripts/git/check_lint.sh' + " + [ "${status}" -eq 0 ] +} + +# ── CGW_SKIP_LINT=1 ──────────────────────────────────────────────────────────── + +@test "CGW_SKIP_LINT=1 exits 0" { + run bash -c " + cd '${TEST_REPO_DIR}' + export SCRIPT_DIR='${CGW_PROJECT_ROOT}/scripts/git' + export PROJECT_ROOT='${TEST_REPO_DIR}' + export CGW_SKIP_LINT=1 + bash '${CGW_PROJECT_ROOT}/scripts/git/check_lint.sh' + " + [ "${status}" -eq 0 ] +} + +# ── Mock lint passing ───────────────────────────────────────────────────────── + +@test "with lint tool returning 0: check_lint exits 0" { + install_mock_lint + run bash -c " + cd '${TEST_REPO_DIR}' + export SCRIPT_DIR='${CGW_PROJECT_ROOT}/scripts/git' + export PROJECT_ROOT='${TEST_REPO_DIR}' + export CGW_LINT_CMD=ruff + export CGW_FORMAT_CMD='' + bash '${CGW_PROJECT_ROOT}/scripts/git/check_lint.sh' + " + [ "${status}" -eq 0 ] +} + +@test "with lint tool returning 0: output contains PASSED" { + install_mock_lint + run bash -c " + cd '${TEST_REPO_DIR}' + export SCRIPT_DIR='${CGW_PROJECT_ROOT}/scripts/git' + export PROJECT_ROOT='${TEST_REPO_DIR}' + export CGW_LINT_CMD=ruff + export CGW_FORMAT_CMD='' + bash '${CGW_PROJECT_ROOT}/scripts/git/check_lint.sh' + " + [[ "${output}" == *"PASSED"* ]] +} + +# ── Mock lint failing ───────────────────────────────────────────────────────── + +@test "with lint tool returning 1: check_lint exits non-zero" { + MOCK_LINT_EXIT=1 install_mock_lint + run bash -c " + cd '${TEST_REPO_DIR}' + export SCRIPT_DIR='${CGW_PROJECT_ROOT}/scripts/git' + export PROJECT_ROOT='${TEST_REPO_DIR}' + export CGW_LINT_CMD=ruff + export CGW_FORMAT_CMD='' + bash '${CGW_PROJECT_ROOT}/scripts/git/check_lint.sh' + " + [ "${status}" -ne 0 ] +} + +@test "with lint tool returning 1: output contains FAILED" { + MOCK_LINT_EXIT=1 install_mock_lint + run bash -c " + cd '${TEST_REPO_DIR}' + export SCRIPT_DIR='${CGW_PROJECT_ROOT}/scripts/git' + export PROJECT_ROOT='${TEST_REPO_DIR}' + export CGW_LINT_CMD=ruff + export CGW_FORMAT_CMD='' + bash '${CGW_PROJECT_ROOT}/scripts/git/check_lint.sh' + " + [[ "${output}" == *"FAILED"* ]] +} + +# ── --skip-md-lint ──────────────────────────────────────────────────────────── + +@test "--skip-md-lint skips markdown lint step" { + install_mock_lint + install_mock_markdownlint + run bash -c " + cd '${TEST_REPO_DIR}' + export SCRIPT_DIR='${CGW_PROJECT_ROOT}/scripts/git' + export PROJECT_ROOT='${TEST_REPO_DIR}' + export CGW_LINT_CMD=ruff + export CGW_FORMAT_CMD='' + export CGW_MARKDOWNLINT_CMD=markdownlint-cli2 + bash '${CGW_PROJECT_ROOT}/scripts/git/check_lint.sh' --skip-md-lint + " + # markdownlint mock log should NOT have been called + [ ! -f "${MOCK_BIN_DIR}/mdlint.log" ] || \ + ! grep -q "markdownlint" "${MOCK_BIN_DIR}/mdlint.log" 2>/dev/null +} + +# ── --modified-only ──────────────────────────────────────────────────────────── + +@test "--modified-only with no modified files exits 0" { + install_mock_lint + run bash -c " + cd '${TEST_REPO_DIR}' + export SCRIPT_DIR='${CGW_PROJECT_ROOT}/scripts/git' + export PROJECT_ROOT='${TEST_REPO_DIR}' + export CGW_LINT_CMD=ruff + export CGW_FORMAT_CMD='' + bash '${CGW_PROJECT_ROOT}/scripts/git/check_lint.sh' --modified-only + " + [ "${status}" -eq 0 ] +} diff --git a/tests/integration/commit_enhanced.bats b/tests/integration/commit_enhanced.bats new file mode 100644 index 0000000..968289f --- /dev/null +++ b/tests/integration/commit_enhanced.bats @@ -0,0 +1,125 @@ +#!/usr/bin/env bats +# tests/integration/commit_enhanced.bats - Integration tests for commit_enhanced.sh +# Runs: bats tests/integration/commit_enhanced.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 development +} + +teardown() { + cleanup_test_repo +} + +# Helper: run commit_enhanced.sh with shared env vars +_run_commit() { + # PATH is already correct from setup_mock_bin; PROJECT_ROOT pins scripts to TEST_REPO_DIR. + bash -c " + cd '${TEST_REPO_DIR}' + export SCRIPT_DIR='${CGW_PROJECT_ROOT}/scripts/git' + export PROJECT_ROOT='${TEST_REPO_DIR}' + export CGW_LINT_CMD=ruff + export CGW_FORMAT_CMD='' + export CGW_NON_INTERACTIVE=1 + bash '${CGW_PROJECT_ROOT}/scripts/git/commit_enhanced.sh' $* + " +} + +# ── No staged changes ───────────────────────────────────────────────────────── + +@test "no staged changes exits 0 with no-changes message" { + run _run_commit "\"feat: test\"" + # Script should exit 0 and mention no changes + [ "${status}" -eq 0 ] + [[ "${output}" == *"No changes"* ]] || [[ "${output}" == *"nothing to commit"* ]] || \ + [[ "${output}" == *"no changes"* ]] +} + +# ── Missing commit message ──────────────────────────────────────────────────── + +@test "missing commit message exits 1" { + echo "test content" > "${TEST_REPO_DIR}/test_file.txt" + git -C "${TEST_REPO_DIR}" add test_file.txt + run _run_commit "" + [ "${status}" -eq 1 ] +} + +# ── Invalid commit prefix ───────────────────────────────────────────────────── + +@test "invalid commit prefix warns in non-interactive mode" { + echo "content" > "${TEST_REPO_DIR}/new_file.txt" + git -C "${TEST_REPO_DIR}" add new_file.txt + run _run_commit "\"wip: bad prefix\"" + # Non-interactive: should warn or fail — either warns about prefix or exits non-zero + [[ "${output}" == *"prefix"* ]] || [[ "${output}" == *"format"* ]] || [ "${status}" -ne 0 ] +} + +# ── Valid conventional commit ───────────────────────────────────────────────── + +@test "valid conventional commit with staged file succeeds" { + echo "feature content" > "${TEST_REPO_DIR}/feature.txt" + git -C "${TEST_REPO_DIR}" add feature.txt + run _run_commit "--skip-lint \"feat: add feature file\"" + [ "${status}" -eq 0 ] +} + +@test "valid conventional commit appears in git log" { + echo "another feature" > "${TEST_REPO_DIR}/another.txt" + git -C "${TEST_REPO_DIR}" add another.txt + _run_commit "--skip-lint \"feat: add another file\"" + last_msg=$(git -C "${TEST_REPO_DIR}" log -1 --format="%s") + [ "${last_msg}" = "feat: add another file" ] +} + +# ── Local-only file protection ──────────────────────────────────────────────── + +@test "CLAUDE.md is never staged or committed" { + # Create CLAUDE.md and stage everything + echo "# Claude" > "${TEST_REPO_DIR}/CLAUDE.md" + echo "real content" > "${TEST_REPO_DIR}/real.txt" + git -C "${TEST_REPO_DIR}" add . + _run_commit "--skip-lint \"feat: add real content\"" || true + # CLAUDE.md must not appear in git tree + tracked=$(git -C "${TEST_REPO_DIR}" ls-files CLAUDE.md) + [ -z "${tracked}" ] +} + +# ── --skip-lint flag ────────────────────────────────────────────────────────── + +@test "--skip-lint skips lint step" { + echo "skip lint test" > "${TEST_REPO_DIR}/skip_test.txt" + git -C "${TEST_REPO_DIR}" add skip_test.txt + run _run_commit "--skip-lint \"feat: skip lint test\"" + [ "${status}" -eq 0 ] + # ruff mock log should be empty or absent when skipped +} + +# ── --staged-only flag ──────────────────────────────────────────────────────── + +@test "--staged-only does not auto-stage unstaged files" { + echo "unstaged" > "${TEST_REPO_DIR}/unstaged.txt" + # Do NOT git add — file is untracked + run _run_commit "--skip-lint --staged-only \"feat: staged only\"" + # Unstaged file should not end up committed + tracked=$(git -C "${TEST_REPO_DIR}" ls-files unstaged.txt) + [ -z "${tracked}" ] +} + +# ── --non-interactive flag ──────────────────────────────────────────────────── + +@test "--non-interactive auto-stages tracked modified files" { + # Create a tracked file and modify it without staging + echo "initial" > "${TEST_REPO_DIR}/tracked.txt" + git -C "${TEST_REPO_DIR}" add tracked.txt + git -C "${TEST_REPO_DIR}" commit --quiet -m "chore: add tracked" + echo "modified" > "${TEST_REPO_DIR}/tracked.txt" + # Non-interactive should auto-stage the modification + run _run_commit "--skip-lint \"feat: auto-staged change\"" + [ "${status}" -eq 0 ] +} diff --git a/tests/integration/configure.bats b/tests/integration/configure.bats new file mode 100644 index 0000000..4407a79 --- /dev/null +++ b/tests/integration/configure.bats @@ -0,0 +1,94 @@ +#!/usr/bin/env bats +# tests/integration/configure.bats - Integration tests for configure.sh +# Runs: bats tests/integration/configure.bats + +bats_require_minimum_version 1.5.0 +load '../helpers/setup' +load '../helpers/mocks' + +setup() { + create_test_repo + setup_mock_bin + install_mock_lint +} + +teardown() { + cleanup_test_repo +} + +_run_configure() { + # PATH is already correct from setup_mock_bin; PROJECT_ROOT pins scripts to TEST_REPO_DIR. + bash -c " + cd '${TEST_REPO_DIR}' + export SCRIPT_DIR='${CGW_PROJECT_ROOT}/scripts/git' + export PROJECT_ROOT='${TEST_REPO_DIR}' + export CGW_NON_INTERACTIVE=1 + bash '${CGW_PROJECT_ROOT}/scripts/git/configure.sh' $* + " +} + +# ── --non-interactive config generation ─────────────────────────────────────── + +@test "--non-interactive generates .cgw.conf" { + run _run_configure "--non-interactive" + [ "${status}" -eq 0 ] + [ -f "${TEST_REPO_DIR}/.cgw.conf" ] +} + +@test "generated .cgw.conf contains CGW_SOURCE_BRANCH" { + _run_configure "--non-interactive" || true + if [ -f "${TEST_REPO_DIR}/.cgw.conf" ]; then + grep -q "CGW_SOURCE_BRANCH" "${TEST_REPO_DIR}/.cgw.conf" + fi +} + +@test "generated .cgw.conf contains CGW_TARGET_BRANCH" { + _run_configure "--non-interactive" || true + if [ -f "${TEST_REPO_DIR}/.cgw.conf" ]; then + grep -q "CGW_TARGET_BRANCH" "${TEST_REPO_DIR}/.cgw.conf" + fi +} + +# ── Branch detection ────────────────────────────────────────────────────────── + +@test "detects main as target branch" { + _run_configure "--non-interactive" || true + if [ -f "${TEST_REPO_DIR}/.cgw.conf" ]; then + grep -q "main" "${TEST_REPO_DIR}/.cgw.conf" + fi +} + +# ── --reconfigure overwrites existing ──────────────────────────────────────── + +@test "--reconfigure overwrites existing .cgw.conf" { + echo "CGW_LINT_CMD=old-value" > "${TEST_REPO_DIR}/.cgw.conf" + _run_configure "--non-interactive --reconfigure" || true + if [ -f "${TEST_REPO_DIR}/.cgw.conf" ]; then + # Old value should be replaced + ! grep -q "^CGW_LINT_CMD=old-value$" "${TEST_REPO_DIR}/.cgw.conf" || true + fi +} + +# ── Lint tool detection ─────────────────────────────────────────────────────── + +@test "detects ruff when available in PATH" { + _run_configure "--non-interactive" || true + if [ -f "${TEST_REPO_DIR}/.cgw.conf" ]; then + grep -q "ruff\|CGW_LINT_CMD" "${TEST_REPO_DIR}/.cgw.conf" + fi +} + +@test "detects ruff when pyproject.toml exists" { + echo "[tool.ruff]" > "${TEST_REPO_DIR}/pyproject.toml" + _run_configure "--non-interactive" || true + if [ -f "${TEST_REPO_DIR}/.cgw.conf" ]; then + grep -q "CGW_LINT_CMD\|ruff" "${TEST_REPO_DIR}/.cgw.conf" + fi +} + +# ── Exit code ──────────────────────────────────────────────────────────────── + +@test "configure.sh exits 0 in non-interactive mode" { + run _run_configure "--non-interactive" + [ "${status}" -eq 0 ] +} diff --git a/tests/integration/create_pr.bats b/tests/integration/create_pr.bats new file mode 100644 index 0000000..23962d1 --- /dev/null +++ b/tests/integration/create_pr.bats @@ -0,0 +1,186 @@ +#!/usr/bin/env bats +# tests/integration/create_pr.bats - Integration tests for create_pr.sh +# Runs: bats tests/integration/create_pr.bats + +bats_require_minimum_version 1.5.0 +load '../helpers/setup' +load '../helpers/mocks' + +setup() { + create_test_repo_with_remote + setup_mock_bin + # Start on development which is already 1 commit ahead of main + git -C "${TEST_REPO_DIR}" checkout development +} + +teardown() { + cleanup_test_repo +} + +_run_create_pr() { + # PATH is already set correctly by setup_mock_bin (MOCK_BIN_DIR prepended and exported). + # Merge stderr into stdout (2>&1) so that err() messages appear in $output for assertions. + # PROJECT_ROOT must be pinned to TEST_REPO_DIR so scripts don't auto-detect the real CGW repo. + ( + cd "${TEST_REPO_DIR}" || exit 1 + export SCRIPT_DIR="${CGW_PROJECT_ROOT}/scripts/git" + export PROJECT_ROOT="${TEST_REPO_DIR}" + export CGW_SOURCE_BRANCH=development + export CGW_TARGET_BRANCH=main + export CGW_NON_INTERACTIVE=1 + bash "${CGW_PROJECT_ROOT}/scripts/git/create_pr.sh" "$@" + ) 2>&1 +} + +# ── No gh in PATH ───────────────────────────────────────────────────────────── + +@test "no gh CLI in PATH exits 1" { + hide_gh + run _run_create_pr + [ "${status}" -eq 1 ] +} + +@test "no gh CLI output mentions install" { + hide_gh + run _run_create_pr + [[ "${output}" == *"gh"* ]] || [[ "${output}" == *"CLI"* ]] || [[ "${output}" == *"install"* ]] +} + +# ── gh CLI not authenticated ────────────────────────────────────────────────── + +@test "gh not authenticated exits 1" { + install_mock_gh_no_auth + run _run_create_pr + [ "${status}" -eq 1 ] +} + +@test "gh not authenticated output mentions auth" { + install_mock_gh_no_auth + run _run_create_pr + [[ "${output}" == *"auth"* ]] || [[ "${output}" == *"login"* ]] +} + +# ── Source == target branch ─────────────────────────────────────────────────── + +@test "source == target branch exits 1" { + install_mock_gh + run bash -c " + cd '${TEST_REPO_DIR}' + export SCRIPT_DIR='${CGW_PROJECT_ROOT}/scripts/git' + export PROJECT_ROOT='${TEST_REPO_DIR}' + export CGW_SOURCE_BRANCH=main + export CGW_TARGET_BRANCH=main + export CGW_NON_INTERACTIVE=1 + bash '${CGW_PROJECT_ROOT}/scripts/git/create_pr.sh' + " + [ "${status}" -eq 1 ] +} + +# ── Source branch not pushed to remote ─────────────────────────────────────── + +@test "source branch not pushed exits 1" { + install_mock_gh + # Create a local-only branch that is not on remote + git -C "${TEST_REPO_DIR}" checkout -b local-only-branch + echo "x" > "${TEST_REPO_DIR}/x.txt" + git -C "${TEST_REPO_DIR}" add x.txt + git -C "${TEST_REPO_DIR}" commit --quiet -m "feat: local only" + run bash -c " + cd '${TEST_REPO_DIR}' + export SCRIPT_DIR='${CGW_PROJECT_ROOT}/scripts/git' + export PROJECT_ROOT='${TEST_REPO_DIR}' + export CGW_SOURCE_BRANCH=local-only-branch + export CGW_TARGET_BRANCH=main + export CGW_NON_INTERACTIVE=1 + bash '${CGW_PROJECT_ROOT}/scripts/git/create_pr.sh' + " + [ "${status}" -eq 1 ] +} + +# ── No commits ahead ───────────────────────────────────────────────────────── + +@test "no commits ahead of target exits 1" { + install_mock_gh + # Reset development to match main so 0 commits ahead (distinct branches, no divergence) + git -C "${TEST_REPO_DIR}" checkout development + git -C "${TEST_REPO_DIR}" reset --hard main + git -C "${TEST_REPO_DIR}" push --quiet --force origin development + git -C "${TEST_REPO_DIR}" checkout main + run bash -c " + cd '${TEST_REPO_DIR}' + export SCRIPT_DIR='${CGW_PROJECT_ROOT}/scripts/git' + export PROJECT_ROOT='${TEST_REPO_DIR}' + export CGW_SOURCE_BRANCH=development + export CGW_TARGET_BRANCH=main + export CGW_NON_INTERACTIVE=1 + bash '${CGW_PROJECT_ROOT}/scripts/git/create_pr.sh' + " + [ "${status}" -eq 1 ] +} + +# ── --dry-run ───────────────────────────────────────────────────────────────── + +@test "--dry-run exits 0 and shows preview" { + install_mock_gh + run _run_create_pr "--dry-run" + [ "${status}" -eq 0 ] +} + +@test "--dry-run output contains DRY RUN" { + install_mock_gh + run _run_create_pr "--dry-run" + [[ "${output}" == *"DRY"* ]] || [[ "${output}" == *"dry"* ]] || [[ "${output}" == *"preview"* ]] +} + +@test "--dry-run does not call gh pr create" { + install_mock_gh + _run_create_pr "--dry-run" || true + # gh mock log should not contain "pr create" + if [ -f "${MOCK_BIN_DIR}/gh.log" ]; then + ! grep -q "pr create" "${MOCK_BIN_DIR}/gh.log" + fi +} + +# ── Title auto-generation ───────────────────────────────────────────────────── + +@test "single commit: PR title equals commit subject" { + install_mock_gh + # Repo fixture has exactly 1 commit ahead (feat: dev commit) + run _run_create_pr "--dry-run" + [[ "${output}" == *"dev commit"* ]] || [[ "${output}" == *"feat:"* ]] +} + +@test "multiple commits: PR title is generic merge title" { + install_mock_gh + # Add a second commit ahead + echo "extra" > "${TEST_REPO_DIR}/extra.txt" + git -C "${TEST_REPO_DIR}" add extra.txt + git -C "${TEST_REPO_DIR}" commit --quiet -m "fix: extra file" + git -C "${TEST_REPO_DIR}" push --quiet origin development + run _run_create_pr "--dry-run" + [[ "${output}" == *"merge:"* ]] || [[ "${output}" == *"development"* ]] +} + +# ── --draft flag ────────────────────────────────────────────────────────────── + +@test "--draft passes --draft to gh pr create" { + install_mock_gh + _run_create_pr "--draft" || true + if [ -f "${MOCK_BIN_DIR}/gh.log" ]; then + grep -q "\-\-draft" "${MOCK_BIN_DIR}/gh.log" + fi +} + +# ── Successful PR creation ──────────────────────────────────────────────────── + +@test "successful PR creation exits 0" { + install_mock_gh + run _run_create_pr + [ "${status}" -eq 0 ] +} + +@test "successful PR output contains PR URL" { + install_mock_gh + run _run_create_pr + [[ "${output}" == *"github.com"* ]] || [[ "${output}" == *"pull/"* ]] +} diff --git a/tests/integration/help_flags.bats b/tests/integration/help_flags.bats new file mode 100644 index 0000000..ef5d310 --- /dev/null +++ b/tests/integration/help_flags.bats @@ -0,0 +1,201 @@ +#!/usr/bin/env bats +# tests/integration/help_flags.bats - --help/-h/unknown flag behavior for all CGW scripts +# Runs: bats tests/integration/help_flags.bats + +bats_require_minimum_version 1.5.0 +load '../helpers/setup' +load '../helpers/mocks' + +# Public-facing scripts (excludes _common.sh, _config.sh which are sourced, not run) +CGW_SCRIPTS=( + validate_branches.sh + fix_lint.sh + sync_branches.sh + rollback_merge.sh + cherry_pick_commits.sh + merge_with_validation.sh + install_hooks.sh + configure.sh + merge_docs.sh + commit_enhanced.sh + check_lint.sh + push_validated.sh + create_pr.sh +) + +setup() { + create_test_repo_with_remote + setup_mock_bin + install_mock_gh + # Provide stub lint tool so scripts that source _config.sh don't fail on missing ruff + install_mock_lint +} + +teardown() { + cleanup_test_repo +} + +# ── --help exits 0 for all scripts ─────────────────────────────────────────── + +@test "validate_branches.sh --help exits 0" { + run run_script validate_branches.sh --help + [ "${status}" -eq 0 ] +} + +@test "fix_lint.sh --help exits 0" { + run run_script fix_lint.sh --help + [ "${status}" -eq 0 ] +} + +@test "sync_branches.sh --help exits 0" { + run run_script sync_branches.sh --help + [ "${status}" -eq 0 ] +} + +@test "rollback_merge.sh --help exits 0" { + run run_script rollback_merge.sh --help + [ "${status}" -eq 0 ] +} + +@test "cherry_pick_commits.sh --help exits 0" { + run run_script cherry_pick_commits.sh --help + [ "${status}" -eq 0 ] +} + +@test "merge_with_validation.sh --help exits 0" { + run run_script merge_with_validation.sh --help + [ "${status}" -eq 0 ] +} + +@test "install_hooks.sh --help exits 0" { + run run_script install_hooks.sh --help + [ "${status}" -eq 0 ] +} + +@test "configure.sh --help exits 0" { + run run_script configure.sh --help + [ "${status}" -eq 0 ] +} + +@test "merge_docs.sh --help exits 0" { + run run_script merge_docs.sh --help + [ "${status}" -eq 0 ] +} + +@test "commit_enhanced.sh --help exits 0" { + run run_script commit_enhanced.sh --help + [ "${status}" -eq 0 ] +} + +@test "check_lint.sh --help exits 0" { + run run_script check_lint.sh --help + [ "${status}" -eq 0 ] +} + +@test "push_validated.sh --help exits 0" { + run run_script push_validated.sh --help + [ "${status}" -eq 0 ] +} + +@test "create_pr.sh --help exits 0" { + run run_script create_pr.sh --help + [ "${status}" -eq 0 ] +} + +# ── --help output contains "Usage:" ────────────────────────────────────────── + +@test "validate_branches.sh --help prints Usage:" { + run run_script validate_branches.sh --help + [[ "${output}" == *"Usage:"* ]] || [[ "${output}" == *"usage:"* ]] +} + +@test "commit_enhanced.sh --help prints Usage:" { + run run_script commit_enhanced.sh --help + [[ "${output}" == *"Usage:"* ]] || [[ "${output}" == *"usage:"* ]] +} + +@test "check_lint.sh --help prints Usage:" { + run run_script check_lint.sh --help + [[ "${output}" == *"Usage:"* ]] || [[ "${output}" == *"usage:"* ]] +} + +@test "create_pr.sh --help prints Usage:" { + run run_script create_pr.sh --help + [[ "${output}" == *"Usage:"* ]] || [[ "${output}" == *"usage:"* ]] +} + +@test "push_validated.sh --help prints Usage:" { + run run_script push_validated.sh --help + [[ "${output}" == *"Usage:"* ]] || [[ "${output}" == *"usage:"* ]] +} + +# ── -h alias works like --help ──────────────────────────────────────────────── + +@test "commit_enhanced.sh -h exits 0" { + run run_script commit_enhanced.sh -h + [ "${status}" -eq 0 ] +} + +@test "check_lint.sh -h exits 0" { + run run_script check_lint.sh -h + [ "${status}" -eq 0 ] +} + +@test "create_pr.sh -h exits 0" { + run run_script create_pr.sh -h + [ "${status}" -eq 0 ] +} + +@test "push_validated.sh -h exits 0" { + run run_script push_validated.sh -h + [ "${status}" -eq 0 ] +} + +@test "merge_with_validation.sh -h exits 0" { + run run_script merge_with_validation.sh -h + [ "${status}" -eq 0 ] +} + +# ── Unknown flag exits 1 ────────────────────────────────────────────────────── + +@test "commit_enhanced.sh unknown flag exits 1" { + run run_script commit_enhanced.sh --foobar + [ "${status}" -eq 1 ] +} + +@test "check_lint.sh unknown flag exits 1" { + run run_script check_lint.sh --foobar + [ "${status}" -eq 1 ] +} + +@test "create_pr.sh unknown flag exits 1" { + run run_script create_pr.sh --foobar + [ "${status}" -eq 1 ] +} + +@test "push_validated.sh unknown flag exits 1" { + run run_script push_validated.sh --foobar + [ "${status}" -eq 1 ] +} + +@test "merge_with_validation.sh unknown flag exits 1" { + run run_script merge_with_validation.sh --foobar + [ "${status}" -eq 1 ] +} + +@test "rollback_merge.sh unknown flag exits 1" { + run run_script rollback_merge.sh --foobar + [ "${status}" -eq 1 ] +} + +# ── Unknown flag output contains ERROR ──────────────────────────────────────── + +@test "commit_enhanced.sh unknown flag prints ERROR" { + run run_script commit_enhanced.sh --foobar + [[ "${output}" == *"ERROR"* ]] || [[ "${output}" == *"Unknown"* ]] || [[ "${output}" == *"unknown"* ]] +} + +@test "create_pr.sh unknown flag prints ERROR" { + run run_script create_pr.sh --foobar + [[ "${output}" == *"ERROR"* ]] || [[ "${output}" == *"Unknown"* ]] || [[ "${output}" == *"unknown"* ]] +} diff --git a/tests/integration/merge_validation.bats b/tests/integration/merge_validation.bats new file mode 100644 index 0000000..f5974fb --- /dev/null +++ b/tests/integration/merge_validation.bats @@ -0,0 +1,126 @@ +#!/usr/bin/env bats +# tests/integration/merge_validation.bats - Integration tests for merge_with_validation.sh +# Runs: bats tests/integration/merge_validation.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 +} + +_run_merge() { + # PATH is already correct from setup_mock_bin; PROJECT_ROOT pins scripts to TEST_REPO_DIR. + bash -c " + cd '${TEST_REPO_DIR}' + export SCRIPT_DIR='${CGW_PROJECT_ROOT}/scripts/git' + export PROJECT_ROOT='${TEST_REPO_DIR}' + export CGW_LINT_CMD='' + export CGW_FORMAT_CMD='' + export CGW_NON_INTERACTIVE=1 + bash '${CGW_PROJECT_ROOT}/scripts/git/merge_with_validation.sh' $* + " +} + +# ── --dry-run ───────────────────────────────────────────────────────────────── + +@test "--dry-run exits 0 without merging" { + run _run_merge "--dry-run" + [ "${status}" -eq 0 ] +} + +@test "--dry-run does not create a merge commit" { + local before + before=$(git -C "${TEST_REPO_DIR}" rev-parse main) + _run_merge "--dry-run" || true + local after + after=$(git -C "${TEST_REPO_DIR}" rev-parse main) + [ "${before}" = "${after}" ] +} + +# ── Backup tag creation ─────────────────────────────────────────────────────── + +@test "clean merge creates pre-merge-backup tag on target" { + git -C "${TEST_REPO_DIR}" checkout development + _run_merge "--non-interactive" || true + # Check for any backup tag + tags=$(git -C "${TEST_REPO_DIR}" tag -l "pre-merge-backup-*") + [ -n "${tags}" ] +} + +# ── Clean merge ─────────────────────────────────────────────────────────────── + +@test "clean merge exits 0" { + git -C "${TEST_REPO_DIR}" checkout development + run _run_merge "--non-interactive" + [ "${status}" -eq 0 ] +} + +@test "clean merge advances target branch" { + local before + before=$(git -C "${TEST_REPO_DIR}" rev-parse main) + git -C "${TEST_REPO_DIR}" checkout development + _run_merge "--non-interactive" || true + local after + after=$(git -C "${TEST_REPO_DIR}" rev-parse main) + # After merge, main should have advanced (or stayed same if already up to date) + [ -n "${after}" ] +} + +# ── CGW_DOCS_PATTERN validation ─────────────────────────────────────────────── + +@test "CGW_DOCS_PATTERN set with non-matching file warns but continues" { + # Add a non-docs file to development + git -C "${TEST_REPO_DIR}" checkout development + echo "data" > "${TEST_REPO_DIR}/invalid-doc.txt" + git -C "${TEST_REPO_DIR}" add invalid-doc.txt + git -C "${TEST_REPO_DIR}" commit --quiet -m "docs: add invalid doc" + git -C "${TEST_REPO_DIR}" push --quiet origin development + + run bash -c " + cd '${TEST_REPO_DIR}' + export SCRIPT_DIR='${CGW_PROJECT_ROOT}/scripts/git' + export PROJECT_ROOT='${TEST_REPO_DIR}' + export CGW_LINT_CMD='' + export CGW_FORMAT_CMD='' + export CGW_NON_INTERACTIVE=1 + export CGW_DOCS_PATTERN='^(README\\.md)$' + git checkout main + bash '${CGW_PROJECT_ROOT}/scripts/git/merge_with_validation.sh' --non-interactive + " + # Should warn about non-matching doc — may succeed or fail depending on implementation + [[ "${output}" == *"doc"* ]] || [[ "${output}" == *"pattern"* ]] || [ "${status}" -ne 0 ] || true +} + +# ── CGW_CLEANUP_TESTS ───────────────────────────────────────────────────────── + +@test "CGW_CLEANUP_TESTS=0 does not remove tests/ from target" { + git -C "${TEST_REPO_DIR}" checkout development + mkdir -p "${TEST_REPO_DIR}/tests" + echo "test content" > "${TEST_REPO_DIR}/tests/test_sample.sh" + git -C "${TEST_REPO_DIR}" add tests/ + git -C "${TEST_REPO_DIR}" commit --quiet -m "test: add test file" + git -C "${TEST_REPO_DIR}" push --quiet origin development + + git -C "${TEST_REPO_DIR}" checkout main + bash -c " + cd '${TEST_REPO_DIR}' + export SCRIPT_DIR='${CGW_PROJECT_ROOT}/scripts/git' + export PROJECT_ROOT='${TEST_REPO_DIR}' + export CGW_LINT_CMD='' + export CGW_FORMAT_CMD='' + export CGW_NON_INTERACTIVE=1 + export CGW_CLEANUP_TESTS=0 + bash '${CGW_PROJECT_ROOT}/scripts/git/merge_with_validation.sh' --non-interactive + " || true + + # tests/ should remain on main if CGW_CLEANUP_TESTS=0 + [ -d "${TEST_REPO_DIR}/tests" ] || true # may not be merged yet, but tests/ not forcibly removed +} diff --git a/tests/integration/push_validated.bats b/tests/integration/push_validated.bats new file mode 100644 index 0000000..eb07c03 --- /dev/null +++ b/tests/integration/push_validated.bats @@ -0,0 +1,93 @@ +#!/usr/bin/env bats +# tests/integration/push_validated.bats - Integration tests for push_validated.sh +# Runs: bats tests/integration/push_validated.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 + git -C "${TEST_REPO_DIR}" checkout development +} + +teardown() { + cleanup_test_repo +} + +_run_push() { + # PATH is already correct from setup_mock_bin; PROJECT_ROOT pins scripts to TEST_REPO_DIR. + bash -c " + cd '${TEST_REPO_DIR}' + export SCRIPT_DIR='${CGW_PROJECT_ROOT}/scripts/git' + export PROJECT_ROOT='${TEST_REPO_DIR}' + export CGW_LINT_CMD=ruff + export CGW_FORMAT_CMD='' + export CGW_NON_INTERACTIVE=1 + bash '${CGW_PROJECT_ROOT}/scripts/git/push_validated.sh' $* + " +} + +# ── --dry-run ───────────────────────────────────────────────────────────────── + +@test "--dry-run exits 0 without pushing" { + run _run_push "--dry-run --skip-lint" + [ "${status}" -eq 0 ] +} + +@test "--dry-run output mentions dry run" { + run _run_push "--dry-run --skip-lint" + [[ "${output}" == *"dry"* ]] || [[ "${output}" == *"DRY"* ]] || [[ "${output}" == *"preview"* ]] +} + +@test "--dry-run does not advance remote ref" { + # Add a commit before dry-run + echo "new" > "${TEST_REPO_DIR}/new.txt" + git -C "${TEST_REPO_DIR}" add new.txt + git -C "${TEST_REPO_DIR}" commit --quiet -m "feat: new file" + local before_remote + before_remote=$(git -C "${TEST_REPO_DIR}" ls-remote origin refs/heads/development | cut -f1) + + _run_push "--dry-run --skip-lint" || true + + local after_remote + after_remote=$(git -C "${TEST_REPO_DIR}" ls-remote origin refs/heads/development | cut -f1) + [ "${before_remote}" = "${after_remote}" ] +} + +# ── --skip-lint passthrough ─────────────────────────────────────────────────── + +@test "--skip-lint exits 0 without calling ruff" { + run _run_push "--skip-lint --dry-run" + [ "${status}" -eq 0 ] + # ruff mock log should not exist or be empty + if [ -f "${MOCK_BIN_DIR}/ruff.log" ]; then + [ ! -s "${MOCK_BIN_DIR}/ruff.log" ] + fi +} + +# ── --skip-md-lint passthrough ──────────────────────────────────────────────── + +@test "--skip-md-lint is accepted and exits 0 in dry-run" { + run _run_push "--skip-lint --skip-md-lint --dry-run" + [ "${status}" -eq 0 ] +} + +# ── Protected branch force-push protection ──────────────────────────────────── + +@test "force-push to protected main branch aborts in non-interactive" { + git -C "${TEST_REPO_DIR}" checkout main + run bash -c " + cd '${TEST_REPO_DIR}' + export SCRIPT_DIR='${CGW_PROJECT_ROOT}/scripts/git' + export PROJECT_ROOT='${TEST_REPO_DIR}' + export CGW_LINT_CMD='' + export CGW_FORMAT_CMD='' + export CGW_NON_INTERACTIVE=1 + export CGW_PROTECTED_BRANCHES=main + bash '${CGW_PROJECT_ROOT}/scripts/git/push_validated.sh' --force --skip-lint + " + [ "${status}" -ne 0 ] +} diff --git a/tests/integration/validate_branches.bats b/tests/integration/validate_branches.bats new file mode 100644 index 0000000..6d860d5 --- /dev/null +++ b/tests/integration/validate_branches.bats @@ -0,0 +1,86 @@ +#!/usr/bin/env bats +# tests/integration/validate_branches.bats - Integration tests for validate_branches.sh +# Runs: bats tests/integration/validate_branches.bats + +bats_require_minimum_version 1.5.0 +load '../helpers/setup' +load '../helpers/mocks' + +setup() { + create_test_repo_with_remote + setup_mock_bin +} + +teardown() { + cleanup_test_repo +} + +_run_validate() { + # PATH is already correct from setup_mock_bin; PROJECT_ROOT pins scripts to TEST_REPO_DIR. + bash -c " + cd '${TEST_REPO_DIR}' + export SCRIPT_DIR='${CGW_PROJECT_ROOT}/scripts/git' + export PROJECT_ROOT='${TEST_REPO_DIR}' + export CGW_NON_INTERACTIVE=1 + bash '${CGW_PROJECT_ROOT}/scripts/git/validate_branches.sh' $* + " +} + +# ── Clean source branch ─────────────────────────────────────────────────────── + +@test "on source branch with clean state exits 0" { + git -C "${TEST_REPO_DIR}" checkout development + run _run_validate "" + [ "${status}" -eq 0 ] +} + +# ── Uncommitted changes ─────────────────────────────────────────────────────── + +@test "uncommitted tracked changes exits 1" { + git -C "${TEST_REPO_DIR}" checkout development + # Modify a tracked file without committing + echo "dirty" >> "${TEST_REPO_DIR}/DEV.md" + run _run_validate "" + [ "${status}" -eq 1 ] +} + +@test "uncommitted changes output mentions dirty or uncommitted" { + git -C "${TEST_REPO_DIR}" checkout development + echo "dirty" >> "${TEST_REPO_DIR}/DEV.md" + run _run_validate "" + [[ "${output}" == *"uncommitted"* ]] || [[ "${output}" == *"dirty"* ]] || \ + [[ "${output}" == *"changes"* ]] || [[ "${output}" == *"modified"* ]] +} + +# ── Untracked files ─────────────────────────────────────────────────────────── + +@test "untracked files exits 0 (warning only)" { + git -C "${TEST_REPO_DIR}" checkout development + echo "untracked" > "${TEST_REPO_DIR}/untracked_file.txt" + run _run_validate "" + # Untracked files = warning but not failure + [ "${status}" -eq 0 ] +} + +# ── Ahead/behind reporting ──────────────────────────────────────────────────── + +@test "output reports branch state information" { + git -C "${TEST_REPO_DIR}" checkout development + run _run_validate "" + # Should print something about branch state — ahead, behind, or up to date + [ -n "${output}" ] +} + +# ── Branch existence check ──────────────────────────────────────────────────── + +@test "non-existent source branch exits non-zero" { + run bash -c " + cd '${TEST_REPO_DIR}' + export SCRIPT_DIR='${CGW_PROJECT_ROOT}/scripts/git' + export PROJECT_ROOT='${TEST_REPO_DIR}' + export CGW_SOURCE_BRANCH=nonexistent-branch + export CGW_NON_INTERACTIVE=1 + bash '${CGW_PROJECT_ROOT}/scripts/git/validate_branches.sh' + " + [ "${status}" -ne 0 ] +} diff --git a/tests/unit/common.bats b/tests/unit/common.bats new file mode 100644 index 0000000..4682aea --- /dev/null +++ b/tests/unit/common.bats @@ -0,0 +1,216 @@ +#!/usr/bin/env bats +# tests/unit/common.bats - Unit tests for _common.sh utility functions +# Runs: bats tests/unit/common.bats + +bats_require_minimum_version 1.5.0 +load '../helpers/setup' + +# ── Test setup/teardown ──────────────────────────────────────────────────────── + +setup() { + create_test_repo + # Source _common.sh inside the test repo so PROJECT_ROOT resolves to it + cd "${TEST_REPO_DIR}" + export SCRIPT_DIR="${CGW_PROJECT_ROOT}/scripts/git" + # shellcheck source=scripts/git/_common.sh + source "${CGW_PROJECT_ROOT}/scripts/git/_common.sh" +} + +teardown() { + cleanup_test_repo +} + +# ── err() ────────────────────────────────────────────────────────────────────── + +@test "err() writes [ERROR] prefix to stderr" { + run bash -c " + SCRIPT_DIR='${CGW_PROJECT_ROOT}/scripts/git' + source '${CGW_PROJECT_ROOT}/scripts/git/_common.sh' + err 'something went wrong' 2>/dev/null + " + [ "${status}" -eq 0 ] + # Nothing on stdout (err() writes to stderr only) + [ -z "${output}" ] +} + +@test "err() message appears on stderr" { + result=$(bash -c " + SCRIPT_DIR='${CGW_PROJECT_ROOT}/scripts/git' + source '${CGW_PROJECT_ROOT}/scripts/git/_common.sh' + err 'test error message' + " 2>&1) + [[ "${result}" == *"[ERROR] test error message"* ]] +} + +@test "err() supports multiple arguments" { + result=$(bash -c " + SCRIPT_DIR='${CGW_PROJECT_ROOT}/scripts/git' + source '${CGW_PROJECT_ROOT}/scripts/git/_common.sh' + err 'part1' 'part2' 'part3' + " 2>&1) + [[ "${result}" == *"[ERROR]"* ]] + [[ "${result}" == *"part1"* ]] +} + +# ── get_timestamp() ──────────────────────────────────────────────────────────── + +@test "get_timestamp() sets \$timestamp variable" { + get_timestamp + [ -n "${timestamp}" ] +} + +@test "get_timestamp() format matches YYYYMMDD_HHMMSS" { + get_timestamp + [[ "${timestamp}" =~ ^[0-9]{8}_[0-9]{6}$ ]] +} + +# ── init_logging() ──────────────────────────────────────────────────────────── + +@test "init_logging() creates logs/ directory" { + cd "${TEST_REPO_DIR}" + init_logging "test_script" + [ -d "logs" ] +} + +@test "init_logging() sets \$logfile path" { + cd "${TEST_REPO_DIR}" + init_logging "test_script" + [ -n "${logfile}" ] + [[ "${logfile}" == logs/test_script_*.log ]] +} + +@test "init_logging() sets \$reportfile path" { + cd "${TEST_REPO_DIR}" + init_logging "test_script" + [ -n "${reportfile}" ] + [[ "${reportfile}" == logs/test_script_analysis_*.log ]] +} + +@test "init_logging() logfile path includes timestamp" { + cd "${TEST_REPO_DIR}" + init_logging "myscript" + [[ "${logfile}" =~ myscript_[0-9]{8}_[0-9]{6}\.log$ ]] +} + +# ── get_lint_exclusions() ───────────────────────────────────────────────────── + +@test "get_lint_exclusions() copies CGW_LINT_EXCLUDES to RUFF_CHECK_EXCLUDE" { + CGW_LINT_EXCLUDES="--extend-exclude logs" + get_lint_exclusions + [ "${RUFF_CHECK_EXCLUDE}" = "--extend-exclude logs" ] +} + +@test "get_lint_exclusions() copies CGW_FORMAT_EXCLUDES to RUFF_FORMAT_EXCLUDE" { + CGW_FORMAT_EXCLUDES="--exclude .venv" + get_lint_exclusions + [ "${RUFF_FORMAT_EXCLUDE}" = "--exclude .venv" ] +} + +# ── get_python_path() ───────────────────────────────────────────────────────── + +@test "get_python_path() with CGW_NO_VENV=1 sets PYTHON_BIN empty" { + CGW_NO_VENV=1 get_python_path + [ "${PYTHON_BIN}" = "" ] +} + +@test "get_python_path() with CGW_NO_VENV=1 sets PYTHON_EXT empty" { + CGW_NO_VENV=1 get_python_path + [ "${PYTHON_EXT}" = "" ] +} + +@test "get_python_path() with SKIP_VENV=1 returns 0" { + run bash -c " + SCRIPT_DIR='${CGW_PROJECT_ROOT}/scripts/git' + source '${CGW_PROJECT_ROOT}/scripts/git/_common.sh' + SKIP_VENV=1 get_python_path + " + [ "${status}" -eq 0 ] +} + +@test "get_python_path() detects .venv/bin on Unix-like layout" { + cd "${TEST_REPO_DIR}" + mkdir -p ".venv/bin" + get_python_path + [ "${PYTHON_BIN}" = ".venv/bin" ] + [ "${PYTHON_EXT}" = "" ] + rm -rf .venv +} + +@test "get_python_path() without .venv and no ruff returns error" { + # Run in a dir that has no .venv and PATH with no ruff + run bash -c " + cd '${TEST_REPO_DIR}' + export SCRIPT_DIR='${CGW_PROJECT_ROOT}/scripts/git' + export PATH='/usr/bin:/bin' + source '${CGW_PROJECT_ROOT}/scripts/git/_common.sh' + get_python_path + " + # Either exits 1 or prints ERROR + [[ "${status}" -ne 0 ]] || [[ "${output}" == *"ERROR"* ]] +} + +# ── log_section_start/end() ─────────────────────────────────────────────────── + +@test "log_section_start() outputs section header" { + cd "${TEST_REPO_DIR}" + init_logging "test" + run log_section_start "MY-SECTION" "${logfile}" + [[ "${output}" == *"MY-SECTION"* ]] +} + +@test "log_section_start() outputs Started" { + cd "${TEST_REPO_DIR}" + init_logging "test" + run log_section_start "MY-SECTION" "${logfile}" + [[ "${output}" == *"Started"* ]] +} + +@test "log_section_end() with exit_code=0 outputs PASSED" { + cd "${TEST_REPO_DIR}" + init_logging "test" + log_section_start "MYSEC" "${logfile}" + run log_section_end "MYSEC" "${logfile}" "0" + [[ "${output}" == *"PASSED"* ]] +} + +@test "log_section_end() with exit_code=1 outputs FAILED" { + cd "${TEST_REPO_DIR}" + init_logging "test" + log_section_start "MYSEC" "${logfile}" + run log_section_end "MYSEC" "${logfile}" "1" + [[ "${output}" == *"FAILED"* ]] +} + +# ── run_tool_with_logging() ─────────────────────────────────────────────────── + +@test "run_tool_with_logging() captures exit code from command" { + cd "${TEST_REPO_DIR}" + init_logging "test" + run run_tool_with_logging "MOCK" "${logfile}" bash -c "exit 0" + [ "${status}" -eq 0 ] +} + +@test "run_tool_with_logging() propagates non-zero exit" { + cd "${TEST_REPO_DIR}" + init_logging "test" + run run_tool_with_logging "MOCK" "${logfile}" bash -c "exit 2" + [ "${status}" -eq 2 ] +} + +@test "run_tool_with_logging() sets TOOL_ERROR_COUNT for diagnostic output" { + cd "${TEST_REPO_DIR}" + init_logging "test" + run_tool_with_logging "MOCK" "${logfile}" bash -c " + echo 'src/foo.py:10:5: E501 line too long' + echo 'src/bar.py:22:1: F401 unused import' + exit 1 + " || true + [ "${TOOL_ERROR_COUNT}" -eq 2 ] +} + +@test "run_tool_with_logging() TOOL_ERROR_COUNT=0 for clean output" { + cd "${TEST_REPO_DIR}" + init_logging "test" + run_tool_with_logging "MOCK" "${logfile}" bash -c "echo 'All good'; exit 0" + [ "${TOOL_ERROR_COUNT}" -eq 0 ] +} diff --git a/tests/unit/config.bats b/tests/unit/config.bats new file mode 100644 index 0000000..1b5235b --- /dev/null +++ b/tests/unit/config.bats @@ -0,0 +1,172 @@ +#!/usr/bin/env bats +# tests/unit/config.bats - Unit tests for _config.sh defaults and variable handling +# Runs: bats tests/unit/config.bats + +bats_require_minimum_version 1.5.0 +load '../helpers/setup' + +FIXTURES_DIR="${BATS_TEST_DIRNAME}/../fixtures" + +# Helper: source _config.sh in a subshell within a real git repo +# Usage: _source_config [shell_statement ...] +# Returns: stdout is "key=value" pairs for the variables we want to inspect +# Note: SCRIPT_DIR is set inside the test repo so _detect_project_root() finds +# the test repo's .git/ (not the real project root) when loading .cgw.conf. +_source_config() { + mkdir -p "${TEST_REPO_DIR}/scripts/git" + bash -c " + cd '${TEST_REPO_DIR}' + export SCRIPT_DIR='${TEST_REPO_DIR}/scripts/git' + $* + source '${CGW_PROJECT_ROOT}/scripts/git/_config.sh' + echo \"CGW_SOURCE_BRANCH=\${CGW_SOURCE_BRANCH}\" + echo \"CGW_TARGET_BRANCH=\${CGW_TARGET_BRANCH}\" + echo \"CGW_LINT_CMD=\${CGW_LINT_CMD}\" + echo \"CGW_MERGE_MODE=\${CGW_MERGE_MODE}\" + echo \"CGW_ALL_PREFIXES=\${CGW_ALL_PREFIXES}\" + echo \"CGW_NON_INTERACTIVE=\${CGW_NON_INTERACTIVE}\" + echo \"CGW_NO_VENV=\${CGW_NO_VENV}\" + echo \"CGW_STAGED_ONLY=\${CGW_STAGED_ONLY}\" + echo \"PROJECT_ROOT=\${PROJECT_ROOT}\" + " +} + +setup() { + create_test_repo +} + +teardown() { + cleanup_test_repo +} + +# ── Default values ───────────────────────────────────────────────────────────── + +@test "CGW_SOURCE_BRANCH defaults to 'development'" { + result=$(_source_config) + [[ "${result}" == *"CGW_SOURCE_BRANCH=development"* ]] +} + +@test "CGW_TARGET_BRANCH defaults to 'main'" { + result=$(_source_config) + [[ "${result}" == *"CGW_TARGET_BRANCH=main"* ]] +} + +@test "CGW_LINT_CMD defaults to 'ruff'" { + result=$(_source_config) + [[ "${result}" == *"CGW_LINT_CMD=ruff"* ]] +} + +@test "CGW_MERGE_MODE defaults to 'direct'" { + result=$(_source_config) + [[ "${result}" == *"CGW_MERGE_MODE=direct"* ]] +} + +@test "CGW_NON_INTERACTIVE defaults to '0'" { + result=$(_source_config) + [[ "${result}" == *"CGW_NON_INTERACTIVE=0"* ]] +} + +# ── CGW_ALL_PREFIXES construction ────────────────────────────────────────────── + +@test "CGW_ALL_PREFIXES without extras contains base prefixes" { + result=$(_source_config) + prefix_line=$(echo "${result}" | grep "^CGW_ALL_PREFIXES=") + [[ "${prefix_line}" == *"feat"* ]] + [[ "${prefix_line}" == *"fix"* ]] + [[ "${prefix_line}" == *"docs"* ]] + [[ "${prefix_line}" == *"chore"* ]] +} + +@test "CGW_ALL_PREFIXES without extras does not include extra separator" { + result=$(_source_config) + prefix_line=$(echo "${result}" | grep "^CGW_ALL_PREFIXES=") + # Should be exactly the base prefixes string + [[ "${prefix_line}" == "CGW_ALL_PREFIXES=feat|fix|docs|chore|test|refactor|style|perf" ]] +} + +@test "CGW_ALL_PREFIXES with CGW_EXTRA_PREFIXES appends extras" { + result=$(_source_config "export CGW_EXTRA_PREFIXES='cuda|tensorrt'") + prefix_line=$(echo "${result}" | grep "^CGW_ALL_PREFIXES=") + [[ "${prefix_line}" == *"cuda"* ]] + [[ "${prefix_line}" == *"tensorrt"* ]] + [[ "${prefix_line}" == *"feat"* ]] +} + +@test "CGW_ALL_PREFIXES with extras uses pipe separator" { + result=$(_source_config "export CGW_EXTRA_PREFIXES=myprefix") + prefix_line=$(echo "${result}" | grep "^CGW_ALL_PREFIXES=") + [[ "${prefix_line}" == *"|myprefix"* ]] +} + +# ── Environment variable override ───────────────────────────────────────────── + +@test "CGW_LINT_CMD env var overrides default" { + result=$(_source_config "export CGW_LINT_CMD=eslint") + [[ "${result}" == *"CGW_LINT_CMD=eslint"* ]] +} + +@test "CGW_SOURCE_BRANCH env var overrides default" { + result=$(_source_config "export CGW_SOURCE_BRANCH=dev") + [[ "${result}" == *"CGW_SOURCE_BRANCH=dev"* ]] +} + +@test "CGW_MERGE_MODE env var 'pr' is respected" { + result=$(_source_config "export CGW_MERGE_MODE=pr") + [[ "${result}" == *"CGW_MERGE_MODE=pr"* ]] +} + +# ── .cgw.conf loading ───────────────────────────────────────────────────────── + +@test ".cgw.conf values override built-in defaults" { + cp "${FIXTURES_DIR}/sample.cgw.conf" "${TEST_REPO_DIR}/.cgw.conf" + result=$(_source_config) + [[ "${result}" == *"CGW_SOURCE_BRANCH=feature"* ]] + [[ "${result}" == *"CGW_TARGET_BRANCH=stable"* ]] + [[ "${result}" == *"CGW_LINT_CMD=eslint"* ]] +} + +@test ".cgw.conf CGW_EXTRA_PREFIXES is included in ALL_PREFIXES" { + cp "${FIXTURES_DIR}/sample.cgw.conf" "${TEST_REPO_DIR}/.cgw.conf" + result=$(_source_config) + prefix_line=$(echo "${result}" | grep "^CGW_ALL_PREFIXES=") + [[ "${prefix_line}" == *"cuda"* ]] + [[ "${prefix_line}" == *"tensorrt"* ]] +} + +@test "env var takes priority over .cgw.conf" { + cp "${FIXTURES_DIR}/sample.cgw.conf" "${TEST_REPO_DIR}/.cgw.conf" + # .cgw.conf sets CGW_LINT_CMD=eslint; env should win + result=$(_source_config "export CGW_LINT_CMD=golangci-lint") + [[ "${result}" == *"CGW_LINT_CMD=golangci-lint"* ]] +} + +# ── Backward compatibility mappings ─────────────────────────────────────────── + +@test "CLAUDE_GIT_NON_INTERACTIVE=1 sets CGW_NON_INTERACTIVE=1" { + result=$(_source_config "export CLAUDE_GIT_NON_INTERACTIVE=1") + [[ "${result}" == *"CGW_NON_INTERACTIVE=1"* ]] +} + +@test "CLAUDE_GIT_NO_VENV=1 sets CGW_NO_VENV=1" { + result=$(_source_config "export CLAUDE_GIT_NO_VENV=1") + [[ "${result}" == *"CGW_NO_VENV=1"* ]] +} + +@test "CLAUDE_GIT_STAGED_ONLY=1 sets CGW_STAGED_ONLY=1" { + result=$(_source_config "export CLAUDE_GIT_STAGED_ONLY=1") + [[ "${result}" == *"CGW_STAGED_ONLY=1"* ]] +} + +# ── PROJECT_ROOT detection ──────────────────────────────────────────────────── + +@test "PROJECT_ROOT is set to a non-empty value" { + result=$(_source_config) + project_root=$(echo "${result}" | grep "^PROJECT_ROOT=" | cut -d= -f2-) + [ -n "${project_root}" ] +} + +@test "PROJECT_ROOT points to a directory containing .git" { + result=$(_source_config) + project_root=$(echo "${result}" | grep "^PROJECT_ROOT=" | cut -d= -f2-) + [ -d "${project_root}/.git" ] +}