diff --git a/.gitattributes b/.gitattributes index e6c9874..bab1a86 100644 --- a/.gitattributes +++ b/.gitattributes @@ -9,6 +9,7 @@ hooks/pre-commit text eol=lf # Text files — normalize to LF +.shellcheckrc text eol=lf *.md text eol=lf *.conf text eol=lf *.example text eol=lf diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 103f0e7..af5115f 100644 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -12,7 +12,7 @@ set -uo pipefail echo "Checking for local-only files..." -PROBLEMATIC_FILES=$(git diff --cached --name-status | grep -E "^[AM][[:space:]]+(CLAUDE\.md|SESSION_LOG\.md|\.claude|logs)" || true) +PROBLEMATIC_FILES=$(git diff --cached --name-status | grep -E "^[AM][[:space:]]+(CLAUDE\.md|MEMORY\.md|\.claude|logs)" || true) if [[ -n "${PROBLEMATIC_FILES}" ]]; then echo "ERROR: Attempting to add or modify local-only files!" @@ -28,7 +28,7 @@ if [[ -n "${PROBLEMATIC_FILES}" ]]; then exit 1 fi -DELETED_FILES=$(git diff --cached --name-status | grep -E "^D[[:space:]]+(CLAUDE\.md|SESSION_LOG\.md|\.claude|logs)" || true) +DELETED_FILES=$(git diff --cached --name-status | grep -E "^D[[:space:]]+(CLAUDE\.md|MEMORY\.md|\.claude|logs)" || true) if [[ -n "${DELETED_FILES}" ]]; then echo " Local-only files being removed from git tracking (allowed)" fi diff --git a/.githooks/pre-push b/.githooks/pre-push index e9ab420..af5084b 100644 --- a/.githooks/pre-push +++ b/.githooks/pre-push @@ -13,8 +13,8 @@ # The patterns below are populated by configure.sh from CGW_LOCAL_FILES # and CGW_ALL_PREFIXES. To regenerate, run: ./scripts/git/configure.sh --skip-skill # -# CONVENTIONAL PREFIXES: feat\|fix\|docs\|chore\|test\|refactor\|style\|perf -# LOCAL FILES PATTERN: CLAUDE\.md|SESSION_LOG\.md|\.claude|logs +# CONVENTIONAL PREFIXES: feat|fix|docs|chore|test|refactor|style|perf +# LOCAL FILES PATTERN: CLAUDE\.md|MEMORY\.md|\.claude|logs set -uo pipefail @@ -22,8 +22,8 @@ set -uo pipefail # Read stdin: remote , url , # --------------------------------------------------------------------------- -LOCAL_FILES_PATTERN="CLAUDE\.md|SESSION_LOG\.md|\.claude|logs" -ALL_PREFIXES="feat\|fix\|docs\|chore\|test\|refactor\|style\|perf" +LOCAL_FILES_PATTERN="CLAUDE\.md|MEMORY\.md|\.claude|logs" +ALL_PREFIXES="feat|fix|docs|chore|test|refactor|style|perf" # Collect push info from stdin (git passes it to pre-push) while read -r LOCAL_REF LOCAL_SHA REMOTE_REF REMOTE_SHA; do diff --git a/install.cmd b/install.cmd index 7d0c1c2..207c852 100644 --- a/install.cmd +++ b/install.cmd @@ -8,16 +8,23 @@ setlocal EnableDelayedExpansion :: :: Usage: Double-click or run from cmd/terminal :: Requires: Git for Windows (provides bash) +:: +:: Note on special characters in paths: +:: Paths containing ! are corrupted by EnableDelayedExpansion (cmd.exe +:: limitation). Paths with & | < > work correctly because all echo +:: lines use the echo( trick which prevents cmd.exe from parsing +:: meta-characters in the output. :: ============================================================ set "CGW_DIR=%~dp0" if "%CGW_DIR:~-1%"=="\" set "CGW_DIR=%CGW_DIR:~0,-1%" +set "EXIT_CODE=0" echo. echo =================================================== echo CGW (claude-git-workflow) Installer echo =================================================== -echo Source: %CGW_DIR% +echo( Source: !CGW_DIR! echo. rem --- Get target path --- @@ -37,7 +44,7 @@ if "%TARGET_DIR%"=="" ( ) echo. -echo Target: %TARGET_DIR% +echo( Target: !TARGET_DIR! echo. rem --- Pre-install checks --- @@ -48,9 +55,21 @@ set "CHECKS_PASSED=1" rem All checks use goto to avoid CMD if/else fall-through with special chars in echo +rem PI-00: Target must not be the CGW source directory (prevent self-install) +if /i "!TARGET_DIR!"=="!CGW_DIR!" goto :pi00_fail +rem Also compare with trailing backslash stripped CGW_DIR +goto :pi00_pass +:pi00_fail +echo [FAIL] PI-00 Target is the CGW source directory -- cannot install into itself +set "CHECKS_PASSED=0" +goto :pi00_done +:pi00_pass +echo [PASS] PI-00 Target is not the CGW source directory +:pi00_done + rem PI-01: Target path exists -if exist "%TARGET_DIR%\" goto :pi01_pass -echo [FAIL] PI-01 Target path does not exist: %TARGET_DIR% +if exist "!TARGET_DIR!\" goto :pi01_pass +echo( [FAIL] PI-01 Target path does not exist: !TARGET_DIR! set "CHECKS_PASSED=0" goto :pi01_done :pi01_pass @@ -58,8 +77,8 @@ echo [PASS] PI-01 Target path exists :pi01_done rem PI-02: Target is a git repo -if exist "%TARGET_DIR%\.git\" goto :pi02_pass -if exist "%TARGET_DIR%\.git" goto :pi02_pass +if exist "!TARGET_DIR!\.git\" goto :pi02_pass +if exist "!TARGET_DIR!\.git" goto :pi02_pass echo [FAIL] PI-02 No .git directory found -- not a git repository set "CHECKS_PASSED=0" goto :pi02_done @@ -69,7 +88,16 @@ echo [PASS] PI-02 Target is a git repository rem PI-03: bash available where bash >nul 2>&1 -if not %ERRORLEVEL%==0 goto :pi03_fail +if not !ERRORLEVEL!==0 ( + rem Try common Git for Windows install locations + if exist "C:\Program Files\Git\bin\bash.exe" ( + set "PATH=C:\Program Files\Git\bin;!PATH!" + ) else if exist "C:\Program Files (x86)\Git\bin\bash.exe" ( + set "PATH=C:\Program Files (x86)\Git\bin;!PATH!" + ) +) +where bash >nul 2>&1 +if not !ERRORLEVEL!==0 goto :pi03_fail echo [PASS] PI-03 bash available goto :pi03_done :pi03_fail @@ -80,12 +108,12 @@ set "CHECKS_PASSED=0" rem PI-04: CGW source files complete set "SOURCE_OK=1" -if not exist "%CGW_DIR%\scripts\git\configure.sh" set "SOURCE_OK=0" -if not exist "%CGW_DIR%\hooks\pre-commit" set "SOURCE_OK=0" -if not exist "%CGW_DIR%\hooks\pre-push" set "SOURCE_OK=0" -if not exist "%CGW_DIR%\skill\SKILL.md" set "SOURCE_OK=0" -if not exist "%CGW_DIR%\command\auto-git-workflow.md" set "SOURCE_OK=0" -if not "%SOURCE_OK%"=="1" goto :pi04_fail +if not exist "!CGW_DIR!\scripts\git\configure.sh" set "SOURCE_OK=0" +if not exist "!CGW_DIR!\hooks\pre-commit" set "SOURCE_OK=0" +if not exist "!CGW_DIR!\hooks\pre-push" set "SOURCE_OK=0" +if not exist "!CGW_DIR!\skill\SKILL.md" set "SOURCE_OK=0" +if not exist "!CGW_DIR!\command\auto-git-workflow.md" set "SOURCE_OK=0" +if not "!SOURCE_OK!"=="1" goto :pi04_fail echo [PASS] PI-04 CGW source files complete goto :pi04_done :pi04_fail @@ -96,18 +124,18 @@ set "CHECKS_PASSED=0" :pi04_done rem PI-05: Existing CGW install detection -if not exist "%TARGET_DIR%\scripts\git\configure.sh" goto :pi05_clean +if not exist "!TARGET_DIR!\scripts\git\configure.sh" goto :pi05_clean echo [WARN] PI-05 CGW scripts already present in target set /p "OVERWRITE= Overwrite existing installation? [y/N]: " if /i "!OVERWRITE!"=="y" goto :pi05_done echo Aborting. Run with a clean target or choose overwrite. -goto :end +goto :abort :pi05_clean echo [PASS] PI-05 No existing CGW installation :pi05_done rem PI-06: Existing .githooks/pre-commit -if not exist "%TARGET_DIR%\.githooks\pre-commit" goto :pi06_clean +if not exist "!TARGET_DIR!\.githooks\pre-commit" goto :pi06_clean echo [WARN] PI-06 Existing .githooks\pre-commit found echo It will be backed up to .githooks\pre-commit.bak goto :pi06_done @@ -118,18 +146,18 @@ echo [INFO] PI-06 No existing .githooks\pre-commit echo. :: Abort if hard checks failed -if not "%CHECKS_PASSED%"=="1" goto :checks_failed +if not "!CHECKS_PASSED!"=="1" goto :checks_failed goto :checks_ok :checks_failed echo One or more required checks failed. Installation aborted. echo. -goto :end +goto :abort :checks_ok rem --- Confirm --- echo --- Installation Summary --- echo. -echo Will copy into: %TARGET_DIR% +echo( Will copy into: !TARGET_DIR! echo scripts\git\ (25 shell scripts) echo hooks\ (pre-commit + pre-push templates) echo skill\ (Claude Code skill source) @@ -140,24 +168,32 @@ echo Then run: configure.sh (interactive) echo Finally: offer to remove temp files (hooks\, skill\, command\) echo. set /p "CONFIRM=Proceed with installation? [Y/n]: " -if /i "%CONFIRM%"=="n" goto :cancel -if /i "%CONFIRM%"=="no" goto :cancel +if /i "!CONFIRM!"=="n" goto :cancel +if /i "!CONFIRM!"=="no" goto :cancel goto :install_start :cancel echo Installation cancelled. -goto :end +goto :abort :install_start echo. rem --- Backup existing .githooks/ hook templates --- -if not exist "%TARGET_DIR%\.githooks\pre-commit" goto :backup_pc_done -copy /y "%TARGET_DIR%\.githooks\pre-commit" "%TARGET_DIR%\.githooks\pre-commit.bak" >nul -echo Backed up .githooks\pre-commit -^> .githooks\pre-commit.bak +if not exist "!TARGET_DIR!\.githooks\pre-commit" goto :backup_pc_done +copy /y "!TARGET_DIR!\.githooks\pre-commit" "!TARGET_DIR!\.githooks\pre-commit.bak" >nul +if errorlevel 1 ( + echo [WARN] Could not back up .githooks\pre-commit -- continuing without backup +) else ( + echo Backed up .githooks\pre-commit -^> .githooks\pre-commit.bak +) :backup_pc_done -if not exist "%TARGET_DIR%\.githooks\pre-push" goto :backup_done -copy /y "%TARGET_DIR%\.githooks\pre-push" "%TARGET_DIR%\.githooks\pre-push.bak" >nul -echo Backed up .githooks\pre-push -^> .githooks\pre-push.bak +if not exist "!TARGET_DIR!\.githooks\pre-push" goto :backup_done +copy /y "!TARGET_DIR!\.githooks\pre-push" "!TARGET_DIR!\.githooks\pre-push.bak" >nul +if errorlevel 1 ( + echo [WARN] Could not back up .githooks\pre-push -- continuing without backup +) else ( + echo Backed up .githooks\pre-push -^> .githooks\pre-push.bak +) :backup_done rem --- Copy files --- @@ -165,59 +201,67 @@ echo --- Copying Files --- echo. rem scripts/git/ -if not exist "%TARGET_DIR%\scripts\git\" mkdir "%TARGET_DIR%\scripts\git\" -xcopy /y /q "%CGW_DIR%\scripts\git\*.sh" "%TARGET_DIR%\scripts\git\" >nul +if not exist "!TARGET_DIR!\scripts\git\" mkdir "!TARGET_DIR!\scripts\git\" +xcopy /y /q "!CGW_DIR!\scripts\git\*.sh" "!TARGET_DIR!\scripts\git\" >nul if errorlevel 1 goto :cp_scripts_fail -for /f %%c in ('dir /b "%TARGET_DIR%\scripts\git\*.sh" 2^>nul ^| find /c ".sh"') do echo [OK] Copied %%c scripts to scripts\git\ +for /f %%c in ('dir /b "!TARGET_DIR!\scripts\git\*.sh" 2^>nul ^| find /c ".sh"') do echo [OK] Copied %%c scripts to scripts\git\ goto :cp_scripts_done :cp_scripts_fail echo [ERR] Failed to copy scripts\git\ -goto :end +goto :abort :cp_scripts_done rem hooks/ -if not exist "%TARGET_DIR%\hooks\" mkdir "%TARGET_DIR%\hooks\" -copy /y "%CGW_DIR%\hooks\pre-commit" "%TARGET_DIR%\hooks\pre-commit" >nul +if not exist "!TARGET_DIR!\hooks\" mkdir "!TARGET_DIR!\hooks\" +copy /y "!CGW_DIR!\hooks\pre-commit" "!TARGET_DIR!\hooks\pre-commit" >nul if errorlevel 1 goto :cp_hooks_fail -copy /y "%CGW_DIR%\hooks\pre-push" "%TARGET_DIR%\hooks\pre-push" >nul +copy /y "!CGW_DIR!\hooks\pre-push" "!TARGET_DIR!\hooks\pre-push" >nul if errorlevel 1 goto :cp_hooks_fail echo [OK] Copied hooks\pre-commit + hooks\pre-push templates goto :cp_hooks_done :cp_hooks_fail echo [ERR] Failed to copy hook templates from hooks\ -goto :end +goto :abort :cp_hooks_done rem skill/ -if not exist "%TARGET_DIR%\skill\" mkdir "%TARGET_DIR%\skill\" -xcopy /y /q /e "%CGW_DIR%\skill\" "%TARGET_DIR%\skill\" >nul +if not exist "!TARGET_DIR!\skill\" mkdir "!TARGET_DIR!\skill\" +xcopy /y /q /e "!CGW_DIR!\skill\" "!TARGET_DIR!\skill\" >nul if errorlevel 1 goto :cp_skill_fail echo [OK] Copied skill\ goto :cp_skill_done :cp_skill_fail echo [ERR] Failed to copy skill\ -goto :end +goto :abort :cp_skill_done rem command/ -if not exist "%TARGET_DIR%\command\" mkdir "%TARGET_DIR%\command\" -xcopy /y /q /e "%CGW_DIR%\command\" "%TARGET_DIR%\command\" >nul +if not exist "!TARGET_DIR!\command\" mkdir "!TARGET_DIR!\command\" +xcopy /y /q /e "!CGW_DIR!\command\" "!TARGET_DIR!\command\" >nul if errorlevel 1 goto :cp_cmd_fail echo [OK] Copied command\ goto :cp_cmd_done :cp_cmd_fail echo [ERR] Failed to copy command\ -goto :end +goto :abort :cp_cmd_done rem cgw.conf.example (optional) -if not exist "%CGW_DIR%\cgw.conf.example" goto :cp_example_done -copy /y "%CGW_DIR%\cgw.conf.example" "%TARGET_DIR%\cgw.conf.example" >nul -echo [OK] Copied cgw.conf.example +if not exist "!CGW_DIR!\cgw.conf.example" goto :cp_example_done +copy /y "!CGW_DIR!\cgw.conf.example" "!TARGET_DIR!\cgw.conf.example" >nul +if errorlevel 1 ( + echo [WARN] Could not copy cgw.conf.example +) else ( + echo [OK] Copied cgw.conf.example +) :cp_example_done rem Ensure .sh files are executable (needed by Git Bash on Windows) -pushd "%TARGET_DIR%" +pushd "!TARGET_DIR!" +if errorlevel 1 ( + echo( [ERR] Cannot enter target directory: !TARGET_DIR! + goto :abort +) bash -c "chmod +x scripts/git/*.sh 2>/dev/null" >nul 2>&1 echo. @@ -233,9 +277,10 @@ set "CONFIGURE_EXIT=!ERRORLEVEL!" popd echo. -if "%CONFIGURE_EXIT%"=="0" goto :cfg_ok -echo [WARN] configure.sh exited with code %CONFIGURE_EXIT% +if "!CONFIGURE_EXIT!"=="0" goto :cfg_ok +echo [WARN] configure.sh exited with code !CONFIGURE_EXIT! echo Installation may be incomplete. Check output above. +set "EXIT_CODE=1" goto :cfg_done :cfg_ok echo configure.sh completed successfully. @@ -247,17 +292,22 @@ echo --- Post-Install Cleanup --- echo. echo The following directories were needed only during installation echo and can be safely removed from the target project: -echo %TARGET_DIR%\hooks\ -echo %TARGET_DIR%\skill\ -echo %TARGET_DIR%\command\ +echo( !TARGET_DIR!\hooks\ +echo( !TARGET_DIR!\skill\ +echo( !TARGET_DIR!\command\ echo. set /p "CLEANUP=Remove temporary install files? [Y/n]: " -if /i "%CLEANUP%"=="n" goto :cleanup_skip -if /i "%CLEANUP%"=="no" goto :cleanup_skip -if exist "%TARGET_DIR%\hooks\" rmdir /s /q "%TARGET_DIR%\hooks\" -if exist "%TARGET_DIR%\skill\" rmdir /s /q "%TARGET_DIR%\skill\" -if exist "%TARGET_DIR%\command\" rmdir /s /q "%TARGET_DIR%\command\" -echo Removed hooks\, skill\, command\ +if /i "!CLEANUP!"=="n" goto :cleanup_skip +if /i "!CLEANUP!"=="no" goto :cleanup_skip +set "REMOVED_DIRS=" +if exist "!TARGET_DIR!\hooks\" ( rmdir /s /q "!TARGET_DIR!\hooks\" & if not exist "!TARGET_DIR!\hooks\" set "REMOVED_DIRS=!REMOVED_DIRS! hooks\" ) +if exist "!TARGET_DIR!\skill\" ( rmdir /s /q "!TARGET_DIR!\skill\" & if not exist "!TARGET_DIR!\skill\" set "REMOVED_DIRS=!REMOVED_DIRS! skill\" ) +if exist "!TARGET_DIR!\command\" ( rmdir /s /q "!TARGET_DIR!\command\" & if not exist "!TARGET_DIR!\command\" set "REMOVED_DIRS=!REMOVED_DIRS! command\" ) +if "!REMOVED_DIRS!"=="" ( + echo [WARN] Could not fully remove temp directories (files may be locked) +) else ( + echo( Removed:!REMOVED_DIRS! +) goto :cleanup_done :cleanup_skip echo Temp files kept. @@ -270,21 +320,26 @@ echo Installation Complete echo =================================================== echo. -if not exist "%TARGET_DIR%\scripts\git\commit_enhanced.sh" goto :sum_scripts_done -for /f %%c in ('dir /b "%TARGET_DIR%\scripts\git\*.sh" 2^>nul ^| find /c ".sh"') do echo Scripts: %TARGET_DIR%\scripts\git\ ^(%%c files^) +if not exist "!TARGET_DIR!\scripts\git\commit_enhanced.sh" goto :sum_scripts_done +for /f %%c in ('dir /b "!TARGET_DIR!\scripts\git\*.sh" 2^>nul ^| find /c ".sh"') do echo( Scripts: !TARGET_DIR!\scripts\git\ ^(%%c files^) :sum_scripts_done -if exist "%TARGET_DIR%\.cgw.conf" echo Config: %TARGET_DIR%\.cgw.conf -if exist "%TARGET_DIR%\.git\hooks\pre-commit" echo Git hooks: %TARGET_DIR%\.git\hooks\pre-commit + pre-push -if exist "%TARGET_DIR%\.claude\skills\auto-git-workflow\SKILL.md" echo Claude skill: %TARGET_DIR%\.claude\skills\auto-git-workflow\ -if exist "%TARGET_DIR%\.claude\commands\auto-git-workflow.md" echo Slash cmd: %TARGET_DIR%\.claude\commands\auto-git-workflow.md +if exist "!TARGET_DIR!\.cgw.conf" echo( Config: !TARGET_DIR!\.cgw.conf +if exist "!TARGET_DIR!\.git\hooks\pre-commit" echo( Git hooks: !TARGET_DIR!\.git\hooks\pre-commit + pre-push +if exist "!TARGET_DIR!\.claude\skills\auto-git-workflow\SKILL.md" echo( Claude skill: !TARGET_DIR!\.claude\skills\auto-git-workflow\ +if exist "!TARGET_DIR!\.claude\commands\auto-git-workflow.md" echo( Slash cmd: !TARGET_DIR!\.claude\commands\auto-git-workflow.md echo. echo Quick start (from your project root in Git Bash): echo bash scripts/git/commit_enhanced.sh "feat: your feature" echo. +goto :end + +:abort +set "EXIT_CODE=1" +goto :end + :end echo. pause -endlocal -exit /b 0 +endlocal & exit /b %EXIT_CODE% diff --git a/scripts/git/_config.sh b/scripts/git/_config.sh index 08e52c6..4c741a0 100644 --- a/scripts/git/_config.sh +++ b/scripts/git/_config.sh @@ -17,26 +17,26 @@ # 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 } # 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 + PROJECT_ROOT="$(_detect_project_root)" || exit 1 fi # ============================================================================ @@ -50,26 +50,26 @@ fi _CGW_CONF="${PROJECT_ROOT}/.cgw.conf" if [[ -f "${_CGW_CONF}" ]]; then - # 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 + # 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 # ============================================================================ @@ -90,9 +90,9 @@ _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) @@ -101,16 +101,16 @@ export CGW_ALL_PREFIXES # consumed by commit_enhanced.sh (cross-file, not detect # that haven't configured a linter yet). # 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}" +[[ -z "${CGW_LINT_CHECK_ARGS+x}" ]] && CGW_LINT_CHECK_ARGS="check ." +[[ -z "${CGW_LINT_FIX_ARGS+x}" ]] && CGW_LINT_FIX_ARGS="check --fix ." +[[ -z "${CGW_LINT_EXCLUDES+x}" ]] && CGW_LINT_EXCLUDES="--extend-exclude logs --extend-exclude .venv" # Set CGW_FORMAT_CMD="" to disable formatting checks. # 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}" +[[ -z "${CGW_FORMAT_CHECK_ARGS+x}" ]] && CGW_FORMAT_CHECK_ARGS="format --check ." +[[ -z "${CGW_FORMAT_FIX_ARGS+x}" ]] && CGW_FORMAT_FIX_ARGS="format ." +[[ -z "${CGW_FORMAT_EXCLUDES+x}" ]] && 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" @@ -120,7 +120,7 @@ CGW_MARKDOWNLINT_ARGS="${CGW_MARKDOWNLINT_ARGS:-**/*.md !CLAUDE.md !MEMORY.md}" # --- Modified-only lint file extensions --- # Space-separated glob patterns used by check_lint.sh / fix_lint.sh --modified-only. # Default matches Python files. Override for other languages (e.g. "*.js *.ts" or "*.go"). -CGW_LINT_EXTENSIONS="${CGW_LINT_EXTENSIONS:-*.py}" +[[ -z "${CGW_LINT_EXTENSIONS+x}" ]] && CGW_LINT_EXTENSIONS="*.py" # --- Merge conflict style (merge_with_validation.sh) --- # Set to "diff3" to show the base version in conflict markers (Pro Git recommended). diff --git a/scripts/git/changelog_generate.sh b/scripts/git/changelog_generate.sh index 04c1c02..329d6f8 100644 --- a/scripts/git/changelog_generate.sh +++ b/scripts/git/changelog_generate.sh @@ -28,225 +28,237 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "${SCRIPT_DIR}/_common.sh" main() { - local from_ref="" - local to_ref="HEAD" - local output_format="md" - local output_file="" - local include_merges=0 - - while [[ $# -gt 0 ]]; do - case "$1" in - --help | -h) - echo "Usage: ./scripts/git/changelog_generate.sh [OPTIONS]" - echo "" - echo "Generate a categorized changelog from conventional commits." - echo "" - echo "Options:" - echo " --from Start ref (exclusive; default: latest semver tag or root)" - echo " --to End ref inclusive (default: HEAD)" - echo " --format Output format: md (default) or text" - echo " --output Write to file (default: stdout)" - echo " --include-merges Also include merge commits (default: skipped)" - echo " -h, --help Show this help" - echo "" - echo "Commit types recognized (CGW_ALL_PREFIXES):" - echo " feat, fix, docs, chore, test, refactor, style, perf" - echo " Plus any extras configured via CGW_EXTRA_PREFIXES" - echo "" - echo "Examples:" - echo " ./scripts/git/changelog_generate.sh" - echo " ./scripts/git/changelog_generate.sh --from v1.0.0 --to v1.1.0" - echo " ./scripts/git/changelog_generate.sh --from v1.0.0 --output CHANGELOG.md" - exit 0 - ;; - --from) from_ref="${2:-}"; shift ;; - --to) to_ref="${2:-}"; shift ;; - --format) output_format="${2:-md}"; shift ;; - --output) output_file="${2:-}"; shift ;; - --include-merges) include_merges=1 ;; - *) - echo "[ERROR] Unknown flag: $1" >&2 - exit 1 - ;; - esac - shift - done - - cd "${PROJECT_ROOT}" || { - err "Cannot find project root" - exit 1 - } - - # Validate output format - case "${output_format}" in - md | text) ;; - *) - err "Unknown format: ${output_format} (use 'md' or 'text')" - exit 1 - ;; - esac - - # Auto-detect from_ref: latest semver tag - if [[ -z "${from_ref}" ]]; then - from_ref=$(git tag -l "v[0-9]*" | sort -V | tail -1 2>/dev/null || true) - if [[ -z "${from_ref}" ]]; then - # No semver tags -- use root commit (all history) - from_ref=$(git rev-list --max-parents=0 HEAD 2>/dev/null | head -1 || true) - fi - fi - - # Validate refs - if ! git rev-parse "${to_ref}" >/dev/null 2>&1; then - err "Invalid --to ref: ${to_ref}" - exit 1 - fi - if [[ -n "${from_ref}" ]]; then - if ! git rev-parse "${from_ref}" >/dev/null 2>&1; then - err "Invalid --from ref: ${from_ref}" - exit 1 - fi - fi - - # Determine git log range - local log_range - if [[ -n "${from_ref}" ]]; then - log_range="${from_ref}..${to_ref}" - else - log_range="${to_ref}" - fi - - # Get to_ref description for header - local to_desc - to_desc=$(git describe --tags --exact-match "${to_ref}" 2>/dev/null || \ - git log -1 --format="%h" "${to_ref}" 2>/dev/null || echo "${to_ref}") - local to_date - to_date=$(git log -1 --format="%ad" --date=short "${to_ref}" 2>/dev/null || date +%Y-%m-%d) - - # Collect commits in range - local merge_flag="--no-merges" - [[ ${include_merges} -eq 1 ]] && merge_flag="" - - # shellcheck disable=SC2086 # merge_flag intentionally word-splits when empty - local commits - commits=$(git log ${merge_flag} --format="%H|%s|%b" "${log_range}" 2>/dev/null || true) - - if [[ -z "${commits}" ]]; then - echo "No commits found in range: ${log_range}" >&2 - exit 0 - fi - - # Categorize commits by conventional type - # Categories: feat, fix, docs, perf, refactor, style, test, chore, other - local -a cat_feat=() cat_fix=() cat_docs=() cat_perf=() - local -a cat_refactor=() cat_style=() cat_test=() cat_chore=() cat_other=() - - while IFS='|' read -r hash subject body; do - [[ -z "${hash}" ]] && continue - - local prefix rest - if echo "${subject}" | grep -qE "^[a-zA-Z]+:"; then - prefix=$(echo "${subject}" | sed 's/:.*//') - rest=$(echo "${subject}" | sed 's/^[^:]*: *//') - else - prefix="other" - rest="${subject}" - fi - - # Get short hash and PR reference if any - local short_hash - short_hash=$(git log -1 --format="%h" "${hash}" 2>/dev/null || echo "${hash:0:7}") - - local entry="${rest} (${short_hash})" - - case "${prefix}" in - feat) cat_feat+=("${entry}") ;; - fix) cat_fix+=("${entry}") ;; - docs) cat_docs+=("${entry}") ;; - perf) cat_perf+=("${entry}") ;; - refactor) cat_refactor+=("${entry}") ;; - style) cat_style+=("${entry}") ;; - test) cat_test+=("${entry}") ;; - chore) cat_chore+=("${entry}") ;; - *) cat_other+=("${entry}") ;; - esac - done <<< "${commits}" - - # Build output directly from the already-categorized arrays - declare -A cats - cats[feat]="" cats[fix]="" cats[docs]="" cats[perf]="" - cats[refactor]="" cats[style]="" cats[test]="" cats[chore]="" cats[other]="" - - for item in "${cat_feat[@]+"${cat_feat[@]}"}"; do cats[feat]+=" - ${item}"$'\n'; done - for item in "${cat_fix[@]+"${cat_fix[@]}"}"; do cats[fix]+=" - ${item}"$'\n'; done - for item in "${cat_docs[@]+"${cat_docs[@]}"}"; do cats[docs]+=" - ${item}"$'\n'; done - for item in "${cat_perf[@]+"${cat_perf[@]}"}"; do cats[perf]+=" - ${item}"$'\n'; done - for item in "${cat_refactor[@]+"${cat_refactor[@]}"}"; do cats[refactor]+=" - ${item}"$'\n'; done - for item in "${cat_style[@]+"${cat_style[@]}"}"; do cats[style]+=" - ${item}"$'\n'; done - for item in "${cat_test[@]+"${cat_test[@]}"}"; do cats[test]+=" - ${item}"$'\n'; done - for item in "${cat_chore[@]+"${cat_chore[@]}"}"; do cats[chore]+=" - ${item}"$'\n'; done - for item in "${cat_other[@]+"${cat_other[@]}"}"; do cats[other]+=" - ${item}"$'\n'; done - - local section_map_md=( - "feat:New Features" - "fix:Bug Fixes" - "perf:Performance Improvements" - "docs:Documentation" - "refactor:Refactoring" - "test:Tests" - "style:Code Style" - "chore:Maintenance" - "other:Other Changes" - ) - local section_map_text=( - "feat:New Features" - "fix:Bug Fixes" - "perf:Performance" - "docs:Documentation" - "refactor:Refactoring" - "test:Tests" - "style:Style" - "chore:Maintenance" - "other:Other" - ) - - local output="" - if [[ "${output_format}" == "md" ]]; then - output="## ${to_desc} (${to_date})"$'\n\n' - [[ -n "${from_ref}" ]] && output+="> Changes since \`${from_ref}\`"$'\n\n' - - local has_any=0 - for sec in "${section_map_md[@]}"; do - local key="${sec%%:*}" - local title="${sec#*:}" - if [[ -n "${cats[${key}]}" ]]; then - output+="### ${title}"$'\n\n' - output+="${cats[${key}]}"$'\n' - has_any=1 - fi - done - [[ ${has_any} -eq 0 ]] && output+="_No categorized commits found in this range._"$'\n' - else - output="${to_desc} (${to_date})"$'\n' - output+="$(printf '=%.0s' {1..40})"$'\n' - [[ -n "${from_ref}" ]] && output+="Changes since ${from_ref}"$'\n\n' - - for sec in "${section_map_text[@]}"; do - local key="${sec%%:*}" - local title="${sec#*:}" - if [[ -n "${cats[${key}]}" ]]; then - output+="${title}:"$'\n' - output+="${cats[${key}]}"$'\n' - fi - done - fi - - # Write output - if [[ -n "${output_file}" ]]; then - echo "${output}" >"${output_file}" - echo "[OK] Changelog written to: ${output_file}" >&2 - else - echo "${output}" - fi + local from_ref="" + local to_ref="HEAD" + local output_format="md" + local output_file="" + local include_merges=0 + + while [[ $# -gt 0 ]]; do + case "$1" in + --help | -h) + echo "Usage: ./scripts/git/changelog_generate.sh [OPTIONS]" + echo "" + echo "Generate a categorized changelog from conventional commits." + echo "" + echo "Options:" + echo " --from Start ref (exclusive; default: latest semver tag or root)" + echo " --to End ref inclusive (default: HEAD)" + echo " --format Output format: md (default) or text" + echo " --output Write to file (default: stdout)" + echo " --include-merges Also include merge commits (default: skipped)" + echo " -h, --help Show this help" + echo "" + echo "Commit types recognized (CGW_ALL_PREFIXES):" + echo " feat, fix, docs, chore, test, refactor, style, perf" + echo " Plus any extras configured via CGW_EXTRA_PREFIXES" + echo "" + echo "Examples:" + echo " ./scripts/git/changelog_generate.sh" + echo " ./scripts/git/changelog_generate.sh --from v1.0.0 --to v1.1.0" + echo " ./scripts/git/changelog_generate.sh --from v1.0.0 --output CHANGELOG.md" + exit 0 + ;; + --from) + from_ref="${2:-}" + shift + ;; + --to) + to_ref="${2:-}" + shift + ;; + --format) + output_format="${2:-md}" + shift + ;; + --output) + output_file="${2:-}" + shift + ;; + --include-merges) include_merges=1 ;; + *) + echo "[ERROR] Unknown flag: $1" >&2 + exit 1 + ;; + esac + shift + done + + cd "${PROJECT_ROOT}" || { + err "Cannot find project root" + exit 1 + } + + # Validate output format + case "${output_format}" in + md | text) ;; + *) + err "Unknown format: ${output_format} (use 'md' or 'text')" + exit 1 + ;; + esac + + # Auto-detect from_ref: latest semver tag + if [[ -z "${from_ref}" ]]; then + from_ref=$(git tag -l "v[0-9]*" | sort -V | tail -1 2>/dev/null || true) + if [[ -z "${from_ref}" ]]; then + # No semver tags -- use root commit (all history) + from_ref=$(git rev-list --max-parents=0 HEAD 2>/dev/null | head -1 || true) + fi + fi + + # Validate refs + if ! git rev-parse "${to_ref}" >/dev/null 2>&1; then + err "Invalid --to ref: ${to_ref}" + exit 1 + fi + if [[ -n "${from_ref}" ]]; then + if ! git rev-parse "${from_ref}" >/dev/null 2>&1; then + err "Invalid --from ref: ${from_ref}" + exit 1 + fi + fi + + # Determine git log range + local log_range + if [[ -n "${from_ref}" ]]; then + log_range="${from_ref}..${to_ref}" + else + log_range="${to_ref}" + fi + + # Get to_ref description for header + local to_desc + to_desc=$(git describe --tags --exact-match "${to_ref}" 2>/dev/null || + git log -1 --format="%h" "${to_ref}" 2>/dev/null || echo "${to_ref}") + local to_date + to_date=$(git log -1 --format="%ad" --date=short "${to_ref}" 2>/dev/null || date +%Y-%m-%d) + + # Collect commits in range + local merge_flag="--no-merges" + [[ ${include_merges} -eq 1 ]] && merge_flag="" + + # shellcheck disable=SC2086 # merge_flag intentionally word-splits when empty + local commits + commits=$(git log ${merge_flag} --format="%H|%s|%b" "${log_range}" 2>/dev/null || true) + + if [[ -z "${commits}" ]]; then + echo "No commits found in range: ${log_range}" >&2 + exit 0 + fi + + # Categorize commits by conventional type + # Categories: feat, fix, docs, perf, refactor, style, test, chore, other + local -a cat_feat=() cat_fix=() cat_docs=() cat_perf=() + local -a cat_refactor=() cat_style=() cat_test=() cat_chore=() cat_other=() + + while IFS='|' read -r hash subject _body; do + [[ -z "${hash}" ]] && continue + + local prefix rest + if echo "${subject}" | grep -qE "^[a-zA-Z]+:"; then + prefix="${subject%%:*}" + rest="${subject#*: }" + else + prefix="other" + rest="${subject}" + fi + + # Get short hash and PR reference if any + local short_hash + short_hash=$(git log -1 --format="%h" "${hash}" 2>/dev/null || echo "${hash:0:7}") + + local entry="${rest} (${short_hash})" + + case "${prefix}" in + feat) cat_feat+=("${entry}") ;; + fix) cat_fix+=("${entry}") ;; + docs) cat_docs+=("${entry}") ;; + perf) cat_perf+=("${entry}") ;; + refactor) cat_refactor+=("${entry}") ;; + style) cat_style+=("${entry}") ;; + test) cat_test+=("${entry}") ;; + chore) cat_chore+=("${entry}") ;; + *) cat_other+=("${entry}") ;; + esac + done <<<"${commits}" + + # Build output directly from the already-categorized arrays + declare -A cats + cats[feat]="" cats[fix]="" cats[docs]="" cats[perf]="" + cats[refactor]="" cats[style]="" cats[test]="" cats[chore]="" cats[other]="" + + for item in "${cat_feat[@]+"${cat_feat[@]}"}"; do cats[feat]+=" - ${item}"$'\n'; done + for item in "${cat_fix[@]+"${cat_fix[@]}"}"; do cats[fix]+=" - ${item}"$'\n'; done + for item in "${cat_docs[@]+"${cat_docs[@]}"}"; do cats[docs]+=" - ${item}"$'\n'; done + for item in "${cat_perf[@]+"${cat_perf[@]}"}"; do cats[perf]+=" - ${item}"$'\n'; done + for item in "${cat_refactor[@]+"${cat_refactor[@]}"}"; do cats[refactor]+=" - ${item}"$'\n'; done + for item in "${cat_style[@]+"${cat_style[@]}"}"; do cats[style]+=" - ${item}"$'\n'; done + for item in "${cat_test[@]+"${cat_test[@]}"}"; do cats[test]+=" - ${item}"$'\n'; done + for item in "${cat_chore[@]+"${cat_chore[@]}"}"; do cats[chore]+=" - ${item}"$'\n'; done + for item in "${cat_other[@]+"${cat_other[@]}"}"; do cats[other]+=" - ${item}"$'\n'; done + + local section_map_md=( + "feat:New Features" + "fix:Bug Fixes" + "perf:Performance Improvements" + "docs:Documentation" + "refactor:Refactoring" + "test:Tests" + "style:Code Style" + "chore:Maintenance" + "other:Other Changes" + ) + local section_map_text=( + "feat:New Features" + "fix:Bug Fixes" + "perf:Performance" + "docs:Documentation" + "refactor:Refactoring" + "test:Tests" + "style:Style" + "chore:Maintenance" + "other:Other" + ) + + local output="" + if [[ "${output_format}" == "md" ]]; then + output="## ${to_desc} (${to_date})"$'\n\n' + [[ -n "${from_ref}" ]] && output+="> Changes since \`${from_ref}\`"$'\n\n' + + local has_any=0 + for sec in "${section_map_md[@]}"; do + local key="${sec%%:*}" + local title="${sec#*:}" + if [[ -n "${cats[${key}]}" ]]; then + output+="### ${title}"$'\n\n' + output+="${cats[${key}]}"$'\n' + has_any=1 + fi + done + [[ ${has_any} -eq 0 ]] && output+="_No categorized commits found in this range._"$'\n' + else + output="${to_desc} (${to_date})"$'\n' + output+="$(printf '=%.0s' {1..40})"$'\n' + [[ -n "${from_ref}" ]] && output+="Changes since ${from_ref}"$'\n\n' + + for sec in "${section_map_text[@]}"; do + local key="${sec%%:*}" + local title="${sec#*:}" + if [[ -n "${cats[${key}]}" ]]; then + output+="${title}:"$'\n' + output+="${cats[${key}]}"$'\n' + fi + done + fi + + # Write output + if [[ -n "${output_file}" ]]; then + echo "${output}" >"${output_file}" + echo "[OK] Changelog written to: ${output_file}" >&2 + else + echo "${output}" + fi } main "$@" diff --git a/scripts/git/configure.sh b/scripts/git/configure.sh index a32d9de..ab54d4c 100644 --- a/scripts/git/configure.sh +++ b/scripts/git/configure.sh @@ -69,12 +69,19 @@ _detect_target_branch() { _detect_source_branch() { local target="$1" - # Check common source branch names + # Check common source branch names (local first, then remote tracking) 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 + if git show-ref --verify --quiet "refs/remotes/origin/${name}" 2>/dev/null; then + # Remote-only: create local tracking branch so downstream scripts can + # check out by name without relying on git's DWIM --guess behaviour. + git branch --track "${name}" "origin/${name}" >/dev/null 2>&1 || true + echo "${name}" + return 0 + fi done # Most recently committed branch that isn't target local recent @@ -327,8 +334,8 @@ _install_hook() { # by reading CGW_EXTRA_PREFIXES from the just-written .cgw.conf. local _base_prefixes="feat|fix|docs|chore|test|refactor|style|perf" local _extra_prefixes - _extra_prefixes=$(grep -m1 '^CGW_EXTRA_PREFIXES=' "${PROJECT_ROOT}/.cgw.conf" \ - | sed 's/CGW_EXTRA_PREFIXES=//;s/"//g' || true) + _extra_prefixes=$(grep -m1 '^CGW_EXTRA_PREFIXES=' "${PROJECT_ROOT}/.cgw.conf" | + sed 's/CGW_EXTRA_PREFIXES=//;s/"//g' || true) local _all_prefixes if [[ -n "${_extra_prefixes}" ]]; then _all_prefixes="${_base_prefixes}|${_extra_prefixes}" @@ -339,8 +346,8 @@ _install_hook() { all_prefixes_escaped="${all_prefixes_escaped//&/\\&}" all_prefixes_escaped="${all_prefixes_escaped//|/\\|}" sed -e "s|__CGW_LOCAL_FILES_PATTERN__|${sed_files_pattern}|g" \ - -e "s|__CGW_ALL_PREFIXES__|${all_prefixes_escaped}|g" \ - "${pre_push_template}" >"${PROJECT_ROOT}/.githooks/pre-push" + -e "s|__CGW_ALL_PREFIXES__|${all_prefixes_escaped}|g" \ + "${pre_push_template}" >"${PROJECT_ROOT}/.githooks/pre-push" chmod +x "${PROJECT_ROOT}/.githooks/pre-push" fi diff --git a/scripts/git/rebase_safe.sh b/scripts/git/rebase_safe.sh index af33983..1d6b347 100644 --- a/scripts/git/rebase_safe.sh +++ b/scripts/git/rebase_safe.sh @@ -38,597 +38,607 @@ _rebase_original_branch="" _rebase_stash_created=0 _cleanup_rebase() { - # Only restore stash if rebase was aborted mid-way and we created one - if [[ ${_rebase_stash_created} -eq 1 ]]; then - if git rebase --show-current-patch >/dev/null 2>&1 || [[ -d "${PROJECT_ROOT}/.git/rebase-merge" ]] || [[ -d "${PROJECT_ROOT}/.git/rebase-apply" ]]; then - # Rebase still in progress -- don't auto-pop stash, user needs to resolve - echo "" >&2 - echo "[!] Rebase was interrupted with uncommitted changes stashed." >&2 - echo " Resolve conflicts, then: git rebase --continue" >&2 - echo " To restore your stash: git stash pop" >&2 - fi - fi + # Only restore stash if rebase was aborted mid-way and we created one + if [[ ${_rebase_stash_created} -eq 1 ]]; then + if git rebase --show-current-patch >/dev/null 2>&1 || [[ -d "${PROJECT_ROOT}/.git/rebase-merge" ]] || [[ -d "${PROJECT_ROOT}/.git/rebase-apply" ]]; then + # Rebase still in progress -- don't auto-pop stash, user needs to resolve + echo "" >&2 + echo "[!] Rebase was interrupted with uncommitted changes stashed." >&2 + echo " Resolve conflicts, then: git rebase --continue" >&2 + echo " To restore your stash: git stash pop" >&2 + fi + fi } trap _cleanup_rebase EXIT INT TERM _show_help() { - echo "Usage: ./scripts/git/rebase_safe.sh [OPTIONS]" - echo "" - echo "Safe rebase wrapper. Creates a backup tag before any destructive operation." - echo "Refuses to rebase commits that have already been pushed (history-safe by default)." - echo "" - echo "Options:" - echo " --onto Rebase current branch onto this branch" - echo " (default: ${CGW_TARGET_BRANCH})" - echo " --squash-last Interactively squash the last N commits" - echo " --autosquash Apply fixup!/squash! commit prefixes automatically" - echo " (used with --squash-last)" - echo " --autostash Auto-stash dirty working tree before rebase" - echo " --abort Abort the current in-progress rebase" - echo " --continue Continue after manually resolving conflicts" - echo " --skip Skip the current conflicting commit" - echo " --non-interactive Skip confirmation prompts" - echo " --dry-run Show what would happen without rebasing" - echo " -h, --help Show this help" - echo "" - echo "Examples:" - echo " # Rebase feature branch onto main" - echo " ./scripts/git/rebase_safe.sh --onto main" - echo "" - echo " # Squash last 3 commits into one (opens editor)" - echo " ./scripts/git/rebase_safe.sh --squash-last 3" - echo "" - echo " # Squash with auto-applied fixup!/squash! markers" - echo " ./scripts/git/rebase_safe.sh --squash-last 5 --autosquash" - echo "" - echo " # Rebase with dirty working tree (auto-stash)" - echo " ./scripts/git/rebase_safe.sh --onto main --autostash" - echo "" - echo " # Abort an in-progress rebase" - echo " ./scripts/git/rebase_safe.sh --abort" - echo "" - echo " # Continue after resolving conflicts" - echo " ./scripts/git/rebase_safe.sh --continue" - echo "" - echo "[!] WARNING: Rebasing rewrites history. Never rebase commits already pushed" - echo " to a shared branch. This script will warn you if that is the case." + echo "Usage: ./scripts/git/rebase_safe.sh [OPTIONS]" + echo "" + echo "Safe rebase wrapper. Creates a backup tag before any destructive operation." + echo "Refuses to rebase commits that have already been pushed (history-safe by default)." + echo "" + echo "Options:" + echo " --onto Rebase current branch onto this branch" + echo " (default: ${CGW_TARGET_BRANCH})" + echo " --squash-last Interactively squash the last N commits" + echo " --autosquash Apply fixup!/squash! commit prefixes automatically" + echo " (used with --squash-last)" + echo " --autostash Auto-stash dirty working tree before rebase" + echo " --abort Abort the current in-progress rebase" + echo " --continue Continue after manually resolving conflicts" + echo " --skip Skip the current conflicting commit" + echo " --non-interactive Skip confirmation prompts" + echo " --dry-run Show what would happen without rebasing" + echo " -h, --help Show this help" + echo "" + echo "Examples:" + echo " # Rebase feature branch onto main" + echo " ./scripts/git/rebase_safe.sh --onto main" + echo "" + echo " # Squash last 3 commits into one (opens editor)" + echo " ./scripts/git/rebase_safe.sh --squash-last 3" + echo "" + echo " # Squash with auto-applied fixup!/squash! markers" + echo " ./scripts/git/rebase_safe.sh --squash-last 5 --autosquash" + echo "" + echo " # Rebase with dirty working tree (auto-stash)" + echo " ./scripts/git/rebase_safe.sh --onto main --autostash" + echo "" + echo " # Abort an in-progress rebase" + echo " ./scripts/git/rebase_safe.sh --abort" + echo "" + echo " # Continue after resolving conflicts" + echo " ./scripts/git/rebase_safe.sh --continue" + echo "" + echo "[!] WARNING: Rebasing rewrites history. Never rebase commits already pushed" + echo " to a shared branch. This script will warn you if that is the case." } main() { - if [[ $# -eq 0 ]] || [[ "${1:-}" == "--help" ]] || [[ "${1:-}" == "-h" ]]; then - _show_help - exit 0 - fi - - local onto_ref="" - local squash_last=0 - local autosquash=0 - local autostash=0 - local do_abort=0 - local do_continue=0 - local do_skip=0 - local non_interactive=0 - local dry_run=0 - - [[ "${CGW_NON_INTERACTIVE:-0}" == "1" ]] && non_interactive=1 - - while [[ $# -gt 0 ]]; do - case "$1" in - --help | -h) _show_help; exit 0 ;; - --onto) onto_ref="${2:-}"; shift ;; - --squash-last) squash_last="${2:-0}"; shift ;; - --autosquash) autosquash=1 ;; - --autostash) autostash=1 ;; - --abort) do_abort=1 ;; - --continue) do_continue=1 ;; - --skip) do_skip=1 ;; - --non-interactive) non_interactive=1 ;; - --dry-run) dry_run=1 ;; - *) - err "Unknown flag: $1" - echo "Run with --help to see available options" >&2 - exit 1 - ;; - esac - shift - done - - cd "${PROJECT_ROOT}" || { - err "Cannot find project root" - exit 1 - } - - { - echo "=========================================" - echo "Rebase Safe Log" - echo "=========================================" - echo "Start Time: $(date)" - echo "Branch: $(git branch --show-current 2>/dev/null || echo 'detached')" - } >"$logfile" - - # -- Handle in-progress rebase operations ---------------------------------- - if [[ ${do_abort} -eq 1 ]]; then - _cmd_abort - return $? - fi - if [[ ${do_continue} -eq 1 ]]; then - _cmd_continue - return $? - fi - if [[ ${do_skip} -eq 1 ]]; then - _cmd_skip - return $? - fi - - # -- Validate: mutually exclusive main operations --------------------------- - local has_onto=0 - local has_squash=0 - [[ -n "${onto_ref}" ]] && has_onto=1 - [[ "${squash_last}" -gt 0 ]] && has_squash=1 - - if [[ ${has_onto} -eq 0 ]] && [[ ${has_squash} -eq 0 ]]; then - err "Specify an operation: --onto or --squash-last " - echo "Run with --help to see available options" >&2 - exit 1 - fi - if [[ ${has_onto} -eq 1 ]] && [[ ${has_squash} -eq 1 ]]; then - err "Use either --onto or --squash-last, not both" - exit 1 - fi - - # -- Check for already-active rebase --------------------------------------- - if [[ -d "${PROJECT_ROOT}/.git/rebase-merge" ]] || [[ -d "${PROJECT_ROOT}/.git/rebase-apply" ]]; then - echo "[!] A rebase is already in progress." >&2 - echo " Resolve conflicts then:" >&2 - echo " ./scripts/git/rebase_safe.sh --continue" >&2 - echo " ./scripts/git/rebase_safe.sh --abort" >&2 - exit 1 - fi - - # -- Set default onto_ref --------------------------------------------------- - if [[ ${has_onto} -eq 1 ]] && [[ -z "${onto_ref}" ]]; then - onto_ref="${CGW_TARGET_BRANCH}" - echo "Using default onto ref: ${onto_ref}" | tee -a "$logfile" - fi - - if [[ ${has_onto} -eq 1 ]]; then - _cmd_rebase_onto "${onto_ref}" "${autostash}" "${non_interactive}" "${dry_run}" - else - _cmd_squash_last "${squash_last}" "${autosquash}" "${autostash}" "${non_interactive}" "${dry_run}" - fi + if [[ $# -eq 0 ]] || [[ "${1:-}" == "--help" ]] || [[ "${1:-}" == "-h" ]]; then + _show_help + exit 0 + fi + + local onto_ref="" + local squash_last=0 + local autosquash=0 + local autostash=0 + local do_abort=0 + local do_continue=0 + local do_skip=0 + local non_interactive=0 + local dry_run=0 + + [[ "${CGW_NON_INTERACTIVE:-0}" == "1" ]] && non_interactive=1 + + while [[ $# -gt 0 ]]; do + case "$1" in + --help | -h) + _show_help + exit 0 + ;; + --onto) + onto_ref="${2:-}" + shift + ;; + --squash-last) + squash_last="${2:-0}" + shift + ;; + --autosquash) autosquash=1 ;; + --autostash) autostash=1 ;; + --abort) do_abort=1 ;; + --continue) do_continue=1 ;; + --skip) do_skip=1 ;; + --non-interactive) non_interactive=1 ;; + --dry-run) dry_run=1 ;; + *) + err "Unknown flag: $1" + echo "Run with --help to see available options" >&2 + exit 1 + ;; + esac + shift + done + + cd "${PROJECT_ROOT}" || { + err "Cannot find project root" + exit 1 + } + + { + echo "=========================================" + echo "Rebase Safe Log" + echo "=========================================" + echo "Start Time: $(date)" + echo "Branch: $(git branch --show-current 2>/dev/null || echo 'detached')" + } >"$logfile" + + # -- Handle in-progress rebase operations ---------------------------------- + if [[ ${do_abort} -eq 1 ]]; then + _cmd_abort + return $? + fi + if [[ ${do_continue} -eq 1 ]]; then + _cmd_continue + return $? + fi + if [[ ${do_skip} -eq 1 ]]; then + _cmd_skip + return $? + fi + + # -- Validate: mutually exclusive main operations --------------------------- + local has_onto=0 + local has_squash=0 + [[ -n "${onto_ref}" ]] && has_onto=1 + [[ "${squash_last}" -gt 0 ]] && has_squash=1 + + if [[ ${has_onto} -eq 0 ]] && [[ ${has_squash} -eq 0 ]]; then + err "Specify an operation: --onto or --squash-last " + echo "Run with --help to see available options" >&2 + exit 1 + fi + if [[ ${has_onto} -eq 1 ]] && [[ ${has_squash} -eq 1 ]]; then + err "Use either --onto or --squash-last, not both" + exit 1 + fi + + # -- Check for already-active rebase --------------------------------------- + if [[ -d "${PROJECT_ROOT}/.git/rebase-merge" ]] || [[ -d "${PROJECT_ROOT}/.git/rebase-apply" ]]; then + echo "[!] A rebase is already in progress." >&2 + echo " Resolve conflicts then:" >&2 + echo " ./scripts/git/rebase_safe.sh --continue" >&2 + echo " ./scripts/git/rebase_safe.sh --abort" >&2 + exit 1 + fi + + # -- Set default onto_ref --------------------------------------------------- + if [[ ${has_onto} -eq 1 ]] && [[ -z "${onto_ref}" ]]; then + onto_ref="${CGW_TARGET_BRANCH}" + echo "Using default onto ref: ${onto_ref}" | tee -a "$logfile" + fi + + if [[ ${has_onto} -eq 1 ]]; then + _cmd_rebase_onto "${onto_ref}" "${autostash}" "${non_interactive}" "${dry_run}" + else + _cmd_squash_last "${squash_last}" "${autosquash}" "${autostash}" "${non_interactive}" "${dry_run}" + fi } # --------------------------------------------------------------------------- # Shared: create backup tag before any destructive operation # --------------------------------------------------------------------------- _create_backup_tag() { - get_timestamp - local backup_tag="pre-rebase-${timestamp}" - if git tag "${backup_tag}" 2>/dev/null; then - echo "[OK] Backup tag: ${backup_tag}" | tee -a "$logfile" - echo " To restore: git checkout ${backup_tag}" | tee -a "$logfile" - else - echo "[!] Could not create backup tag (continuing)" | tee -a "$logfile" - fi - echo "" | tee -a "$logfile" - echo "${backup_tag}" + get_timestamp + local backup_tag="pre-rebase-${timestamp}" + if git tag "${backup_tag}" 2>/dev/null; then + echo "[OK] Backup tag: ${backup_tag}" | tee -a "$logfile" + echo " To restore: git checkout ${backup_tag}" | tee -a "$logfile" + else + echo "[!] Could not create backup tag (continuing)" | tee -a "$logfile" + fi + echo "" | tee -a "$logfile" + echo "${backup_tag}" } # --------------------------------------------------------------------------- # Shared: warn if current branch has commits already pushed to origin # --------------------------------------------------------------------------- _check_pushed_commits() { - local upstream_count="${1}" - local non_interactive="${2}" - - if [[ "${upstream_count}" -gt 0 ]]; then - echo "[!] WARNING: ${upstream_count} commit(s) on this branch have already been pushed." | tee -a "$logfile" - echo " Rebasing will rewrite history -- you will need to force-push after rebase." | tee -a "$logfile" - echo " This is SAFE only on personal/feature branches, NEVER on shared branches." | tee -a "$logfile" - echo "" | tee -a "$logfile" - if [[ "${non_interactive}" -eq 1 ]]; then - err "Refusing to rebase pushed commits in non-interactive mode (history-safety)" - err "Use interactive mode or acknowledge the risk with --non-interactive after force-push consent" - exit 1 - fi - read -r -p " Rebase anyway? (yes/no): " pushed_confirm - if [[ "${pushed_confirm}" != "yes" ]]; then - echo "Cancelled" - exit 0 - fi - fi + local upstream_count="${1}" + local non_interactive="${2}" + + if [[ "${upstream_count}" -gt 0 ]]; then + echo "[!] WARNING: ${upstream_count} commit(s) on this branch have already been pushed." | tee -a "$logfile" + echo " Rebasing will rewrite history -- you will need to force-push after rebase." | tee -a "$logfile" + echo " This is SAFE only on personal/feature branches, NEVER on shared branches." | tee -a "$logfile" + echo "" | tee -a "$logfile" + if [[ "${non_interactive}" -eq 1 ]]; then + err "Refusing to rebase pushed commits in non-interactive mode (history-safety)" + err "Use interactive mode or acknowledge the risk with --non-interactive after force-push consent" + exit 1 + fi + read -r -p " Rebase anyway? (yes/no): " pushed_confirm + if [[ "${pushed_confirm}" != "yes" ]]; then + echo "Cancelled" + exit 0 + fi + fi } # --------------------------------------------------------------------------- # Shared: handle dirty working tree # --------------------------------------------------------------------------- _handle_dirty_tree() { - local autostash="${1}" - - if ! git diff-index --quiet HEAD -- 2>/dev/null; then - if [[ "${autostash}" -eq 1 ]]; then - echo " Stashing uncommitted changes..." | tee -a "$logfile" - if git stash push -m "rebase_safe auto-stash $(date +%Y%m%d_%H%M%S)" 2>&1 | tee -a "$logfile"; then - _rebase_stash_created=1 - echo " [OK] Changes stashed" | tee -a "$logfile" - else - err "Failed to stash changes -- resolve conflicts first" - exit 1 - fi - else - err "Working tree has uncommitted changes. Use --autostash or commit/stash first." - git diff --stat | head -10 | sed 's/^/ /' - exit 1 - fi - fi + local autostash="${1}" + + if ! git diff-index --quiet HEAD -- 2>/dev/null; then + if [[ "${autostash}" -eq 1 ]]; then + echo " Stashing uncommitted changes..." | tee -a "$logfile" + if git stash push -m "rebase_safe auto-stash $(date +%Y%m%d_%H%M%S)" 2>&1 | tee -a "$logfile"; then + _rebase_stash_created=1 + echo " [OK] Changes stashed" | tee -a "$logfile" + else + err "Failed to stash changes -- resolve conflicts first" + exit 1 + fi + else + err "Working tree has uncommitted changes. Use --autostash or commit/stash first." + git diff --stat | head -10 | sed 's/^/ /' + exit 1 + fi + fi } # --------------------------------------------------------------------------- # Shared: restore stash after successful rebase # --------------------------------------------------------------------------- _restore_stash_if_needed() { - if [[ ${_rebase_stash_created} -eq 1 ]]; then - echo "" | tee -a "$logfile" - echo " Restoring stashed changes..." | tee -a "$logfile" - if git stash pop 2>&1 | tee -a "$logfile"; then - _rebase_stash_created=0 - echo " [OK] Stash restored" | tee -a "$logfile" - else - echo " [!] Stash pop had conflicts -- resolve manually with: git stash show / git stash pop" | tee -a "$logfile" - fi - fi + if [[ ${_rebase_stash_created} -eq 1 ]]; then + echo "" | tee -a "$logfile" + echo " Restoring stashed changes..." | tee -a "$logfile" + if git stash pop 2>&1 | tee -a "$logfile"; then + _rebase_stash_created=0 + echo " [OK] Stash restored" | tee -a "$logfile" + else + echo " [!] Stash pop had conflicts -- resolve manually with: git stash show / git stash pop" | tee -a "$logfile" + fi + fi } # --------------------------------------------------------------------------- # Operation: --onto # --------------------------------------------------------------------------- _cmd_rebase_onto() { - local onto_ref="$1" autostash="$2" non_interactive="$3" dry_run="$4" - - echo "=== Rebase onto ${onto_ref} ===" | tee -a "$logfile" - echo "" | tee -a "$logfile" - - local current_branch - current_branch=$(git branch --show-current 2>/dev/null || true) - - if [[ -z "${current_branch}" ]]; then - err "Cannot rebase in detached HEAD state. Check out a branch first." - exit 1 - fi - - # Validate onto_ref - if ! git rev-parse "${onto_ref}" >/dev/null 2>&1; then - err "Invalid --onto ref: ${onto_ref}" - exit 1 - fi - - # Check if onto_ref is a local or remote branch and fetch latest - if git rev-parse "origin/${onto_ref}" >/dev/null 2>&1; then - echo " Fetching latest ${onto_ref} from origin..." | tee -a "$logfile" - git fetch origin "${onto_ref}" 2>&1 | tee -a "$logfile" || true - fi - - # Count pushed commits (commits on current branch not on origin/current_branch) - local pushed_count=0 - if git rev-parse "origin/${current_branch}" >/dev/null 2>&1; then - pushed_count=$(git rev-list --count "origin/${current_branch}..HEAD" 2>/dev/null || echo "0") - fi - - # Count commits that would be rebased - local rebase_commit_count - rebase_commit_count=$(git rev-list --count "${onto_ref}..HEAD" 2>/dev/null || echo "?") - - # Show plan - echo " Current branch: ${current_branch}" | tee -a "$logfile" - echo " Onto: ${onto_ref} ($(git log -1 --format='%h %s' "${onto_ref}" 2>/dev/null || echo 'unknown'))" | tee -a "$logfile" - echo " Commits to rebase: ${rebase_commit_count}" | tee -a "$logfile" - echo "" | tee -a "$logfile" - - if [[ "${rebase_commit_count}" == "0" ]]; then - echo " Already up to date with ${onto_ref} -- nothing to rebase." - exit 0 - fi - - if [[ "${dry_run}" -eq 1 ]]; then - echo "--- Dry run: no changes made ---" - echo "Would run:" - echo " git rebase ${onto_ref}" - if [[ "${pushed_count}" -gt 0 ]]; then - echo " (then: git push --force-with-lease -- ${pushed_count} commits already pushed)" - fi - exit 0 - fi - - # Warn about pushed commits - _check_pushed_commits "${pushed_count}" "${non_interactive}" - - # Handle dirty tree - _handle_dirty_tree "${autostash}" - - # Confirmation - if [[ "${non_interactive}" -eq 0 ]]; then - read -r -p " Rebase ${current_branch} onto ${onto_ref}? (yes/no): " rebase_confirm - if [[ "${rebase_confirm}" != "yes" ]]; then - echo "Cancelled" - _restore_stash_if_needed - exit 0 - fi - fi - - # Create backup - _create_backup_tag - - log_section_start "GIT REBASE ONTO" "$logfile" - - local rebase_exit=0 - if ! git rebase "${onto_ref}" 2>&1 | tee -a "$logfile"; then - rebase_exit=1 - fi - - log_section_end "GIT REBASE ONTO" "$logfile" "${rebase_exit}" - - if [[ ${rebase_exit} -ne 0 ]]; then - echo "" | tee -a "$logfile" - echo "[FAIL] REBASE HIT CONFLICTS" | tee -a "$logfile" - echo "" | tee -a "$logfile" - echo " Conflicting files:" - git diff --name-only --diff-filter=U 2>/dev/null | sed 's/^/ /' || true - echo "" - echo " Resolve conflicts, then:" - echo " git add " - echo " ./scripts/git/rebase_safe.sh --continue" - echo "" - echo " To abort and restore:" - echo " ./scripts/git/rebase_safe.sh --abort" - echo "" - echo "Full log: $logfile" - # Don't restore stash -- user needs to resolve rebase first - _rebase_stash_created=0 - exit 1 - fi - - _restore_stash_if_needed - - echo "" | tee -a "$logfile" - echo "[OK] REBASE COMPLETE" | tee -a "$logfile" - echo " $(git log -1 --oneline)" | tee -a "$logfile" - echo "" | tee -a "$logfile" - - if [[ "${pushed_count}" -gt 0 ]]; then - echo " [!] Your branch was previously pushed -- force-push required:" - echo " ./scripts/git/push_validated.sh --force-with-lease" - echo "" - fi - - echo "Full log: $logfile" + local onto_ref="$1" autostash="$2" non_interactive="$3" dry_run="$4" + + echo "=== Rebase onto ${onto_ref} ===" | tee -a "$logfile" + echo "" | tee -a "$logfile" + + local current_branch + current_branch=$(git branch --show-current 2>/dev/null || true) + + if [[ -z "${current_branch}" ]]; then + err "Cannot rebase in detached HEAD state. Check out a branch first." + exit 1 + fi + + # Validate onto_ref + if ! git rev-parse "${onto_ref}" >/dev/null 2>&1; then + err "Invalid --onto ref: ${onto_ref}" + exit 1 + fi + + # Check if onto_ref is a local or remote branch and fetch latest + if git rev-parse "origin/${onto_ref}" >/dev/null 2>&1; then + echo " Fetching latest ${onto_ref} from origin..." | tee -a "$logfile" + git fetch origin "${onto_ref}" 2>&1 | tee -a "$logfile" || true + fi + + # Count pushed commits (commits on current branch not on origin/current_branch) + local pushed_count=0 + if git rev-parse "origin/${current_branch}" >/dev/null 2>&1; then + pushed_count=$(git rev-list --count "origin/${current_branch}..HEAD" 2>/dev/null || echo "0") + fi + + # Count commits that would be rebased + local rebase_commit_count + rebase_commit_count=$(git rev-list --count "${onto_ref}..HEAD" 2>/dev/null || echo "?") + + # Show plan + echo " Current branch: ${current_branch}" | tee -a "$logfile" + echo " Onto: ${onto_ref} ($(git log -1 --format='%h %s' "${onto_ref}" 2>/dev/null || echo 'unknown'))" | tee -a "$logfile" + echo " Commits to rebase: ${rebase_commit_count}" | tee -a "$logfile" + echo "" | tee -a "$logfile" + + if [[ "${rebase_commit_count}" == "0" ]]; then + echo " Already up to date with ${onto_ref} -- nothing to rebase." + exit 0 + fi + + if [[ "${dry_run}" -eq 1 ]]; then + echo "--- Dry run: no changes made ---" + echo "Would run:" + echo " git rebase ${onto_ref}" + if [[ "${pushed_count}" -gt 0 ]]; then + echo " (then: git push --force-with-lease -- ${pushed_count} commits already pushed)" + fi + exit 0 + fi + + # Warn about pushed commits + _check_pushed_commits "${pushed_count}" "${non_interactive}" + + # Handle dirty tree + _handle_dirty_tree "${autostash}" + + # Confirmation + if [[ "${non_interactive}" -eq 0 ]]; then + read -r -p " Rebase ${current_branch} onto ${onto_ref}? (yes/no): " rebase_confirm + if [[ "${rebase_confirm}" != "yes" ]]; then + echo "Cancelled" + _restore_stash_if_needed + exit 0 + fi + fi + + # Create backup + _create_backup_tag + + log_section_start "GIT REBASE ONTO" "$logfile" + + local rebase_exit=0 + if ! git rebase "${onto_ref}" 2>&1 | tee -a "$logfile"; then + rebase_exit=1 + fi + + log_section_end "GIT REBASE ONTO" "$logfile" "${rebase_exit}" + + if [[ ${rebase_exit} -ne 0 ]]; then + echo "" | tee -a "$logfile" + echo "[FAIL] REBASE HIT CONFLICTS" | tee -a "$logfile" + echo "" | tee -a "$logfile" + echo " Conflicting files:" + git diff --name-only --diff-filter=U 2>/dev/null | sed 's/^/ /' || true + echo "" + echo " Resolve conflicts, then:" + echo " git add " + echo " ./scripts/git/rebase_safe.sh --continue" + echo "" + echo " To abort and restore:" + echo " ./scripts/git/rebase_safe.sh --abort" + echo "" + echo "Full log: $logfile" + # Don't restore stash -- user needs to resolve rebase first + _rebase_stash_created=0 + exit 1 + fi + + _restore_stash_if_needed + + echo "" | tee -a "$logfile" + echo "[OK] REBASE COMPLETE" | tee -a "$logfile" + echo " $(git log -1 --oneline)" | tee -a "$logfile" + echo "" | tee -a "$logfile" + + if [[ "${pushed_count}" -gt 0 ]]; then + echo " [!] Your branch was previously pushed -- force-push required:" + echo " ./scripts/git/push_validated.sh --force-with-lease" + echo "" + fi + + echo "Full log: $logfile" } # --------------------------------------------------------------------------- # Operation: --squash-last # --------------------------------------------------------------------------- _cmd_squash_last() { - local squash_n="$1" autosquash="$2" autostash="$3" non_interactive="$4" dry_run="$5" - - echo "=== Squash Last ${squash_n} Commits ===" | tee -a "$logfile" - echo "" | tee -a "$logfile" - - local current_branch - current_branch=$(git branch --show-current 2>/dev/null || true) - - if [[ -z "${current_branch}" ]]; then - err "Cannot rebase in detached HEAD state. Check out a branch first." - exit 1 - fi - - # Validate N - if ! [[ "${squash_n}" =~ ^[0-9]+$ ]] || [[ "${squash_n}" -lt 2 ]]; then - err "--squash-last requires a number >= 2 (got: ${squash_n})" - exit 1 - fi - - local commit_count - commit_count=$(git rev-list --count HEAD 2>/dev/null || echo "0") - if [[ "${commit_count}" -lt "${squash_n}" ]]; then - err "Cannot squash ${squash_n} commits -- branch only has ${commit_count} commit(s)" - exit 1 - fi - - # Count pushed commits in the squash range - local pushed_count=0 - if git rev-parse "origin/${current_branch}" >/dev/null 2>&1; then - # Count how many of the last N commits exist on origin - pushed_count=$(git rev-list --count "origin/${current_branch}..HEAD" 2>/dev/null || echo "0") - # Clamp to squash range - if [[ "${pushed_count}" -gt "${squash_n}" ]]; then - pushed_count="${squash_n}" - fi - fi - - # Show commits to be squashed - echo " Commits to squash:" | tee -a "$logfile" - git log --oneline -"${squash_n}" 2>/dev/null | sed 's/^/ /' | tee -a "$logfile" - echo "" | tee -a "$logfile" - - if [[ "${dry_run}" -eq 1 ]]; then - echo "--- Dry run: no changes made ---" - local squash_flag="" - [[ "${autosquash}" -eq 1 ]] && squash_flag=" --autosquash" - echo "Would run:" - echo " git rebase -i${squash_flag} HEAD~${squash_n}" - if [[ "${pushed_count}" -gt 0 ]]; then - echo " (then: git push --force-with-lease -- ${pushed_count} commits already pushed)" - fi - exit 0 - fi - - # Warn about pushed commits - _check_pushed_commits "${pushed_count}" "${non_interactive}" - - # Handle dirty tree - _handle_dirty_tree "${autostash}" - - if [[ "${non_interactive}" -eq 0 ]] && [[ "${autosquash}" -eq 0 ]]; then - echo " An editor will open for you to mark commits (squash, fixup, reword, etc.)" - echo " Change 'pick' to 'squash' (or 's') to fold a commit into the one above it." - echo "" - read -r -p " Open interactive rebase for last ${squash_n} commits? (yes/no): " squash_confirm - if [[ "${squash_confirm}" != "yes" ]]; then - echo "Cancelled" - _restore_stash_if_needed - exit 0 - fi - elif [[ "${non_interactive}" -eq 1 ]] && [[ "${autosquash}" -eq 0 ]]; then - err "Interactive squash requires an editor -- use --autosquash for non-interactive squash" - err "(commits must be prefixed with 'squash!' or 'fixup!' for --autosquash to work)" - exit 1 - fi - - # Create backup - _create_backup_tag - - log_section_start "GIT REBASE INTERACTIVE" "$logfile" - - local rebase_exit=0 - local rebase_args=(-i "HEAD~${squash_n}") - [[ "${autosquash}" -eq 1 ]] && rebase_args=(-i --autosquash "HEAD~${squash_n}") - - # shellcheck disable=SC2068 # Intentional: rebase_args expands correctly - if ! git rebase "${rebase_args[@]}" 2>&1 | tee -a "$logfile"; then - rebase_exit=1 - fi - - log_section_end "GIT REBASE INTERACTIVE" "$logfile" "${rebase_exit}" - - if [[ ${rebase_exit} -ne 0 ]]; then - echo "" | tee -a "$logfile" - echo "[FAIL] INTERACTIVE REBASE HIT CONFLICTS" | tee -a "$logfile" - echo "" | tee -a "$logfile" - echo " Resolve conflicts, then:" - echo " git add " - echo " ./scripts/git/rebase_safe.sh --continue" - echo "" - echo " To abort and restore:" - echo " ./scripts/git/rebase_safe.sh --abort" - echo "" - echo "Full log: $logfile" - _rebase_stash_created=0 - exit 1 - fi - - _restore_stash_if_needed - - echo "" | tee -a "$logfile" - echo "[OK] SQUASH COMPLETE" | tee -a "$logfile" - echo " $(git log -1 --oneline)" | tee -a "$logfile" - echo "" | tee -a "$logfile" - - if [[ "${pushed_count}" -gt 0 ]]; then - echo " [!] Your branch was previously pushed -- force-push required:" - echo " ./scripts/git/push_validated.sh --force-with-lease" - echo "" - fi - - echo "Full log: $logfile" + local squash_n="$1" autosquash="$2" autostash="$3" non_interactive="$4" dry_run="$5" + + echo "=== Squash Last ${squash_n} Commits ===" | tee -a "$logfile" + echo "" | tee -a "$logfile" + + local current_branch + current_branch=$(git branch --show-current 2>/dev/null || true) + + if [[ -z "${current_branch}" ]]; then + err "Cannot rebase in detached HEAD state. Check out a branch first." + exit 1 + fi + + # Validate N + if ! [[ "${squash_n}" =~ ^[0-9]+$ ]] || [[ "${squash_n}" -lt 2 ]]; then + err "--squash-last requires a number >= 2 (got: ${squash_n})" + exit 1 + fi + + local commit_count + commit_count=$(git rev-list --count HEAD 2>/dev/null || echo "0") + if [[ "${commit_count}" -lt "${squash_n}" ]]; then + err "Cannot squash ${squash_n} commits -- branch only has ${commit_count} commit(s)" + exit 1 + fi + + # Count pushed commits in the squash range + local pushed_count=0 + if git rev-parse "origin/${current_branch}" >/dev/null 2>&1; then + # Count how many of the last N commits exist on origin + pushed_count=$(git rev-list --count "origin/${current_branch}..HEAD" 2>/dev/null || echo "0") + # Clamp to squash range + if [[ "${pushed_count}" -gt "${squash_n}" ]]; then + pushed_count="${squash_n}" + fi + fi + + # Show commits to be squashed + echo " Commits to squash:" | tee -a "$logfile" + git log --oneline -"${squash_n}" 2>/dev/null | sed 's/^/ /' | tee -a "$logfile" + echo "" | tee -a "$logfile" + + if [[ "${dry_run}" -eq 1 ]]; then + echo "--- Dry run: no changes made ---" + local squash_flag="" + [[ "${autosquash}" -eq 1 ]] && squash_flag=" --autosquash" + echo "Would run:" + echo " git rebase -i${squash_flag} HEAD~${squash_n}" + if [[ "${pushed_count}" -gt 0 ]]; then + echo " (then: git push --force-with-lease -- ${pushed_count} commits already pushed)" + fi + exit 0 + fi + + # Warn about pushed commits + _check_pushed_commits "${pushed_count}" "${non_interactive}" + + # Handle dirty tree + _handle_dirty_tree "${autostash}" + + if [[ "${non_interactive}" -eq 0 ]] && [[ "${autosquash}" -eq 0 ]]; then + echo " An editor will open for you to mark commits (squash, fixup, reword, etc.)" + echo " Change 'pick' to 'squash' (or 's') to fold a commit into the one above it." + echo "" + read -r -p " Open interactive rebase for last ${squash_n} commits? (yes/no): " squash_confirm + if [[ "${squash_confirm}" != "yes" ]]; then + echo "Cancelled" + _restore_stash_if_needed + exit 0 + fi + elif [[ "${non_interactive}" -eq 1 ]] && [[ "${autosquash}" -eq 0 ]]; then + err "Interactive squash requires an editor -- use --autosquash for non-interactive squash" + err "(commits must be prefixed with 'squash!' or 'fixup!' for --autosquash to work)" + exit 1 + fi + + # Create backup + _create_backup_tag + + log_section_start "GIT REBASE INTERACTIVE" "$logfile" + + local rebase_exit=0 + local rebase_args=(-i "HEAD~${squash_n}") + [[ "${autosquash}" -eq 1 ]] && rebase_args=(-i --autosquash "HEAD~${squash_n}") + + # shellcheck disable=SC2068 # Intentional: rebase_args expands correctly + if ! git rebase "${rebase_args[@]}" 2>&1 | tee -a "$logfile"; then + rebase_exit=1 + fi + + log_section_end "GIT REBASE INTERACTIVE" "$logfile" "${rebase_exit}" + + if [[ ${rebase_exit} -ne 0 ]]; then + echo "" | tee -a "$logfile" + echo "[FAIL] INTERACTIVE REBASE HIT CONFLICTS" | tee -a "$logfile" + echo "" | tee -a "$logfile" + echo " Resolve conflicts, then:" + echo " git add " + echo " ./scripts/git/rebase_safe.sh --continue" + echo "" + echo " To abort and restore:" + echo " ./scripts/git/rebase_safe.sh --abort" + echo "" + echo "Full log: $logfile" + _rebase_stash_created=0 + exit 1 + fi + + _restore_stash_if_needed + + echo "" | tee -a "$logfile" + echo "[OK] SQUASH COMPLETE" | tee -a "$logfile" + echo " $(git log -1 --oneline)" | tee -a "$logfile" + echo "" | tee -a "$logfile" + + if [[ "${pushed_count}" -gt 0 ]]; then + echo " [!] Your branch was previously pushed -- force-push required:" + echo " ./scripts/git/push_validated.sh --force-with-lease" + echo "" + fi + + echo "Full log: $logfile" } # --------------------------------------------------------------------------- # Operation: --abort # --------------------------------------------------------------------------- _cmd_abort() { - echo "=== Abort Rebase ===" | tee -a "$logfile" - echo "" - - if [[ ! -d "${PROJECT_ROOT}/.git/rebase-merge" ]] && [[ ! -d "${PROJECT_ROOT}/.git/rebase-apply" ]]; then - echo " No rebase in progress." - exit 0 - fi - - echo " Aborting rebase..." | tee -a "$logfile" - if git rebase --abort 2>&1 | tee -a "$logfile"; then - echo "" - echo "[OK] Rebase aborted -- returned to: $(git branch --show-current 2>/dev/null || echo 'previous state')" - if [[ ${_rebase_stash_created} -eq 1 ]]; then - echo "" - echo " Your auto-stash is still saved. To restore:" - echo " git stash pop" - fi - else - err "rebase --abort failed -- run 'git rebase --abort' manually" - exit 1 - fi + echo "=== Abort Rebase ===" | tee -a "$logfile" + echo "" + + if [[ ! -d "${PROJECT_ROOT}/.git/rebase-merge" ]] && [[ ! -d "${PROJECT_ROOT}/.git/rebase-apply" ]]; then + echo " No rebase in progress." + exit 0 + fi + + echo " Aborting rebase..." | tee -a "$logfile" + if git rebase --abort 2>&1 | tee -a "$logfile"; then + echo "" + echo "[OK] Rebase aborted -- returned to: $(git branch --show-current 2>/dev/null || echo 'previous state')" + if [[ ${_rebase_stash_created} -eq 1 ]]; then + echo "" + echo " Your auto-stash is still saved. To restore:" + echo " git stash pop" + fi + else + err "rebase --abort failed -- run 'git rebase --abort' manually" + exit 1 + fi } # --------------------------------------------------------------------------- # Operation: --continue # --------------------------------------------------------------------------- _cmd_continue() { - echo "=== Continue Rebase ===" | tee -a "$logfile" - echo "" - - if [[ ! -d "${PROJECT_ROOT}/.git/rebase-merge" ]] && [[ ! -d "${PROJECT_ROOT}/.git/rebase-apply" ]]; then - echo " No rebase in progress." - exit 0 - fi - - # Check for unresolved conflicts - local unresolved - unresolved=$(git diff --name-only --diff-filter=U 2>/dev/null || true) - if [[ -n "${unresolved}" ]]; then - err "Unresolved conflicts still present -- resolve and 'git add' them first:" - echo "${unresolved}" | sed 's/^/ /' - exit 1 - fi - - echo " Continuing rebase..." | tee -a "$logfile" - if GIT_EDITOR=true git rebase --continue 2>&1 | tee -a "$logfile"; then - echo "" - echo "[OK] Rebase continued" - # Check if rebase is now complete - if [[ ! -d "${PROJECT_ROOT}/.git/rebase-merge" ]] && [[ ! -d "${PROJECT_ROOT}/.git/rebase-apply" ]]; then - echo " Rebase complete!" - _restore_stash_if_needed - else - echo " More conflicts to resolve -- fix them, then run --continue again." - fi - else - err "rebase --continue failed -- check for remaining conflicts" - echo "" - echo " Conflicting files:" - git diff --name-only --diff-filter=U 2>/dev/null | sed 's/^/ /' || true - echo "" - echo " To abort: ./scripts/git/rebase_safe.sh --abort" - exit 1 - fi + echo "=== Continue Rebase ===" | tee -a "$logfile" + echo "" + + if [[ ! -d "${PROJECT_ROOT}/.git/rebase-merge" ]] && [[ ! -d "${PROJECT_ROOT}/.git/rebase-apply" ]]; then + echo " No rebase in progress." + exit 0 + fi + + # Check for unresolved conflicts + local unresolved + unresolved=$(git diff --name-only --diff-filter=U 2>/dev/null || true) + if [[ -n "${unresolved}" ]]; then + err "Unresolved conflicts still present -- resolve and 'git add' them first:" + # shellcheck disable=SC2001 # sed needed for per-line prefix on multi-line string + echo "${unresolved}" | sed 's/^/ /' + exit 1 + fi + + echo " Continuing rebase..." | tee -a "$logfile" + if GIT_EDITOR=true git rebase --continue 2>&1 | tee -a "$logfile"; then + echo "" + echo "[OK] Rebase continued" + # Check if rebase is now complete + if [[ ! -d "${PROJECT_ROOT}/.git/rebase-merge" ]] && [[ ! -d "${PROJECT_ROOT}/.git/rebase-apply" ]]; then + echo " Rebase complete!" + _restore_stash_if_needed + else + echo " More conflicts to resolve -- fix them, then run --continue again." + fi + else + err "rebase --continue failed -- check for remaining conflicts" + echo "" + echo " Conflicting files:" + git diff --name-only --diff-filter=U 2>/dev/null | sed 's/^/ /' || true + echo "" + echo " To abort: ./scripts/git/rebase_safe.sh --abort" + exit 1 + fi } # --------------------------------------------------------------------------- # Operation: --skip # --------------------------------------------------------------------------- _cmd_skip() { - echo "=== Skip Rebase Commit ===" | tee -a "$logfile" - echo "" - - if [[ ! -d "${PROJECT_ROOT}/.git/rebase-merge" ]] && [[ ! -d "${PROJECT_ROOT}/.git/rebase-apply" ]]; then - echo " No rebase in progress." - exit 0 - fi - - echo " [!] Skipping current commit -- its changes will be dropped." - echo " Current patch:" - git log ORIG_HEAD -1 --oneline 2>/dev/null | sed 's/^/ /' || true - echo "" - - if git rebase --skip 2>&1 | tee -a "$logfile"; then - echo "" - echo "[OK] Commit skipped" - if [[ ! -d "${PROJECT_ROOT}/.git/rebase-merge" ]] && [[ ! -d "${PROJECT_ROOT}/.git/rebase-apply" ]]; then - echo " Rebase complete!" - _restore_stash_if_needed - fi - else - err "rebase --skip failed" - exit 1 - fi + echo "=== Skip Rebase Commit ===" | tee -a "$logfile" + echo "" + + if [[ ! -d "${PROJECT_ROOT}/.git/rebase-merge" ]] && [[ ! -d "${PROJECT_ROOT}/.git/rebase-apply" ]]; then + echo " No rebase in progress." + exit 0 + fi + + echo " [!] Skipping current commit -- its changes will be dropped." + echo " Current patch:" + git log ORIG_HEAD -1 --oneline 2>/dev/null | sed 's/^/ /' || true + echo "" + + if git rebase --skip 2>&1 | tee -a "$logfile"; then + echo "" + echo "[OK] Commit skipped" + if [[ ! -d "${PROJECT_ROOT}/.git/rebase-merge" ]] && [[ ! -d "${PROJECT_ROOT}/.git/rebase-apply" ]]; then + echo " Rebase complete!" + _restore_stash_if_needed + fi + else + err "rebase --skip failed" + exit 1 + fi } main "$@" diff --git a/scripts/git/repo_health.sh b/scripts/git/repo_health.sh index 2a35036..a1722ae 100644 --- a/scripts/git/repo_health.sh +++ b/scripts/git/repo_health.sh @@ -23,200 +23,197 @@ source "${SCRIPT_DIR}/_common.sh" # human_size - Convert bytes to human-readable string (POSIX: uses awk, not bc) human_size() { - local bytes="$1" - if ((bytes >= 1073741824)); then - awk "BEGIN{printf \"%.1f GB\", ${bytes}/1073741824}" - elif ((bytes >= 1048576)); then - awk "BEGIN{printf \"%.1f MB\", ${bytes}/1048576}" - elif ((bytes >= 1024)); then - awk "BEGIN{printf \"%.1f KB\", ${bytes}/1024}" - else - printf "%d B" "${bytes}" - fi + local bytes="$1" + if ((bytes >= 1073741824)); then + awk "BEGIN{printf \"%.1f GB\", ${bytes}/1073741824}" + elif ((bytes >= 1048576)); then + awk "BEGIN{printf \"%.1f MB\", ${bytes}/1048576}" + elif ((bytes >= 1024)); then + awk "BEGIN{printf \"%.1f KB\", ${bytes}/1024}" + else + printf "%d B" "${bytes}" + fi } main() { - local run_gc=0 - local full_fsck=0 - local large_threshold_mb=10 - - while [[ $# -gt 0 ]]; do - case "$1" in - --help | -h) - echo "Usage: ./scripts/git/repo_health.sh [OPTIONS]" - echo "" - echo "Check repository health, find large files, and optionally run maintenance." - echo "" - echo "Options:" - echo " --gc Run git gc (garbage collection) -- removes unreachable objects" - echo " --full Run git fsck --full (slower, more thorough)" - echo " --large Report files >N MB in git history (default: 10)" - echo " -h, --help Show this help" - echo "" - echo "Checks performed:" - echo " 1. Repository integrity (git fsck)" - echo " 2. Object store size" - echo " 3. Large files in git history" - echo " 4. Stale backup tags count" - echo " 5. Branch divergence summary" - exit 0 - ;; - --gc) run_gc=1 ;; - --full) full_fsck=1 ;; - --large) - large_threshold_mb="${2:-10}" - shift - ;; - *) - echo "[ERROR] Unknown flag: $1" >&2 - exit 1 - ;; - esac - shift - done - - cd "${PROJECT_ROOT}" || { - err "Cannot find project root" - exit 1 - } - - local overall_ok=0 - - echo "=== Repository Health Check ===" - echo "Repository: ${PROJECT_ROOT}" - echo "Date: $(date)" - echo "" - - # -- [1] Integrity check (git fsck) --------------------------------------- - echo "--- [1/5] Integrity Check ---" - local fsck_args=("--no-reflogs") - [[ ${full_fsck} -eq 1 ]] && fsck_args=("--full") - - local fsck_output - if fsck_output=$(git fsck "${fsck_args[@]}" 2>&1); then - echo " [OK] No integrity issues found" - else - echo " [!] Integrity issues detected:" - echo "${fsck_output}" | grep -v "^Checking" | head -20 - overall_ok=1 - fi - echo "" - - # -- [2] Object store size ------------------------------------------------- - echo "--- [2/5] Object Store Size ---" - local git_dir - git_dir="$(git rev-parse --git-dir 2>/dev/null)" - - # Use git count-objects (cross-platform, no GNU du needed) - local obj_size_kb=0 pack_count=0 - while IFS=': ' read -r key val; do - case "${key}" in - size) obj_size_kb=$((obj_size_kb + val)) ;; - size-pack) obj_size_kb=$((obj_size_kb + val)) ;; - packs) pack_count="${val}" ;; - esac - done < <(git count-objects -v 2>/dev/null) - local obj_size_bytes=$((obj_size_kb * 1024)) - echo " Objects: $(human_size "${obj_size_bytes}")" - echo " Packs: ${pack_count}" - - if [[ ${pack_count} -gt 3 ]]; then - echo " [!] Many pack files (${pack_count}) -- consider running: git gc" - fi - - # Worktree size -- use POSIX du -sk (1024-byte blocks), available everywhere - local wt_size_kb - wt_size_kb=$(du -sk . 2>/dev/null | cut -f1 || echo "0") - local wt_size_bytes=$((wt_size_kb * 1024)) - echo " Worktree: $(human_size "${wt_size_bytes}") (includes .git)" - echo "" - - # -- [3] Large files in history -------------------------------------------- - echo "--- [3/5] Large Files in History (>${large_threshold_mb}MB) ---" - local threshold_bytes=$((large_threshold_mb * 1048576)) - local large_count=0 - - # Use git cat-file to find large blobs - while IFS=' ' read -r size hash; do - if [[ ${size} -gt ${threshold_bytes} ]]; then - # Find the path for this blob - local path - path=$(git log --all --find-object="${hash}" --oneline --name-only 2>/dev/null | grep -v "^[0-9a-f]" | head -1 || echo "(unknown path)") - printf " %s %s\n" "$(human_size "${size}")" "${path:-${hash}}" - ((large_count++)) || true - fi - done < <(git cat-file --batch-check='%(objectsize) %(objectname)' --batch-all-objects 2>/dev/null | awk '$1 ~ /^[0-9]+$/') - - if [[ ${large_count} -eq 0 ]]; then - echo " [OK] No files exceed ${large_threshold_mb}MB threshold" - else - echo "" - echo " [!] ${large_count} large file(s) found in git history" - echo " Consider: git lfs track for future additions" - overall_ok=1 - fi - echo "" - - # -- [4] Backup tag count -------------------------------------------------- - echo "--- [4/5] Backup Tags ---" - local backup_count - backup_count=$(git tag -l "pre-merge-backup-*" "pre-cherry-pick-*" "pre-docs-merge-*" "pre-bisect-*" "pre-rebase-*" "pre-undo-commit-*" 2>/dev/null | wc -l | tr -d ' ') - echo " Backup tags: ${backup_count}" - - if [[ ${backup_count} -gt 20 ]]; then - echo " [!] Many backup tags -- consider running:" - echo " ./scripts/git/branch_cleanup.sh --tags --execute" - elif [[ ${backup_count} -gt 0 ]]; then - echo " Most recent: $(git tag -l 'pre-merge-backup-*' 'pre-cherry-pick-*' 'pre-docs-merge-*' 'pre-bisect-*' 'pre-rebase-*' 'pre-undo-commit-*' | sort -r | head -1)" - fi - echo "" - - # -- [5] Branch summary ---------------------------------------------------- - echo "--- [5/5] Branch Status ---" - local current_branch - current_branch=$(git branch --show-current 2>/dev/null || echo "(detached)") - echo " Current: ${current_branch}" - - for branch in "${CGW_SOURCE_BRANCH}" "${CGW_TARGET_BRANCH}"; do - if git show-ref --verify --quiet "refs/heads/${branch}" 2>/dev/null; then - local ahead behind remote_ref="refs/remotes/origin/${branch}" - if git show-ref --verify --quiet "${remote_ref}" 2>/dev/null; then - ahead=$(git rev-list --count "origin/${branch}..${branch}" 2>/dev/null || echo "?") - behind=$(git rev-list --count "${branch}..origin/${branch}" 2>/dev/null || echo "?") - echo " ${branch}: ${ahead} ahead, ${behind} behind origin" - else - echo " ${branch}: (no remote tracking branch)" - fi - fi - done - echo "" - - # -- Garbage collection (optional) ---------------------------------------- - if [[ ${run_gc} -eq 1 ]]; then - echo "--- Garbage Collection ---" - echo "Running git gc --auto..." - if git gc --auto 2>&1 | grep -v "^Auto packing\|^Counting\|^Delta\|^Compressing\|^Writing\|^Total" | head -10; then - echo " [OK] Garbage collection complete" - else - echo " [OK] Nothing to collect" - fi - echo "" - fi - - # -- Summary --------------------------------------------------------------- - echo "=== Summary ===" - if [[ ${overall_ok} -eq 0 ]]; then - echo "[OK] Repository is healthy" - else - echo "[!] Issues found -- review output above" - echo "" - echo "Common fixes:" - echo " Integrity issues: git fsck --full" - echo " Pack files: git gc" - echo " Large files: git lfs track '*.ext'" - fi - - return ${overall_ok} + local run_gc=0 + local full_fsck=0 + local large_threshold_mb=10 + + while [[ $# -gt 0 ]]; do + case "$1" in + --help | -h) + echo "Usage: ./scripts/git/repo_health.sh [OPTIONS]" + echo "" + echo "Check repository health, find large files, and optionally run maintenance." + echo "" + echo "Options:" + echo " --gc Run git gc (garbage collection) -- removes unreachable objects" + echo " --full Run git fsck --full (slower, more thorough)" + echo " --large Report files >N MB in git history (default: 10)" + echo " -h, --help Show this help" + echo "" + echo "Checks performed:" + echo " 1. Repository integrity (git fsck)" + echo " 2. Object store size" + echo " 3. Large files in git history" + echo " 4. Stale backup tags count" + echo " 5. Branch divergence summary" + exit 0 + ;; + --gc) run_gc=1 ;; + --full) full_fsck=1 ;; + --large) + large_threshold_mb="${2:-10}" + shift + ;; + *) + echo "[ERROR] Unknown flag: $1" >&2 + exit 1 + ;; + esac + shift + done + + cd "${PROJECT_ROOT}" || { + err "Cannot find project root" + exit 1 + } + + local overall_ok=0 + + echo "=== Repository Health Check ===" + echo "Repository: ${PROJECT_ROOT}" + echo "Date: $(date)" + echo "" + + # -- [1] Integrity check (git fsck) --------------------------------------- + echo "--- [1/5] Integrity Check ---" + local fsck_args=("--no-reflogs") + [[ ${full_fsck} -eq 1 ]] && fsck_args=("--full") + + local fsck_output + if fsck_output=$(git fsck "${fsck_args[@]}" 2>&1); then + echo " [OK] No integrity issues found" + else + echo " [!] Integrity issues detected:" + echo "${fsck_output}" | grep -v "^Checking" | head -20 + overall_ok=1 + fi + echo "" + + # -- [2] Object store size ------------------------------------------------- + echo "--- [2/5] Object Store Size ---" + # Use git count-objects (cross-platform, no GNU du needed) + local obj_size_kb=0 pack_count=0 + while IFS=': ' read -r key val; do + case "${key}" in + size) obj_size_kb=$((obj_size_kb + val)) ;; + size-pack) obj_size_kb=$((obj_size_kb + val)) ;; + packs) pack_count="${val}" ;; + esac + done < <(git count-objects -v 2>/dev/null) + local obj_size_bytes=$((obj_size_kb * 1024)) + echo " Objects: $(human_size "${obj_size_bytes}")" + echo " Packs: ${pack_count}" + + if [[ ${pack_count} -gt 3 ]]; then + echo " [!] Many pack files (${pack_count}) -- consider running: git gc" + fi + + # Worktree size -- use POSIX du -sk (1024-byte blocks), available everywhere + local wt_size_kb + wt_size_kb=$(du -sk . 2>/dev/null | cut -f1 || echo "0") + local wt_size_bytes=$((wt_size_kb * 1024)) + echo " Worktree: $(human_size "${wt_size_bytes}") (includes .git)" + echo "" + + # -- [3] Large files in history -------------------------------------------- + echo "--- [3/5] Large Files in History (>${large_threshold_mb}MB) ---" + local threshold_bytes=$((large_threshold_mb * 1048576)) + local large_count=0 + + # Use git cat-file to find large blobs + while IFS=' ' read -r size hash; do + if [[ ${size} -gt ${threshold_bytes} ]]; then + # Find the path for this blob + local path + path=$(git log --all --find-object="${hash}" --oneline --name-only 2>/dev/null | grep -v "^[0-9a-f]" | head -1 || echo "(unknown path)") + printf " %s %s\n" "$(human_size "${size}")" "${path:-${hash}}" + ((large_count++)) || true + fi + done < <(git cat-file --batch-check='%(objectsize) %(objectname)' --batch-all-objects 2>/dev/null | awk '$1 ~ /^[0-9]+$/') + + if [[ ${large_count} -eq 0 ]]; then + echo " [OK] No files exceed ${large_threshold_mb}MB threshold" + else + echo "" + echo " [!] ${large_count} large file(s) found in git history" + echo " Consider: git lfs track for future additions" + overall_ok=1 + fi + echo "" + + # -- [4] Backup tag count -------------------------------------------------- + echo "--- [4/5] Backup Tags ---" + local backup_count + backup_count=$(git tag -l "pre-merge-backup-*" "pre-cherry-pick-*" "pre-docs-merge-*" "pre-bisect-*" "pre-rebase-*" "pre-undo-commit-*" 2>/dev/null | wc -l | tr -d ' ') + echo " Backup tags: ${backup_count}" + + if [[ ${backup_count} -gt 20 ]]; then + echo " [!] Many backup tags -- consider running:" + echo " ./scripts/git/branch_cleanup.sh --tags --execute" + elif [[ ${backup_count} -gt 0 ]]; then + echo " Most recent: $(git tag -l 'pre-merge-backup-*' 'pre-cherry-pick-*' 'pre-docs-merge-*' 'pre-bisect-*' 'pre-rebase-*' 'pre-undo-commit-*' | sort -r | head -1)" + fi + echo "" + + # -- [5] Branch summary ---------------------------------------------------- + echo "--- [5/5] Branch Status ---" + local current_branch + current_branch=$(git branch --show-current 2>/dev/null || echo "(detached)") + echo " Current: ${current_branch}" + + for branch in "${CGW_SOURCE_BRANCH}" "${CGW_TARGET_BRANCH}"; do + if git show-ref --verify --quiet "refs/heads/${branch}" 2>/dev/null; then + local ahead behind remote_ref="refs/remotes/origin/${branch}" + if git show-ref --verify --quiet "${remote_ref}" 2>/dev/null; then + ahead=$(git rev-list --count "origin/${branch}..${branch}" 2>/dev/null || echo "?") + behind=$(git rev-list --count "${branch}..origin/${branch}" 2>/dev/null || echo "?") + echo " ${branch}: ${ahead} ahead, ${behind} behind origin" + else + echo " ${branch}: (no remote tracking branch)" + fi + fi + done + echo "" + + # -- Garbage collection (optional) ---------------------------------------- + if [[ ${run_gc} -eq 1 ]]; then + echo "--- Garbage Collection ---" + echo "Running git gc --auto..." + if git gc --auto 2>&1 | grep -v "^Auto packing\|^Counting\|^Delta\|^Compressing\|^Writing\|^Total" | head -10; then + echo " [OK] Garbage collection complete" + else + echo " [OK] Nothing to collect" + fi + echo "" + fi + + # -- Summary --------------------------------------------------------------- + echo "=== Summary ===" + if [[ ${overall_ok} -eq 0 ]]; then + echo "[OK] Repository is healthy" + else + echo "[!] Issues found -- review output above" + echo "" + echo "Common fixes:" + echo " Integrity issues: git fsck --full" + echo " Pack files: git gc" + echo " Large files: git lfs track '*.ext'" + fi + + return ${overall_ok} } main "$@" diff --git a/scripts/git/undo_last.sh b/scripts/git/undo_last.sh index 645ad72..df8c0be 100644 --- a/scripts/git/undo_last.sh +++ b/scripts/git/undo_last.sh @@ -29,388 +29,395 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "${SCRIPT_DIR}/_common.sh" _show_help() { - echo "Usage: ./scripts/git/undo_last.sh [OPTIONS]" - echo "" - echo "Safe undo operations with backup tags." - echo "" - echo "Subcommands:" - echo " commit Undo the last commit, keeping all changes staged" - echo " (git reset --soft HEAD~1 -- history-rewriting, local only)" - echo " unstage ... Remove file(s) from staging area" - echo " discard ... Discard working-tree changes to file(s)" - echo " WARNING: This cannot be undone!" - echo " amend-message Replace the last commit message" - echo " (local only -- do not use after pushing)" - echo "" - echo "Options:" - echo " --non-interactive Skip confirmation prompts" - echo " --dry-run Preview without making changes" - echo " -h, --help Show this help" - echo "" - echo "Examples:" - echo " ./scripts/git/undo_last.sh commit" - echo " ./scripts/git/undo_last.sh unstage src/file.py" - echo " ./scripts/git/undo_last.sh discard src/file.py" - echo " ./scripts/git/undo_last.sh amend-message 'fix: correct typo in header'" + echo "Usage: ./scripts/git/undo_last.sh [OPTIONS]" + echo "" + echo "Safe undo operations with backup tags." + echo "" + echo "Subcommands:" + echo " commit Undo the last commit, keeping all changes staged" + echo " (git reset --soft HEAD~1 -- history-rewriting, local only)" + echo " unstage ... Remove file(s) from staging area" + echo " discard ... Discard working-tree changes to file(s)" + echo " WARNING: This cannot be undone!" + echo " amend-message Replace the last commit message" + echo " (local only -- do not use after pushing)" + echo "" + echo "Options:" + echo " --non-interactive Skip confirmation prompts" + echo " --dry-run Preview without making changes" + echo " -h, --help Show this help" + echo "" + echo "Examples:" + echo " ./scripts/git/undo_last.sh commit" + echo " ./scripts/git/undo_last.sh unstage src/file.py" + echo " ./scripts/git/undo_last.sh discard src/file.py" + echo " ./scripts/git/undo_last.sh amend-message 'fix: correct typo in header'" } main() { - if [[ $# -eq 0 ]] || [[ "${1:-}" == "--help" ]] || [[ "${1:-}" == "-h" ]]; then - _show_help - exit 0 - fi - - local subcommand="$1" - shift - - local non_interactive=0 - local dry_run=0 - - [[ "${CGW_NON_INTERACTIVE:-0}" == "1" ]] && non_interactive=1 - - # Pre-scan remaining args for global flags - local -a positional=() - while [[ $# -gt 0 ]]; do - case "$1" in - --non-interactive) non_interactive=1 ;; - --dry-run) dry_run=1 ;; - --help | -h) _show_help; exit 0 ;; - --*) echo "[ERROR] Unknown flag: $1" >&2; exit 1 ;; - *) positional+=("$1") ;; - esac - shift - done - - cd "${PROJECT_ROOT}" || { - err "Cannot find project root" - exit 1 - } - - init_logging "undo_last" - - { - echo "=========================================" - echo "Undo Last Log" - echo "=========================================" - echo "Start Time: $(date)" - echo "Branch: $(git branch --show-current)" - } >"$logfile" - - case "${subcommand}" in - commit) _cmd_undo_commit "${non_interactive}" "${dry_run}" ;; - unstage) _cmd_unstage "${non_interactive}" "${dry_run}" "${positional[@]+"${positional[@]}"}" ;; - discard) _cmd_discard "${non_interactive}" "${dry_run}" "${positional[@]+"${positional[@]}"}" ;; - amend-message) _cmd_amend_message "${non_interactive}" "${dry_run}" "${positional[@]+"${positional[@]}"}" ;; - *) - echo "[ERROR] Unknown subcommand: ${subcommand}" >&2 - echo "Run with --help to see available subcommands" >&2 - exit 1 - ;; - esac + if [[ $# -eq 0 ]] || [[ "${1:-}" == "--help" ]] || [[ "${1:-}" == "-h" ]]; then + _show_help + exit 0 + fi + + local subcommand="$1" + shift + + local non_interactive=0 + local dry_run=0 + + [[ "${CGW_NON_INTERACTIVE:-0}" == "1" ]] && non_interactive=1 + + # Pre-scan remaining args for global flags + local -a positional=() + while [[ $# -gt 0 ]]; do + case "$1" in + --non-interactive) non_interactive=1 ;; + --dry-run) dry_run=1 ;; + --help | -h) + _show_help + exit 0 + ;; + --*) + echo "[ERROR] Unknown flag: $1" >&2 + exit 1 + ;; + *) positional+=("$1") ;; + esac + shift + done + + cd "${PROJECT_ROOT}" || { + err "Cannot find project root" + exit 1 + } + + init_logging "undo_last" + + { + echo "=========================================" + echo "Undo Last Log" + echo "=========================================" + echo "Start Time: $(date)" + echo "Branch: $(git branch --show-current)" + } >"$logfile" + + case "${subcommand}" in + commit) _cmd_undo_commit "${non_interactive}" "${dry_run}" ;; + unstage) _cmd_unstage "${non_interactive}" "${dry_run}" "${positional[@]+"${positional[@]}"}" ;; + discard) _cmd_discard "${non_interactive}" "${dry_run}" "${positional[@]+"${positional[@]}"}" ;; + amend-message) _cmd_amend_message "${non_interactive}" "${dry_run}" "${positional[@]+"${positional[@]}"}" ;; + *) + echo "[ERROR] Unknown subcommand: ${subcommand}" >&2 + echo "Run with --help to see available subcommands" >&2 + exit 1 + ;; + esac } # --------------------------------------------------------------------------- # Subcommand: commit # --------------------------------------------------------------------------- _cmd_undo_commit() { - local non_interactive="$1" dry_run="$2" - - echo "=== Undo Last Commit ===" - echo "" - - # Must have at least one commit to undo - if ! git rev-parse HEAD >/dev/null 2>&1; then - err "Repository has no commits to undo" - exit 1 - fi - - local commit_count - commit_count=$(git rev-list --count HEAD 2>/dev/null || echo "0") - if [[ "${commit_count}" -le 1 ]]; then - err "Cannot undo the initial commit (no parent to reset to)" - exit 1 - fi - - # Warn if commit appears to have been pushed - local current_branch upstream_ref - current_branch=$(git branch --show-current) - upstream_ref="refs/remotes/origin/${current_branch}" - if git show-ref --verify --quiet "${upstream_ref}" 2>/dev/null; then - local ahead - ahead=$(git rev-list --count "origin/${current_branch}..HEAD" 2>/dev/null || echo "0") - if [[ "${ahead}" -eq 0 ]]; then - echo "[!] WARNING: The last commit appears to have been pushed to origin." - echo " Undoing it locally will create a diverged state requiring force-push." - if [[ "${non_interactive}" -eq 0 ]]; then - read -r -p " Continue anyway? (yes/no): " pushed_confirm - if [[ "${pushed_confirm}" != "yes" ]]; then - echo "Cancelled" - exit 0 - fi - else - err "Aborting -- last commit has been pushed; use --revert in rollback_merge.sh instead" - exit 1 - fi - fi - fi - - echo "Last commit to undo:" - git log -1 --oneline - echo "" - echo "After undo: all changes will be staged (git reset --soft HEAD~1)" - echo "" - - if [[ "${dry_run}" -eq 1 ]]; then - echo "--- Dry run: no changes made ---" - echo "Would run: git reset --soft HEAD~1" - exit 0 - fi - - if [[ "${non_interactive}" -eq 0 ]]; then - read -r -p "Undo this commit? (yes/no): " confirm - if [[ "${confirm}" != "yes" ]]; then - echo "Cancelled" - exit 0 - fi - fi - - # Create backup tag before reset - get_timestamp - local backup_tag="pre-undo-commit-${timestamp}" - if git tag "${backup_tag}" 2>/dev/null; then - echo "[OK] Backup tag: ${backup_tag}" - else - echo "[!] Could not create backup tag (continuing)" - fi - - if git reset --soft HEAD~1; then - echo "" - echo "[OK] COMMIT UNDONE" - echo " All changes are now staged." - echo " Backup: git reset --hard ${backup_tag} (to restore)" - echo " Next: review staged files, edit if needed, then re-commit" - else - err "Reset failed" - exit 1 - fi + local non_interactive="$1" dry_run="$2" + + echo "=== Undo Last Commit ===" + echo "" + + # Must have at least one commit to undo + if ! git rev-parse HEAD >/dev/null 2>&1; then + err "Repository has no commits to undo" + exit 1 + fi + + local commit_count + commit_count=$(git rev-list --count HEAD 2>/dev/null || echo "0") + if [[ "${commit_count}" -le 1 ]]; then + err "Cannot undo the initial commit (no parent to reset to)" + exit 1 + fi + + # Warn if commit appears to have been pushed + local current_branch upstream_ref + current_branch=$(git branch --show-current) + upstream_ref="refs/remotes/origin/${current_branch}" + if git show-ref --verify --quiet "${upstream_ref}" 2>/dev/null; then + local ahead + ahead=$(git rev-list --count "origin/${current_branch}..HEAD" 2>/dev/null || echo "0") + if [[ "${ahead}" -eq 0 ]]; then + echo "[!] WARNING: The last commit appears to have been pushed to origin." + echo " Undoing it locally will create a diverged state requiring force-push." + if [[ "${non_interactive}" -eq 0 ]]; then + read -r -p " Continue anyway? (yes/no): " pushed_confirm + if [[ "${pushed_confirm}" != "yes" ]]; then + echo "Cancelled" + exit 0 + fi + else + err "Aborting -- last commit has been pushed; use --revert in rollback_merge.sh instead" + exit 1 + fi + fi + fi + + echo "Last commit to undo:" + git log -1 --oneline + echo "" + echo "After undo: all changes will be staged (git reset --soft HEAD~1)" + echo "" + + if [[ "${dry_run}" -eq 1 ]]; then + echo "--- Dry run: no changes made ---" + echo "Would run: git reset --soft HEAD~1" + exit 0 + fi + + if [[ "${non_interactive}" -eq 0 ]]; then + read -r -p "Undo this commit? (yes/no): " confirm + if [[ "${confirm}" != "yes" ]]; then + echo "Cancelled" + exit 0 + fi + fi + + # Create backup tag before reset + get_timestamp + local backup_tag="pre-undo-commit-${timestamp}" + if git tag "${backup_tag}" 2>/dev/null; then + echo "[OK] Backup tag: ${backup_tag}" + else + echo "[!] Could not create backup tag (continuing)" + fi + + if git reset --soft HEAD~1; then + echo "" + echo "[OK] COMMIT UNDONE" + echo " All changes are now staged." + echo " Backup: git reset --hard ${backup_tag} (to restore)" + echo " Next: review staged files, edit if needed, then re-commit" + else + err "Reset failed" + exit 1 + fi } # --------------------------------------------------------------------------- # Subcommand: unstage # --------------------------------------------------------------------------- _cmd_unstage() { - local non_interactive="$1" dry_run="$2" - shift 2 - local files=("$@") - - echo "=== Unstage Files ===" - echo "" - - if [[ ${#files[@]} -eq 0 ]]; then - # No files specified: show all staged and let user pick - local staged - staged=$(git diff --cached --name-only) - if [[ -z "${staged}" ]]; then - echo " Nothing is staged." - exit 0 - fi - echo " Staged files:" - echo "${staged}" | sed 's/^/ /' - echo "" - err "Specify file(s) to unstage. Example: ./scripts/git/undo_last.sh unstage " - exit 1 - fi - - # Validate each file is actually staged - local -a to_unstage=() - for f in "${files[@]}"; do - if git diff --cached --name-only | grep -qF "${f}"; then - to_unstage+=("${f}") - else - echo " [!] Not staged: ${f} (skipping)" - fi - done - - if [[ ${#to_unstage[@]} -eq 0 ]]; then - echo " Nothing to unstage." - exit 0 - fi - - echo " Files to unstage:" - for f in "${to_unstage[@]}"; do echo " ${f}"; done - echo "" - - if [[ "${dry_run}" -eq 1 ]]; then - echo "--- Dry run: no changes made ---" - echo "Would run: git reset HEAD ${to_unstage[*]}" - exit 0 - fi - - if [[ "${non_interactive}" -eq 0 ]]; then - read -r -p " Unstage ${#to_unstage[@]} file(s)? (yes/no): " confirm - if [[ "${confirm}" != "yes" ]]; then - echo "Cancelled" - exit 0 - fi - fi - - for f in "${to_unstage[@]}"; do - if git reset HEAD "${f}" 2>/dev/null; then - echo " [OK] Unstaged: ${f}" - else - echo " [FAIL] Failed: ${f}" >&2 - fi - done + local non_interactive="$1" dry_run="$2" + shift 2 + local files=("$@") + + echo "=== Unstage Files ===" + echo "" + + if [[ ${#files[@]} -eq 0 ]]; then + # No files specified: show all staged and let user pick + local staged + staged=$(git diff --cached --name-only) + if [[ -z "${staged}" ]]; then + echo " Nothing is staged." + exit 0 + fi + echo " Staged files:" + # shellcheck disable=SC2001 # sed needed for per-line prefix on multi-line string + echo "${staged}" | sed 's/^/ /' + echo "" + err "Specify file(s) to unstage. Example: ./scripts/git/undo_last.sh unstage " + exit 1 + fi + + # Validate each file is actually staged + local -a to_unstage=() + for f in "${files[@]}"; do + if git diff --cached --name-only | grep -qF "${f}"; then + to_unstage+=("${f}") + else + echo " [!] Not staged: ${f} (skipping)" + fi + done + + if [[ ${#to_unstage[@]} -eq 0 ]]; then + echo " Nothing to unstage." + exit 0 + fi + + echo " Files to unstage:" + for f in "${to_unstage[@]}"; do echo " ${f}"; done + echo "" + + if [[ "${dry_run}" -eq 1 ]]; then + echo "--- Dry run: no changes made ---" + echo "Would run: git reset HEAD ${to_unstage[*]}" + exit 0 + fi + + if [[ "${non_interactive}" -eq 0 ]]; then + read -r -p " Unstage ${#to_unstage[@]} file(s)? (yes/no): " confirm + if [[ "${confirm}" != "yes" ]]; then + echo "Cancelled" + exit 0 + fi + fi + + for f in "${to_unstage[@]}"; do + if git reset HEAD "${f}" 2>/dev/null; then + echo " [OK] Unstaged: ${f}" + else + echo " [FAIL] Failed: ${f}" >&2 + fi + done } # --------------------------------------------------------------------------- # Subcommand: discard # --------------------------------------------------------------------------- _cmd_discard() { - local non_interactive="$1" dry_run="$2" - shift 2 - local files=("$@") - - echo "=== Discard Working-Tree Changes ===" - echo "" - echo " [!] WARNING: This permanently discards uncommitted changes." - echo " Changes cannot be recovered after this operation." - echo "" - - if [[ ${#files[@]} -eq 0 ]]; then - err "Specify file(s) to discard. Example: ./scripts/git/undo_last.sh discard " - exit 1 - fi - - # Validate each file has modifications - local -a to_discard=() - for f in "${files[@]}"; do - if [[ ! -f "${f}" ]] && [[ ! -d "${f}" ]]; then - echo " [!] Not found: ${f} (skipping)" - continue - fi - if git diff --name-only -- "${f}" | grep -qF "${f}"; then - to_discard+=("${f}") - else - echo " [!] No unstaged changes: ${f} (skipping)" - fi - done - - if [[ ${#to_discard[@]} -eq 0 ]]; then - echo " Nothing to discard." - exit 0 - fi - - echo " Files to discard changes in:" - for f in "${to_discard[@]}"; do - git diff --stat -- "${f}" | head -3 | sed 's/^/ /' - done - echo "" - - if [[ "${dry_run}" -eq 1 ]]; then - echo "--- Dry run: no changes made ---" - echo "Would run: git checkout -- ${to_discard[*]}" - exit 0 - fi - - if [[ "${non_interactive}" -eq 0 ]]; then - read -r -p " PERMANENTLY discard changes in ${#to_discard[@]} file(s)? (yes/no): " confirm - if [[ "${confirm}" != "yes" ]]; then - echo "Cancelled" - exit 0 - fi - else - err "Refusing to discard in non-interactive mode (data loss risk)" - err "Run interactively or use: git checkout -- " - exit 1 - fi - - for f in "${to_discard[@]}"; do - if git checkout -- "${f}" 2>/dev/null; then - echo " [OK] Discarded: ${f}" - else - echo " [FAIL] Failed: ${f}" >&2 - fi - done + local non_interactive="$1" dry_run="$2" + shift 2 + local files=("$@") + + echo "=== Discard Working-Tree Changes ===" + echo "" + echo " [!] WARNING: This permanently discards uncommitted changes." + echo " Changes cannot be recovered after this operation." + echo "" + + if [[ ${#files[@]} -eq 0 ]]; then + err "Specify file(s) to discard. Example: ./scripts/git/undo_last.sh discard " + exit 1 + fi + + # Validate each file has modifications + local -a to_discard=() + for f in "${files[@]}"; do + if [[ ! -f "${f}" ]] && [[ ! -d "${f}" ]]; then + echo " [!] Not found: ${f} (skipping)" + continue + fi + if git diff --name-only -- "${f}" | grep -qF "${f}"; then + to_discard+=("${f}") + else + echo " [!] No unstaged changes: ${f} (skipping)" + fi + done + + if [[ ${#to_discard[@]} -eq 0 ]]; then + echo " Nothing to discard." + exit 0 + fi + + echo " Files to discard changes in:" + for f in "${to_discard[@]}"; do + git diff --stat -- "${f}" | head -3 | sed 's/^/ /' + done + echo "" + + if [[ "${dry_run}" -eq 1 ]]; then + echo "--- Dry run: no changes made ---" + echo "Would run: git checkout -- ${to_discard[*]}" + exit 0 + fi + + if [[ "${non_interactive}" -eq 0 ]]; then + read -r -p " PERMANENTLY discard changes in ${#to_discard[@]} file(s)? (yes/no): " confirm + if [[ "${confirm}" != "yes" ]]; then + echo "Cancelled" + exit 0 + fi + else + err "Refusing to discard in non-interactive mode (data loss risk)" + err "Run interactively or use: git checkout -- " + exit 1 + fi + + for f in "${to_discard[@]}"; do + if git checkout -- "${f}" 2>/dev/null; then + echo " [OK] Discarded: ${f}" + else + echo " [FAIL] Failed: ${f}" >&2 + fi + done } # --------------------------------------------------------------------------- # Subcommand: amend-message # --------------------------------------------------------------------------- _cmd_amend_message() { - local non_interactive="$1" dry_run="$2" - shift 2 - local new_msg="${1:-}" - - echo "=== Amend Last Commit Message ===" - echo "" - - if ! git rev-parse HEAD >/dev/null 2>&1; then - err "Repository has no commits" - exit 1 - fi - - if [[ -z "${new_msg}" ]]; then - err "New message required. Example: ./scripts/git/undo_last.sh amend-message 'fix: correct typo'" - exit 1 - fi - - # Validate conventional format - if ! echo "${new_msg}" | grep -qE "^(${CGW_ALL_PREFIXES}):"; then - echo " [!] Message does not follow conventional format: ${new_msg}" - echo " Expected: : (types: ${CGW_ALL_PREFIXES/|/, })" - if [[ "${non_interactive}" -eq 0 ]]; then - read -r -p " Continue anyway? (yes/no): " format_confirm - if [[ "${format_confirm}" != "yes" ]]; then - echo "Cancelled" - exit 0 - fi - fi - fi - - # Warn if commit has been pushed - local current_branch upstream_ref - current_branch=$(git branch --show-current) - upstream_ref="refs/remotes/origin/${current_branch}" - if git show-ref --verify --quiet "${upstream_ref}" 2>/dev/null; then - local ahead - ahead=$(git rev-list --count "origin/${current_branch}..HEAD" 2>/dev/null || echo "0") - if [[ "${ahead}" -eq 0 ]]; then - echo " [!] WARNING: This commit appears to have been pushed. Amending will require force-push." - if [[ "${non_interactive}" -eq 1 ]]; then - err "Refusing to amend pushed commit in non-interactive mode" - exit 1 - fi - read -r -p " Amend anyway? (yes/no): " pushed_confirm - [[ "${pushed_confirm}" != "yes" ]] && echo "Cancelled" && exit 0 - fi - fi - - echo " Current message: $(git log -1 --format='%s')" - echo " New message: ${new_msg}" - echo "" - - if [[ "${dry_run}" -eq 1 ]]; then - echo "--- Dry run: no changes made ---" - echo "Would run: git commit --amend -m '${new_msg}'" - exit 0 - fi - - if [[ "${non_interactive}" -eq 0 ]]; then - read -r -p " Amend commit message? (yes/no): " confirm - if [[ "${confirm}" != "yes" ]]; then - echo "Cancelled" - exit 0 - fi - fi - - if git commit --amend --no-edit -m "${new_msg}"; then - echo "" - echo "[OK] Message updated: $(git log -1 --oneline)" - else - err "Amend failed" - exit 1 - fi + local non_interactive="$1" dry_run="$2" + shift 2 + local new_msg="${1:-}" + + echo "=== Amend Last Commit Message ===" + echo "" + + if ! git rev-parse HEAD >/dev/null 2>&1; then + err "Repository has no commits" + exit 1 + fi + + if [[ -z "${new_msg}" ]]; then + err "New message required. Example: ./scripts/git/undo_last.sh amend-message 'fix: correct typo'" + exit 1 + fi + + # Validate conventional format + if ! echo "${new_msg}" | grep -qE "^(${CGW_ALL_PREFIXES}):"; then + echo " [!] Message does not follow conventional format: ${new_msg}" + echo " Expected: : (types: ${CGW_ALL_PREFIXES/|/, })" + if [[ "${non_interactive}" -eq 0 ]]; then + read -r -p " Continue anyway? (yes/no): " format_confirm + if [[ "${format_confirm}" != "yes" ]]; then + echo "Cancelled" + exit 0 + fi + fi + fi + + # Warn if commit has been pushed + local current_branch upstream_ref + current_branch=$(git branch --show-current) + upstream_ref="refs/remotes/origin/${current_branch}" + if git show-ref --verify --quiet "${upstream_ref}" 2>/dev/null; then + local ahead + ahead=$(git rev-list --count "origin/${current_branch}..HEAD" 2>/dev/null || echo "0") + if [[ "${ahead}" -eq 0 ]]; then + echo " [!] WARNING: This commit appears to have been pushed. Amending will require force-push." + if [[ "${non_interactive}" -eq 1 ]]; then + err "Refusing to amend pushed commit in non-interactive mode" + exit 1 + fi + read -r -p " Amend anyway? (yes/no): " pushed_confirm + [[ "${pushed_confirm}" != "yes" ]] && echo "Cancelled" && exit 0 + fi + fi + + echo " Current message: $(git log -1 --format='%s')" + echo " New message: ${new_msg}" + echo "" + + if [[ "${dry_run}" -eq 1 ]]; then + echo "--- Dry run: no changes made ---" + echo "Would run: git commit --amend -m '${new_msg}'" + exit 0 + fi + + if [[ "${non_interactive}" -eq 0 ]]; then + read -r -p " Amend commit message? (yes/no): " confirm + if [[ "${confirm}" != "yes" ]]; then + echo "Cancelled" + exit 0 + fi + fi + + if git commit --amend --no-edit -m "${new_msg}"; then + echo "" + echo "[OK] Message updated: $(git log -1 --oneline)" + else + err "Amend failed" + exit 1 + fi } main "$@"