diff --git a/.gitattributes b/.gitattributes index e03d96cd..9a0a204a 100644 --- a/.gitattributes +++ b/.gitattributes @@ -4,3 +4,9 @@ *.sh text eol=lf scripts/check-naming-and-style.sh text eol=lf .githooks/pre-commit text eol=lf + +# Batch files must be CRLF. cmd.exe parses a lone LF inconsistently around +# labels and goto, so an LF-only .cmd can run its happy path and then jump +# nowhere on the error path - which is the path that matters in a promote +# script. +*.cmd text eol=crlf diff --git a/tools/git-push.cmd b/tools/git-push.cmd new file mode 100644 index 00000000..741a3049 --- /dev/null +++ b/tools/git-push.cmd @@ -0,0 +1,63 @@ +@echo off +setlocal + +rem Push a branch to the internal git server, from Windows. +rem +rem tools\git-push.cmd push the current branch +rem tools\git-push.cmd wj push a named branch +rem +rem CREDENTIALS ARE NOT THIS SCRIPT'S JOB HERE. +rem +rem The bash version of this script reads a token out of secrets.md and feeds it +rem to git through GIT_ASKPASS, because that box has no credential store. Windows +rem does: Git for Windows installs Git Credential Manager and enables it by +rem default, so `git push` prompts once and Windows Credential Manager keeps it +rem after that. +rem +rem So do NOT port the token handling here. Copying a token into a script, an +rem environment variable or a remote URL on a machine that already has a +rem credential store is strictly worse than the thing it replaces. +rem +rem If a push here asks for a password every time, the fix is to check the +rem credential helper, not to embed a token: +rem git config --global credential.helper manager + +cd /d "%~dp0.." + +set "BRANCH=%~1" +if "%BRANCH%"=="" ( + for /f "delims=" %%b in ('git rev-parse --abbrev-ref HEAD') do set "BRANCH=%%b" +) + +if "%BRANCH%"=="HEAD" ( + echo ERROR: detached HEAD. Check out a branch, or name one. + exit /b 1 +) + +git show-ref --verify --quiet "refs/heads/%BRANCH%" +if errorlevel 1 ( + echo ERROR: no such local branch: %BRANCH% + exit /b 1 +) + +rem A push of a dirty tree pushes the LAST commit, not what is in front of you. +rem That reads as a successful push and is found out much later. +for /f "delims=" %%s in ('git status --porcelain') do ( + echo Working tree is not clean: + git status --short + echo. + echo Commit or stash first - a push of a dirty tree pushes the LAST commit, + echo not what you are looking at. + exit /b 1 +) + +echo Pushing %BRANCH% ... +git push origin "%BRANCH%" +if errorlevel 1 ( + echo. + echo Push failed. + exit /b 1 +) + +echo Pushed %BRANCH%. +endlocal diff --git a/tools/promote-to-main.cmd b/tools/promote-to-main.cmd new file mode 100644 index 00000000..b141020d --- /dev/null +++ b/tools/promote-to-main.cmd @@ -0,0 +1,154 @@ +@echo off +setlocal enabledelayedexpansion + +rem Take specific commits from wj to main, from Windows. +rem +rem tools\promote-to-main.cmd [ ...] +rem tools\promote-to-main.cmd --dry-run ... +rem +rem Commits are applied in the order given, so give them oldest first. +rem +rem WHY CHERRY-PICK AND NOT A MERGE +rem +rem wj holds this site's work. Some of it is product - the rest of the world +rem should have it - and some belongs to this site alone. Merging the branch +rem takes both; that is how a site branch quietly becomes the product. Naming +rem the commits keeps the decision explicit and per change. +rem +rem It does NOT push. Read what it prints, then run tools\git-push.cmd main. + +cd /d "%~dp0.." + +set "DRYRUN=0" +if "%~1"=="--dry-run" ( + set "DRYRUN=1" + shift +) + +if "%~1"=="" ( + echo usage: tools\promote-to-main.cmd [--dry-run] ^ [^ ...] + echo. + echo Give commits oldest first. Find them with: + echo git log --oneline main..wj + exit /b 1 +) + +rem The ADR-015 site-literal check is a shell script, and it is the one gate +rem that makes a site branch survivable: it catches a hostname or share path +rem riding along in a commit that looked generic. Running the promotion without +rem it would defeat the point, so this refuses rather than skipping it quietly. +where bash >nul 2>&1 +if errorlevel 1 ( + echo ERROR: bash was not found on PATH. + echo. + echo The naming/style gate ^(scripts\check-naming-and-style.sh^) needs it, and + echo that gate is what stops a site-specific literal reaching main. Promoting + echo without it is not safe. + echo. + echo Git for Windows ships bash. Add its usr\bin to PATH, or run this from + echo Git Bash using tools/promote-to-main.sh instead. + exit /b 1 +) + +for /f "delims=" %%b in ('git rev-parse --abbrev-ref HEAD') do set "STARTING_BRANCH=%%b" + +for /f "delims=" %%s in ('git status --porcelain') do ( + echo Working tree is not clean: + git status --short + echo. + echo Cherry-picking onto main from here would mix uncommitted work into the + echo promotion, or fail halfway and leave you on main with a dirty tree. + exit /b 1 +) + +rem Resolve and describe first, so a typo is caught before anything moves. +echo About to promote onto main, in this order: +for %%r in (%*) do ( + git rev-parse --verify --quiet "%%r^^{commit}" >nul + if errorlevel 1 ( + echo ERROR: not a commit: %%r + exit /b 1 + ) + for /f "delims=" %%l in ('git log --oneline -1 %%r') do echo %%l +) +echo. + +if "%DRYRUN%"=="1" ( + echo --dry-run: files each commit touches, so you can see what reaches main. + for %%r in (%*) do ( + echo. + for /f "delims=" %%l in ('git log --oneline -1 %%r') do echo --- %%l --- + git show --stat --oneline %%r + ) + echo. + echo Nothing was changed. + exit /b 0 +) + +git checkout main +if errorlevel 1 goto :restore +for /f "delims=" %%h in ('git rev-parse HEAD') do set "MAIN_BEFORE=%%h" + +for %%r in (%*) do ( + echo. + for /f "delims=" %%l in ('git log --oneline -1 %%r') do echo ==^> picking %%l + git cherry-pick -x %%r + if errorlevel 1 ( + echo. + echo That commit does not apply cleanly to main. + echo Resolve it by hand if it is worth having: + echo git status + echo git cherry-pick --abort + goto :restore + ) +) + +echo. +echo ==^> main gates + +echo --- naming, style, site literals ^(ADR-015^) --- +bash scripts/check-naming-and-style.sh +if errorlevel 1 goto :restore + +echo --- generated project map --- +venv\Scripts\python scripts\gen_project_map.py --check >nul 2>&1 +if errorlevel 1 ( + echo Project map is stale; regenerating. + venv\Scripts\python scripts\gen_project_map.py + git add docs/PROJECT-MAP.md + git commit -q -m "Regenerate the project map after promotion" +) + +echo --- python suite --- +venv\Scripts\python -m pytest -q +if errorlevel 1 goto :restore + +echo --- frontend --- +pushd frontend +call npm test --silent +set "FRONTEND_STATUS=!errorlevel!" +popd +if not "!FRONTEND_STATUS!"=="0" goto :restore + +echo. +echo main is now: +git log --oneline %MAIN_BEFORE%..HEAD +echo. +echo Gates passed. NOT pushed - review the above, then: +echo tools\git-push.cmd main +echo. +echo You are on main. Return to your branch with: +echo git checkout %STARTING_BRANCH% +exit /b 0 + +:restore +echo. +echo Promotion did NOT complete. +git rev-parse --verify --quiet CHERRY_PICK_HEAD >nul 2>&1 +if not errorlevel 1 ( + echo Aborting the in-progress cherry-pick ... + git cherry-pick --abort +) +git checkout "%STARTING_BRANCH%" >nul 2>&1 +echo Back on %STARTING_BRANCH%. main is unchanged and nothing was pushed. +exit /b 1